diff --git a/cloud/apps/relay/src/admin-route-transient-app.test.ts b/cloud/apps/relay/src/admin-route-transient-app.test.ts index 1145d5964fb..70e5e981fe5 100644 --- a/cloud/apps/relay/src/admin-route-transient-app.test.ts +++ b/cloud/apps/relay/src/admin-route-transient-app.test.ts @@ -53,23 +53,6 @@ describe('admin routes under a database that is briefly out of reach', () => { expect(response.headers.get('Retry-After')).toBe('7') expect(await response.json()).toEqual({ error: 'database_temporarily_unavailable' }) }) - - it('answers admission-selector/status with a retryable 503 instead of a conflict', async () => { - const inspectCellAdmissionSelector = vi.fn(async () => { - throw poolTimeout() - }) - const app = appWith({ inspectCellAdmissionSelector }) - - const response = await app.request( - '/v1/admin/admission-selector/status', - adminRequest({ v: 1, attemptId: '22222222-2222-4222-8222-222222222222' }) - ) - - expect(response.status).toBe(503) - expect(response.headers.get('Retry-After')).toBe('5') - expect(await response.json()).toEqual({ error: 'database_temporarily_unavailable' }) - }) - }) function config(overrides: Partial = {}): RelayConfig { diff --git a/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts b/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts index 4a1a2aff115..da21d0579ff 100644 --- a/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts +++ b/cloud/apps/relay/src/assignment/migrations/dead-cell-evacuation-precheck.test.ts @@ -1,53 +1,14 @@ -import pg from 'pg' -import { afterAll, afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { RelayAssignmentStore } from '../assignment-store.js' import type { RelayCellConfig } from '../../config.js' -import { - openInMemoryRelayDatabase, - openRelayDatabase, - type RelayDatabase -} from '../../database/database.js' +import { openInMemoryRelayDatabase, type RelayDatabase } from '../../database/database.js' -const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL -const schema = 'relay_dead_cell_precheck_test' const CELLS: RelayCellConfig[] = [ { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } ] -function scopedUrl(): string { - const url = new URL(databaseUrl!) - url.searchParams.set('options', `-c search_path=${schema}`) - return url.toString() -} - -async function onAdmin(sql: string): Promise { - const client = new pg.Client({ connectionString: databaseUrl }) - await client.connect() - try { - await client.query(sql) - } finally { - await client.end() - } -} - -const dialects: [string, () => Promise][] = [ - ['sqlite', openInMemoryRelayDatabase], - ...(databaseUrl - ? [ - [ - 'postgres', - async () => { - await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) - await onAdmin(`CREATE SCHEMA ${schema}`) - return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) - } - ] as [string, () => Promise] - ] - : []) -] - -describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) => { +describe('dead-cell evacuation pre-check', () => { let database: RelayDatabase | undefined afterEach(async () => { @@ -55,12 +16,8 @@ describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) database = undefined }) - afterAll(async () => { - if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) - }) - async function setup(now: () => number) { - database = await open() + database = await openInMemoryRelayDatabase() const store = new RelayAssignmentStore(database, now, { requireLiveCells: true, heartbeatTtlMs: 45_000 diff --git a/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts b/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts index 011e5c99313..22bf3a13267 100644 --- a/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/drain-return-admission.test.ts @@ -39,31 +39,10 @@ function admitted(result: DrainReturnGrant | DrainReturnDeferral): DrainReturnGr return result } -function deferred(result: DrainReturnGrant | DrainReturnDeferral): DrainReturnDeferral { - if (result.kind !== 'deferred') throw new Error('expected a deferral') - return result -} - const host = (index: number): string => `host${String(index).padStart(12, '0')}` describe('drain-return admission', () => { - it('answers a host’s own early retry with its interval, not a place behind the cohort', async () => { - const { lane } = harness() - admitted(await lane.acquire(host(0))).lease.release() - - // A row-busy refusal says "retry in 1 s"; that redial is the host's own. - const repeat = deferred(await lane.acquire(host(0))) - expect(repeat).toMatchObject({ - reason: 'host-rate-limited', - retryAfterSeconds: 2 - }) - - // And it booked nothing: the next overflow still gets the first slot. - admitted(await lane.acquire(host(1))) - expect(deferred(await lane.acquire(host(2))).retryAfterSeconds).toBe(2) - }) - it('lets a queued placement go before a queued drain return', async () => { const { lane, placement } = harness({ maxQueued: 1 }) const drain = admitted(await lane.acquire(host(0))) diff --git a/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts b/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts index 1a2b795252a..87176b7e53c 100644 --- a/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts +++ b/cloud/apps/relay/src/assignment/public-admission/public-assignment-reconnect-lane.test.ts @@ -64,25 +64,6 @@ describe('public assignment reconnect lane', () => { expect((await second).status).toBe(200) }) - it('rejects on the fast lane when the verification probe fails transiently', async () => { - const host = 'tttttttttttttttt' - const assign = vi.fn() - const resolve = vi.fn(async () => { - throw Object.assign(new Error('deadlock detected'), { code: '40P01' }) - }) - const app = createRelayApp(config(), { - store: {} as never, - assignments: { assign, resolve } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - - const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true })) - - expect(response.status).toBe(503) - expect(response.headers.get('retry-after')).toBe('2') - }) - it('refuses a dial whose assignment row is busy at once, and frees the fast lane', async () => { const busy = 'bbbbbbbbbbbbbbbb' const next = 'nnnnnnnnnnnnnnnn' @@ -113,38 +94,6 @@ describe('public assignment reconnect lane', () => { warn.mockRestore() }) - it('asks a busy resume to retry in a second', async () => { - const host = 'rrrrrrrrrrrrrrrr' - const app = createRelayApp(config(), { - store: { - resolveResume: vi.fn(async () => ({ userId: 'user-1', relayDeviceId: 'device-1' })) - } as never, - assignments: { - assign: vi.fn(async () => { - throw new RelayAssignmentRowBusyError() - }), - resolve: vi.fn(async () => null) - } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const response = await app.request('/v1/resolve', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - v: 1, - relayHostId: host, - resumeToken: Buffer.alloc(32, 1).toString('base64url') - }) - }) - - expect(response.status).toBe(503) - expect(response.headers.get('retry-after')).toBe('1') - expect(await response.json()).toEqual({ error: 'relay_assignment_row_busy' }) - warn.mockRestore() - }) }) function assignmentRequest(relayHostId: string, extra: { reconnect?: boolean } = {}): RequestInit { diff --git a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-reconciliation.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-reconciliation.test.ts index b38b0924c44..e66de86e5d7 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-reconciliation.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-reconciliation.test.ts @@ -39,23 +39,6 @@ async function setup() { } describe('idle cutover durable reconciliation', () => { - it('only permits reopening when the exact source still owns the assignment', async () => { - const { store } = await setup() - expect(await store.reconcileIdleRegionalRehome(request)).toBe('not-committed') - await store.activateControl(request, { - cellId: request.sourceCellId, - assignmentEpoch: request.sourceAssignmentEpoch, - generation: request.sourceGeneration + 1, - cellIncarnation: request.sourceCellIncarnation - }) - expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') - }) - - it('does not reopen an obsolete source after an assignment change', async () => { - const { store } = await setup() - await store.startEvacuation(request, request.targetCellId) - expect(await store.reconcileIdleRegionalRehome(request)).toBe('stale') - }) it('waits for an outstanding assignment transaction before deciding authority', async () => { const { database, store } = await setup() diff --git a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts index 18a95333bbb..2f97cdc67b2 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-store.test.ts @@ -111,15 +111,6 @@ async function setup() { } describe('constrained idle regional assignment transaction', () => { - it('sees durable control changes between candidate polls', async () => { - const { store, database, safety } = await setup() - await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) - await database.query("UPDATE relay_region_rehome_control SET enabled = 1 WHERE control_id = 'global'") - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(1) - await database.query("UPDATE relay_region_rehome_control SET enabled = 0 WHERE control_id = 'global'") - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) - }) it.each([10, 11])('reserves source activity plus assignment at target capacity %i', async (capacity) => { const { store, database, safety, request } = await setup() @@ -321,7 +312,6 @@ describe('constrained idle regional assignment transaction', () => { }) expect(await database.query('SELECT * FROM relay_region_rehome_attempts')).toEqual([]) }) - }) function holdStatement(database: RelayDatabase, fragment: string) { diff --git a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts index 6c4c559f06f..bf2df745a3d 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/idle-regional-rehome-window.test.ts @@ -220,23 +220,4 @@ describe('idle regional rehome candidate window', () => { expect(seen.size).toBe(363) expect(wrapped).toBe(true) }) - - it('excludes a host inside its rehome cooldown and takes it back after', async () => { - const { store, database, safety, now } = await setup() - await seedHost(store, identity) - await database.query( - `INSERT INTO relay_region_rehome_attempts - (attempt_id, user_id, relay_host_id, preferred_region, source_cell_id, source_cell_incarnation, - target_cell_id, target_cell_incarnation, previous_epoch, assignment_epoch, drain_grace_ms, - send_attempts, created_at, updated_at) - VALUES ('cooled', ?, ?, 'asia-east2', 'us', ?, 'asia-idle', ?, 0, 9, 0, 0, ?, ?)`, - [identity.userId, identity.relayHostId, incarnations[0], incarnations[2], now - 1_000, now] - ) - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toEqual([]) - await database.query('UPDATE relay_region_rehome_attempts SET created_at = ?', [ - now - 604_800_000 - 1 - ]) - expect(await store.selectIdleRegionalRehomeCandidates(safety)).toHaveLength(3) - }) - }) diff --git a/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts index beb6a3c58d1..396db5fb319 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/region-correction-restart.test.ts @@ -85,15 +85,6 @@ describe('persisted region decisions across director restart', () => { .toMatchObject({ reportStatus: 'stale' }) }) - it('uses server expiry after a restart regardless of an old client report', async () => { - const context = await setup() - context.setNow(context.window.expiresAt) - const restarted = await context.restart() - expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) - .toMatchObject({ reportStatus: 'expired' }) - expect(await restarted.store.previewRegionCorrection()).toEqual({ expired: 1 }) - }) - it('does not interpret a persisted future-policy window using the old policy after rollback', async () => { const context = await setup() await context.database.query('UPDATE relay_region_decisions SET policy_version = 2') @@ -105,15 +96,4 @@ describe('persisted region decisions across director restart', () => { expect(row.preferred_region).toBeNull() expect(row.report_json).toBeNull() }) - - it('keeps generation ordering when the server clock moves backwards across restart', async () => { - const context = await setup() - context.setNow(1_000_000_000 - 60_000) - const restarted = await context.restart() - const successor = (await context.issue(restarted.store)).window! - expect(successor.generation).toBe(2) - expect(successor.expiresAt).toBe(context.window.expiresAt - 60_000) - expect(await restarted.store.exchangeRegionCorrection(identity, context.report, context.window.assignmentEpoch)) - .toMatchObject({ reportStatus: 'stale' }) - }) }) diff --git a/cloud/apps/relay/src/assignment/regional-rehome/region-correction-store.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/region-correction-store.test.ts index 384945db575..e9220bd8ee5 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/region-correction-store.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/region-correction-store.test.ts @@ -147,53 +147,6 @@ describe('ordered region decisions and migration outcomes', () => { ).toMatchObject({ reportStatus: 'stale' }) }) - it('compares with assigned region, preserves hints, and never extends a window on report', async () => { - const context = await setup() - await context.store.assign(identity, 'asia-east2') - const issued = await window(context) - expect(issued.incumbentRegion).toBe('us-central1') - context.advance(50) - await context.store.exchangeRegionCorrection( - identity, - { - v: 1, - action: 'report', - generation: issued.generation, - assignmentEpoch: issued.assignmentEpoch, - policyVersion: 1, - outcome: 'conclusive', - measurements: { 'us-central1': 110, 'asia-east2': 90 } - }, - issued.assignmentEpoch - ) - expect(await context.store.previewRegionCorrection()).toEqual({ ineligible: 1 }) - const row = (await context.database.query(`SELECT * FROM relay_region_decisions`))[0]! - expect(Number(row.expires_at)).toBe(issued.expiresAt) - const hint = ( - await context.database.query( - `SELECT preferred_region FROM relay_assignment_region_preferences WHERE user_id = ?`, - [identity.userId] - ) - )[0] - expect(hint?.preferred_region).toBe('asia-east2') - context.advance(24 * 60 * 60_000) - expect( - await context.store.exchangeRegionCorrection( - identity, - { - v: 1, - action: 'report', - generation: issued.generation, - assignmentEpoch: issued.assignmentEpoch, - policyVersion: 1, - outcome: 'inconclusive', - reason: 'late' - }, - issued.assignmentEpoch - ) - ).toMatchObject({ reportStatus: 'expired' }) - }) - it('rejects stale assignment basis and requires both thresholds', async () => { const context = await setup() for (const measurements of [ diff --git a/cloud/apps/relay/src/assignment/regional-rehome/regional-rehome-store.test.ts b/cloud/apps/relay/src/assignment/regional-rehome/regional-rehome-store.test.ts index dd0c445ed15..c7e128a36bb 100644 --- a/cloud/apps/relay/src/assignment/regional-rehome/regional-rehome-store.test.ts +++ b/cloud/apps/relay/src/assignment/regional-rehome/regional-rehome-store.test.ts @@ -30,68 +30,6 @@ const targetIncarnation = '22222222-2222-4222-8222-222222222222' describe('regional rehome assignment state', () => { - it('moves one live preferred host and completes without disabling its source cell', async () => { - const context = await setup() - const identity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } - const neighbor = { userId: 'user-2', relayHostId: 'ponmlkjihgfedcba' } - const sourceControl = await activatePreferredSource(context, identity) - await activateSource(context, neighbor) - - const attempt = await context.store.tryIdleRehome() - expect(attempt).toMatchObject({ - userId: identity.userId, - relayHostId: identity.relayHostId, - sourceCellId: source.id, - sourceCellIncarnation: sourceIncarnation, - targetCellId: target.id, - targetCellIncarnation: targetIncarnation, - previousEpoch: 1, - assignmentEpoch: 2, - sendAttempts: 1 - }) - expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) - expect(await context.store.completeReadyRegionalRehomes()).toBe(0) - expect( - await context.database.query( - 'SELECT drain_outcome FROM relay_region_rehome_attempts WHERE attempt_id = ?', - [attempt!.attemptId] - ) - ).toEqual([{ drain_outcome: 'accepted' }]) - - const targetControl = await context.store.activateControl(identity, { - cellId: target.id, - assignmentEpoch: 2, - generation: 1 - }) - await context.store.markMigrationTargetRegistered(identity, { - cellId: target.id, - assignmentEpoch: 2 - }) - expect(await context.store.completeReadyRegionalRehomes()).toBe(0) - await context.store.releaseActivity(identity, sourceControl) - expect(await context.store.completeReadyRegionalRehomes()).toBe(1) - - expect(await context.store.resolve(identity)).toMatchObject({ - cellId: target.id, - assignmentEpoch: 2 - }) - expect(await context.store.resolve(neighbor)).toMatchObject({ cellId: source.id }) - expect( - await context.database.query( - `SELECT completed_at, aborted_at FROM relay_assignment_migrations - WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - ) - ).toEqual([{ completed_at: context.now(), aborted_at: null }]) - expect( - await context.database.query( - `SELECT drain_receipt_at, completed_at, aborted_at FROM relay_region_rehome_attempts` - ) - ).toEqual([{ drain_receipt_at: context.now(), completed_at: context.now(), aborted_at: null }]) - expect(targetControl).toMatch(/^control:/) - await context.database.close() - }) - it('completes healthy candidates past a poisoned attempt and logs it', async () => { const context = await setup() const poisoned = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -146,8 +84,6 @@ describe('regional rehome assignment state', () => { ).toEqual([{ completed_at: context.now() }]) await context.database.close() }) - - }) type Context = Awaited> @@ -251,18 +187,6 @@ async function activatePreferredSource( return control } -async function activateSource( - context: Context, - identity: { userId: string; relayHostId: string } -): Promise { - const assignment = await context.store.assign(identity, undefined, 'us-central1') - return await context.store.activateControl(identity, { - cellId: source.id, - assignmentEpoch: assignment.assignmentEpoch, - generation: 1 - }) -} - async function heartbeat( store: IdleRehomeTestStore, cell: typeof source | typeof target, diff --git a/cloud/apps/relay/src/assignment/relay-region-placement.test.ts b/cloud/apps/relay/src/assignment/relay-region-placement.test.ts index 25873f15666..dec09f344fa 100644 --- a/cloud/apps/relay/src/assignment/relay-region-placement.test.ts +++ b/cloud/apps/relay/src/assignment/relay-region-placement.test.ts @@ -45,16 +45,6 @@ describe('Relay regional placement', () => { afterEach(async () => await database.close()) - it('falls back globally only when the target region has no safe general cell', async () => { - await store.configureCell(CELLS[1]!, 'migration-only') - await store.configureCell(CELLS[2]!, 'migration-only') - await store.configureCell(CELLS[3]!, 'migration-only') - - await expect( - store.assign({ userId: 'fallback-user', relayHostId: 'fallbackhost0001' }, 'asia-east2') - ).resolves.toMatchObject({ cellId: 'us-c1', region: 'us-central1' }) - }) - it('preserves sticky assignments while updating only explicit preferences', async () => { const identity = { userId: 'sticky-user', relayHostId: 'stickyhost000001' } await expect(store.assign(identity)).resolves.toMatchObject({ cellId: 'us-c1' }) @@ -219,20 +209,4 @@ describe('Relay regional placement', () => { }) }) - it('expires identity-linked region preferences after 30 days', async () => { - const identity = { userId: 'expiry-user', relayHostId: 'expiryhost000001' } - await store.assign(identity, 'asia-east2') - now += 30 * 24 * 60 * 60_000 - await expect(store.releaseExpiredRegionPreferences()).resolves.toBe(0) - now += 1 - - await expect(store.releaseExpiredRegionPreferences()).resolves.toBe(1) - await expect( - database.query( - `SELECT preferred_region FROM relay_assignment_region_preferences - WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - ) - ).resolves.toEqual([]) - }) }) diff --git a/cloud/apps/relay/src/assignment/reserve-placement/reserve-ledger-writer.test.ts b/cloud/apps/relay/src/assignment/reserve-placement/reserve-ledger-writer.test.ts index 66a5e7b15fc..297cc74c640 100644 --- a/cloud/apps/relay/src/assignment/reserve-placement/reserve-ledger-writer.test.ts +++ b/cloud/apps/relay/src/assignment/reserve-placement/reserve-ledger-writer.test.ts @@ -26,20 +26,6 @@ describe('reserve ledger writer', () => { ) )[0] - it('writes a booked join, and only a higher epoch replaces it', async () => { - const { database: db, writer } = await setup() - writer.enqueue({ ...HOST, cellId: 'c1', epoch: 5 }) - await writer.flush() - expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) - writer.enqueue({ ...HOST, cellId: 'c2', epoch: 4 }) - writer.enqueue({ ...HOST, cellId: 'c3', epoch: 5 }) - await writer.flush() - expect(await row(db)).toMatchObject({ cell_id: 'c1', assignment_epoch: 5 }) - writer.enqueue({ ...HOST, cellId: 'c2', epoch: 6 }) - await writer.flush() - expect(await row(db)).toMatchObject({ cell_id: 'c2', assignment_epoch: 6 }) - }) - it('drops a moved row’s counters but keeps a same-cell row’s', async () => { const { database: db, writer } = await setup() await db.query( diff --git a/cloud/apps/relay/src/cell-status-reserve-view.test.ts b/cloud/apps/relay/src/cell-status-reserve-view.test.ts deleted file mode 100644 index e554c245818..00000000000 --- a/cloud/apps/relay/src/cell-status-reserve-view.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { RelayConfig } from './config.js' - -vi.mock('./authentication/admin-token-verifier.js', () => ({ - createAdminTokenVerifier: () => async (token: string) => token === 'deploy-token', - createReadOnlyAdminTokenVerifier: () => async () => false, - createRegionalRehomeControlApplyTokenVerifier: () => async () => false, - createRegionalRehomeRuntimeTokenVerifier: () => async () => false, - createRegionalRehomeTokenVerifier: () => async () => false, - createRuntimeTokenVerifier: () => async () => false -})) - -vi.mock('./authentication/relay-token-verifier.js', () => ({ - createRelayTokenVerifier: () => async () => null, - readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null -})) - -import { createRelayApp } from './app.js' -import { RelayAssignmentStore } from './assignment/assignment-store.js' -import type { CellReserveClient } from './cell/reservations/cell-reserve-client.js' -import { openInMemoryRelayDatabase, type RelayDatabase } from './database/database.js' -import { ReserveAssignment, type ReservePlacementMode } from './assignment/reserve-placement/reserve-assignment.js' -import { ReservePlacer } from './assignment/reserve-placement/reserve-placement.js' -import { - ShadowSeatDirectory, - type SeatFeedCell, - type SeatFeedResponse -} from './cell/seats/shadow-seat-directory.js' - -const INCARNATION = '11111111-1111-4111-8111-111111111111' -const CELLS = [ - { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, - { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } -] -const PER_HOST_FIELDS = [ - 'reservedRequests', - 'assignments', - 'activityLeases', - 'activityRequestUnits', - 'restartBlockingActivityLeases', - 'restartBlockingActivityRequestUnits', - 'restartBlockingReservedRequests', - 'outgoingMigrations', - 'incomingMigrations' -] - -function feed(cellId: string, overrides: Partial = {}): SeatFeedResponse { - return { - v: 1, - cellId, - incarnation: INCARNATION, - seq: 1, - at: 0, - draining: false, - counts: { seats: 40, controls: 42, bookings: 3, units: 45, ceiling: 500 }, - intake: { perSec: 5, burst: 20, tokens: 20 }, - flagsApplied: { generation: 3, flags: { admitMode: 'reserve' } }, - admitModeEffective: 'reserve', - admitModeRaw: 'reserve', - full: [], - ...overrides - } -} - -describe('reserve-aware cell status', () => { - let database: RelayDatabase | undefined - - afterEach(async () => { - await database?.close() - database = undefined - }) - - async function setup( - options: { mode?: ReservePlacementMode; startedAt?: number; placement?: boolean } = {} - ) { - const now = { value: 100_000 } - database = await openInMemoryRelayDatabase() - const store = new RelayAssignmentStore(database, () => now.value, { heartbeatTtlMs: 45_000 }) - await store.reconcileCells(CELLS) - for (const cell of CELLS) { - await store.recordCellHeartbeat({ - cellId: cell.id, - cellUrl: cell.url, - cellIncarnation: INCARNATION, - startedAt: 50, - ready: true, - observedRequests: 0 - }) - } - await store.assign({ userId: 'user-a', relayHostId: 'host000000000001' }) - await store.setCellAdmitMode('cell-a', 'reserve') - const cells: SeatFeedCell[] = CELLS.map((cell) => ({ - cellId: cell.id, - cellUrl: cell.url, - region: 'us-central1', - heartbeatExpiresAt: Number.MAX_SAFE_INTEGER, - requiredForComplete: true, - general: true - })) - const directory = new ShadowSeatDirectory() - directory.setCells(cells.map((cell) => cell.cellId)) - const reservePlacement = new ReserveAssignment({ - mode: options.mode ?? 'on', - directory, - cells: () => cells, - startedAt: options.startedAt ?? 0, - placer: new ReservePlacer( - () => now.value, - () => 0.25 - ), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status route never books or demotes. - client: {} as CellReserveClient, - readRow: async () => null, - now: () => now.value - }) - const app = createRelayApp(config(), { - store: {} as never, - assignments: store, - ...(options.placement === false ? {} : { reservePlacement }), - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - const status = async (body: Record) => - await app.request('/v1/admin/cell-status', { - method: 'POST', - headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' }, - body: JSON.stringify({ v: 1, ...body }) - }) - return { store, directory, now, status } - } - - it('reads a database cell exactly as today, and leaves a reserve cell without per-host counts', async () => { - const { store } = await setup() - expect(await store.reserveAwareCellDeploymentStatus('cell-b')).toEqual({ - admitMode: 'db', - status: await store.cellDeploymentStatus('cell-b') - }) - await expect(store.cellDeploymentStatus('cell-a')).rejects.toThrow('cell_in_reserve_mode') - const reserve = await store.reserveAwareCellDeploymentStatus('cell-a') - expect(reserve).toMatchObject({ - admitMode: 'reserve', - status: { - cellId: 'cell-a', - runtime: { cellIncarnation: INCARNATION, ready: true, heartbeatFresh: true } - } - }) - expect(Object.keys(reserve.status).filter((key) => PER_HOST_FIELDS.includes(key))).toEqual([]) - await database!.query( - 'ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone' - ) - const unknown = new RelayAssignmentStore(database!, () => 100_000) - await expect(unknown.reserveAwareCellDeploymentStatus('cell-b')).rejects.toThrow( - 'reserve_mode_unknown' - ) - }) - - it('keeps the 404 for a reserve cell without the opt-in, and 400s an unknown view', async () => { - const { status } = await setup() - const refused = await status({ cellId: 'cell-a' }) - expect(refused.status).toBe(404) - expect(await refused.json()).toEqual({ error: 'cell_in_reserve_mode' }) - expect((await status({ cellId: 'cell-a', view: 'all' })).status).toBe(400) - }) - - it('answers a reserve cell from the map, with perHost null and never zeros', async () => { - const { directory, now, status } = await setup() - directory.apply('cell-a', feed('cell-a', { draining: true }), now.value, now.value - 1_500) - directory.apply( - 'cell-b', - feed('cell-b', { admitModeEffective: 'db', admitModeRaw: 'db' }), - now.value, - now.value - ) - now.value += 20_000 - const response = await status({ cellId: 'cell-a', view: 'reserve-aware' }) - expect(response.status).toBe(200) - const body = await statusBody(response) - expect(body.status).toMatchObject({ - cellId: 'cell-a', - admissionState: 'general', - runtime: { cellIncarnation: INCARNATION }, - perHost: null, - reserve: { - admitMode: 'reserve', - feedStatus: 'live', - // Reported, not enforced: the caller rejects a stale map. - polledAgeMs: 21_500, - incarnation: INCARNATION, - draining: true, - controls: 42, - seats: 40, - bookings: 3, - units: 45, - ceiling: 500, - intakePerSec: 5, - free: 500 - (45 - 3) - 3 - } - }) - expect(Object.keys(body.status).filter((key) => PER_HOST_FIELDS.includes(key))).toEqual([]) - }) - - it('reports nulls, not zeros, for a restarted cell that has not applied its reserve switch', async () => { - const { directory, now, status } = await setup() - directory.apply( - 'cell-a', - feed('cell-a', { - counts: { seats: 0, controls: 0 }, - intake: undefined, - flagsApplied: { generation: 0, flags: {} }, - admitModeEffective: 'db', - admitModeRaw: 'db' - }), - now.value, - now.value - ) - const body = await statusBody(await status({ cellId: 'cell-a', view: 'reserve-aware' })) - expect(body.status.reserve).toMatchObject({ - admitMode: 'db', - bookings: null, - units: null, - ceiling: null, - intakePerSec: null, - free: null - }) - }) - - it('answers a retryable 503 while this director cannot vouch for the cell', async () => { - const unavailable = async (response: Response) => ({ - status: response.status, - retryAfter: response.headers.get('Retry-After'), - body: await response.json() - }) - const expected = (reason: string) => ({ - status: 503, - retryAfter: '5', - body: { error: 'reserve_status_unavailable', reason } - }) - for (const options of [{ mode: 'dry-run' as const }, { placement: false }]) { - const { directory, now, status } = await setup(options) - directory.apply('cell-a', feed('cell-a'), now.value, now.value) - expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual( - expected('placement-off') - ) - await database?.close() - } - // A starting director with one cell unheard. - const starting = await setup({ startedAt: 99_000 }) - starting.directory.apply('cell-a', feed('cell-a'), starting.now.value, starting.now.value) - expect( - await unavailable(await starting.status({ cellId: 'cell-a', view: 'reserve-aware' })) - ).toEqual(expected('map-incomplete')) - await database?.close() - const { directory, now, status } = await setup() - directory.apply('cell-b', feed('cell-b'), now.value, now.value) - directory.markNoFeed('cell-a', now.value) - expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual( - expected('no-feed') - ) - // A feed without the drain flag or counts is an older image: no feed, never an empty cell. - directory.apply('cell-a', feed('cell-a', { draining: undefined }), now.value, now.value) - expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual( - expected('no-feed') - ) - directory.apply('cell-a', feed('cell-a', { counts: undefined }), now.value, now.value) - expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual( - expected('no-feed') - ) - // The process that answered the feed is not the one heartbeating the runtime row. - directory.apply( - 'cell-a', - feed('cell-a', { incarnation: '22222222-2222-4222-8222-222222222222' }), - now.value, - now.value - ) - expect(await unavailable(await status({ cellId: 'cell-a', view: 'reserve-aware' }))).toEqual( - expected('incarnation-mismatch') - ) - }) -}) - -async function statusBody(response: Response) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the route answers { v, status } on 200. - return (await response.json()) as { v: number; status: Record } -} - -function config(overrides: Partial = {}): RelayConfig { - return { - port: 8080, - publicUrl: 'https://relay.example.test', - cellUrl: 'https://relay.example.test', - region: 'us-central1', - authIssuer: 'https://auth.example.test', - authAudience: 'orca-relay', - jwksUrl: 'https://auth.example.test/jwks', - assignmentSigningKey: new Uint8Array(32), - role: 'director', - cellId: 'director', - cells: [], - adminAudience: 'https://relay.example.test/v1/admin/drain', - deployServiceAccount: 'deploy@example.test', - runtimeServiceAccount: 'relay-cell@example.test', - adminJwksUrl: 'https://auth.example.test/jwks', - databasePoolMax: 10, - publicAssignmentsEnabled: true, - publicAssignmentConcurrency: 2, - publicAssignmentQueueMax: 128, - publicAssignmentWaitMs: 4_000, - publicResolveConcurrency: 1, - publicResolveWaitMs: 5_000, - publicAssignmentRetryAfterSeconds: 5, - dataDir: './data', - ...overrides - } -} diff --git a/cloud/apps/relay/src/cell/admission/cell-admission-selector.test.ts b/cloud/apps/relay/src/cell/admission/cell-admission-selector.test.ts index f7c4164afc1..3069552a6e0 100644 --- a/cloud/apps/relay/src/cell/admission/cell-admission-selector.test.ts +++ b/cloud/apps/relay/src/cell/admission/cell-admission-selector.test.ts @@ -1,4 +1,3 @@ -import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract/assignment-invariants' import { createHash } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' import { RelayAssignmentStore } from '../../assignment/assignment-store.js' @@ -27,41 +26,6 @@ const BASE_MEMBERSHIP = { general: ['c1', 'c2', 'c3'] } -class FailAfterIntentDatabase implements RelayDatabase { - private transactionsUntilFailure = Number.POSITIVE_INFINITY - - constructor(private readonly delegate: RelayDatabase) {} - - failSecondTransaction(): void { - this.transactionsUntilFailure = 2 - } - - async query(sql: string, params?: unknown[]): Promise { - return await this.delegate.query(sql, params) - } - - async queryLocked( - sql: string, - params?: unknown[], - options?: RelayLockOptions - ): Promise { - return await this.delegate.queryLocked(sql, params, options) - } - - async transaction(operation: (transaction: RelayDatabase) => Promise): Promise { - this.transactionsUntilFailure-- - if (this.transactionsUntilFailure === 0) { - this.transactionsUntilFailure = Number.POSITIVE_INFINITY - throw new Error('injected_commit_ambiguity') - } - return await this.delegate.transaction(operation) - } - - async close(): Promise { - await this.delegate.close() - } -} - class AfterTransactionDatabase implements RelayDatabase { private afterTransaction: (() => Promise) | undefined @@ -154,34 +118,6 @@ describe('relay cell admission selector', () => { }) }) - it('excludes existing-only and migration-only cells from dormant placement', async () => { - let now = 100 - const store = await setup(() => now) - const identity = { userId: 'dormant', relayHostId: 'host000000000001' } - const assignment = await store.assign(identity) - const control = await store.activateControl(identity, { - cellId: assignment.cellId, - assignmentEpoch: assignment.assignmentEpoch, - generation: 1 - }) - await store.releaseActivity(identity, control) - now += ASSIGNMENT_LIMITS.dormantTtlMs + 1 - - await store.applyCellAdmissionSelector({ - attemptId: 'cutover_002', - expectedGeneration: 0, - expectedMembershipSha256: BASE_MEMBERSHIP_SHA256, - membership: INITIAL_MEMBERSHIP - }) - - await expect(store.rebalanceDormant(identity, 'c2')).rejects.toThrow( - 'target_cell_unavailable' - ) - await expect(store.rebalanceDormant(identity, 'c3')).resolves.toMatchObject({ - cellId: 'c3' - }) - }) - it('does not recover an unfenced dead existing-only assignment', async () => { let now = 100 const store = await setup(() => now, true) @@ -283,94 +219,6 @@ describe('relay cell admission selector', () => { ]) }) - it('persists failed CAS intent without mutating current membership', async () => { - const store = await setup(() => 100) - await store.applyCellAdmissionSelector({ - attemptId: 'cutover_005', - expectedGeneration: 0, - expectedMembershipSha256: BASE_MEMBERSHIP_SHA256, - membership: INITIAL_MEMBERSHIP - }) - - await expect( - store.applyCellAdmissionSelector({ - attemptId: 'stale_attempt', - expectedGeneration: 5, - membership: { - existingOnly: ['c1'], - migrationOnly: [], - general: ['c2', 'c3'] - } - }) - ).rejects.toThrow('admission_selector_generation_mismatch') - await expect(store.inspectCellAdmissionSelector('stale_attempt')).resolves.toMatchObject({ - selector: { generation: 1, membership: INITIAL_MEMBERSHIP }, - intent: { state: 'diverged' } - }) - }) - - it('rejects duplicate or incomplete selector membership before mutation', async () => { - const store = await setup(() => 100) - - await expect( - store.applyCellAdmissionSelector({ - attemptId: 'duplicate_001', - expectedGeneration: 0, - expectedMembershipSha256: BASE_MEMBERSHIP_SHA256, - membership: { - existingOnly: ['c1', 'c1'], - migrationOnly: ['c2'], - general: ['c3'] - } - }) - ).rejects.toThrow('admission_selector_duplicate_cell') - await expect( - store.applyCellAdmissionSelector({ - attemptId: 'incomplete_001', - expectedGeneration: 0, - expectedMembershipSha256: BASE_MEMBERSHIP_SHA256, - membership: { - existingOnly: ['c1'], - migrationOnly: ['c2'], - general: [] - } - }) - ).rejects.toThrow('admission_selector_incomplete_membership') - await expect(store.inspectCellAdmissionSelector()).resolves.toMatchObject({ - selector: { - generation: 0, - membership: { existingOnly: [], migrationOnly: [], general: ['c1', 'c2', 'c3'] } - } - }) - }) - - it('inspects an unchanged durable intent after an ambiguous apply failure', async () => { - const underlying = await openInMemoryRelayDatabase() - const failingDatabase = new FailAfterIntentDatabase(underlying) - database = failingDatabase - const store = new RelayAssignmentStore(failingDatabase, () => 100) - await store.reconcileCells(CELLS) - failingDatabase.failSecondTransaction() - - const input = { - attemptId: 'ambiguous_001', - expectedGeneration: 0, - expectedMembershipSha256: BASE_MEMBERSHIP_SHA256, - membership: INITIAL_MEMBERSHIP - } - await expect(store.applyCellAdmissionSelector(input)).rejects.toThrow( - 'injected_commit_ambiguity' - ) - await expect(store.inspectCellAdmissionSelector(input.attemptId)).resolves.toMatchObject({ - selector: { generation: 0 }, - intent: { state: 'unchanged' } - }) - await expect(store.applyCellAdmissionSelector(input)).resolves.toMatchObject({ - changed: true, - selector: { generation: 1, membership: INITIAL_MEMBERSHIP } - }) - }) - it('allows only one concurrent CAS and never re-enables legacy cells', async () => { const store = await setup(() => 100) await store.applyCellAdmissionSelector({ @@ -416,24 +264,6 @@ describe('relay cell admission selector', () => { ).rejects.toThrow('admission_selector_legacy_reenable') }) - it('preserves the selector boundary across compatible reconciliation', async () => { - const store = await setup(() => 100) - await store.applyCellAdmissionSelector({ - attemptId: 'cutover_007', - expectedGeneration: 0, - expectedMembershipSha256: BASE_MEMBERSHIP_SHA256, - membership: INITIAL_MEMBERSHIP - }) - - await expect(store.reconcileCells(CELLS, false)).resolves.toBeUndefined() - await expect(store.reconcileCells([CELLS[0]!, CELLS[2]!])).rejects.toThrow( - 'admission_selector_boundary_active' - ) - await expect(store.inspectCellAdmissionSelector()).resolves.toMatchObject({ - selector: { generation: 1, membership: INITIAL_MEMBERSHIP } - }) - }) - it('atomically adds exact migration-only cells after the selector boundary', async () => { const store = await setup(() => 100) await store.applyCellAdmissionSelector({ diff --git a/cloud/apps/relay/src/cell/admission/cell-connection-cap.test.ts b/cloud/apps/relay/src/cell/admission/cell-connection-cap.test.ts index d3e20f33e75..75e17a40b7a 100644 --- a/cloud/apps/relay/src/cell/admission/cell-connection-cap.test.ts +++ b/cloud/apps/relay/src/cell/admission/cell-connection-cap.test.ts @@ -1,14 +1,11 @@ -import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayAssignmentStore } from '../../assignment/assignment-store.js' -import { encodeMembership, type CellAdmissionMembership } from './cell-admission-selector.js' import type { RelayCellConfig } from '../../config.js' import { openInMemoryRelayDatabase, type RelayDatabase } from '../../database/database.js' const HEARTBEAT_TTL_MS = 45_000 const BOUND = 60 const OLD_INCARNATION = '11111111-1111-4111-8111-111111111111' -const NEW_INCARNATION = '22222222-2222-4222-8222-222222222222' function cell(index: number, connectionHardCap: 1_000 | 3_000): RelayCellConfig { const id = `us-c${String(index).padStart(2, '0')}` @@ -40,7 +37,7 @@ type Report = { async function setup(cells: RelayCellConfig[]) { const database = await openInMemoryRelayDatabase() databases.push(database) - let now = 100 + const now = 100 const store = new RelayAssignmentStore(database, () => now, { requireLiveCells: true, heartbeatTtlMs: HEARTBEAT_TTL_MS @@ -81,32 +78,13 @@ async function setup(cells: RelayCellConfig[]) { } return rows } - const isolateForRoll = async (cellId: string): Promise => { - const current = await store.inspectCellAdmissionSelector() - const membership: CellAdmissionMembership = { - existingOnly: [], - migrationOnly: [cellId], - general: cells.map(({ id }) => id).filter((id) => id !== cellId) - } - await store.applyCellAdmissionSelector({ - attemptId: `isolate-${cellId}`, - expectedGeneration: current.selector.generation, - expectedMembershipSha256: createHash('sha256') - .update(encodeMembership(current.selector.membership)) - .digest('hex'), - membership, - rollIsolatedCells: [cellId] - }) - } return { database, store, heartbeat, capacity, reportedHardCap, - telemetryRows, - isolateForRoll, - setNow: (value: number) => (now = value) + telemetryRows } } @@ -215,63 +193,4 @@ describe('effective connection cap = min(configured, reported)', () => { harness.store.assign({ userId: 'user-a', relayHostId: 'host000000000001' }) ).rejects.toThrow('relay_capacity_exhausted') }) - - it('places a mixed fleet against each cell’s own reported cap', async () => { - const small = cell(1, 3_000) - const large = cell(2, 3_000) - const harness = await setup([small, large]) - await harness.heartbeat(small, { connectionHardCap: 1_000, units: 840 }) - await harness.heartbeat(large, { connectionHardCap: 3_000, units: 840 }) - - expect(await harness.capacity(small.id)).toMatchObject({ normalAdmissionPause: 840 }) - expect(await harness.capacity(large.id)).toMatchObject({ normalAdmissionPause: 2_840 }) - for (let index = 0; index < 4; index++) { - await expect( - harness.store.assign({ userId: `user-${index}`, relayHostId: `host00000000000${index}` }) - ).resolves.toMatchObject({ cellId: large.id }) - } - }) - - it('has no unavailable window while a cell rolls 1,000 -> 3,000', async () => { - const cells = [cell(1, 1_000), cell(2, 1_000)] - const [rolled, neighbour] = cells - const harness = await setup(cells) - const identity = { userId: 'user-a', relayHostId: 'host000000000001' } - await harness.heartbeat(rolled!, { connectionHardCap: 1_000 }) - await harness.heartbeat(neighbour!, { connectionHardCap: 1_000, units: 500 }) - const home = await harness.store.assign(identity) - expect(home.cellId).toBe(rolled!.id) - await harness.store.reconcileCells( - cells.map((_, index) => cell(index + 1, 3_000)), - true - ) - - // The old process keeps heartbeating its 1,000 well past the TTL; the host stays sticky. - for (const at of [30_000, 60_000, 90_000]) { - harness.setNow(at) - await harness.heartbeat(rolled!, { connectionHardCap: 1_000 }) - await harness.heartbeat(neighbour!, { connectionHardCap: 1_000, units: 500 }) - await expect(harness.store.assign(identity)).resolves.toMatchObject({ - cellId: rolled!.id, - assignmentEpoch: home.assignmentEpoch - }) - } - // The roll isolates the live old process: the isolated-incumbent lane re-places the host. - await harness.isolateForRoll(rolled!.id) - const moved = await harness.store.assign(identity) - expect(moved).toMatchObject({ cellId: neighbour!.id, placed: true }) - - // The replacement boots and reports the new cap. - harness.setNow(120_000) - await harness.heartbeat(rolled!, { - connectionHardCap: 3_000, - incarnation: NEW_INCARNATION, - startedAt: 110_000 - }) - expect(await harness.capacity(rolled!.id)).toMatchObject({ - effectiveHardCap: 3_000, - heartbeatFresh: true - }) - }) - }) diff --git a/cloud/apps/relay/src/cell/control-flags/relay-control-flag-channel.test.ts b/cloud/apps/relay/src/cell/control-flags/relay-control-flag-channel.test.ts index 6e6eeee42c8..60358c5d59b 100644 --- a/cloud/apps/relay/src/cell/control-flags/relay-control-flag-channel.test.ts +++ b/cloud/apps/relay/src/cell/control-flags/relay-control-flag-channel.test.ts @@ -77,25 +77,6 @@ describe('control flag channel', () => { .filter((line) => line.includes(`"event":"${CELL_FLAGS_APPLIED_EVENT}"`)) .map((line): unknown => JSON.parse(line)) - it('flips a flag on and back off, logging each applied generation once', async () => { - const google = fakeGoogle(cellObject(5, { readinessLocal: true, ticketCheck: 'shadow' })) - const channel = cellChannel(google.fetchImpl) - await channel.poll() - await channel.poll() - expect(google.state.storageRequests[1]?.searchParams.get('ifGenerationNotMatch')).toBe('5') - google.state.object = cellObject(7, { readinessLocal: false, ticketCheck: 'off' }) - await channel.poll() - expect(channel.applied()).toEqual({ generation: 7, flags: CELL_FLAG_DEFAULTS }) - expect(appliedLines()).toEqual([ - { - event: CELL_FLAGS_APPLIED_EVENT, - generation: 5, - flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true, ticketCheck: 'shadow' } - }, - { event: CELL_FLAGS_APPLIED_EVENT, generation: 7, flags: CELL_FLAG_DEFAULTS } - ]) - }) - it('keeps the applied values through every kind of lost contact, logging once per cause', async () => { const google = fakeGoogle(cellObject(5, { readinessLocal: true })) const channel = cellChannel(google.fetchImpl) diff --git a/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man-slots.blackbox.test.ts b/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man-slots.blackbox.test.ts index 7c0eba9cacb..59eb5dd1637 100644 --- a/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man-slots.blackbox.test.ts +++ b/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man-slots.blackbox.test.ts @@ -128,13 +128,4 @@ describe('dead-man trip slots', () => { expect(seated.socket.readyState).toBe(WebSocket.OPEN) }, 30_000) - it('without the flag, trips with every slot taken, as before', async () => { - let now = Date.now() - const { database, reserve } = await cell(() => now) - await trippedPeer(database, 'production-gce-c90', now) - await reserve.bookedHost(3) - expect(reserve.relay.sessions.inReserveMode()).toBe(true) - now += reserveDeadManWindowMs(reserve.config.cellId) + 1_000 - expect(reserve.relay.sessions.inReserveMode()).toBe(false) - }, 30_000) }) diff --git a/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man.test.ts b/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man.test.ts index 518925c8268..7ced8b04125 100644 --- a/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man.test.ts +++ b/cloud/apps/relay/src/cell/reservations/cell-reserve-dead-man.test.ts @@ -55,15 +55,6 @@ describe('ReserveDeadMan', () => { ]) }) - it('keeps the plain window for a later reserve write once a director has been in touch', () => { - const { clock, deadMan } = setup(3 * WINDOW_MS) - deadMan.mode(reserve(1)) - deadMan.contact() - expect(deadMan.mode(reserve(2))).toBe('reserve') - clock.value += WINDOW_MS + 1 - expect(deadMan.mode(reserve(2))).toBe('db') - }) - // A claim answered after the director came back must not trip the cell later without a slot. it('drops a permit for a silence that contact has ended', () => { const { clock, deadMan, events } = setup() diff --git a/cloud/apps/relay/src/cell/seats/cell-poll-reachability.test.ts b/cloud/apps/relay/src/cell/seats/cell-poll-reachability.test.ts index fc6201b5989..38fe72400b5 100644 --- a/cloud/apps/relay/src/cell/seats/cell-poll-reachability.test.ts +++ b/cloud/apps/relay/src/cell/seats/cell-poll-reachability.test.ts @@ -50,7 +50,7 @@ const answer = (status: number) => new Response('{}', { status }) // Every cell serves a feed page on the first round; `respond` answers every round after it. function poller( respond: (cellId: string) => Response | Promise, - options: { identityFails?: boolean; cells?: SeatFeedCell[]; neverServed?: ReadonlySet } = {} + options: { cells?: SeatFeedCell[]; neverServed?: ReadonlySet } = {} ) { let now = 1_000 let round = 0 @@ -66,7 +66,6 @@ function poller( return round === 0 ? page(cellId) : await respond(cellId) }), identityToken: vi.fn(async () => { - if (options.identityFails && round > 0) throw new Error('metadata_unavailable') return 'token-1' }), now: () => now, @@ -107,13 +106,6 @@ describe('seat-feed poller reachability', () => { } }) - it('does not blame a cell for a token this director cannot mint', async () => { - const cells = poller((cellId) => page(cellId), { identityFails: true }) - const at = await cells.run(CELL_POLL_DEAD_STREAK_MS) - cells.stop() - expect(cells.deadCellVerdict('c2', at)).toBeNull() - }) - it('counts only cells that served a feed: legacy 404 rows neither fill the sample nor dilute it', async () => { // Staging's shape: a few real cells and 16 legacy rows. Every real cell fails. const cells = poller(() => answer(502), { cells: [...CELLS.slice(0, 3), ...LEGACY] }) diff --git a/cloud/apps/relay/src/cell/seats/shadow-seat-directory.test.ts b/cloud/apps/relay/src/cell/seats/shadow-seat-directory.test.ts index 373a119003e..517e2966282 100644 --- a/cloud/apps/relay/src/cell/seats/shadow-seat-directory.test.ts +++ b/cloud/apps/relay/src/cell/seats/shadow-seat-directory.test.ts @@ -351,17 +351,6 @@ describe('startShadowSeatPoller', () => { expect(directory.cellState('cell-b')?.flagsApplied).toBeUndefined() }) - it('completes without a cell that is dead, empty or isolated', async () => { - const cell = poller({ 'cell-a': () => json(feed({ seq: 1, full: [] })) }, 'all', async () => [ - CELLS[0]!, - { ...CELLS[1]!, requiredForComplete: false } - ]) - await cell.tick() - cell.stop() - expect(cell.directory.cellState('cell-b')).toMatchObject({ status: 'pending' }) - expect(cell.directory.isComplete()).toBe(true) - }) - it('treats a malformed or mislabelled body as unverifiable', async () => { const cell = poller({ 'cell-a': () => json({ v: 2 }), diff --git a/cloud/apps/relay/src/connections/cell-phone-path.blackbox.test.ts b/cloud/apps/relay/src/connections/cell-phone-path.blackbox.test.ts index 822c12000a3..f41cd74388a 100644 --- a/cloud/apps/relay/src/connections/cell-phone-path.blackbox.test.ts +++ b/cloud/apps/relay/src/connections/cell-phone-path.blackbox.test.ts @@ -2,11 +2,9 @@ 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 { reserveDeadManWindowMs } from '../cell/reservations/cell-reserve-dead-man.js' import { openRelayDatabase } from '../database/database.js' import { nextFrame, - RESERVE_MODE_CELL_ID, startReserveModeCell } from '../test-fixtures/reserve-mode-cell.js' import { StatementRecordingDatabase } from '../test-fixtures/statement-recording-database.js' @@ -22,14 +20,6 @@ const OTHER_CELL = { capacityRequests: 900 } -async function until(check: () => Promise, timeoutMs = 10_000): Promise { - const deadline = Date.now() + timeoutMs - while (!(await check())) { - if (Date.now() > deadline) throw new Error('condition not reached') - await new Promise((resolve) => setTimeout(resolve, 50)) - } -} - describe('the memory phone path', () => { const cleanup: Array<() => Promise | void> = [] @@ -67,14 +57,12 @@ describe('the memory phone path', () => { // A phone resumed and spliced to its desktop, waiting to confirm. async function splicedPhone( phonePath: 'db' | 'memory', - device: { expiresAt?: number } = {}, - clock: { now?: () => number; beforeDial?: () => void } = {} + device: { expiresAt?: number } = {} ) { - const reserve = await cell(clock.now) + const reserve = await cell() reserve.setAdmitMode('reserve', { phonePath }) const seated = await reserve.bookedHost(3) await reserve.installDevice(seated.identity.hostId, TOKEN, device) - clock.beforeDial?.() const connOpen = nextFrame(seated.socket, 'conn-open') const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN) const basisConnId = String((await connOpen).connId) @@ -97,44 +85,6 @@ describe('the memory phone path', () => { const deviceRow = async (reserve: Awaited>) => (await reserve.raw.query(`SELECT current_expires_at FROM relay_devices`))[0] - it('admits a seated phone resume without an activity lease', async () => { - const { reserve, seated } = await seatedPhoneHost('memory') - const connOpen = nextFrame(seated.socket, 'conn-open') - const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN) - expect(await connOpen).toMatchObject({ kind: 'resume', relayDeviceId: 'device-1' }) - await reserve.attachData(await connOpen, seated.ack.generation) - expect(await phone.hello).toMatchObject({ - type: 'relay-hello', - ok: true, - credentialKind: 'resume' - }) - expect(await reserve.activityLeases()).toEqual([]) - }) - - it('persists the connection basis and activity leases while phonePath is db', async () => { - const { reserve, seated } = await seatedPhoneHost('db') - const connOpen = nextFrame(seated.socket, 'conn-open') - const phone = await reserve.dialPhone(seated.identity.hostId, TOKEN) - await reserve.attachData(await connOpen, seated.ack.generation) - expect(await phone.hello).toMatchObject({ type: 'relay-hello', ok: true }) - expect((await reserve.activityLeases()).map((lease) => lease.activity_kind).sort()).toEqual([ - 'confirmation', - 'splice' - ]) - expect( - await reserve.raw.query( - `SELECT basis_conn_id, relay_device_id, credential_kind, active FROM relay_connection_bases` - ) - ).toEqual([ - { - basis_conn_id: String((await connOpen).connId), - relay_device_id: 'device-1', - credential_kind: 'resume', - active: 1 - } - ]) - }) - it('closes a revoked or expired device 4401', async () => { const reserve = await cell() reserve.setAdmitMode('reserve', { phonePath: 'memory' }) @@ -275,24 +225,6 @@ describe('the memory phone path', () => { expect(await confirm('confirm-2')).toMatchObject({ code: 'confirmation_not_active' }) }) - it('answers a grace-version confirm renewed:false with its expiry unchanged, as today', async () => { - const { reserve, confirm } = await splicedPhone('memory') - const stored = Number((await deviceRow(reserve))?.current_expires_at) - // An install rotated the credential after the accept read, so the accepted version is grace. - await reserve.raw.query( - `UPDATE relay_devices SET grace_hash = current_hash, grace_version = 1, grace_expires_at = ?, - current_hash = 'rotated', current_version = 2`, - [Date.now() + 60_000] - ) - expect(await confirm('confirm-1')).toMatchObject({ - type: 'device-resume-confirmed', - currentVersion: 2, - acceptedAs: 'current', - renewed: false, - resumeExpiresAt: stored - }) - }) - it('durably confirms and renews a database-path resume, releasing only its confirmation lease', async () => { const { reserve, confirm, basisConnId } = await splicedPhone('db') const before = Number((await deviceRow(reserve))?.current_expires_at) @@ -314,70 +246,6 @@ describe('the memory phone path', () => { expect(await confirm('confirm-1')).toEqual(answer) }) - describe('rolling a memory cell back', () => { - // Flip back by the switch, or by the dead-man; either way the confirm finishes from memory. - type Cell = Awaited> - type Advance = (ms: number) => void - type Rollback = { - beforeDial?: (advance: Advance) => void - roll: (reserve: Cell, advance: Advance) => void - } - // The dead-man trips just after the phone's accept: its window ran from the host's booking. - const window = reserveDeadManWindowMs(RESERVE_MODE_CELL_ID) - const rollbacks: Record = { - 'admitMode=db': { roll: (reserve) => reserve.setAdmitMode('db', { phonePath: 'memory' }) }, - 'phonePath=db': { roll: (reserve) => reserve.setAdmitMode('reserve', { phonePath: 'db' }) }, - 'a dead-man trip': { - beforeDial: (advance) => advance(window - 5_000), - roll: (_reserve, advance) => advance(6_000) - } - } - - for (const [name, rollback] of Object.entries(rollbacks)) { - it(`finishes an in-flight confirm on its memory basis after ${name}, writing and leaking no lease`, async () => { - let now = Date.now() - const advance: Advance = (ms) => { - now += ms - } - const spliced = await splicedPhone( - 'memory', - {}, - { - now: () => now, - beforeDial: () => rollback.beforeDial?.(advance) - } - ) - const { reserve, seated } = spliced - // The flag tool records reserve in Postgres before the cell admits from memory. - await reserve.relay.assignments.reconcileCells([...reserve.config.cells, OTHER_CELL]) - await reserve.relay.assignments.setCellAdmitMode(reserve.config.cellId, 'reserve') - rollback.roll(reserve, advance) - expect(reserve.relay.sessions.phonePathEffective()).toBe('db') - expect(await spliced.confirm('confirm-1')).toMatchObject({ - type: 'device-resume-confirmed', - renewed: true - }) - // Only the control lease a flip back re-registers; nothing for the phone. - const leases = async () => - (await reserve.activityLeases()).map((lease) => lease.activity_kind) - expect((await leases()).filter((kind) => kind !== 'control')).toEqual([]) - // Postgres still records reserve, so evacuation leaves the cell alone. - await expect( - reserve.relay.assignments.startEvacuation( - { userId: 'user-1', relayHostId: seated.identity.hostId }, - OTHER_CELL.id - ) - ).rejects.toThrow('cell_in_reserve_mode') - // The splice still runs, and closing it releases nothing it never took. - expect(spliced.phone.socket.readyState).toBe(spliced.phone.socket.OPEN) - await until(async () => reserve.relay.sessions.reregistrationPending() === 0) - spliced.phone.socket.close() - await spliced.phone.closed - expect((await leases()).filter((kind) => kind !== 'control')).toEqual([]) - }) - } - }) - // E-dep-6 in miniature: the staging drill blocks one memory cell's database for 10 minutes. it('rides out a database outage: splices keep forwarding, new work fails cleanly and recovers', async () => { let now = Date.now() diff --git a/cloud/apps/relay/src/connections/host-session-registry.test.ts b/cloud/apps/relay/src/connections/host-session-registry.test.ts deleted file mode 100644 index 1208ebd1658..00000000000 --- a/cloud/apps/relay/src/connections/host-session-registry.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { EventEmitter } from 'node:events' -import { CONTROL_CONTINUITY_LIMITS } from '@orca-cloud/relay-contract/control-continuity' -import { RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract/close-codes' -import { RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME } from '@orca-cloud/relay-contract/control-messages' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type WebSocket from 'ws' -import type { RelayAssignmentStore } from '../assignment/assignment-store.js' -import type { RelayConfig } from '../config.js' -import type { RelayCredentialStore } from '../credentials/credential-store.js' -import { CONTROL_RENEWAL_BATCH_INTERVAL_MS } from '../control-leases/control-renewal-batch.js' -import type { - ControlRenewalOutcome -} from '../control-leases/control-renewal-statement.js' -import { - HostSessionRegistry, - IDLE_REHOME_MIN_CONTROL_AGE_MS, - type HostSession -} from './host-session-registry.js' -import type { RelayRuntimeObserver } from '../observability/relay-observability.js' -import type { RelayTokenClaims } from '../authentication/relay-token-verifier.js' -import { ProcessQueuedByteBudget } from './splice-forwarder.js' - -// A due renewal leaves the heartbeat as a batch enqueue, so the store only sees -// the tick once the batch window closes. -async function closeRenewalWindow(): Promise { - await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS) -} - -class FakeSocket extends EventEmitter { - readonly OPEN = 1 - readonly CLOSING = 2 - readonly CLOSED = 3 - readyState = this.OPEN - readonly send = vi.fn() - readonly close = vi.fn((code?: number, reason?: string) => { - this.readyState = this.CLOSED - this.emit('close', code, Buffer.from(reason ?? '')) - }) - readonly terminate = vi.fn(() => { - this.readyState = this.CLOSED - this.emit('close') - }) -} - -type ActivateSession = ( - socket: WebSocket, - identity: RelayTokenClaims, - existing: HostSession | null, - generation: number, - rebind: boolean, - assignmentEpoch: number, - appVersion?: string -) => Promise - -const config = { - port: 8080, - publicUrl: 'https://relay-c3.example.com', - cellUrl: 'https://relay-c3.example.com', - authIssuer: 'https://auth.example.com', - authAudience: 'orca-relay', - jwksUrl: 'https://auth.example.com/jwks', - assignmentSigningKey: new Uint8Array(32), - role: 'cell', - cellId: 'production-gce-c3', - cells: [ - { - id: 'production-gce-c3', - url: 'https://relay-c3.example.com', - capacityRequests: 4_000 - } - ], - adminAudience: 'https://relay-c3.example.com/v1/admin/drain', - deployServiceAccount: 'deploy@example.com', - runtimeServiceAccount: 'runtime@example.com', - adminJwksUrl: 'https://auth.example.com/admin-jwks', - databasePoolMax: 10, - publicAssignmentsEnabled: true, - publicAssignmentConcurrency: 2, - publicAssignmentQueueMax: 128, - publicAssignmentWaitMs: 4_000, - publicResolveConcurrency: 1, - publicResolveWaitMs: 5_000, - publicAssignmentRetryAfterSeconds: 5, - dataDir: './test-data' -} satisfies RelayConfig - -const identity = { - sub: 'user-1', - prof: 'profile-1', - relayHostId: 'abcdefghijklmnop', - purpose: 'host-control', - exp: 4_102_444_800 -} satisfies RelayTokenClaims - -function deferred(): { - promise: Promise - resolve(value: T): void -} { - let resolve!: (value: T) => void - const promise = new Promise((complete) => { - resolve = complete - }) - return { promise, resolve } -} - -function createRegistry( - activateControl: RelayAssignmentStore['activateControl'], - store: Partial = {}, - verifyRelayToken: (token: string) => Promise = vi.fn() -): { - registry: HostSessionRegistry - activate: ActivateSession - acquireActivity: ReturnType - renewControlActivities: ReturnType> - releaseActivity: ReturnType - observer: { - recordAuth: ReturnType - recordControlClose: ReturnType - recordPostProofClose: ReturnType - recordSpliceClose: ReturnType - } -} { - const acquireActivity = vi.fn().mockResolvedValue(undefined) - const releaseActivity = vi.fn().mockResolvedValue(true) - const renewControlActivities = vi - .fn() - .mockImplementation(async (rows) => rows.map(() => 'renewed')) - const assignments = { - activateControl, - markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined), - resolve: vi.fn().mockResolvedValue({ cellId: config.cellId }), - acquireActivity, - renewControlActivities, - releaseActivity - } as unknown as RelayAssignmentStore - const observer = { - recordAuth: vi.fn(), - recordForwardedBytes: vi.fn(), - recordHttp: vi.fn(), - recordReconnect: vi.fn(), - recordSql: vi.fn(), - recordControlClose: vi.fn(), - recordPostProofClose: vi.fn(), - recordSpliceClose: vi.fn() - } satisfies RelayRuntimeObserver - const registry = new HostSessionRegistry( - config, - verifyRelayToken, - store as RelayCredentialStore, - assignments, - new ProcessQueuedByteBudget(), - observer, - Date.now, - Math.random, - 'incarnation-1' - ) - // Mirrors the production signature exactly so a future positional shift fails to compile. - const bound = ( - registry as unknown as { - activate: ( - socket: WebSocket, - identity: RelayTokenClaims, - existing: HostSession | null, - generation: number, - rebind: boolean, - assignmentEpoch: number, - appVersion: string, - connectionInclusionWatermark?: number - ) => Promise - } - ).activate.bind(registry) - const activate: ActivateSession = ( - socket, - identity, - existing, - generation, - rebind, - assignmentEpoch, - appVersion = '1.4.173' - ) => bound(socket, identity, existing, generation, rebind, assignmentEpoch, appVersion) - return { - registry, - activate, - acquireActivity, - renewControlActivities, - releaseActivity, - observer - } -} - -describe('host session cleanup races', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => { - vi.clearAllTimers() - vi.useRealTimers() - }) - - // c34 ghost seat came from activation outliving orphan grace and reviving a removed session outside the map. - it('refuses a rebind whose session left the map while its activation was in flight', async () => { - const blocked = deferred() - const activateControl = vi - .fn() - .mockResolvedValueOnce('control:production-gce-c3:1') - .mockReturnValueOnce(blocked.promise) - const { registry, activate, releaseActivity } = createRegistry(activateControl) - const key = { userId: identity.sub, relayHostId: identity.relayHostId } - const originalSocket = new FakeSocket() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. - await activate(originalSocket as unknown as WebSocket, identity, null, 1, false, 1) - const original = registry.get(key) - originalSocket.close(1006, 'network reset') - expect(original?.state).toBe('orphaned') - - const rebindSocket = new FakeSocket() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeSocket implements the registry's WebSocket event and lifecycle surface. - const rebinding = activate(rebindSocket as unknown as WebSocket, identity, original, 1, true, 1) - await vi.advanceTimersByTimeAsync(0) - // The grace runs out while the database answers slowly. - await vi.advanceTimersByTimeAsync(CONTROL_CONTINUITY_LIMITS.orphanGraceMs) - expect(registry.get(key)).toBeNull() - blocked.resolve('control:production-gce-c3:1') - await rebinding - - expect(rebindSocket.close).toHaveBeenCalledWith( - RELAY_CLOSE_CODE.PEER_DROPPED, - 'control activation superseded' - ) - expect(registry.get(key)).toBeNull() - expect(original?.state).toBe('closed') - const page = registry.seatFeed(null) - expect('full' in page && page.full).toEqual([]) - expect(releaseActivity).toHaveBeenCalledWith(key, 'control:production-gce-c3:1') - }) - - it('ignores a denial belonging to the socket before a same-generation rebind', async () => { - const h = createRegistry(vi.fn().mockResolvedValue('control:production-gce-c3:1')) - const oldSocket = new FakeSocket() - await h.activate(oldSocket as unknown as WebSocket, identity, null, 1, false, 1) - const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! - let finish!: (outcomes: ControlRenewalOutcome[]) => void - h.renewControlActivities.mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve - }) - ) - await vi.advanceTimersByTimeAsync(15_000) - await closeRenewalWindow() - const replacement = new FakeSocket() - await h.activate(replacement as unknown as WebSocket, identity, session, 1, true, 1) - finish(['activity_cell_not_authoritative']) - await vi.advanceTimersByTimeAsync(0) - expect(replacement.close).not.toHaveBeenCalled() - expect(session.socket).toBe(replacement) - expect(session.generation).toBe(1) - }) -}) - -describe('source-owned idle cutover', () => { - beforeEach(() => vi.useFakeTimers()) - afterEach(() => { - vi.clearAllTimers() - vi.useRealTimers() - }) - const request = { - attemptId: 'idle-1', - userId: identity.sub, - relayHostId: identity.relayHostId, - sourceAssignmentEpoch: 1, - sourceGeneration: 1, - sourceCellIncarnation: 'incarnation-1', - targetCellId: 'target' - } - async function source() { - const h = createRegistry(vi.fn().mockResolvedValue('control:1')) - const socket = new FakeSocket() - h.registry.acceptControl( - socket as unknown as WebSocket, - identity, - undefined, - new Set([RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME]) - ) - socket.removeAllListeners('message') - await h.activate(socket as unknown as WebSocket, identity, null, 1, false, 1) - // Moves the clock without firing the heartbeat timers that would close the session. - vi.setSystemTime(Date.now() + IDLE_REHOME_MIN_CONTROL_AGE_MS) - return { ...h, socket, session: h.registry.get(request)! } - } - - it('rejects new accepts and replacements synchronously while a commit awaits', async () => { - const h = await source() - const result = deferred<{ outcome: 'deferred' }>() - const commit = vi.fn().mockReturnValue(result.promise) - const moving = h.registry.idleRehome( - request, - commit, - vi.fn().mockResolvedValue('not-committed') - ) - const duplicate = h.registry.idleRehome( - request, - commit, - vi.fn().mockResolvedValue('not-committed') - ) - const client = new FakeSocket() - const release = vi.fn() - await h.registry.acceptClient( - client as unknown as WebSocket, - identity.relayHostId, - 'credential', - { release } as never - ) - expect(client.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) - expect(release).toHaveBeenCalledOnce() - const replacement = new FakeSocket() - await h.activate(replacement as unknown as WebSocket, identity, h.session, 2, false, 1) - expect(replacement.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, expect.any(String)) - result.resolve({ outcome: 'deferred' }) - await moving - await duplicate - expect(commit).toHaveBeenCalledOnce() - expect(h.socket.close).not.toHaveBeenCalled() - expect( - await h.registry.idleRehome( - { ...request, attemptId: 'next' }, - vi.fn().mockResolvedValue({ outcome: 'committed' }), - vi.fn().mockResolvedValue('not-committed') - ) - ).toEqual({ outcome: 'committed' }) - }) -}) diff --git a/cloud/apps/relay/src/control-leases/control-reservation-reconnect-claim.test.ts b/cloud/apps/relay/src/control-leases/control-reservation-reconnect-claim.test.ts index abc4cb147c4..b7db80d670f 100644 --- a/cloud/apps/relay/src/control-leases/control-reservation-reconnect-claim.test.ts +++ b/cloud/apps/relay/src/control-leases/control-reservation-reconnect-claim.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { RelayAssignmentStore } from '../assignment/assignment-store.js' import type { RelayCellConfig } from '../config.js' -import { - openInMemoryRelayDatabase, - openRelayDatabase, - type RelayDatabase -} from '../database/database.js' +import { openInMemoryRelayDatabase, type RelayDatabase } from '../database/database.js' const KEY_PREFIX = 'reconnect-claim' const USER_ID = `${KEY_PREFIX}-user-1` @@ -21,54 +17,19 @@ const CELL_INCARNATION = '11111111-1111-4111-8111-111111111111' const IDENTITY = { userId: USER_ID, relayHostId: RELAY_HOST_ID } const CONTROL_ACTIVITY_ID = `control:${CELL.id}:1` -const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL -const backends: { name: string; open: () => Promise }[] = [ - { name: 'sqlite', open: openInMemoryRelayDatabase }, - ...(databaseUrl - ? [ - { - name: 'postgres', - open: () => openRelayDatabase({ databaseUrl, dataDir: '' }) - } - ] - : []) -] - const databases: RelayDatabase[] = [] -async function removeScopedRows(database: RelayDatabase): Promise { - await database.query( - `DELETE FROM relay_control_connection_reservations WHERE user_id LIKE '${KEY_PREFIX}-%'` - ) - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE '${KEY_PREFIX}-%'` - ) - await database.query(`DELETE FROM relay_assignments WHERE user_id LIKE '${KEY_PREFIX}-%'`) - for (const table of [ - 'relay_cell_connection_snapshots', - 'relay_cell_connection_runtime', - 'relay_cell_connection_limits', - 'relay_cell_runtime', - 'relay_cells' - ]) { - await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [CELL.id]) - } -} - afterEach(async () => { for (const database of databases.splice(0)) { - await removeScopedRows(database) await database.close() } }) async function setup( - open: () => Promise, now: () => number ): Promise<{ database: RelayDatabase; store: RelayAssignmentStore }> { - const database = await open() + const database = await openInMemoryRelayDatabase() databases.push(database) - await removeScopedRows(database) const store = new RelayAssignmentStore(database, now, { requireLiveCells: true, heartbeatTtlMs: 45_000 @@ -113,10 +74,10 @@ async function reservations( })) } -describe.each(backends)('control reservation claim on reconnect ($name)', ({ open }) => { +describe('control reservation claim on reconnect', () => { it('claims the fresh reservation when the same generation reconnects', async () => { let now = 100_000_000 - const { database, store } = await setup(open, () => now) + const { database, store } = await setup(() => now) const assignment = await store.assign(IDENTITY) await store.activateControl(IDENTITY, { @@ -158,7 +119,7 @@ describe.each(backends)('control reservation claim on reconnect ($name)', ({ ope it('still refuses to claim a second reservation while the first is outstanding', async () => { let now = 100_000_000 - const { database, store } = await setup(open, () => now) + const { database, store } = await setup(() => now) const assignment = await store.assign(IDENTITY) await store.activateControl(IDENTITY, { diff --git a/cloud/apps/relay/src/control-leases/released-reservation-prune.test.ts b/cloud/apps/relay/src/control-leases/released-reservation-prune.test.ts index 3a8b2a768d0..4d65b1dac7b 100644 --- a/cloud/apps/relay/src/control-leases/released-reservation-prune.test.ts +++ b/cloud/apps/relay/src/control-leases/released-reservation-prune.test.ts @@ -63,12 +63,7 @@ async function remainingIds(database: RelayDatabase): Promise { return rows.map((row) => String(row.reservation_id)) } -const dialects: [string, () => Promise][] = [ - ['sqlite', openInMemoryRelayDatabase], - ...(databaseUrl ? [['postgres', openPostgres] as [string, () => Promise]] : []) -] - -describe.each(dialects)('released reservation prune (%s)', (_dialect, open) => { +describe('released reservation prune', () => { let database: RelayDatabase | undefined afterEach(async () => { @@ -76,12 +71,8 @@ describe.each(dialects)('released reservation prune (%s)', (_dialect, open) => { database = undefined }) - afterAll(async () => { - if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) - }) - it('deletes only released rows older than the retention window', async () => { - database = await open() + database = await openInMemoryRelayDatabase() await insertReservation(database, 'a-old-released', 'released', STALE - 1) await insertReservation(database, 'b-edge-released', 'released', STALE) await insertReservation(database, 'c-fresh-released', 'released', STALE + 1) diff --git a/cloud/apps/relay/src/credentials/credential-store-cleanup.test.ts b/cloud/apps/relay/src/credentials/credential-store-cleanup.test.ts index ffab5abc2de..6758eb18359 100644 --- a/cloud/apps/relay/src/credentials/credential-store-cleanup.test.ts +++ b/cloud/apps/relay/src/credentials/credential-store-cleanup.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { AUDIT_EVENT_RETENTION_MS, - hashCredential, CONFIRM_RESULT_RETENTION_MS, RelayCredentialStore, type RelayIdentity @@ -292,54 +291,4 @@ describe('credential cleanup invite reaper', () => { expect(await remainingIds(database, 'relay_audit_events', 'id')).toEqual(['audit-recent']) await database.close() }) - - it('still replays a confirm result inside retention', async () => { - const database = await openInMemoryRelayDatabase() - let now = NOW - DAY_MS - const store = new RelayCredentialStore(database, () => now) - try { - const invite = await store.createInvite(identity, 'device-1') - const reservation = await store.reserveCredential(identity.relayHostId, invite.inviteToken) - if (!reservation) { - throw new Error('invite did not reserve') - } - await store.recordConnectionBasis({ - ...reservation, - basisConnId: 'invite-basis', - owningControlGeneration: 1, - deadline: reservation.leaseExpiresAt - }) - await store.installCredential({ - ...identity, - relayDeviceId: 'device-1', - reqId: 'install-1', - newResumeTokenHash: hashCredential('resume-token'), - owningControlGeneration: 1, - authorization: { mode: 'relay-basis', basisConnId: 'invite-basis' } - }) - const resume = await store.reserveCredential(identity.relayHostId, 'resume-token') - if (!resume) { - throw new Error('resume did not reserve') - } - await store.recordConnectionBasis({ - ...resume, - basisConnId: 'resume-basis', - owningControlGeneration: 1, - deadline: resume.leaseExpiresAt - }) - const request = { - ...identity, - reqId: 'confirm-1', - basisConnId: 'resume-basis', - owningControlGeneration: 1 - } - const confirmed = await store.confirmResume(request) - expect(confirmed).toMatchObject({ renewed: true, acceptedAs: 'current' }) - now = NOW - await store.cleanup() - expect(await store.confirmResume(request)).toEqual(confirmed) - } finally { - await database.close() - } - }) }) diff --git a/cloud/apps/relay/src/credentials/resume-confirmed-audit-buffer.test.ts b/cloud/apps/relay/src/credentials/resume-confirmed-audit-buffer.test.ts deleted file mode 100644 index eb94d957544..00000000000 --- a/cloud/apps/relay/src/credentials/resume-confirmed-audit-buffer.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { openInMemoryRelayDatabase, type RelayDatabase, type SqlRow } from '../database/database.js' -import { ResumeConfirmedAuditBuffer } from './resume-confirmed-audit-buffer.js' - -const event = (reqId: string) => ({ - userId: 'user-1', - relayHostId: 'host000000000001', - relayDeviceId: 'device-1', - detail: { reqId, basisConnId: `basis-${reqId}`, renewed: true } -}) - -describe('the batched resume-confirmed audit', () => { - let inner: RelayDatabase | undefined - - afterEach(async () => { - await inner?.close() - inner = undefined - }) - - async function setup() { - inner = await openInMemoryRelayDatabase() - const database = inner - const buffer = new ResumeConfirmedAuditBuffer(database, Date.now, () => undefined) - const rows = async (): Promise => - await inner!.query( - `SELECT type, relay_device_id, detail_json FROM relay_audit_events ORDER BY at, id` - ) - return { database, buffer, rows } - } - - it('retries a flush whose commit outcome was lost without writing any row twice', async () => { - const { database, buffer, rows } = await setup() - buffer.add(event('a')) - buffer.add(event('b')) - // The insert commits, then the reply is lost. - const query = database.query.bind(database) - let lost = true - database.query = async (sql, params) => { - const result = await query(sql, params) - if (lost) { - lost = false - throw new Error('Connection terminated unexpectedly') - } - return result - } - await buffer.flush() - await buffer.flush() - expect(await rows()).toHaveLength(2) - buffer.stop() - }) - -}) diff --git a/cloud/apps/relay/src/database/postgres-lock-wait-sample-postgres.test.ts b/cloud/apps/relay/src/database/postgres-lock-wait-sample-postgres.test.ts deleted file mode 100644 index 6873b486738..00000000000 --- a/cloud/apps/relay/src/database/postgres-lock-wait-sample-postgres.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { RelayAssignmentStore } from '../assignment/assignment-store.js' -import { openRelayDatabase, type RelayDatabase } from './database.js' -import { readPostgresLockWaitSample } from './postgres-lock-wait-sample.js' - -const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL -const describePostgres = databaseUrl ? describe : describe.skip - -const cell = { - id: 'lock-wait-sample-cell', - url: 'https://lock-wait-sample.example.com', - capacityRequests: 10 -} - -// The split rests on application_name surviving into pg_stat_activity for both -// the waiter and its blocker, which only a real server shows. -describePostgres('PostgreSQL lock-wait sample', () => { - let cellDatabase: RelayDatabase - let directorDatabase: RelayDatabase - - beforeAll(async () => { - cellDatabase = await openRelayDatabase({ - databaseUrl, - dataDir: '', - applicationName: `orca-relay/cell/${cell.id}` - }) - directorDatabase = await openRelayDatabase({ - databaseUrl, - dataDir: '', - applicationName: 'orca-relay/director/director' - }) - await new RelayAssignmentStore(directorDatabase).reconcileCells([cell]) - }) - - afterAll(async () => { - for (const table of ['relay_cell_regions', 'relay_cell_admission']) { - await directorDatabase?.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id]) - } - await directorDatabase?.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [cell.id]) - await cellDatabase?.close() - await directorDatabase?.close() - }) - - // Every waiter after the first is blocked by the first waiter's tuple lock, so - // only the root of the chain names the cell transaction that holds the row. - it('attributes a convoy of directors to the cell holding the relay_cells row', async () => { - expect(await readPostgresLockWaitSample(directorDatabase)).toEqual([]) - - let release!: () => void - const released = new Promise((resolve) => (release = resolve)) - let held!: () => void - const holding = new Promise((resolve) => (held = resolve)) - const holder = cellDatabase.transaction(async (transaction) => { - await transaction.query( - `UPDATE relay_cells SET reserved_requests = reserved_requests WHERE cell_id = ?`, - [cell.id] - ) - held() - await released - }) - await holding - const waiters = Array.from({ length: 3 }, () => - directorDatabase.transaction(async (transaction) => { - await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cell.id]) - }) - ) - - try { - await expect - .poll(async () => await readPostgresLockWaitSample(directorDatabase), { timeout: 900 }) - .toEqual([ - { waiterRole: 'director', table: 'relay_cells', holderRole: 'cell', waiters: 3 } - ]) - } finally { - release() - await Promise.all([holder, ...waiters]) - } - }) -}) diff --git a/cloud/apps/relay/src/database/postgres-statement-stats-postgres.test.ts b/cloud/apps/relay/src/database/postgres-statement-stats-postgres.test.ts index 7f1efd76202..035d0af0ff2 100644 --- a/cloud/apps/relay/src/database/postgres-statement-stats-postgres.test.ts +++ b/cloud/apps/relay/src/database/postgres-statement-stats-postgres.test.ts @@ -1,7 +1,6 @@ import { randomUUID } from 'node:crypto' import pg from 'pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { openRelayDatabase } from './database.js' import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL @@ -52,28 +51,6 @@ describePostgres('optional PostgreSQL statement statistics', () => { return result.rows[0]!.present } - it('exposes an existing collector idempotently, and skips servers without one', async () => { - const url = await freshDatabase() - const database = await openRelayDatabase({ databaseUrl: url, dataDir: '' }) - await database.close() - const client = await connect(url) - try { - expect(await installed(client)).toBe(preloaded) - if (preloaded) { - const before = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') - await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) - const after = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') - expect(after.rows).toEqual(before.rows) - await client.query('SELECT calls, wal_bytes, shared_blks_dirtied FROM public.pg_stat_statements LIMIT 1') - } else { - await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) - expect(await installed(client)).toBe(false) - } - } finally { - await client.end() - } - }) - it('tolerates missing extension privileges', async () => { const client = await connect(await freshDatabase()) const role = `relay_stats_role_${randomUUID().replaceAll('-', '')}` diff --git a/cloud/apps/relay/src/database/postgres-transaction-recovery.test.ts b/cloud/apps/relay/src/database/postgres-transaction-recovery.test.ts index 597bcc0386a..382b6e12f5e 100644 --- a/cloud/apps/relay/src/database/postgres-transaction-recovery.test.ts +++ b/cloud/apps/relay/src/database/postgres-transaction-recovery.test.ts @@ -100,43 +100,16 @@ describePostgres('PostgreSQL transaction recovery', () => { 'relay_assignment_migration_incarnations', 'relay_control_connection_reservations' ]) { - await database.query(`DELETE FROM ${table} WHERE user_id IN (?, ?)`, [ - 'completion-race-user', - 'completion-contention-user' + await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [ + 'completion-race-user' ]) } for (const table of ['relay_cell_admission', 'relay_cell_regions']) { - await database.query(`DELETE FROM ${table} WHERE cell_id IN (?, ?, ?, ?)`, [ + await database.query(`DELETE FROM ${table} WHERE cell_id IN (?, ?)`, [ 'completion-race-source', - 'completion-race-target', - 'completion-contention-source', - 'completion-contention-target' + 'completion-race-target' ]) } - for (const identity of [ - { userId: 'released-order-user', relayHostId: 'releasedorder001' }, - { userId: 'normalized-order-user', relayHostId: 'normalizedorder1' }, - { userId: 'atomic-final-user-a', relayHostId: 'atomicfinalhosta' }, - { userId: 'atomic-final-user-b', relayHostId: 'atomicfinalhostb' }, - { userId: 'lease-assignment-contention-user', relayHostId: 'leaseassignment1' } - ]) { - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - ) - await database.query( - `DELETE FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, - [identity.userId, identity.relayHostId] - ) - } - await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [ - 'released-order-cell', - 'normalized-order-cell' - ]) - await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, ['atomic-final-cell']) - await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [ - 'lease-assignment-contention-cell' - ]) await database.query( `DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE 'reconcile-user-%'` ) @@ -159,110 +132,6 @@ describePostgres('PostgreSQL transaction recovery', () => { 'completion-race-source', 'completion-race-target' ]) - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, - ['completion-contention-user'] - ) - await database.query(`DELETE FROM relay_assignment_migrations WHERE user_id = ?`, [ - 'completion-contention-user' - ]) - await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [ - 'completion-contention-user' - ]) - await database.query(`DELETE FROM relay_cell_runtime WHERE cell_id IN (?, ?)`, [ - 'completion-contention-source', - 'completion-contention-target' - ]) - await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [ - 'completion-contention-source', - 'completion-contention-target' - ]) - await database.query(`DELETE FROM relay_cell_fences WHERE cell_id = ?`, [ - 'dead-source-contention-source' - ]) - await database.query( - `DELETE FROM relay_control_connection_reservations WHERE user_id = ?`, - ['dead-source-contention-user'] - ) - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, - ['dead-source-contention-user'] - ) - await database.query( - `DELETE FROM relay_assignment_migration_incarnations WHERE user_id = ?`, - ['dead-source-contention-user'] - ) - await database.query(`DELETE FROM relay_assignment_migrations WHERE user_id = ?`, [ - 'dead-source-contention-user' - ]) - await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [ - 'dead-source-contention-user' - ]) - await database.query(`DELETE FROM relay_cell_runtime WHERE cell_id IN (?, ?)`, [ - 'dead-source-contention-source', - 'dead-source-contention-target' - ]) - await database.query(`DELETE FROM relay_cell_admission WHERE cell_id IN (?, ?)`, [ - 'dead-source-contention-source', - 'dead-source-contention-target' - ]) - await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [ - 'dead-source-contention-source', - 'dead-source-contention-target' - ]) - await database.query( - `DELETE FROM relay_assignment_activity_leases - WHERE user_id IN (?, ?)`, - ['lease-contention-user', 'aggregate-contention-user'] - ) - await database.query( - `DELETE FROM relay_assignments - WHERE user_id IN (?, ?)`, - ['lease-contention-user', 'aggregate-contention-user'] - ) - await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?)`, [ - 'lease-contention-cell', - 'aggregate-contention-cell' - ]) - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, - ['sustained-lock-user'] - ) - await database.query(`DELETE FROM relay_assignments WHERE user_id = ?`, [ - 'sustained-lock-user' - ]) - await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [ - 'sustained-lock-cell' - ]) - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, - ['sticky-cell-contention-user'] - ) - await database.query( - `DELETE FROM relay_assignments WHERE user_id = ?`, - ['sticky-cell-contention-user'] - ) - await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [ - 'sticky-cell-contention-cell' - ]) - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE 'postgres-user-%'` - ) - await database.query(`DELETE FROM relay_assignments WHERE user_id LIKE 'postgres-user-%'`) - await database.query(`DELETE FROM relay_cells WHERE cell_id IN (?, ?, ?)`, [ - 'cell-a', - 'cell-b', - 'cell-c' - ]) - await database.query( - `DELETE FROM relay_assignment_activity_leases WHERE user_id LIKE 'sticky-fast-user-%'` - ) - await database.query( - `DELETE FROM relay_assignments WHERE user_id LIKE 'sticky-fast-user-%'` - ) - await database.query(`DELETE FROM relay_cells WHERE cell_id = ?`, [ - 'sticky-fast-cell' - ]) }) it('retries an entire deadlock victim transaction on a fresh attempt', async () => { diff --git a/cloud/apps/relay/src/edge-relay.test.ts b/cloud/apps/relay/src/edge-relay.test.ts index 43b62345ee4..f39f1631332 100644 --- a/cloud/apps/relay/src/edge-relay.test.ts +++ b/cloud/apps/relay/src/edge-relay.test.ts @@ -129,15 +129,4 @@ describe('edge move', () => { expect(moved.assignmentEpoch).toBe(placed.assignmentEpoch + 4) }) - it('rolls back: with the cohort off, the host is re-placed on GCE above the edge epoch', async () => { - const { database, store, edge } = await setup({ requireLiveCells: true }) - await edge.ensureEdgeCell() - await store.assign(IDENTITY) - await settle(database) - const moved = await edge.moveToEdge(IDENTITY, 0) - const back = await store.assign(IDENTITY) - expect(back.cellId).toBe(GCE_CELL.id) - expect(back.assignmentEpoch).toBeGreaterThan(moved.assignmentEpoch) - expect(await edge.edgeAssignment(IDENTITY)).toBeNull() - }) }) diff --git a/cloud/apps/relay/src/host-whereabouts-app.test.ts b/cloud/apps/relay/src/host-whereabouts-app.test.ts deleted file mode 100644 index e8072835260..00000000000 --- a/cloud/apps/relay/src/host-whereabouts-app.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type * as AdminTokenVerifier from './authentication/admin-token-verifier.js' -import type { HostWhereaboutsRow } from './assignment/assignment-store.js' -import { RelayAssignmentStore } from './assignment/assignment-store.js' -import type { RelayConfig } from './config.js' -import { openInMemoryRelayDatabase } from './database/database.js' -import { ShadowSeatDirectory } from './cell/seats/shadow-seat-directory.js' - -vi.mock('./authentication/admin-token-verifier.js', async (importOriginal) => { - const actual = await importOriginal() - const identities = new Map[0]>([ - ['deploy-token', 'deploy'], - ['capacity-token', 'capacity'] - ]) - return { - ...actual, - // The real route allow-list behind a token-to-identity stub. - createAdminTokenVerifier: () => async (token: string, route?: string) => { - const identity = identities.get(token) - if (identity === undefined) return false - return !route || actual.relayAdminIdentityMayAccess(identity, route) - }, - createReadOnlyAdminTokenVerifier: () => async () => false, - createRegionalRehomeControlApplyTokenVerifier: () => async () => false, - createRegionalRehomeRuntimeTokenVerifier: () => async () => false, - createRegionalRehomeTokenVerifier: () => async () => false, - createRuntimeTokenVerifier: () => async () => false - } -}) - -vi.mock('./authentication/relay-token-verifier.js', () => ({ - createRelayTokenVerifier: () => async () => null, - readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null -})) - -import { createRelayApp } from './app.js' - -const HOST = 'hhhhhhhhhhhhhhhh' -const ROW: HostWhereaboutsRow = { - cellId: 'cell-a', - assignmentEpoch: 3, - leaseExpiresAt: 5_000, - openMigration: null -} - -function request(body: unknown, token = 'deploy-token'): RequestInit { - return { - method: 'POST', - headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, - body: JSON.stringify(body) - } -} - -function directory(): ShadowSeatDirectory { - const shadow = new ShadowSeatDirectory() - shadow.setCells(['cell-a', 'cell-b']) - shadow.apply( - 'cell-a', - { - v: 1, - cellId: 'cell-a', - incarnation: 'inc-a', - seq: 1, - at: 1, - flagsApplied: { generation: '7', flags: { readinessLocal: false } }, - full: [ - { - userId: 'user-a', - relayHostId: HOST, - epoch: 3, - generation: 1, - state: 'active', - joinedAt: 1 - } - ] - }, - Date.now() - ) - shadow.markNoFeed('cell-b', Date.now()) - return shadow -} - -function appWith( - hostWhereabouts: (identity: { userId: string; relayHostId: string }) => Promise, - options: { shadowSeats?: ShadowSeatDirectory; role?: RelayConfig['role'] } = {} -) { - return createRelayApp(config(options.role), { - store: {} as never, - // SAFETY: the whereabouts route reads only hostWhereabouts. - assignments: { hostWhereabouts } as never, - drain: vi.fn(), - ready: vi.fn(async () => true), - cellIncarnation: 'director-incarnation', - shadowSeats: options.shadowSeats - }) -} - -describe('POST /v1/admin/host-whereabouts', () => { - it('requires admin auth and a director', async () => { - const lookup = vi.fn(async () => ROW) - const app = appWith(lookup) - const body = { v: 1, relayHostId: HOST, userId: 'user-a' } - - expect((await app.request('/v1/admin/host-whereabouts', request(body, 'other'))).status).toBe( - 401 - ) - const cell = appWith(lookup, { role: 'cell' }) - expect((await cell.request('/v1/admin/host-whereabouts', request(body))).status).toBe(404) - expect( - (await app.request('/v1/admin/host-whereabouts', request({ ...body, extra: 1 }))).status - ).toBe(400) - }) - - - it('names the user from the map when the request has only a host id', async () => { - const lookup = vi.fn(async () => ROW) - const app = appWith(lookup, { shadowSeats: directory() }) - - const response = await app.request( - '/v1/admin/host-whereabouts', - request({ v: 1, relayHostId: HOST }) - ) - - expect(await response.json()).toMatchObject({ users: [{ userId: 'user-a', database: ROW }] }) - }) - - it('judges the verdict as /v1/resolve would when the row names a dead cell', async () => { - const shadow = directory() - // cell-a's heartbeat had already run out when the cell list was read. - shadow.setCells(['cell-a', 'cell-b'], ['cell-a', 'cell-b'], { - readAt: Date.now(), - expiresAt: new Map([ - ['cell-a', Date.now() - 1], - ['cell-b', Date.now() + 60_000] - ]) - }) - const app = appWith(async () => ROW, { shadowSeats: shadow }) - - const response = await app.request( - '/v1/admin/host-whereabouts', - request({ v: 1, relayHostId: HOST, userId: 'user-a' }) - ) - - expect(await response.json()).toMatchObject({ - users: [{ database: ROW, verdict: { class: 'map-only-cell-unlive', explained: true } }] - }) - }) -}) - -describe('RelayAssignmentStore.hostWhereabouts', () => { - it('reads the assignment row and its open migration by primary key', async () => { - const database = await openInMemoryRelayDatabase() - try { - const store = new RelayAssignmentStore(database, () => 100) - await store.reconcileCells([ - { id: 'cell-a', url: 'https://cell-a.example.test', capacityRequests: 10 }, - { id: 'cell-b', url: 'https://cell-b.example.test', capacityRequests: 10 } - ]) - const identity = { userId: 'user-a', relayHostId: HOST } - expect(await store.hostWhereabouts(identity)).toBeNull() - - await store.assign(identity) - - await database.query( - `INSERT INTO relay_assignment_migrations - (user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch, - source_request_units, target_reserved_units, expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ['user-a', HOST, 'cell-a', 'cell-b', 1, 2, 1, 1, 900, 100, 100] - ) - expect((await store.hostWhereabouts(identity))?.openMigration).toEqual({ - sourceCellId: 'cell-a', - targetCellId: 'cell-b', - previousEpoch: 1, - assignmentEpoch: 2, - expiresAt: 900, - targetRegisteredAt: null - }) - } finally { - await database.close() - } - }) -}) - -function config(role: RelayConfig['role'] = 'director'): RelayConfig { - return { - port: 8080, - publicUrl: 'https://relay.example.test', - cellUrl: 'https://relay.example.test', - authIssuer: 'https://auth.example.test', - authAudience: 'orca-relay', - jwksUrl: 'https://auth.example.test/jwks', - assignmentSigningKey: new TextEncoder().encode('assignment-key-with-at-least-32-bytes'), - role, - cellId: role === 'cell' ? 'cell-a' : 'director', - cells: [], - adminAudience: 'https://relay.example.test/v1/admin/drain', - deployServiceAccount: 'deploy@example.test', - runtimeServiceAccount: 'runtime@example.test', - adminJwksUrl: 'https://auth.example.test/jwks', - databasePoolMax: 3, - publicAssignmentsEnabled: true, - publicAssignmentConcurrency: 2, - publicAssignmentQueueMax: 128, - publicAssignmentWaitMs: 4_000, - publicResolveConcurrency: 1, - publicResolveWaitMs: 5_000, - publicAssignmentRetryAfterSeconds: 5, - dataDir: './data' - } -} diff --git a/cloud/apps/relay/src/regional-host-drain-app.test.ts b/cloud/apps/relay/src/regional-host-drain-app.test.ts index 566843673df..5658c294633 100644 --- a/cloud/apps/relay/src/regional-host-drain-app.test.ts +++ b/cloud/apps/relay/src/regional-host-drain-app.test.ts @@ -87,7 +87,6 @@ describe('idle regional cutover endpoint', () => { }) describe('regional host drain endpoint', () => { - it('accepts only the dedicated identity and exact cell generation', async () => { const drainHost = vi.fn(() => 'accepted' as const) const app = createRelayApp(config(), { @@ -116,22 +115,6 @@ describe('regional host drain endpoint', () => { expect(drainHost).toHaveBeenCalledOnce() }) - it('rejects a failed asynchronous drain instead of acknowledging it', async () => { - const app = createRelayApp(config(), { - store: {} as never, - assignments: {} as never, - drain: vi.fn(), - drainHost: async () => { - throw new Error('activity_cell_not_authoritative') - }, - cellIncarnation, - ready: vi.fn(async () => true) - }) - const response = await post(app, 'rehome-token', request) - expect(response.status).toBe(409) - expect(await response.json()).toEqual({ error: 'activity_cell_not_authoritative' }) - }) - it('rechecks synthetic host absence after asynchronous identity proof', async () => { let hostExists = false const drainHost = vi.fn(() => 'host-not-connected' as const) @@ -160,81 +143,6 @@ describe('regional host drain endpoint', () => { expect(response.status).toBe(409) expect(drainHost).not.toHaveBeenCalled() }) - -}) - -describe('regional rehome director controls', () => { - - it('applies the durable switch only with matching confirmation', async () => { - const inspectRegionalRehomeControl = vi.fn().mockResolvedValue({ - generation: 0, - enabled: false - }) - const applyRegionalRehomeControl = vi.fn(async (input) => ({ - ...input, - generation: input.expectedGeneration + 1 - })) - const app = createRelayApp(config({ role: 'director', cellId: 'director' }), { - store: {} as never, - assignments: { - inspectRegionalRehomeControl, - applyRegionalRehomeControl - } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - expect( - ( - await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { - v: 1, - action: 'inspect' - }) - ).status - ).toBe(200) - const apply = { - v: 1, - action: 'apply', - expectedGeneration: 0, - enabled: true, - notBefore: 100, - ratePerMinute: 10, - preferenceMaxAgeMs: 24 * 60 * 60_000, - hostCooldownMs: 7 * 24 * 60 * 60_000, - drainGraceMs: 60_000, - confirmation: 'ENABLE_REGIONAL_REHOMING' - } - expect( - (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', apply)).status - ).toBe(200) - expect(applyRegionalRehomeControl).toHaveBeenCalledOnce() - expect( - ( - await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', { - v: 1, - action: 'inspect' - }) - ).status - ).toBe(200) - expect( - (await postPath(app, '/v1/admin/regional-rehome-control', 'monitor-token', apply)).status - ).toBe(403) - expect( - ( - await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', { - ...apply, - confirmation: 'DISABLE_REGIONAL_REHOMING' - }) - ).status - ).toBe(400) - // The per-host cooldown is part of the durable shape an operator must state. - const { hostCooldownMs: _omitted, ...withoutCooldown } = apply - expect( - (await postPath(app, '/v1/admin/regional-rehome-control', 'deploy-token', withoutCooldown)) - .status - ).toBe(400) - expect(applyRegionalRehomeControl).toHaveBeenCalledExactlyOnceWith(apply) - }) - }) async function post( diff --git a/cloud/apps/relay/src/relay-region-app.test.ts b/cloud/apps/relay/src/relay-region-app.test.ts deleted file mode 100644 index 1bb2dcd5051..00000000000 --- a/cloud/apps/relay/src/relay-region-app.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RelayConfig } from './config.js' - -const fakes = vi.hoisted(() => ({ - verifyRelayToken: vi.fn(async (token: string) => ({ sub: 'user-1', relayHostId: token })) -})) - -vi.mock('./authentication/relay-token-verifier.js', () => ({ - createRelayTokenVerifier: () => fakes.verifyRelayToken, - readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null -})) - -import { createRelayApp } from './app.js' - -describe('Relay region API', () => { - - it('returns successful placement when optional window storage is unavailable', async () => { - const app = createRelayApp(config(), { - store: {} as never, - assignments: { - assign: async () => ({ - cellId: 'asia-c1', - region: 'asia-east2', - cellUrl: 'https://asia-c1.relay.example.test', - assignmentEpoch: 7 - }), - exchangeRegionCorrection: async () => { - throw new Error('database unavailable') - } - } as never, - drain: vi.fn(), - ready: async () => true - }) - const response = await app.request( - '/v1/assign', - assignmentRequest('abcdefghijklmnop', { - preferredRegion: 'asia-east2', - regionCorrection: { v: 1, action: 'issue-window' } - }) - ) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - cellUrl: 'https://asia-c1.relay.example.test', - assignmentEpoch: 7 - }) - }) - - it('does not place or write a legacy hint when reporting migration evidence', async () => { - const current = { - userId: 'user-1', - relayHostId: 'abcdefghijklmnop', - cellId: 'asia-c1', - cellUrl: 'https://asia-c1.relay.example.test', - region: 'asia-east2', - assignmentEpoch: 7, - leaseExpiresAt: Date.now() + 300_000 - } - const assign = vi.fn() - const resolve = vi.fn(async () => current) - const exchangeRegionCorrection = vi.fn(async () => ({ v: 1, reportStatus: 'accepted' })) - const app = createRelayApp(config(), { - store: {} as never, - assignments: { assign, resolve, exchangeRegionCorrection } as never, - drain: vi.fn(), - ready: async () => true - }) - const regionCorrection = { - v: 1, - action: 'report', - generation: 2, - assignmentEpoch: 7, - policyVersion: 1, - outcome: 'conclusive', - measurements: { 'us-central1': 40, 'asia-east2': 180 } - } - const response = await app.request( - '/v1/assign', - assignmentRequest('abcdefghijklmnop', { - preferredRegion: 'us-central1', - regionCorrection - }) - ) - expect(response.status).toBe(200) - expect(assign).not.toHaveBeenCalled() - expect(exchangeRegionCorrection).toHaveBeenCalledWith( - { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }, - regionCorrection, - 7 - ) - expect(((await response.json()) as { assignmentEpoch: number }).assignmentEpoch).toBe(7) - }) - - it('does not manufacture an assignment for a report whose assignment disappeared', async () => { - const assign = vi.fn() - const exchangeRegionCorrection = vi.fn() - const app = createRelayApp(config(), { - store: {} as never, - assignments: { assign, resolve: async () => null, exchangeRegionCorrection } as never, - drain: vi.fn(), - ready: async () => true - }) - const response = await app.request( - '/v1/assign', - assignmentRequest('abcdefghijklmnop', { - regionCorrection: { - v: 1, - action: 'report', - generation: 2, - assignmentEpoch: 7, - policyVersion: 1, - outcome: 'inconclusive', - reason: 'timeout' - } - }) - ) - expect(response.status).toBe(409) - expect(assign).not.toHaveBeenCalled() - expect(exchangeRegionCorrection).not.toHaveBeenCalled() - }) - - it('answers a pool that cannot hand out a client with a retryable 503', async () => { - const regionCatalog = vi.fn(async () => { - throw new Error('Connection terminated due to connection timeout') - }) - const app = createRelayApp(config({ publicAssignmentRetryAfterSeconds: 7 }), { - store: {} as never, - assignments: { regionCatalog } as never, - drain: vi.fn(), - ready: vi.fn(async () => true) - }) - - const response = await app.request('/v1/regions') - - expect(response.status).toBe(503) - expect(response.headers.get('Retry-After')).toBe('7') - expect(await response.json()).toEqual({ error: 'region_catalog_temporarily_unavailable' }) - }) - -}) - -function assignmentRequest(relayHostId: string, extra: Record): RequestInit { - return { - method: 'POST', - headers: { - authorization: `Bearer ${relayHostId}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ v: 1, relayHostId, ...extra }) - } -} - -function config(overrides: Partial = {}): RelayConfig { - return { - port: 8080, - publicUrl: 'https://relay.example.test', - cellUrl: 'https://relay.example.test', - region: 'us-central1', - authIssuer: 'https://auth.example.test', - authAudience: 'orca-relay', - jwksUrl: 'https://auth.example.test/jwks', - assignmentSigningKey: new TextEncoder().encode('assignment-key-with-at-least-32-bytes'), - role: 'director', - cellId: 'director', - cells: [], - adminAudience: 'https://relay.example.test/v1/admin/drain', - deployServiceAccount: 'deploy@example.test', - runtimeServiceAccount: 'runtime@example.test', - adminJwksUrl: 'https://auth.example.test/jwks', - databasePoolMax: 3, - publicAssignmentsEnabled: true, - regionalPlacementEnabled: true, - publicAssignmentConcurrency: 2, - publicAssignmentQueueMax: 128, - publicAssignmentWaitMs: 4_000, - publicResolveConcurrency: 1, - publicResolveWaitMs: 5_000, - publicAssignmentRetryAfterSeconds: 5, - dataDir: './data', - ...overrides - } -} diff --git a/cloud/apps/relay/src/relay-sigterm-exit.blackbox.test.ts b/cloud/apps/relay/src/relay-sigterm-exit.blackbox.test.ts deleted file mode 100644 index c72236c13c2..00000000000 --- a/cloud/apps/relay/src/relay-sigterm-exit.blackbox.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { ChildProcess } from 'node:child_process' -import { spawnProcess } from '@orca/process-host' -import { mkdtempSync, rmSync } from 'node:fs' -import { connect, createServer, type Socket } from 'node:net' -import { tmpdir } from 'node:os' -import { resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, expect, it } from 'vitest' -import WebSocket from 'ws' - -const appDirectory = fileURLToPath(new URL('..', import.meta.url)) -const cleanups: (() => void)[] = [] -afterEach(() => { - for (const cleanup of cleanups.splice(0).toReversed()) { - cleanup() - } -}) - -async function unusedPort(): Promise { - const server = createServer() - await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('missing test port') - } - await new Promise((resolveClose) => server.close(() => resolveClose())) - return address.port -} - -async function startRelay(port: number): Promise { - const dataDirectory = mkdtempSync(resolve(tmpdir(), 'orca-relay-sigterm-exit-')) - const url = `http://127.0.0.1:${port}` - const child = spawnProcess({ - program: process.execPath, - args: ['--import', 'tsx', 'src/index.ts'], - cwd: appDirectory, - // Explicit so an inherited DATABASE_URL cannot point the relay at a shared database. - env: { - PATH: process.env.PATH ?? '', - ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), - PORT: String(port), - ORCA_RELAY_PUBLIC_URL: url, - ORCA_RELAY_CELL_URL: url, - ORCA_RELAY_AUTH_ISSUER: 'http://127.0.0.1:9', - ORCA_RELAY_JWKS_URL: 'http://127.0.0.1:9/jwks', - ORCA_RELAY_ASSIGNMENT_SIGNING_KEY: 'test-assignment-key-with-at-least-32-bytes', - ORCA_RELAY_DATA_DIR: dataDirectory, - ORCA_RELAY_ADMIN_AUDIENCE: `${url}/v1/admin/drain`, - ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT: 'deploy@example.com', - ORCA_RELAY_ADMIN_JWKS_URL: 'http://127.0.0.1:9/jwks' - }, - stdio: ['ignore', 'pipe', 'pipe'] - }) - cleanups.push(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL') - } - rmSync(dataDirectory, { recursive: true, force: true }) - }) - await new Promise((resolveReady, reject) => { - let stderr = '' - child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString())) - child.stdout?.on('data', (chunk: Buffer) => { - if (chunk.toString().includes('[orca-relay] listening')) { - resolveReady() - } - }) - child.once('exit', (code) => reject(new Error(`relay exited before ready: ${code}\n${stderr}`))) - }) - return child -} - -// Pauses reading so the peer never answers the relay's close frame. -async function unresponsiveWebSocketPeer(port: number): Promise { - const socket = new WebSocket(`ws://127.0.0.1:${port}/v1/connect/abcdefghijklmnop`) - socket.on('error', () => undefined) - await new Promise((resolveOpen) => socket.once('open', () => resolveOpen())) - const raw = (socket as unknown as { _socket: Socket })._socket - raw.pause() - return raw -} - -// Resolves once the listener refuses new connections, i.e. the relay has begun shutting down. -async function waitUntilListenerClosed(port: number): Promise { - const deadline = Date.now() + 4_000 - while (Date.now() < deadline) { - const accepted = await new Promise((resolveProbe) => { - const probe = connect(port, '127.0.0.1') - probe.once('connect', () => { - probe.destroy() - resolveProbe(true) - }) - probe.once('error', () => resolveProbe(false)) - }) - if (!accepted) { - return - } - await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)) - } - throw new Error('relay kept accepting connections after SIGTERM') -} - -// Peers that are not idle to server.close(); unbounded, a silent one held exit until SIGKILL. -it('exits within the shutdown bound on SIGTERM with an unresponsive upgraded WebSocket peer', async () => { - const port = await unusedPort() - const relay = await startRelay(port) - const peer = await unresponsiveWebSocketPeer(port) - cleanups.push(() => peer.destroy()) - const peerClosed = new Promise((resolveClose) => peer.once('close', () => resolveClose())) - - const startedAt = Date.now() - const exited = new Promise((resolveExit) => - relay.once('exit', (code) => resolveExit(code)) - ) - relay.kill('SIGTERM') - // A second signal must not restart or extend shutdown. Send it only once the first has been - // handled, so the two cannot coalesce into one delivery. - await waitUntilListenerClosed(port) - relay.kill('SIGTERM') - - expect(await exited).toBe(0) - // Under the 10 s backstop: the socket grace, not the backstop, released it. - expect(Date.now() - startedAt).toBeLessThan(9_000) - // A paused peer only observes the relay's FIN once it reads again. - peer.resume() - await peerClosed -}, 15_000) diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 044c4c20559..6b7e1e34ae7 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -130,7 +130,6 @@ 1 test-no-real-time-wait:src/main/browser/cdp/input/guest-focused-frame-route.test.ts 3 test-no-real-time-wait:src/main/browser/cookies/browser-cookie-import-concurrency.test.ts 1 test-no-real-time-wait:src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-native-compatibility.electron.test.ts -1 test-no-real-time-wait:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity-cross-context.electron.test.ts 1 test-no-real-time-wait:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts 1 test-no-real-time-wait:src/main/browser/network/tunnels/execution-route-socket-duplex.test.ts 1 test-no-real-time-wait:src/main/browser/network/tunnels/remote-browser-socks-server.test.ts @@ -555,7 +554,6 @@ 1 test-timeout-needs-reason:src/main/browser/cookies/browser-cookie-import-partition.electron.test.ts 1 test-timeout-needs-reason:src/main/browser/cookies/browser-cookie-import-validated-partition.electron.test.ts 1 test-timeout-needs-reason:src/main/browser/guest-lifecycle/find/browser-find-frame-traversal.electron.test.ts -1 test-timeout-needs-reason:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity-cross-context.electron.test.ts 1 test-timeout-needs-reason:src/main/browser/identity/wire-probes/browser-session-ua-wire-identity.electron.test.ts 2 test-timeout-needs-reason:src/main/codex/app-server/codex-app-server-teardown.integration.test.ts 1 test-timeout-needs-reason:src/main/codex/hooks/installation/hook-service-legacy-cleanup.test.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index bef6f0b90de..3bc0a19973e 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -317,10 +317,10 @@ 1 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts 3 src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts 3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts -1 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts -1 src/renderer/src/store/terminals/terminal-tab-appearance.ts -1 src/renderer/src/store/terminals/terminal-tab-close-providers.ts -1 src/renderer/src/store/terminals/terminal-tab-retirement.ts +1 src/renderer/src/store/terminals/tabs/creation/terminal-active-workspace-creation.ts +1 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-appearance.ts +1 src/renderer/src/store/terminals/retirement/terminal-tab-close-providers.ts +1 src/renderer/src/store/terminals/retirement/terminal-tab-retirement.ts 1 src/renderer/src/web/preload-api/web-app-api.ts 3 src/renderer/src/web/preload-api/web-preference-normalization.ts 20 src/renderer/src/web/preload-api/web-preferences-store.ts diff --git a/config/legacy-relay-compat-tags-baseline.txt b/config/legacy-relay-compat-tags-baseline.txt index ffcbba0a3fa..ca172741a09 100644 --- a/config/legacy-relay-compat-tags-baseline.txt +++ b/config/legacy-relay-compat-tags-baseline.txt @@ -23,7 +23,7 @@ src/renderer/src/components/terminal-pane/pty-transport/legacy-relay/legacy-rela src/renderer/src/components/terminal-pane/pty-connection/output/foreground-output-refresh.ts src/renderer/src/components/terminal-pane/pty-connection/host-ownership/unserved-ssh-pane-connect.ts src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts -src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.ts +src/renderer/src/store/terminals/retirement/legacy-relay-bridged-tab-close.ts src/shared/legacy-relay-bridge-contract.ts src/shared/ssh-types.ts src/shared/telemetry-ssh-host-server-event-schemas.ts diff --git a/config/main-sync-fs-baseline.txt b/config/main-sync-fs-baseline.txt index 4952f5ff34b..04d9c48fd04 100644 --- a/config/main-sync-fs-baseline.txt +++ b/config/main-sync-fs-baseline.txt @@ -182,6 +182,7 @@ 5 src/main/daemon/admission/host-terminal-ledger.ts 1 src/main/daemon/admission/legacy-restore-records.ts 1 src/main/daemon/daemon-entry.ts +1 src/main/daemon/daemon-process-name.ts 1 src/main/daemon/endpoint/identity/daemon-endpoint-incarnation.ts 3 src/main/daemon/endpoint/identity/daemon-pid-identity.ts 2 src/main/daemon/endpoint/identity/daemon-pid-record-quarantine.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f35cd88221a..0e009d6c046 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -5941,14 +5941,14 @@ "invariant": "Within one terminal tab, each PTY has at most one layout leaf, pane manager surface, and xterm renderer owner. Restore and remote mirroring must normalize duplicate ownership before replay while retaining active-leaf focus, scrollback, pane authority, agent metadata, and distinct sibling PTYs.", "oracle": "Current adopted test policy at native 6f2e: retire the sampled PTY/focus permutation callback and its assertion reference. This loses its ownership-uniqueness, rooted-focus, immutability and idempotence alarms over those combinations. Eleven direct component callbacks and the depth/store/E2E file selectors remain; no equivalent replacement or fresh execution is claimed. Previous metadata account (preserved verbatim): Audit1925 (sources1922/1923): Partial ownership suite loses fixed first-leaf/rootless/dangling-selection/idempotence/identity examples. Both existing active assertion aliases stay backed by exact retained active-leaf metadata pruning and idempotence across PTY/focus permutation callbacks; metadata collision and repeated-leaf challenges survive. No deleted fixed example is assigned replacement coverage. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Historical prior account (preserved verbatim): Launch Orca with a real daemon PTY running a synchronized full-screen stream, close the desktop client without killing the daemon process, seed the persisted tab layout with two leaves bound to that PTY, and relaunch. Hide the restored tab behind a sibling and reveal it, then require the stream to remain visible with exactly one manager pane, one xterm DOM node, one root leaf, one PTY binding, and one unique PTY. Component contracts cover persisted replay, rootless and repeated-leaf layouts, metadata repair, and deeply nested input. Retained store contracts cover live/future authority transfer onto a retained leaf and preservation of a prior detach destination.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-layout/terminal-topology/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/layout/terminal-layout-pty-ownership.test.ts src/renderer/src/store/terminals/terminal-layout-pty-ownership.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/workspace-layout/terminal-topology/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/layout/terminal-layout-pty-ownership.test.ts src/renderer/src/store/terminals/layout/terminal-layout-pty-ownership.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/shared/workspace-layout/terminal-topology/terminal-layout-pty-ownership-depth.test.ts", "src/renderer/src/components/terminal-pane/layout/terminal-layout-pty-ownership.test.ts", - "src/renderer/src/store/terminals/terminal-layout-pty-ownership.test.ts", + "src/renderer/src/store/terminals/layout/terminal-layout-pty-ownership.test.ts", "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts" ], "assertionRefs": [ @@ -5961,7 +5961,7 @@ "assertions": ["keeps the active leaf and prunes the stale surface plus its metadata"] }, { - "file": "src/renderer/src/store/terminals/terminal-layout-pty-ownership.test.ts", + "file": "src/renderer/src/store/terminals/layout/terminal-layout-pty-ownership.test.ts", "assertions": [ "moves live and future pane authority onto the retained PTY leaf", "does not steal authority from a pane that was already detached to another tab" @@ -6179,7 +6179,7 @@ "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal/surface/orca-runtime-terminal-close-continuity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/construction/orca-runtime.test.ts", - "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server/testing/authority/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/persistence/restoring-sessions/persistence-pane-identity-migration.test.ts src/main/persistence/loading-store/terminal-bindings/persistence-pty-binding-reconciliation.test.ts src/renderer/src/store/slices/agent-status/agent-pane-authority.test.ts src/renderer/src/store/terminals/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/terminals/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server/testing/authority/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/persistence/restoring-sessions/persistence-pane-identity-migration.test.ts src/main/persistence/loading-store/terminal-bindings/persistence-pty-binding-reconciliation.test.ts src/renderer/src/store/slices/agent-status/agent-pane-authority.test.ts src/renderer/src/store/terminals/layout/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/terminals/retirement/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/process-retirement/pty-descendant-termination.test.ts src/main/daemon/terminal-host/testing/terminal-host.test.ts src/main/providers/local-pty/local-pty-provider-shutdown.test.ts src/main/runtime/workspaces/removal/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-tab-close-restart-persistence.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", @@ -6200,8 +6200,8 @@ "src/main/providers/local-pty/local-pty-provider-shutdown.test.ts", "src/main/runtime/workspaces/removal/worktree-teardown.test.ts", "src/renderer/src/store/slices/agent-status/agent-pane-authority.test.ts", - "src/renderer/src/store/terminals/terminal-pane-detach-agent-identity.test.ts", - "src/renderer/src/store/terminals/terminal-tab-retirement-store.test.ts", + "src/renderer/src/store/terminals/layout/terminal-pane-detach-agent-identity.test.ts", + "src/renderer/src/store/terminals/retirement/terminal-tab-retirement-store.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "tests/e2e/terminal-tab-close-restart-persistence.spec.ts", "tests/e2e/terminal-parked-close-retirement.spec.ts", @@ -9346,7 +9346,7 @@ "oracle": "Audit3390 composes root-adopted3382/independent3383 and3386/independent3387 and3388/independent3389: intentionally retires216 authored registrations/230 expanded cases in42 whole authored suites plus5 zero-case registration drivers, without equivalent replacement. Lost local alarms include fake PTY identity/routing/startup/replay/mode/title/mouse/IME/scroll matrices, broad startup identity and file-open wiring, mock mailbox title recovery, mobile row parity and whole-v-selective save parity. Retained24 registrations/26 expanded cases in22 suites preserve actual xterm Windows buffer/wrap/scroll, typed TOML scalar, real Promise cancellation/settlement, HTTP Codex scanner/projector semantics, durable recovery admission/newer-schema bytes, federation completion/ACK/SQLite atomicity and failed stale-run-write versus imported durable history. Additional retained clock-domain scheduling, durable freeze/ACK/checkpoint ordering, ABA dirty intent, SQL atomicity, folder-domain retier/last-note deletion, scrollback durable no-sync staging, PanelBridge subscriber lifetime and reserved-send publication preserve narrower concrete behaviors. Current finite owner drift assessed in bounded defining bodies; retained callback bytes unchanged. Historical notes and complete dated receipts keep their original exact scopes. No new execution, equivalent replacement, native focus/device/pixel, live remote or global mixed-version guarantee is claimed. Audit3368 narrows stream-reconnect coverage to the actual ten synthetic transport epochs retaining one PTY identity, accepting and holding detached input for exactly-once later delivery, current decoded output and per-epoch subscription release. Prior rejection wording was inaccurate. Phase/backoff/cutoff/manual reconnect and parser/OSC/pause/retained-image/fatal/latch/claim/early-close alarms are intentionally lost without equivalent replacement. The Linux multi-pane reconnect receipt remains verbatim with its original scope; no fresh live SSH/provider/PTY/UI/mobile/mixed-version/cross-platform evidence is claimed. Audit3359 retires whole host-surface-replacement and create-handoff suites, removing their compound unit command tokens, test-file entries and assertion references. Fake polling/deadline/subscription, supplied stale-handle ownership, close and replacement examples are intentionally lost without equivalent replacement. The unrelated Linux paired multi-pane reconnect receipt stays verbatim with its original scope. No fresh SSH/provider/network/PTY recovery, historical-import, mobile, mixed-version or cross-platform validation is claimed. Audit3342 retires the complete client create-outcome identity/options/recovery suite and the malformed-or-contradictory repo-owner response matrix. Client replay identity, options, no-downgrade, quiescence/budget/manual retry, authoritative probe-error and malformed-owner refusal alarms are intentionally lost without equivalent replacement. Remaining repo-owner late catalog and old-host re-adoption boundaries do not replace them. The unrelated Linux paired-reconnect receipt stays verbatim with its original scope. Surviving selectors are unexecuted here; no fresh SSH/provider/process-deduplication, mobile, mixed-version or cross-platform validation is claimed. Audit3326 adopts reconciled source audit3324 and intentionally retires these low-value examples and their regression alarms: keeps a sidebar row for every still-live pane after a long sleep — Whole suite: 279 lines manually replay renderer ownership registry/status writes, snapshot patch/set, six-pane fixture inventory, and sidebar selectors/builders; expected pane IDs are production-computed. Does not run sleep/wake/transport or actual sidebar entry. Retires reconnect row-preservation assembly alarm.; still retires the regular TUI surface after an explicit terminal exit — Ordinary explicit-exit callback/map cleanup is repeated in the stale/new stream lifecycle case below. Retires separate regular-TUI baseline; keeper is not equivalent process/UI proof.; asks the HUB to recover an expired SSH pane and rebinds the host identity — Expired SSH owner error must ask the owning HUB to recover, then adopt fixed replacement handle, resubscribe and report rebind; actual transport dispatch and handle state prevent wrong-client recovery.; does not recover a pane whose relay reply was an identity mismatch — Identity mismatch means a different live PTY, so actual transport must surface refusal without recover/create, keeping original identity. Prevents destructive duplicate agent recovery.; fails closed when an older HUB cannot recover an expired SSH pane — Old HUB method_not_found is scripted mock refusal/error echo with no actual skewed host; repeats generic recovery failure propagation. Retires old-host recovery fallback refusal example.; names a held recovery's stopped WSL distro in readable copy, not the raw hold code — Pins exact WSL stopped-distro error prose after mock code; translation/copy assertion with no actual held recovery behavior. Retires readable-copy alarm.. Retained tests do not replace these alarms. Dated unrelated evidence remains scoped to its original execution; no historical source/import-closure equivalence, fresh execution, real network/provider/SSH, rendered UI, mobile, mixed-version or cross-platform validation is claimed. Audit 3318 reduces runtime-environment-ssh-state.test.ts, intentionally losing metadata membership-pruning/new-target-only-read and other-environment/local-bucket isolation alarms. Preserve generation/failed-full-refresh assertion and unknown-target authoritative-list reread; these narrower cases are not equivalent replacements. The unchanged active two-case Linux reconnect materialization/sidebar receipt and covered platform retain their exact scope and do not validate the reduced SSH state selector. Preserve unrelated SSH assertions, selectors and archived history. No fresh SSH discovery, provider/PTY/network, rendered, mobile, mixed-version, cross-platform or full-gate execution is claimed. Audit 3312 retires the ordinary six-pane distinct-live-handle reconnect/resubscription alarm from paired-reconnect-multi-pane-materialization.test.ts. The unchanged parked-pane activation/rematerialization case remains narrower and is not an equivalent replacement. The complete prior seven-contract macOS receipt is archived as historical evidence; it does not validate the reduced selector. No fresh socket, PTY, sleep/wake, provider, platform, mobile, mixed-version or rendered execution is claimed. Audit3096: remove the deleted reconnect-banner selector and its two UI aliases. The reduced recovery-state selector retains only the deadline cancelling pending backoff and explicit retry owning a new epoch alias; healthy-replacement stale-schedule and independent caller-cutoff cancellation aliases retire. Other retained state cases cover reentrant retry, owner settlement and same-epoch parked revival, without replacing those removed alarms. Prior admission-selector retirement, archived July 28 receipt, unrelated selectors/claims/receipts and old gaps remain unchanged; no fresh execution is claimed. Audit3086: retire the destroyed fresh-session fallback settlement/non-destructive reattach alias and its direct selector; retain the detach/attach selector and assertion unchanged. The retained admission cases cover adopt-before-data and explicitly buffered-death admission without a new spawn, without replacing fallback retirement. July 28 evidence was already archived by audit3077; no duplicate retirement or new execution is claimed. Audit2133 current-parent preparation: Current reduced sources/selectors intentionally retire the alarms listed in knownGaps; prior dated runs and replacement claims do not validate the changed set. Historical prior account (preserved verbatim): Audit2076 (sources2073/2074): Intentionally retire these low-value alarms without replacement: src/main/ssh/connection/ssh-connection-manager.test.ts: tears a failed startup down quietly, so its error is not replaced by a disconnect; marks a dial that never reached the host, so setup reports SSH’s reason; removes the registration when bulk teardown fails; leaves a connection whose own reconnect is asking the user alone; clears the pool entry when the closed connection is still the registered one; still reuses a connection that is already up; dials a user's connection test. Existing unrelated evidenceRuns retain their original scope; unchanged dated receipt objects do not validate changed source bytes or reduced selectors. No fresh gate execution or historical import-closure equivalence is claimed. Frozen current literal-import graph reaches these changed test bytes; unresolved local and computed edges remain debt, and current reachability is not historical execution or equivalent replacement evidence. Historical prior account (preserved verbatim): Audit2052 (sources2049/2050/2051): Remove complete low-value restored-pane-title and pane-visibility-identity suites and their current selectors. Their supplied snapshot/title and nullable identity-comparison alarms are intentionally lost without replacement. Unrelated dated evidenceRuns remain unchanged and do not validate the reduced commands. No fresh gate execution, actual PTY/provider, rendered, remote/mobile or cross-platform result is claimed. Historical prior account (preserved verbatim): Audit1983: Delete the terminal-create idempotency forwarding suite and its prospective selector. Preserve2 unrelated receipts without claiming the lost forwarding alarm was replaced. Changed-source prior results do not validate reduced-source bytes. No fresh execution, equivalent replacement, red/green, measured budget, soak, provider/PTY, rendered, live remote/mobile or cross-platform result is claimed. Historical prior account (preserved verbatim): Audit1931 (sources1928/1929): Delete complete transport-unmount and queued-initial-CWD helper suites: despite lifecycle-shaped fixtures, the unmount owner returns a Boolean PTY-id predicate, while CWD cases check nullish field selection and callback counts. Lose their detach/destroy/teardown and one-shot-CWD supplied examples. Partial queued-pane-startup loses three identical/empty/nonowner identity examples, retaining four exact one-shot/replacement ownership callbacks. No assertion object maps to these three sources, so unrelated alias objects remain byte-exact. Preserve the current81ed prior removal of pane-split-startup; stale bd948 source metadata snapshots are not replayed. No actual process/PTY startup, transport shutdown, live SSH or rendered recovery is proven by this metadata change. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Historical prior account (preserved verbatim): Audit1925 (sources1922/1923): Delete the sole supplied public-SSH-state spread/sanitized-error projection and its provider-authority-pair alias. Also delete the complete tiny one-shot split-startup wrapper suite: callback-visible temporary field/reset, sequential payload and thrown-sentinel reset alarms disappear. No SSH provider epoch handoff, connection, process startup or actual terminal split was performed by either deleted suite. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Historical prior account (preserved verbatim): Audit1903 removes whole mocked detach, supplied geometry and/or close-request suites selected by this gate. Their pending-CWD/PTY-binding argument/order, fabricated hit-test and close bookkeeping alarms are intentionally lost without replacement. Valid remaining selectors/assertions and unchanged-selector execution receipts retain their original scope. Reduced unit selections have no fresh broad gate, real PTY/provider, rendered focus, or measured-budget validation. Historical prior account (preserved verbatim): Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: Existing exact unchanged-selector receipts remain the only current execution evidence. Changed source selectors have no fresh execution result. Historical prior-source oracle account: The retained unchanged-source receipts are the only current execution evidence; reduced-source assertions await validation. Historical oracle account (not a current claim for changed sources): The retained unchanged-source receipts are the only current execution evidence; reduced-source assertions await validation. Historical oracle account (not a current claim for changed sources): Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and direct SSH binding retention. They assert SSH state changes leave PTY bindings in place, relay identity survives, and another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination. A focused recovery oracle surfaces two distinct failures through one transport, completes authoritative replay, and requires both matching clear callbacks; pane-state tests require only the active pane's error to render, matching-pane recovery to preserve sibling and unrelated errors, dismissal to re-admit later failures, and distinct storms to retain only the newest eight messages within 24 lines and 4,000 characters. The retained store replacement assertion observes one atomic pane/tab subscriber publication; repeated rotations and snapshot-first pending-restart migration no longer have their direct store examples. Preload layout cleanup retires the mock SSH forwarding/type-census suite without replacement: preload wiring alarms are lost; shared authority-admission tests cover narrower behavior. Historical receipts retain their original scope.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/paired-reconnect-multi-pane-materialization.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/mounting/queued-pane-startup.test.ts src/renderer/src/components/terminal-pane/parking/one-shot-park-replay.test.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/store/terminals/terminal-pty-identity-replacement.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/components/terminal-pane/mounting/queued-pane-startup.test.ts src/renderer/src/components/terminal-pane/parking/one-shot-park-replay.test.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/store/terminals/pty/terminal-pty-identity-replacement.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/remote-runtime-pty-recovery-state.test.ts src/shared/remote-runtime/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime/control/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime/remote-runtime-client-error-classification.test.ts src/main/runtime/terminal/launch/orca-runtime-terminal-create-idempotency.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/ui/workspace-navigation-history-hydration.test.ts src/renderer/src/store/repos/repos-ssh-host-reconciliation.test.ts --reporter=dot", @@ -9364,7 +9364,7 @@ "src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-stream-reconnect.test.ts", "src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport-expired-pane-recovery.test.ts", "src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/recovery/remote-runtime-pty-recovery-state.test.ts", - "src/renderer/src/store/terminals/terminal-pty-identity-replacement.test.ts", + "src/renderer/src/store/terminals/pty/terminal-pty-identity-replacement.test.ts", "src/shared/remote-runtime/remote-runtime-socket-liveness.test.ts", "src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts", "src/shared/remote-runtime/control/remote-runtime-shared-control-socket-generation.test.ts", @@ -13469,12 +13469,11 @@ "invariant": "A Quick Command selected for a new terminal remains owned by that tab through a recovery remount, executes exactly once only after the successor PTY binds, and never reaches an unrelated terminal.", "oracle": "Audit2052 (sources2049/2050/2051): Remove complete low-value restored-pane-title and pane-visibility-identity suites and their current selectors. Their supplied snapshot/title and nullable identity-comparison alarms are intentionally lost without replacement. Unrelated dated evidenceRuns remain unchanged and do not validate the reduced commands. No fresh gate execution, actual PTY/provider, rendered, remote/mobile or cross-platform result is claimed. Historical prior account (preserved verbatim): Audit1931 (sources1928/1929): Delete complete transport-unmount and queued-initial-CWD helper suites: despite lifecycle-shaped fixtures, the unmount owner returns a Boolean PTY-id predicate, while CWD cases check nullish field selection and callback counts. Lose their detach/destroy/teardown and one-shot-CWD supplied examples. Partial queued-pane-startup loses three identical/empty/nonowner identity examples, retaining four exact one-shot/replacement ownership callbacks. No assertion object maps to these three sources, so unrelated alias objects remain byte-exact. Preserve the current81ed prior removal of pane-split-startup; stale bd948 source metadata snapshots are not replayed. No actual process/PTY startup, transport shutdown, live SSH or rendered recovery is proven by this metadata change. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Historical prior account (preserved verbatim): Audit1925 (sources1922/1923): Delete the complete pane-split-startup wrapper suite and selector. Its temporary dependency-field exposure/reset, sequential payload isolation and thrown-sentinel cleanup alarms are lost; no quick command, process or PTY startup was exercised by those callbacks. Remaining queued-startup/ownership sources are not equivalent replacements. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Historical prior account (preserved verbatim): Audit1903 removes whole mocked detach, supplied geometry and/or close-request suites selected by this gate. Their pending-CWD/PTY-binding argument/order, fabricated hit-test and close bookkeeping alarms are intentionally lost without replacement. Valid remaining selectors/assertions and unchanged-selector execution receipts retain their original scope. Reduced unit selections have no fresh broad gate, real PTY/provider, rendered focus, or measured-budget validation. Historical prior account (preserved verbatim): Arm the E2E-only pre-spawn barrier, click the visible Quick Command, force one recovery generation while the original spawn is blocked, and release it. Require one exact semantic xterm marker, an independently written pane/tab identity file matching the bound live PTY, queue consumption only after bind, and a live interactive sibling canary.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection/host-ownership/pty-connection-owner-hydration-backend-retention.test.ts src/renderer/src/components/terminal-pane/mounting/queued-pane-startup.test.ts src/renderer/src/components/terminal-pane/parking/one-shot-park-replay.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/mounting/queued-pane-startup.test.ts src/renderer/src/components/terminal-pane/parking/one-shot-park-replay.test.ts --reporter=dot", "pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ - "src/renderer/src/components/terminal-pane/pty-connection/host-ownership/pty-connection-owner-hydration-backend-retention.test.ts", "src/renderer/src/components/terminal-pane/mounting/queued-pane-startup.test.ts", "src/renderer/src/components/terminal-pane/parking/one-shot-park-replay.test.ts", "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts" diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index 26d3f04ab48..ae7525844d7 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -101,23 +101,23 @@ 5 src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts 2 src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts 6 src/renderer/src/store/terminals/create-terminal-slice.ts -5 src/renderer/src/store/terminals/terminal-layout-state.ts -6 src/renderer/src/store/terminals/terminal-orphan-cleanup.ts -2 src/renderer/src/store/terminals/terminal-pane-hibernation.ts -3 src/renderer/src/store/terminals/terminal-pty-bindings.ts -2 src/renderer/src/store/terminals/terminal-pty-release.ts -1 src/renderer/src/store/terminals/terminal-shutdown-guards.ts -3 src/renderer/src/store/terminals/terminal-shutdown-state.ts -1 src/renderer/src/store/terminals/terminal-tab-agent-launch-pane.ts -2 src/renderer/src/store/terminals/terminal-tab-appearance.ts -6 src/renderer/src/store/terminals/terminal-tab-close.ts -10 src/renderer/src/store/terminals/terminal-tab-creation.ts -7 src/renderer/src/store/terminals/terminal-tab-navigation.ts -3 src/renderer/src/store/terminals/terminal-tab-presentation.ts -2 src/renderer/src/store/terminals/terminal-tab-title-batch.ts -5 src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts -2 src/renderer/src/store/terminals/workspace-terminal-hydration.ts -2 src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +5 src/renderer/src/store/terminals/layout/terminal-layout-state.ts +6 src/renderer/src/store/terminals/retirement/terminal-orphan-cleanup.ts +2 src/renderer/src/store/terminals/sleep/terminal-pane-hibernation.ts +3 src/renderer/src/store/terminals/pty/terminal-pty-bindings.ts +2 src/renderer/src/store/terminals/pty/terminal-pty-release.ts +1 src/renderer/src/store/terminals/sleep/terminal-shutdown-guards.ts +3 src/renderer/src/store/terminals/sleep/terminal-shutdown-state.ts +1 src/renderer/src/store/terminals/tabs/creation/terminal-tab-agent-launch-pane.ts +2 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-appearance.ts +6 src/renderer/src/store/terminals/retirement/terminal-tab-close.ts +10 src/renderer/src/store/terminals/tabs/creation/terminal-tab-creation.ts +7 src/renderer/src/store/terminals/tabs/terminal-tab-navigation.ts +3 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-presentation.ts +2 src/renderer/src/store/terminals/tabs/presentation/terminal-tab-title-batch.ts +5 src/renderer/src/store/terminals/hydration/workspace-terminal-hydration-patch.ts +2 src/renderer/src/store/terminals/hydration/workspace-terminal-hydration.ts +2 src/renderer/src/store/terminals/hydration/workspace-terminal-reconnect.ts 7 src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-held-tabs.ts 4 src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-slices.ts 12 src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-store-patch.ts diff --git a/config/scripts/desktop-distribution/runtime-verification/verify-packaged-daemon-entry.cjs b/config/scripts/desktop-distribution/runtime-verification/verify-packaged-daemon-entry.cjs index 2d51eb60881..dfef71717d9 100644 --- a/config/scripts/desktop-distribution/runtime-verification/verify-packaged-daemon-entry.cjs +++ b/config/scripts/desktop-distribution/runtime-verification/verify-packaged-daemon-entry.cjs @@ -3,7 +3,7 @@ const { spawn, spawnSync } = require('node:child_process') const { randomUUID } = require('node:crypto') const { connect } = require('node:net') const { tmpdir } = require('node:os') -const { join, relative } = require('node:path') +const { basename, join, relative } = require('node:path') const { setTimeout: delay } = require('node:timers/promises') const { macTerminalHostPaths @@ -267,7 +267,7 @@ async function verifyPackagedMacTerminalHostBoots(appPath, { arch, required }) { const stderr = [] // Boot clone outside build tree so ancestor node_modules cannot satisfy missing packaged dependency. const isolated = mkdtempSync(join(tmpdir(), 'orca-terminal-host-boot-')) - const clone = join(isolated, 'Orca Terminal Host.app') + const clone = join(isolated, basename(paths.bundle)) try { const cloned = spawnSync('/bin/cp', ['-cR', paths.bundle, clone], { encoding: 'utf8' }) if (cloned.status !== 0) { diff --git a/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs b/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs index 9d73f7f55fa..d81e208ae3c 100644 --- a/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs +++ b/config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs @@ -15,8 +15,8 @@ const { builtinModules } = require('node:module') const { basename, dirname, join, relative, resolve, sep } = require('node:path') const { pathToFileURL } = require('node:url') -const MAC_TERMINAL_HOST_NAME = 'Orca Terminal Host' -const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host' +const MAC_TERMINAL_HOST_NAME = 'Orca Agents' +const MAC_TERMINAL_HOST_EXECUTABLE = 'Orca Agents' const macTerminalHostSignIgnore = [`/Contents/Helpers/${MAC_TERMINAL_HOST_NAME}\\.app(/|$)`] const NODE_PTY_FILES = [ 'package.json', @@ -194,8 +194,8 @@ function buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs }) { return { ...usageDescriptions, CFBundleIdentifier: appInfo.CFBundleIdentifier, - CFBundleName: 'Orca', - CFBundleDisplayName: 'Orca', + CFBundleName: MAC_TERMINAL_HOST_NAME, + CFBundleDisplayName: MAC_TERMINAL_HOST_NAME, CFBundleExecutable: MAC_TERMINAL_HOST_EXECUTABLE, CFBundlePackageType: 'APPL', CFBundleInfoDictionaryVersion: '6.0', @@ -210,6 +210,15 @@ function buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs }) { function findMacTerminalHostInfoProblems(info, { appId, usageDescriptions, minimumOs }) { const problems = [] + for (const [key, expected] of Object.entries({ + CFBundleName: MAC_TERMINAL_HOST_NAME, + CFBundleDisplayName: MAC_TERMINAL_HOST_NAME, + CFBundleExecutable: MAC_TERMINAL_HOST_EXECUTABLE + })) { + if (info[key] !== expected) { + problems.push(`${key} is ${info[key]}, expected ${expected}`) + } + } if (info.CFBundleIdentifier !== appId) { problems.push(`CFBundleIdentifier is ${info.CFBundleIdentifier}, expected ${appId}`) } diff --git a/config/scripts/static-analysis/ratchets/renderer-state/check-tab-map-seam-ratchet.mjs b/config/scripts/static-analysis/ratchets/renderer-state/check-tab-map-seam-ratchet.mjs index c6743a1ae76..5fe02e109cc 100644 --- a/config/scripts/static-analysis/ratchets/renderer-state/check-tab-map-seam-ratchet.mjs +++ b/config/scripts/static-analysis/ratchets/renderer-state/check-tab-map-seam-ratchet.mjs @@ -96,13 +96,13 @@ export const WRITERS = [ 'src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts', 'src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts', 'src/renderer/src/store/slices/worktrees/teardown/record-key-omission.ts', - 'src/renderer/src/store/terminals/terminal-tab-close.ts', - 'src/renderer/src/store/terminals/terminal-tab-creation.ts', - 'src/renderer/src/store/terminals/terminal-tab-navigation.ts', - 'src/renderer/src/store/terminals/terminal-tab-title-batch.ts', - 'src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts', - 'src/renderer/src/store/terminals/workspace-terminal-hydration.ts', - 'src/renderer/src/store/terminals/workspace-terminal-reconnect.ts', + 'src/renderer/src/store/terminals/retirement/terminal-tab-close.ts', + 'src/renderer/src/store/terminals/tabs/creation/terminal-tab-creation.ts', + 'src/renderer/src/store/terminals/tabs/terminal-tab-navigation.ts', + 'src/renderer/src/store/terminals/tabs/presentation/terminal-tab-title-batch.ts', + 'src/renderer/src/store/terminals/hydration/workspace-terminal-hydration-patch.ts', + 'src/renderer/src/store/terminals/hydration/workspace-terminal-hydration.ts', + 'src/renderer/src/store/terminals/hydration/workspace-terminal-reconnect.ts', 'src/renderer/src/store/workspace-layout/frames/workspace-layout-frame-store-patch.ts' ] export const SEAM_FIELDS = new Set([ diff --git a/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs b/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs index 8d823b050a9..4724e8a18af 100644 --- a/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs +++ b/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs @@ -41,28 +41,32 @@ const HOT_MAPS = new Set([ ]) const OWNER_ROOTS = [ { - rel: 'store/terminals/terminal-parking-input-owner.ts', + rel: 'store/terminals/parking/terminal-parking-input-owner.ts', name: 'acceptTerminalParkingInputs', inputs: [] }, { - rel: 'store/terminals/terminal-parking-input-owner.ts', + rel: 'store/terminals/parking/terminal-parking-input-owner.ts', name: 'publishAcceptedTerminalParkingInputs', inputs: [] }, { - rel: 'store/terminals/terminal-parking-input-owner.ts', + rel: 'store/terminals/parking/terminal-parking-input-owner.ts', name: 'invalidateTerminalParkingPtyInputs', inputs: [] }, { - rel: 'store/terminals/terminal-tab-owner-index.ts', + rel: 'store/terminals/ownership/terminal-tab-owner-index.ts', name: 'update', inputs: ['tabsByWorktree'] }, - { rel: 'store/terminals/sleeping-agent-record-index.ts', name: 'update', inputs: ['records'] }, { - rel: 'store/terminals/sleeping-agent-record-index.ts', + rel: 'store/terminals/sleep/sleeping-agent-record-index.ts', + name: 'update', + inputs: ['records'] + }, + { + rel: 'store/terminals/sleep/sleeping-agent-record-index.ts', name: 'acceptTransition', inputs: ['previous', 'next'] }, @@ -73,14 +77,14 @@ const OWNER_ROOTS = [ } ] const KEY_DIFFS = new Set([ - `${RENDERER}store/terminals/terminal-parking-input-owner.ts#diffUnknownParkingRecordKeys`, - `${RENDERER}store/terminals/terminal-tab-owner-index.ts#diffUnknownTabOwnerKeys`, - `${RENDERER}store/terminals/sleeping-agent-record-index.ts#diffUnknownSleepingRecordKeys`, + `${RENDERER}store/terminals/parking/terminal-parking-input-owner.ts#diffUnknownParkingRecordKeys`, + `${RENDERER}store/terminals/ownership/terminal-tab-owner-index.ts#diffUnknownTabOwnerKeys`, + `${RENDERER}store/terminals/sleep/sleeping-agent-record-index.ts#diffUnknownSleepingRecordKeys`, `${RENDERER}lib/workspace/tabs/worktree-tab-bucket-projection.ts#diffUnknownProjectionKeys` ]) const COLD_RESYNCS = new Set([ - `${RENDERER}store/terminals/terminal-parking-input-owner.ts#resyncTerminalParkingWorkspaceIds`, - `${RENDERER}store/terminals/sleeping-agent-record-index.ts#resyncSleepingAgentRecordKeys` + `${RENDERER}store/terminals/parking/terminal-parking-input-owner.ts#resyncTerminalParkingWorkspaceIds`, + `${RENDERER}store/terminals/sleep/sleeping-agent-record-index.ts#resyncSleepingAgentRecordKeys` ]) function isColdResyncCall(node, source) { @@ -139,15 +143,15 @@ const ADDRESSED_FIELDS = new Set([ 'sleepingAgentSessionsByPaneKey' ]) const ORDINARY_WRITERS = [ - 'store/terminals/terminal-layout-state.ts', - 'store/terminals/terminal-pane-hibernation.ts', - 'store/terminals/terminal-pty-bindings.ts', - 'store/terminals/terminal-pty-release.ts', - 'store/terminals/terminal-orphan-cleanup.ts', - 'store/terminals/terminal-startup-queues.ts', - 'store/terminals/terminal-tab-presentation.ts', - 'store/terminals/terminal-shutdown-state.ts', - 'store/terminals/window-tab-start-in-main.ts', + 'store/terminals/layout/terminal-layout-state.ts', + 'store/terminals/sleep/terminal-pane-hibernation.ts', + 'store/terminals/pty/terminal-pty-bindings.ts', + 'store/terminals/pty/terminal-pty-release.ts', + 'store/terminals/retirement/terminal-orphan-cleanup.ts', + 'store/terminals/tabs/creation/terminal-startup-queues.ts', + 'store/terminals/tabs/presentation/terminal-tab-presentation.ts', + 'store/terminals/sleep/terminal-shutdown-state.ts', + 'store/terminals/tabs/creation/window-tab-start-in-main.ts', 'store/slices/agent-status/agent-status-live-reducer.ts', 'store/slices/agent-status/agent-status-launch-actions.ts', 'store/slices/agent-status/agent-status-provider-session-actions.ts', diff --git a/config/scripts/workflow/source-routing/pr-e2e-source-routing.mjs b/config/scripts/workflow/source-routing/pr-e2e-source-routing.mjs index 7cadca40ba7..dcd141bb974 100644 --- a/config/scripts/workflow/source-routing/pr-e2e-source-routing.mjs +++ b/config/scripts/workflow/source-routing/pr-e2e-source-routing.mjs @@ -447,7 +447,7 @@ export const PR_E2E_SOURCE_ROUTES = [ ], matches: (file) => isProductSource(file) && - /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:layout\/close\/retire-unbound-(?:ipc|runtime)-terminal-pane|terminal-pane-(?:close-admission|close-identity|lifecycle-close|pane-closed|retirement-ownership)|use-terminal-pane-close-actions)|src\/renderer\/src\/store\/(?:terminals\/terminal-tab-close(?:-providers)?|slices\/(?:terminal-tab-retirement|terminal-retirement-teardown-reservation|retired-terminal-tab-state-sweep)))\.ts$/.test( + /^(?:src\/renderer\/src\/components\/terminal-pane\/(?:layout\/close\/retire-unbound-(?:ipc|runtime)-terminal-pane|terminal-pane-(?:close-admission|close-identity|lifecycle-close|pane-closed|retirement-ownership)|use-terminal-pane-close-actions)|src\/renderer\/src\/store\/(?:terminals\/retirement\/(?:terminal-tab-close(?:-providers)?|terminal-tab-retirement|terminal-retirement-teardown-reservation|retired-terminal-tab-state-sweep)))\.ts$/.test( file ) }, diff --git a/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs b/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs index 298e9bb6b60..76cbd285784 100644 --- a/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs +++ b/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs @@ -22,6 +22,7 @@ const LAYOUT_ORACLE_PREFIXES = [ // Window-side layout writers the layout-holder stack replaces; widened while it is open. 'src/renderer/src/store/slices/tabs', 'src/renderer/src/store/terminals/', + 'src/renderer/src/components/sidebar/workspace-card/activity/worktree-terminal-record-selector.ts', 'src/renderer/src/store/workspace-layout/workspace-layout-cache', 'src/renderer/src/components/terminal-pane/', 'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationMenuItem.tsx', diff --git a/config/workspace-record-read-baseline.txt b/config/workspace-record-read-baseline.txt index b6e4e4e16f9..3efc1f265d9 100644 --- a/config/workspace-record-read-baseline.txt +++ b/config/workspace-record-read-baseline.txt @@ -70,4 +70,4 @@ 6 src/renderer/src/store/slices/worktrees/teardown/host-qualified-worktree-row-removal.ts # store writer 8 src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts # store writer 1 src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts # store writer -8 src/renderer/src/store/terminals/workspace-terminal-hydration.ts # store writer +8 src/renderer/src/store/terminals/hydration/workspace-terminal-hydration.ts # store writer diff --git a/docs/agents-md-refs/macos-daemon-tcc-attribution.md b/docs/agents-md-refs/macos-daemon-tcc-attribution.md index 5515ab519a5..796d89894c6 100644 --- a/docs/agents-md-refs/macos-daemon-tcc-attribution.md +++ b/docs/agents-md-refs/macos-daemon-tcc-attribution.md @@ -14,13 +14,14 @@ protected files keeps working after its old executable is deleted. So a packaged macOS GUI app starts its daemon from a private copy that the updater never touches: -- Each packaged macOS app ships a signed helper, `Contents/Helpers/Orca Terminal Host.app` (about +- Each packaged macOS app ships a signed helper, `Contents/Helpers/Orca Agents.app` (about 124 MiB): the pinned Node from `src/shared/node-runtime-pin.ts` as its main executable, the - daemon's `out/main` JS and that slice's node-pty. `config/scripts/macos-terminal-host-bundle.cjs` + daemon's `out/main` JS and that slice's node-pty. + `config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs` builds and signs it in `afterPack`, boots it through one PTY round trip, and fails the build on any layout, Info.plist, OS floor, architecture or signature drift. - `macos-daemon-bundle.ts` keeps one copy per helper build, named by the helper's CDHash, at - `userData/daemon-host/macos/terminal-host-/app.noindex/Orca Terminal Host.app`. The helper + `userData/daemon-host/macos/terminal-host-/app.noindex/Orca Agents.app`. The helper is read from the bundle the running main process executes from, never the whole app. A present copy is reused as is. Otherwise the helper is cloned (APFS clone, regular copy otherwise) into a `.staging--*` sibling, verified once with @@ -55,6 +56,12 @@ Signing the helper with the app's own identifier lets it inherit every existing Disk Access, which a user can only add by hand. A distinct identifier would be a new client: new folder prompts naming it, and a new Full Disk Access row for every user. +The helper's bundle, executable and display name are `Orca Agents`; its identifier, signing team +and designated requirement still match Orca.app. This names the existing terminal-agent process +without changing its privacy client. Native chat providers still run under the GUI process. An +adopted older daemon keeps its original name until it exits; the fork fallback and Windows names +are unchanged. + ## macOS floor The helper's Node declares `LC_BUILD_VERSION minos 13.5`, which is also the helper's @@ -108,6 +115,8 @@ The helper declares no links, documents or types, has a lower `CFBundleVersion` `.noindex` folder, which Spotlight skips, and Orca never registers them. Starting the daemon through launchd leaves no record, but another process running the copy's executable directly can make macOS register it, so retirement runs `lsregister -u` on the bundle before deleting it. +Retirement recognizes both `Orca Agents.app` and the legacy `Orca Terminal Host.app` inside unused +copies, so changing the current bundle name does not leave old LaunchServices registrations behind. The helper's main-executable UUID is Node's, shared by every official Node 24.21.0 binary of that architecture, so it is not unique to Orca. Local Network access still passed in the signed spike: diff --git a/src/main/browser/agent-browser/observation/agent-browser-action-effects.test.ts b/src/main/browser/agent-browser/observation/agent-browser-action-effects.test.ts index 1d5a99ccfb8..fbd5799d9da 100644 --- a/src/main/browser/agent-browser/observation/agent-browser-action-effects.test.ts +++ b/src/main/browser/agent-browser/observation/agent-browser-action-effects.test.ts @@ -66,54 +66,10 @@ function fakeHost( }, admit: vi.fn() } - const emit = (event: BrowserPageEvent): void => pageEvents.forEach((listener) => listener(event)) - return { host, tabs, emit } + return { host } } describe('runWithActionEffects', () => { - it('waits for a navigation the action started and reports where it landed', async () => { - const guest = fakeGuest() - const appTree = '- heading "Welcome ada" [level=1, ref=e9]\n- button "Submit ticket" [ref=e10]' - const { host } = fakeHost(guest, [UNCHANGED, { snapshot: { kind: 'full', tree: appTree } }]) - - const result = await runWithActionEffects(host, async () => { - guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) - // A redirecting submit replaces its first navigation, which then fails as ERR_ABORTED. - guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) - guest.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'http://127.0.0.1/submit', true) - setTimeout(() => { - guest.url = 'http://127.0.0.1/app.html' - guest.title = 'App' - guest.emit('dom-ready') - }, 120) - return { clicked: '@e6' } - }) - - expect(readBrowserActionEffects(result)).toEqual({ - settled: true, - url: 'http://127.0.0.1/app.html', - title: 'App', - page: { lines: appTree.split('\n'), removedLines: 0, full: true, truncated: false } - }) - }) - - it('reports a new tab once it opens and a child window separately', async () => { - const guest = fakeGuest() - const { host, tabs, emit } = fakeHost(guest, [UNCHANGED, UNCHANGED]) - - const result = await runWithActionEffects(host, async () => { - emit({ kind: 'popup', action: 'window', origin: 'https://accounts.example' }) - emit({ kind: 'tab', url: 'http://127.0.0.1/basic2.html' }) - setTimeout(() => tabs.set('page-2', 'http://127.0.0.1/basic2.html'), 100) - return { clicked: '@e18' } - }) - - expect(readBrowserActionEffects(result)).toMatchObject({ - newTabs: [{ browserPageId: 'page-2', url: 'http://127.0.0.1/basic2.html' }], - popups: [{ action: 'window', origin: 'https://accounts.example' }] - }) - }) - it('does not read a replacement guest the caller was never admitted to', async () => { const guest = fakeGuest() const replacement = Object.assign(fakeGuest('http://127.0.0.1/app.html', 'App'), { id: 8 }) diff --git a/src/main/browser/cdp/commands/cdp-bridge-integration.test.ts b/src/main/browser/cdp/commands/cdp-bridge-integration.test.ts deleted file mode 100644 index a852f9d55dd..00000000000 --- a/src/main/browser/cdp/commands/cdp-bridge-integration.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { guestNavigationMethods } from '../../guest-lifecycle/manager/browser-manager-test-harness' -import type * as WorktreeScanCacheModule from '../../../git/worktrees/catalog/worktree-scan-cache' -import type * as WorktreeListingModule from '../../../git/worktrees/catalog/worktree-listing' -import { mkdtempSync } from 'node:fs' -import { RuntimeBrowserCommands } from '../../../runtime/browser/commands/browser-commands' -import { setRuntimeBrowserCommandsFactory } from '../../../runtime/browser/installation/runtime-browser-commands-factory' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createConnection } from 'node:net' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { BrowserManager } from '../../guest-lifecycle/manager/browser-manager' -import { CdpBridge } from './cdp-bridge' -import { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import { OrcaRuntimeRpcServer } from '../../../runtime/rpc/server/runtime-rpc' -import { readRuntimeMetadata } from '../../../runtime/rpc/server/runtime-metadata' - -const { webContentsFromIdMock } = vi.hoisted(() => ({ - webContentsFromIdMock: vi.fn() -})) - -vi.mock('../../../git/worktrees/catalog/worktree-scan-cache', async (importOriginal) => { - const facade = getWorktreeListingMocks() - return { - ...(await importOriginal()), - listWorktrees: facade.listWorktrees - } -}) - -vi.mock('../../../git/worktrees/catalog/worktree-listing', async (importOriginal) => { - const facade = getWorktreeListingMocks() - return { - ...(await importOriginal()), - listWorktreesStrict: facade.listWorktreesStrict - } -}) - -vi.mock('electron', () => ({ - webContents: { fromId: webContentsFromIdMock }, - shell: { openExternal: vi.fn() }, - ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), on: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } -})) - -const { getWorktreeListingMocks } = vi.hoisted(() => { - const createWorktreeListingMocks = () => ({ - listWorktrees: vi.fn().mockResolvedValue([]), - listWorktreesStrict: vi.fn().mockResolvedValue([]) - }) - let listings: ReturnType | undefined - return { getWorktreeListingMocks: () => (listings ??= createWorktreeListingMocks()) } -}) - -type AXNode = { - nodeId: string - backendDOMNodeId?: number - role?: { type: string; value: string } - name?: { type: string; value: string } - properties?: { name: string; value: { type: string; value: unknown } }[] - childIds?: string[] - ignored?: boolean -} - -function axNode( - id: string, - role: string, - name: string, - opts?: { childIds?: string[]; backendDOMNodeId?: number } -): AXNode { - return { - nodeId: id, - backendDOMNodeId: opts?.backendDOMNodeId ?? Number.parseInt(id, 10) * 100, - role: { type: 'role', value: role }, - name: { type: 'computedString', value: name }, - childIds: opts?.childIds - } -} - -const EXAMPLE_COM_TREE: AXNode[] = [ - axNode('1', 'WebArea', 'Example Domain', { childIds: ['2', '3', '4'] }), - axNode('2', 'heading', 'Example Domain'), - axNode('3', 'staticText', 'This domain is for use in illustrative examples.'), - axNode('4', 'link', 'More information...', { backendDOMNodeId: 400 }) -] - -const SEARCH_PAGE_TREE: AXNode[] = [ - axNode('1', 'WebArea', 'Search', { childIds: ['2', '3', '4', '5'] }), - axNode('2', 'navigation', 'Main Nav', { childIds: ['3'] }), - axNode('3', 'link', 'Home', { backendDOMNodeId: 300 }), - axNode('4', 'textbox', 'Search query', { backendDOMNodeId: 400 }), - axNode('5', 'button', 'Search', { backendDOMNodeId: 500 }) -] - -function createMockGuest( - id: number, - url: string, - title: string, - options?: { readyState?: string | (() => string) } -) { - let currentUrl = url - let currentTitle = title - let currentTree = EXAMPLE_COM_TREE - let navHistoryId = 1 - let debuggerAttached = false - - const sendCommandMock = vi.fn(async (method: string, params?: Record) => { - switch (method) { - case 'Page.enable': - case 'DOM.enable': - case 'Accessibility.enable': - return {} - case 'Accessibility.getFullAXTree': - return { nodes: currentTree } - case 'Page.getNavigationHistory': - return { - entries: [{ id: navHistoryId, url: currentUrl }], - currentIndex: 0 - } - case 'Page.navigate': { - const targetUrl = (params as { url: string }).url - if (targetUrl.includes('nonexistent.invalid')) { - return { errorText: 'net::ERR_NAME_NOT_RESOLVED' } - } - navHistoryId++ - currentUrl = targetUrl - if (targetUrl.includes('search.example.com')) { - currentTitle = 'Search' - currentTree = SEARCH_PAGE_TREE - } else { - currentTitle = 'Example Domain' - currentTree = EXAMPLE_COM_TREE - } - return {} - } - case 'Runtime.evaluate': { - const expr = (params as { expression: string }).expression - if (expr === 'document.readyState') { - return { - result: { - value: - typeof options?.readyState === 'function' - ? options.readyState() - : (options?.readyState ?? 'complete') - } - } - } - if (expr === 'location.origin') { - return { result: { value: new URL(currentUrl).origin } } - } - if (expr.includes('innerWidth')) { - return { result: { value: JSON.stringify({ w: 1280, h: 720 }) } } - } - if (expr.includes('scrollBy')) { - return { result: { value: undefined } } - } - if (expr.includes('dispatchEvent')) { - return { result: { value: undefined } } - } - // eslint-disable-next-line no-eval - return { result: { value: String(eval(expr)), type: 'string' } } - } - case 'DOM.scrollIntoViewIfNeeded': - return {} - case 'DOM.getBoxModel': - return { model: { content: [100, 200, 300, 200, 300, 250, 100, 250] } } - case 'Input.dispatchMouseEvent': - return {} - case 'Input.insertText': - return {} - case 'Input.dispatchKeyEvent': - return {} - case 'DOM.focus': - return {} - case 'DOM.describeNode': - return { node: { nodeId: 1 } } - case 'DOM.requestNode': - return { nodeId: 1 } - case 'DOM.resolveNode': - return { object: { objectId: 'obj-1' } } - case 'Runtime.callFunctionOn': - return { result: { value: undefined } } - case 'DOM.setFileInputFiles': - return {} - case 'Page.captureScreenshot': - return { - data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' - } - case 'Page.reload': - return {} - case 'Network.enable': - return {} - case 'Target.setAutoAttach': - return {} - case 'Page.addScriptToEvaluateOnNewDocument': - return { identifier: 'mock-script-id' } - case 'Runtime.enable': - return {} - default: - throw new Error(`Unexpected CDP method: ${method}`) - } - }) - - const debuggerListeners = new Map void)[]>() - - const guest = { - ...guestNavigationMethods(), - id, - isDestroyed: vi.fn(() => false), - isCurrentlyAudible: vi.fn(() => false), - setIgnoreMenuShortcuts: vi.fn(), - getType: vi.fn(() => 'webview'), - getURL: vi.fn(() => currentUrl), - getTitle: vi.fn(() => currentTitle), - setBackgroundThrottling: vi.fn(), - setWindowOpenHandler: vi.fn(), - on: vi.fn(), - off: vi.fn(), - debugger: { - isAttached: vi.fn(() => debuggerAttached), - attach: vi.fn(() => { - if (debuggerAttached) { - throw new Error('Another debugger is already attached') - } - debuggerAttached = true - }), - detach: vi.fn(), - sendCommand: sendCommandMock, - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = debuggerListeners.get(event) ?? [] - handlers.push(handler) - debuggerListeners.set(event, handlers) - }), - removeListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - const handlers = debuggerListeners.get(event) ?? [] - const idx = handlers.indexOf(handler) - if (idx !== -1) { - handlers.splice(idx, 1) - } - }), - removeAllListeners: vi.fn((event: string) => { - debuggerListeners.set(event, []) - }), - off: vi.fn() - } - } - - return { - guest, - sendCommandMock, - setTree(tree: AXNode[]) { - currentTree = tree - }, - emitDebugger(event: string, ...args: unknown[]) { - for (const handler of debuggerListeners.get(event) ?? []) { - handler(...args) - } - }, - emitDebuggerMessage(method: string, params?: Record) { - for (const handler of debuggerListeners.get('message') ?? []) { - handler({}, method, params) - } - } - } -} - -async function sendRequest( - endpoint: string, - request: Record -): Promise> { - return await new Promise((resolve, reject) => { - const socket = createConnection(endpoint) - let buffer = '' - socket.setEncoding('utf8') - socket.once('error', reject) - socket.on('data', (chunk: string) => { - buffer += chunk - const newlineIndex = buffer.indexOf('\n') - if (newlineIndex === -1) { - return - } - const message = buffer.slice(0, newlineIndex) - socket.end() - resolve(JSON.parse(message) as Record) - }) - socket.on('connect', () => { - socket.write(`${JSON.stringify(request)}\n`) - }) - }) -} - -describe('Browser automation pipeline (integration)', () => { - let server: OrcaRuntimeRpcServer - let endpoint: string - let authToken: string - let activeGuestHarness: ReturnType - let cdpBridge: CdpBridge - - const GUEST_WC_ID = 5001 - const RENDERER_WC_ID = 1 - - beforeEach(async () => { - // The Electron entry installs this factory; Node hosts omit browser commands. - setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) - activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain') - const { guest } = activeGuestHarness - webContentsFromIdMock.mockImplementation((id: number) => { - if (id === GUEST_WC_ID) { - return guest - } - return null - }) - - const browserManager = new BrowserManager() - // Production applies these policies in will-attach-webview. - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'page-1', - webContentsId: GUEST_WC_ID, - rendererWebContentsId: RENDERER_WC_ID - }) - - cdpBridge = new CdpBridge(browserManager) - cdpBridge.setActiveTab(GUEST_WC_ID) - - const userDataPath = mkdtempSync(join(tmpdir(), 'browser-e2e-')) - const runtime = new OrcaRuntimeService() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - runtime.setAgentBrowserBridge(cdpBridge as any) - - server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) - await server.start() - - const metadata = readRuntimeMetadata(userDataPath)! - endpoint = metadata.transports[0]!.endpoint - authToken = metadata.authToken! - }) - - afterEach(async () => { - await server.stop() - }) - - async function rpc(method: string, params?: Record) { - const response = await sendRequest(endpoint, { - id: `req_${method}`, - authToken, - method, - ...(params ? { params } : {}) - }) - return response - } - - it('navigates to a URL and invalidates refs', async () => { - await rpc('browser.snapshot') - - const gotoRes = await rpc('browser.goto', { url: 'https://search.example.com' }) - expect(gotoRes.ok).toBe(true) - const gotoResult = gotoRes.result as { url: string; title: string } - expect(gotoResult.url).toBe('https://search.example.com') - expect(gotoResult.title).toBe('Search') - - const clickRes = await rpc('browser.click', { element: '@e1' }) - expect(clickRes.ok).toBe(false) - expect((clickRes.error as { code: string }).code).toBe('browser_stale_ref') - - const snapRes = await rpc('browser.snapshot') - expect(snapRes.ok).toBe(true) - const snapResult = snapRes.result as { snapshot: string; refs: { name: string }[] } - expect(snapResult.snapshot).toContain('Search') - expect(snapResult.refs.map((r) => r.name)).toContain('Search') - expect(snapResult.refs.map((r) => r.name)).toContain('Home') - }) -}) diff --git a/src/main/browser/cdp/commands/cdp-print-to-pdf.test.ts b/src/main/browser/cdp/commands/cdp-print-to-pdf.test.ts index 200a96efab9..6fc79fd5489 100644 --- a/src/main/browser/cdp/commands/cdp-print-to-pdf.test.ts +++ b/src/main/browser/cdp/commands/cdp-print-to-pdf.test.ts @@ -7,17 +7,6 @@ describe('CdpPdfStreamStore', () => { vi.useRealTimers() }) - it('claims only its own handles', () => { - const store = new CdpPdfStreamStore() - const handle = store.create(Buffer.from('pdf')) - - expect(store.ownsHandle({ handle })).toBe(true) - expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/) - expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false) - expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false) - expect(store.ownsHandle({})).toBe(false) - }) - it('reads sequential chunks and reports eof', () => { const store = new CdpPdfStreamStore() const handle = store.create(Buffer.from('abcdef')) @@ -32,23 +21,6 @@ describe('CdpPdfStreamStore', () => { }) }) - it('honors an explicit read offset', () => { - const store = new CdpPdfStreamStore() - const handle = store.create(Buffer.from('abcdef')) - - expect(store.read({ handle, offset: 4 })).toEqual({ - data: Buffer.from('ef').toString('base64'), - eof: true - }) - }) - - it('returns an empty eof chunk when reading past the end', () => { - const store = new CdpPdfStreamStore() - const handle = store.create(Buffer.from('abc')) - - expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true }) - }) - it('evicts an abandoned stream after the TTL', () => { vi.useFakeTimers() const store = new CdpPdfStreamStore() diff --git a/src/main/browser/cdp/input/browser-text-insertion.test.ts b/src/main/browser/cdp/input/browser-text-insertion.test.ts deleted file mode 100644 index 4b86233a9ad..00000000000 --- a/src/main/browser/cdp/input/browser-text-insertion.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { splitBrowserTextInsertionChunks } from '../../../../shared/browser-text-insertion-chunks' - -describe('browser text insertion chunking', () => { - it('keeps a single multibyte character intact when the byte cap is smaller', () => { - const chunks = splitBrowserTextInsertionChunks('😀a', 1) - - expect(chunks).toEqual(['😀', 'a']) - expect(chunks.join('')).toBe('😀a') - }) -}) diff --git a/src/main/browser/cdp/screencast/browser-screencast-first-frame-timeout.test.ts b/src/main/browser/cdp/screencast/browser-screencast-first-frame-timeout.test.ts deleted file mode 100644 index 304652dfaad..00000000000 --- a/src/main/browser/cdp/screencast/browser-screencast-first-frame-timeout.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Buffer } from 'node:buffer' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { startBrowserScreencast } from './browser-screencast-stream' -import type { BrowserScreencastOptions } from './browser-screencast-stream-types' -import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double' - -function start( - webContents: ReturnType, - options: BrowserScreencastOptions -) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream touches only the debugger, isDestroyed and capturePage the doubles provide. - return startBrowserScreencast(webContents as never, options) -} - -function startOptions(viewport: boolean) { - return { - format: 'jpeg' as const, - quality: 70, - maxWidth: 1440, - maxHeight: 1200, - everyNthFrame: 2, - minFrameIntervalMs: 0, - holdPaint: () => () => {}, - ...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}), - onFrame: vi.fn(() => true), - onError: vi.fn() - } -} - -describe('browser screencast first-frame deadline', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - it('drops a capture that settles after the limit', async () => { - let settleCapture!: (image: unknown) => void - const webContents = Object.assign(createMockScreencastWebContents(), { - capturePage: vi.fn( - () => - new Promise((resolve) => { - settleCapture = resolve - }) - ) - }) - const options = startOptions(true) - const session = await start(webContents, options) - await vi.advanceTimersByTimeAsync(10_000) - - settleCapture({ - getSize: () => ({ width: 390, height: 844 }), - toJPEG: () => Buffer.from('late'), - toPNG: () => Buffer.from('late') - }) - await vi.advanceTimersByTimeAsync(1_000) - - expect(options.onFrame).not.toHaveBeenCalled() - session.stop() - await session.done - }) -}) diff --git a/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts b/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts index a07487bfd5b..25748453ed5 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts @@ -1,8 +1,6 @@ import { Buffer } from 'node:buffer' import { describe, expect, it, vi } from 'vitest' -import { decodeBrowserScreencastFrame } from '../../../../shared/browser/screencast/browser-screencast-protocol' - import { startBrowserScreencast } from './browser-screencast-stream' import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double' @@ -76,55 +74,6 @@ async function startOversizedStream(guest: ReturnType, onFra } describe('screencast of a guest smaller than the requested viewport (#30341)', () => { - it('re-emulates at the size the guest can paint and labels frames with it', async () => { - const guest = createGuest('webview') - const onFrame = vi.fn() - const session = await startOversizedStream(guest, onFrame) - await vi.waitFor(() => - expect(guest.debugger.sendCommand).toHaveBeenCalledWith( - 'Page.captureScreenshot', - expect.anything() - ) - ) - expect(emulatedSizes(guest)).toEqual(['1833x1179']) - - emitGuestFrame(guest, 1, { width: 1463, height: 849 }) - await vi.waitFor(() => expect(emulatedSizes(guest)).toEqual(['1833x1179', '1463x849'])) - expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', { - sessionId: 1 - }) - expect(onFrame).not.toHaveBeenCalled() - - emitGuestFrame(guest, 2, { width: 1463, height: 849 }) - await vi.waitFor(() => expect(onFrame).toHaveBeenCalledTimes(1)) - expect(decodeBrowserScreencastFrame(onFrame.mock.calls[0][0])?.metadata).toMatchObject({ - deviceWidth: 1463, - deviceHeight: 849 - }) - - session.stop() - await session.done - }) - - it('gives the requested size back once the guest grows, and follows a new request', async () => { - const guest = createGuest('webview') - const session = await startOversizedStream(guest) - emitGuestFrame(guest, 1, { width: 1463, height: 849 }) - await vi.waitFor(() => expect(emulatedSizes(guest)).toHaveLength(2)) - - emitGuestFrame(guest, 2, { width: 2000, height: 1300 }) - await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1833x1179')) - - await session.updateViewport({ viewportWidth: 1028, viewportHeight: 1179 }) - expect(emulatedSizes(guest).at(-1)).toBe('1028x1179') - - emitGuestFrame(guest, 3, { width: 1463, height: 849 }) - await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1028x849')) - - session.stop() - await session.done - }) - it('keeps dropping guest frames until the fitted size is actually emulated', async () => { const guest = createGuest('webview') let releaseSnapshot!: () => void @@ -183,20 +132,4 @@ describe('screencast of a guest smaller than the requested viewport (#30341)', ( session.stop() }) - - it('keeps the full request for a window guest, which can grow to it', async () => { - const guest = createGuest('window') - const session = await startOversizedStream(guest) - - emitGuestFrame(guest, 1, { width: 1463, height: 849 }) - await vi.waitFor(() => - expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', { - sessionId: 1 - }) - ) - expect(emulatedSizes(guest)).toEqual(['1833x1179']) - - session.stop() - await session.done - }) }) diff --git a/src/main/browser/certificate-trust/browser-certificate-trust-controller.test.ts b/src/main/browser/certificate-trust/browser-certificate-trust-controller.test.ts index 9f4cb327c02..edf10dbb57c 100644 --- a/src/main/browser/certificate-trust/browser-certificate-trust-controller.test.ts +++ b/src/main/browser/certificate-trust/browser-certificate-trust-controller.test.ts @@ -142,15 +142,6 @@ describe('BrowserCertificateTrustController', () => { controller.installSessionRequestGuard(browserSession) }) - it('keeps challenge ownership and expiry when reading a specific guest', () => { - certificateEvent({ controller, guest }) - expect(controller.hasFailureForGuest(guest.id)).toBe(true) - expect(controller.hasFailureForGuest(otherGuest.id)).toBe(false) - now += 300_000 - expect(controller.hasFailureForGuest(guest.id)).toBe(false) - expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: false, reason: 'missing' }) - }) - it('blocks a session-cached certificate for sibling requests until that guest approves it', () => { certificateEvent({ controller, guest }) expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) diff --git a/src/main/browser/guest-lifecycle/popups/browser-page-initiated-tab-budget.test.ts b/src/main/browser/guest-lifecycle/popups/browser-page-initiated-tab-budget.test.ts index 458a2129a98..12155ad5184 100644 --- a/src/main/browser/guest-lifecycle/popups/browser-page-initiated-tab-budget.test.ts +++ b/src/main/browser/guest-lifecycle/popups/browser-page-initiated-tab-budget.test.ts @@ -1,20 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - createPageInitiatedTabBudget, - MAX_PAGE_INITIATED_TABS_PER_WINDOW, - PAGE_INITIATED_TAB_WINDOW_MS -} from './browser-page-initiated-tab-budget' +import { createPageInitiatedTabBudget } from './browser-page-initiated-tab-budget' describe('page-initiated tab budget', () => { - it('refills once the rolling window has passed, so real browsing is unaffected', () => { - const budget = createPageInitiatedTabBudget() - for (let i = 0; i < MAX_PAGE_INITIATED_TABS_PER_WINDOW; i++) { - expect(budget.tryConsume(1_000)).toBe(true) - } - expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS - 1)).toBe(false) - expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS)).toBe(true) - }) - it('slides rather than resetting, so a paced flood cannot outrun the cap', () => { const budget = createPageInitiatedTabBudget(2, 1_000) diff --git a/src/main/browser/guest-lifecycle/prompts/browser-http-auth-prompt.test.ts b/src/main/browser/guest-lifecycle/prompts/browser-http-auth-prompt.test.ts deleted file mode 100644 index 5f1f833ed51..00000000000 --- a/src/main/browser/guest-lifecycle/prompts/browser-http-auth-prompt.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { EventEmitter } from 'node:events' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - resolvePagePromptTarget: vi.fn() -})) - -vi.mock('../manager/browser-manager', () => ({ - browserManager: { - resolvePagePromptTarget: mocks.resolvePagePromptTarget - } -})) - -import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt' -import { handleBrowserGuestLogin } from './browser-http-auth-prompt' -import { BROWSER_PAGE_PROMPT_TIMEOUT_MS, respondToBrowserPagePrompt } from './browser-page-prompts' - -type MockGuest = Electron.WebContents & EventEmitter - -const guests: MockGuest[] = [] - -function mockGuest(pageUrl = 'https://staging.acme.com/'): MockGuest { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prompt only reads id, isDestroyed, getURL and the input, navigation and destroyed events. - const guest = new EventEmitter() as MockGuest - Object.assign(guest, { - id: 100 + guests.length, - isDestroyed: () => false, - getURL: () => pageUrl - }) - guests.push(guest) - return guest -} - -function mockRenderer(id: number): Electron.WebContents { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prompts only read id, isDestroyed, send and subscribe to the window's lifecycle events. - return { - id, - isDestroyed: () => false, - send: vi.fn(), - on: vi.fn(), - off: vi.fn() - } as unknown as Electron.WebContents -} - -function showTabIn(renderer: Electron.WebContents, browserPageId = 'page-1'): void { - mocks.resolvePagePromptTarget.mockReturnValue({ browserPageId, renderer }) -} - -function challenge( - guest: MockGuest, - { - url = 'https://staging.acme.com/', - isRequestForNavigation = true, - firstAuthAttempt = true, - isProxy = false, - realm = 'Staging' - } = {} -) { - const event = { preventDefault: vi.fn() } - const callback = vi.fn() - handleBrowserGuestLogin( - event, - guest, - { url, pid: 1, isRequestForNavigation, firstAuthAttempt }, - { isProxy, scheme: 'basic', host: new URL(url).hostname, port: 443, realm }, - callback - ) - return { asked: event.preventDefault.mock.calls.length > 0, callback } -} - -function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest<'http-auth'>[] { - return vi - .mocked(renderer.send) - .mock.calls.filter(([channel]) => channel === 'browser:page-prompt-requested') - .map(([, request]) => request) -} - -const answer = (renderer: Electron.WebContents, requestId: string, value: unknown) => - respondToBrowserPagePrompt(renderer, { requestId, answer: value }) - -const CREDENTIALS = { username: 'qa', password: 'hunter2' } - -describe('browser HTTP auth prompt', () => { - beforeEach(() => { - vi.useFakeTimers() - mocks.resolvePagePromptTarget.mockReset() - }) - - afterEach(() => { - vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS) - for (const guest of guests.splice(0)) { - guest.emit('destroyed') - } - vi.useRealTimers() - }) - - it.each([ - ['a proxy challenge', { isProxy: true }], - [ - 'an image from another site', - { url: 'https://cdn.other.com/a.png', isRequestForNavigation: false } - ] - ])('leaves %s to Electron, which cancels it', (_name, options) => { - const renderer = mockRenderer(10) - showTabIn(renderer) - - const { asked } = challenge(mockGuest(), options) - - expect(asked).toBe(false) - expect(sentRequests(renderer)).toEqual([]) - }) - - it('shares one prompt per realm and cancels a different challenge meanwhile', async () => { - const renderer = mockRenderer(10) - showTabIn(renderer) - const guest = mockGuest() - - const first = challenge(guest) - const sameRealm = challenge(guest, { - url: 'https://staging.acme.com/logo.png', - isRequestForNavigation: false - }) - const otherRealm = challenge(guest, { realm: 'Admin' }) - - expect([first.asked, sameRealm.asked, otherRealm.asked]).toEqual([true, true, false]) - const [request] = sentRequests(renderer) - expect(sentRequests(renderer)).toHaveLength(1) - await answer(renderer, request.requestId, CREDENTIALS) - expect(first.callback).toHaveBeenCalledWith('qa', 'hunter2') - expect(sameRealm.callback).toHaveBeenCalledWith('qa', 'hunter2') - }) -}) diff --git a/src/main/browser/guest-lifecycle/prompts/browser-javascript-dialogs.test.ts b/src/main/browser/guest-lifecycle/prompts/browser-javascript-dialogs.test.ts index a9efc10b55e..33e8e8bb1e8 100644 --- a/src/main/browser/guest-lifecycle/prompts/browser-javascript-dialogs.test.ts +++ b/src/main/browser/guest-lifecycle/prompts/browser-javascript-dialogs.test.ts @@ -7,7 +7,6 @@ import { getBrowserJavaScriptDialog, installBrowserJavaScriptDialogs } from './browser-javascript-dialogs' -import { respondToBrowserPagePrompt } from './browser-page-prompts' type MockContents = Electron.WebContents & EventEmitter & { debugger: EventEmitter } @@ -58,39 +57,6 @@ function setup() { } describe('browser JavaScript dialogs', () => { - it('asks in the page prompt instead of Electron’s window-wide sheet', () => { - const { guest, electronSheet, openDialog, shownPrompts } = setup() - - const callback = openDialog('confirm', 'Delete everything?') - - expect(electronSheet).not.toHaveBeenCalled() - expect(callback).not.toHaveBeenCalled() - expect(getBrowserJavaScriptDialog(guest.id)).toEqual({ - type: 'confirm', - message: 'Delete everything?', - defaultPromptText: '' - }) - expect(shownPrompts()).toEqual([ - expect.objectContaining({ - kind: 'javascript-dialog', - details: { - type: 'confirm', - message: 'Delete everything?', - defaultPromptText: '', - origin: 'https://shop.example' - } - }) - ]) - }) - - it('names an embedded page that asks, not the site around it', () => { - const { openDialog, shownPrompts } = setup() - - openDialog('prompt', 'Re-enter your password', '', { origin: 'https://ads.example' }) - - expect(shownPrompts()[0]?.details).toMatchObject({ origin: 'https://ads.example' }) - }) - it('takes an agent’s prompt answer and closes the page prompt', () => { const { guest, openDialog, closedPrompts } = setup() const callback = openDialog('prompt', 'Your name?', 'def') @@ -103,19 +69,6 @@ describe('browser JavaScript dialogs', () => { expect(answerBrowserJavaScriptDialog(guest.id, { accept: true, promptText: '' })).toBe(false) }) - it('answers the page with what the user chose in the page prompt', async () => { - const { renderer, guest, openDialog, shownPrompts } = setup() - const callback = openDialog('confirm', 'Leave?') - - await respondToBrowserPagePrompt(renderer, { - requestId: shownPrompts()[0].requestId, - answer: { accept: false, promptText: '' } - }) - - expect(callback).toHaveBeenCalledExactlyOnceWith(false, '') - expect(getBrowserJavaScriptDialog(guest.id)).toBeNull() - }) - it('turns away a second frame’s dialog and keeps the first one answerable', () => { const { guest, openDialog } = setup() const first = openDialog('confirm', 'Delete everything?') diff --git a/src/main/browser/guest-lifecycle/prompts/browser-page-prompts.test.ts b/src/main/browser/guest-lifecycle/prompts/browser-page-prompts.test.ts index 1e391d985b8..e8eccfbbe8c 100644 --- a/src/main/browser/guest-lifecycle/prompts/browser-page-prompts.test.ts +++ b/src/main/browser/guest-lifecycle/prompts/browser-page-prompts.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt' import { BROWSER_PAGE_PROMPT_TIMEOUT_MS, - cancelBrowserPagePrompts, openBrowserPagePrompt, respondToBrowserPagePrompt, showBrowserPagePromptsAwaitingRegistration, @@ -57,13 +56,6 @@ function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest[ .map(([, request]) => request) } -function closedRequestIds(renderer: Electron.WebContents): string[] { - return vi - .mocked(renderer.send) - .mock.calls.filter(([channel]) => channel === 'browser:page-prompt-closed') - .map(([, event]) => event.requestId) -} - const latestId = (renderer: Electron.WebContents): string => sentRequests(renderer).at(-1)!.requestId @@ -172,25 +164,4 @@ describe('browser page prompts', () => { await respondToBrowserPagePrompt(renderer, { requestId: latestId(renderer), answer: true }) expect(ask(guest).opening).toBe('prompting') }) - - it('closes the prompt with a null answer when it expires or its page goes away', async () => { - const renderer = mockRenderer(10) - const expiring = ask(mockGuest(renderer, 'page-1')) - const expiringId = latestId(renderer) - const closingGuest = mockGuest(renderer, 'page-2') - const closing = ask(closingGuest) - const closingId = latestId(renderer) - - cancelBrowserPagePrompts('page-2') - vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS) - await Promise.resolve() - - expect(closedRequestIds(renderer)).toEqual([closingId, expiringId]) - expect(expiring.settle).toHaveBeenCalledWith(null) - expect(closing.settle).toHaveBeenCalledWith(null) - await expect( - respondToBrowserPagePrompt(renderer, { requestId: closingId, answer: true }) - ).resolves.toBe('rejected') - expect(ask(closingGuest).opening).toBe('prompting') - }) }) diff --git a/src/main/browser/identity/mode/browser-retired-profile-identity-inspection.test.ts b/src/main/browser/identity/mode/browser-retired-profile-identity-inspection.test.ts deleted file mode 100644 index eb050a91f96..00000000000 --- a/src/main/browser/identity/mode/browser-retired-profile-identity-inspection.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getOrcaProfileBrowserSessionPartition } from '../../../../shared/orca-profiles' -import { inspectRetiredBrowserSessionProfileUserAgentModes } from './browser-retired-profile-identity-inspection' - -const ORCA_PROFILE_ID = 'local-default' -const PROFILE_ID = '11111111-1111-4111-8111-111111111111' - -function profileWithMode(mode: unknown): Record { - return { - id: PROFILE_ID, - scope: 'isolated', - partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, PROFILE_ID), - label: 'Existing', - source: null, - userAgentMode: mode - } -} - -function unhydratableProfile(extra: Record = {}): Record { - return { - id: 'not-a-uuid', - scope: 'isolated', - partition: 'persist:orca-browser-session-not-a-uuid', - label: 'Unhydratable', - source: null, - ...extra - } -} - -describe('retired browser profile identity inspection', () => { - it('detects an inspectable old choice without removing its bytes', () => { - const profile = profileWithMode('native') - - expect(inspectRetiredBrowserSessionProfileUserAgentModes([profile], ORCA_PROFILE_ID)).toEqual({ - noticePending: true, - degraded: false - }) - expect(profile.userAgentMode).toBe('native') - }) - - it.each([ - { scenario: 'an unreadable mode', entry: profileWithMode('unexpected') }, - { - scenario: 'a mode on an entry that cannot be hydrated', - entry: unhydratableProfile({ userAgentMode: 'native' }) - } - ])('turns $scenario into a degraded notice without throwing', ({ entry }) => { - expect(() => - inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID) - ).not.toThrow() - expect(inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)).toEqual({ - noticePending: true, - degraded: true - }) - }) -}) diff --git a/src/main/browser/identity/wire-probes/browser-session-ua-wire-identity-cross-context.electron.test.ts b/src/main/browser/identity/wire-probes/browser-session-ua-wire-identity-cross-context.electron.test.ts deleted file mode 100644 index d5b3e0c58f9..00000000000 --- a/src/main/browser/identity/wire-probes/browser-session-ua-wire-identity-cross-context.electron.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { spawn, type ChildProcess } from 'node:child_process' -import { createServer } from 'node:net' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { build as buildVite } from 'vite' -import { - BrowserSessionUaCdpCollector, - type BrowserSessionUaCdpRequest, - waitForBrowserCdpEndpoint -} from './browser-session-ua-cdp-collector' -import { - startBrowserSessionUaWireProbeServer, - type WireProbeJavaScriptIdentity, - type WireProbeReceipt -} from './browser-session-ua-wire-probe-server' - -const electronBinary = createRequire(import.meta.url)('electron') as string -const fixtureRoots: string[] = [] -let processIdentityModulePath: string -let exceptionModulePath: string - -beforeAll(async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-modules-')) - fixtureRoots.push(root) - processIdentityModulePath = join(root, 'browser-process-user-agent.cjs') - exceptionModulePath = join(root, 'browser-session-ua.cjs') - await Promise.all([ - buildModule( - 'src/main/browser/identity/browser-process-user-agent.ts', - processIdentityModulePath - ), - buildModule('src/main/browser/identity/requests/browser-session-ua.ts', exceptionModulePath) - ]) -}) - -afterAll(() => { - for (const root of fixtureRoots) { - rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) - } -}) - -type ProbeArm = 'clean' | 'fallback-disabled' - -type ProbeResult = Readonly<{ - arm: ProbeArm - rawUserAgent: string - cleanUserAgent: string - navigatorUserAgent: string - receipts: readonly WireProbeReceipt[] - identities: readonly WireProbeJavaScriptIdentity[] - cdpRequests: readonly BrowserSessionUaCdpRequest[] - cdpDiagnostics: readonly string[] - fixtureResult: string - fixtureStderr: string -}> - -describe('browser session wire identity in cross-site frames and dedicated workers', () => { - it('keeps OOPIF, dedicated-worker, and client-hint identities clean', async () => { - const result = await runProbe('clean') - assertCapturedContexts(result) - const checks = identityChecks(result) - expect(checks.crossSiteDocument).toBe(true) - expect(checks.crossSiteFetch).toBe(true) - expect(checks.dedicatedWorkerScript).toBe(true) - expect(checks.dedicatedWorkerFetch).toBe(true) - expect(checks.clientHints, JSON.stringify(receiptForPath(result.receipts, '/'))).toBe(true) - }, 60_000) -}) - -async function runProbe(arm: ProbeArm): Promise { - const root = mkdtempSync(join(tmpdir(), `orca-wire-cross-context-${arm}-`)) - fixtureRoots.push(root) - const server = await startBrowserSessionUaWireProbeServer() - const resultPath = join(root, 'result.json') - const barrierPath = join(root, 'continue') - const fixturePath = join(root, 'main.cjs') - const cdpPort = await reservePort() - writeFileSync( - fixturePath, - fixtureMain({ - arm, - barrierPath, - exceptionModulePath, - httpOrigin: server.httpOrigin, - processIdentityModulePath, - resultPath - }) - ) - let process: ChildProcess | null = null - let collector: BrowserSessionUaCdpCollector | null = null - let browser: Awaited> | null = null - try { - process = launchFixture(fixturePath, root, cdpPort) - await waitForBrowserCdpEndpoint(cdpPort) - browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) - collector = await BrowserSessionUaCdpCollector.connect(cdpPort) - await collector.installAutoAttach() - writeFileSync(barrierPath, '') - const processResult = await waitForProcess(process) - const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' - expect( - processResult.code, - `${fixtureResult}\n${processResult.stderr}\n${JSON.stringify({ diagnostics: collector.diagnostics, receipts: server.receipts, identities: server.identities })}` - ).toBe(0) - await new Promise((resolve) => setTimeout(resolve, 250)) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes this exact shape before exiting. - const parsed = JSON.parse(fixtureResult) as Omit< - ProbeResult, - | 'receipts' - | 'identities' - | 'cdpRequests' - | 'cdpDiagnostics' - | 'fixtureResult' - | 'fixtureStderr' - > - return { - ...parsed, - fixtureResult, - fixtureStderr: processResult.stderr, - receipts: [...server.receipts], - identities: [...server.identities], - cdpDiagnostics: [...collector.diagnostics], - cdpRequests: collector.snapshot().filter(({ url }) => { - return ( - url.startsWith(server.httpOrigin) || - url.startsWith(server.crossSiteOrigin) || - url.startsWith(server.httpsOrigin) - ) - }) - } - } finally { - await collector?.close().catch(() => {}) - await browser?.close().catch(() => {}) - await server.close() - if (process && process.exitCode === null) { - process.kill('SIGTERM') - } - } -} - -async function buildModule(entry: string, outputPath: string): Promise { - await buildVite({ - configFile: false, - logLevel: 'silent', - build: { - emptyOutDir: false, - lib: { - entry: join(process.cwd(), entry), - formats: ['cjs'], - fileName: () => basename(outputPath) - }, - outDir: join(outputPath, '..'), - target: 'node20', - rollupOptions: { external: ['electron', /^node:/] } - } - }) -} - -function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess { - const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env - const args = [ - fixturePath, - `--user-data-dir=${join(root, 'profile')}`, - `--remote-debugging-port=${cdpPort}`, - '--site-per-process' - ] - if (process.platform === 'linux') { - args.push('--no-sandbox') - } - return spawn( - process.platform === 'linux' ? 'xvfb-run' : electronBinary, - process.platform === 'linux' ? ['--auto-servernum', electronBinary, ...args] : args, - { - env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, - stdio: ['ignore', 'pipe', 'pipe'] - } - ) -} - -function fixtureMain(options: { - arm: ProbeArm - barrierPath: string - exceptionModulePath: string - httpOrigin: string - processIdentityModulePath: string - resultPath: string -}): string { - return String.raw` -const { app, BrowserWindow, session } = require('electron') -const { existsSync, writeFileSync } = require('node:fs') -const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)}) -const { cleanElectronUserAgent } = require(${JSON.stringify(options.processIdentityModulePath)}) -const arm = ${JSON.stringify(options.arm)} -app.setName('OrcaCrossContextFixture') -app.commandLine.appendSwitch('site-per-process') -const rawUserAgent = app.userAgentFallback -if (arm === 'clean') processIdentity.initializeBrowserProcessUserAgent('clean') -const waitForBarrier = async () => { - const deadline = Date.now() + 15000 - while (!existsSync(${JSON.stringify(options.barrierPath)})) { - if (Date.now() >= deadline) throw new Error('startup barrier timeout') - await new Promise(resolve => setTimeout(resolve, 20)) - } -} -async function run() { - const timeout = setTimeout(() => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: 'timeout' })); app.exit(2) }, 20000) - await app.whenReady() - await waitForBarrier() - const sess = session.fromPartition('persist:wire-cross-context') - sess.setCertificateVerifyProc((_request, callback) => callback(0)) - const windows = [] - const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-cross-context', sandbox: true } }) - windows.push(window) - window.webContents.setWindowOpenHandler(() => ({ - action: 'allow', - createWindow: options => { - const popup = new BrowserWindow({ ...options, show: false }) - windows.push(popup) - return popup.webContents - } - })) - await window.loadURL(${JSON.stringify(options.httpOrigin)} + '/?cross-context=1') - const [navigatorUserAgent] = await Promise.all([ - window.webContents.executeJavaScript('navigator.userAgent'), - window.webContents.executeJavaScript('window.probePromise') - ]) - // Allow isolated-frame CDP events to flush before the fixture exits. - await new Promise(resolve => setTimeout(resolve, 1500)) - clearTimeout(timeout) - writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent: cleanElectronUserAgent(rawUserAgent), navigatorUserAgent })) - for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy() - app.exit(0) -} -run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error) })); app.exit(1) }) -` -} - -function assertCapturedContexts(result: ProbeResult): void { - const paths = new Set(result.receipts.map(({ path }) => path)) - for (const path of [ - '/', - '/cross-site-frame', - '/cross-site-frame-fetch', - '/dedicated-worker.js', - '/dedicated-worker-fetch', - '/report/cross-site-frame', - '/report/dedicated-worker' - ]) { - expect( - paths, - `${result.arm} omitted ${path}\n cdp: ${JSON.stringify(result.cdpDiagnostics)}\n receipts: ${JSON.stringify(result.receipts.map((r) => r.path))}\n fixture: ${result.fixtureResult}\n stderr: ${result.fixtureStderr}` - ).toContain(path) - } - expect( - result.cdpDiagnostics.some((message) => message.startsWith('attached:iframe:')), - JSON.stringify(result.cdpDiagnostics) - ).toBe(true) - expect( - result.cdpDiagnostics.some((message) => message.startsWith('attached:worker:')), - JSON.stringify(result.cdpDiagnostics) - ).toBe(true) - expect( - result.receipts - .filter(({ path }) => path === '/cross-site-frame') - .map(({ protocol }) => protocol) - ).toEqual(['https']) - expect( - result.receipts - .filter(({ path }) => path === '/cross-site-frame-fetch') - .map(({ protocol }) => protocol) - ).toEqual(['https']) - expect( - result.cdpRequests.some(({ url, targetType }) => { - return new URL(url).pathname === '/cross-site-frame-fetch' && targetType === 'iframe' - }), - JSON.stringify(result.cdpRequests.filter(({ url }) => url.includes('cross-site-frame-fetch'))) - ).toBe(true) -} - -function identityChecks(result: ProbeResult): Readonly> { - const frameIdentity = identityForContext(result.identities, 'cross-site-frame') - const workerIdentity = identityForContext(result.identities, 'dedicated-worker') - const frameReceipt = receiptForPath(result.receipts, '/cross-site-frame-fetch') - const workerScriptReceipt = receiptForPath(result.receipts, '/dedicated-worker.js') - const workerFetchReceipt = receiptForPath(result.receipts, '/dedicated-worker-fetch') - // Compare hints on a subsequent fetch because initial navigation may omit them. - const rootReceipt = receiptForPath(result.receipts, '/report/document') - return { - crossSiteDocument: frameIdentity.userAgent === result.cleanUserAgent, - crossSiteFetch: frameReceipt.userAgent === result.cleanUserAgent, - dedicatedWorkerScript: workerScriptReceipt.userAgent === result.cleanUserAgent, - dedicatedWorkerFetch: - workerIdentity.userAgent === result.cleanUserAgent && - workerFetchReceipt.userAgent === result.cleanUserAgent, - clientHints: clientHintIdentityIsClean( - rootReceipt, - identityForContext(result.identities, 'document'), - result.cleanUserAgent - ) - } -} - -function clientHintIdentityIsClean( - receipt: WireProbeReceipt, - identity: WireProbeJavaScriptIdentity, - cleanUserAgent: string -): boolean { - // Native UA-CH can remain Chromium-shaped; require a clean legacy UA on the same request. - const secChUa = receipt.clientHints['sec-ch-ua'] - const wireBrands = parseSecChUa(secChUa) - const navigatorBrands = readNavigatorBrands(identity.userAgentData) - if (!secChUa || wireBrands.length === 0 || navigatorBrands.length === 0) { - return false - } - const token = /electron|orca/i - return ( - receipt.userAgent === cleanUserAgent && - !token.test(receipt.userAgent ?? '') && - !token.test(secChUa) && - !navigatorBrands.some(({ brand, version }) => token.test(brand) || token.test(version)) && - sameBrands(wireBrands, navigatorBrands) - ) -} - -function parseSecChUa(value: string | undefined): { brand: string; version: string }[] { - if (!value) { - return [] - } - const brands: { brand: string; version: string }[] = [] - const pattern = /"([^"]+)"\s*;\s*v="([^"]*)"/g - for (const match of value.matchAll(pattern)) { - const brand = match[1] - const version = match[2] - if (brand !== undefined && version !== undefined) { - brands.push({ brand, version }) - } - } - return brands -} - -function readNavigatorBrands(value: unknown): { brand: string; version: string }[] { - if (typeof value !== 'object' || value === null) { - return [] - } - const brandsValue = Object.entries(value).find(([key]) => key === 'brands')?.[1] - if (!Array.isArray(brandsValue)) { - return [] - } - const brands: { brand: string; version: string }[] = [] - for (const entry of brandsValue) { - if (typeof entry !== 'object' || entry === null) { - continue - } - const fields = Object.fromEntries(Object.entries(entry)) - const brand = fields.brand - const version = fields.version - if (typeof brand === 'string' && typeof version === 'string') { - brands.push({ brand, version }) - } - } - return brands -} - -function sameBrands( - left: readonly { brand: string; version: string }[], - right: readonly { brand: string; version: string }[] -): boolean { - const normalize = (brands: readonly { brand: string; version: string }[]) => - brands.map(({ brand, version }) => `${brand}\u0000${version}`).sort() - return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right)) -} - -function identityForContext( - identities: readonly WireProbeJavaScriptIdentity[], - context: string -): WireProbeJavaScriptIdentity { - const matches = identities.filter((identity) => identity.context === context) - expect(matches, context).toHaveLength(1) - return matches[0]! -} - -function receiptForPath(receipts: readonly WireProbeReceipt[], path: string): WireProbeReceipt { - const matches = receipts.filter((receipt) => receipt.path === path) - expect(matches, path).not.toHaveLength(0) - return matches[0]! -} - -async function reservePort(): Promise { - const server = createServer() - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', resolve) - }) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('cdp port unavailable') - } - await new Promise((resolve) => server.close(() => resolve())) - return address.port -} - -function waitForProcess(process: ChildProcess): Promise<{ code: number | null; stderr: string }> { - let stderr = '' - process.stderr?.setEncoding('utf8') - process.stderr?.on('data', (chunk: string) => { - stderr += chunk - }) - return new Promise((resolve, reject) => { - process.once('error', reject) - process.once('exit', (code) => resolve({ code, stderr })) - }) -} diff --git a/src/main/browser/sessions/partition-policies/browser-session-partition-proxy-install.test.ts b/src/main/browser/sessions/partition-policies/browser-session-partition-proxy-install.test.ts deleted file mode 100644 index edccd0a8004..00000000000 --- a/src/main/browser/sessions/partition-policies/browser-session-partition-proxy-install.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { fromPartitionMock } = vi.hoisted(() => { - const sessionsByPartition = new Map>>() - const fromPartitionMock = vi.fn((partition: string) => { - const existing = sessionsByPartition.get(partition) - if (existing) { - return existing - } - const created = { - resolveProxy: vi.fn(async () => 'DIRECT'), - setProxy: vi.fn(async () => {}), - closeAllConnections: vi.fn(async () => {}), - getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/43.0.0 Orca/1.0'), - setUserAgent: vi.fn(), - setPermissionRequestHandler: vi.fn(), - setPermissionCheckHandler: vi.fn(), - setDisplayMediaRequestHandler: vi.fn(), - removeListener: vi.fn(), - on: vi.fn() - } - sessionsByPartition.set(partition, created) - return created - }) - return { sessionsByPartition, fromPartitionMock } -}) - -const identityState = vi.hoisted(() => ({ unavailable: false })) - -vi.mock('electron', () => ({ - session: { - defaultSession: { resolveProxy: vi.fn(async () => 'DIRECT'), setProxy: vi.fn(async () => {}) }, - fromPartition: fromPartitionMock - } -})) -vi.mock('../../guest-lifecycle/manager/browser-manager', () => ({ - browserManager: { - installCertificateRequestGuard: vi.fn(), - removeCertificateRequestGuard: vi.fn(), - notifyPermissionDenied: vi.fn(), - handleGuestWillDownload: vi.fn() - } -})) -vi.mock('../../site-settings/permissions/browser-media-access', () => ({ - hasSystemMediaAccess: vi.fn(() => false), - requestSystemMediaAccess: vi.fn(async () => false) -})) -vi.mock('../../identity/requests/browser-session-ua', () => ({ - installBrowserSessionUserAgentPolicy: vi.fn(() => vi.fn()) -})) -vi.mock('../../identity/browser-process-user-agent', () => ({ - getBrowserProcessUserAgentIdentity: () => { - if (identityState.unavailable) { - throw new Error('Browser process user agent is not initialized') - } - return { - mode: 'clean', - userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36' - } - } -})) -vi.mock('../../webauthn/browser-webauthn-access', () => ({ - allowsBrowserWebAuthnPermission: vi.fn(() => false), - clearBrowserWebAuthnAccessHandlers: vi.fn(), - installBrowserWebAuthnAccessHandlers: vi.fn() -})) - -import { - clearBrowserSessionPartitionPolicies, - installBrowserSessionPartitionPolicies -} from './browser-session-partition-policies' - -import { - applyBrowserSessionProxies, - setBrowserNetworkProxySettingsResolver -} from '../proxy/browser-session-proxy' -import { handleElectronProxyLogin } from '../../../network/electron-proxy-credentials' - -let partitionCounter = 0 -function nextProfile() { - partitionCounter += 1 - const partition = `persist:orca-browser-session-install-${partitionCounter}` - return { - id: `p${partitionCounter}`, - scope: 'isolated' as const, - partition, - label: 'p', - source: null - } -} - -describe('installBrowserSessionPartitionPolicies proxy wiring', () => { - beforeEach(() => { - setBrowserNetworkProxySettingsResolver(null) - // Ignore host-shell proxy variables when testing settings. - for (const key of [ - 'HTTP_PROXY', - 'http_proxy', - 'HTTPS_PROXY', - 'https_proxy', - 'ALL_PROXY', - 'all_proxy' - ]) { - vi.stubEnv(key, '') - } - }) - - afterEach(() => { - setBrowserNetworkProxySettingsResolver(null) - vi.unstubAllEnvs() - identityState.unavailable = false - }) - - it('reports an unavailable process identity through the promise, not a synchronous throw', async () => { - const profile = nextProfile() - identityState.unavailable = true - - let installation: Promise | undefined - expect(() => { - installation = installBrowserSessionPartitionPolicies(profile) - }).not.toThrow() - await expect(installation).rejects.toThrow('Browser process user agent is not initialized') - }) - - it('does not report a new partition ready until Electron finishes applying its proxy', async () => { - let finishWrite: (() => void) | undefined - setBrowserNetworkProxySettingsResolver(() => ({ - httpProxyUrl: 'socks5://127.0.0.1:1080', - httpProxyBypassRules: '' - })) - const profile = nextProfile() - const sess = fromPartitionMock(profile.partition) - sess.setProxy.mockImplementation(() => new Promise((resolve) => (finishWrite = resolve))) - - let ready = false - const installation = installBrowserSessionPartitionPolicies(profile).then(() => (ready = true)) - await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1)) - expect(ready).toBe(false) - - finishWrite?.() - await installation - expect(ready).toBe(true) - }) - - it('recovers partition readiness after one transient proxy rejection', async () => { - setBrowserNetworkProxySettingsResolver(() => ({ - httpProxyUrl: 'http://proxy.example:8080', - httpProxyBypassRules: '' - })) - const profile = nextProfile() - const sess = fromPartitionMock(profile.partition) - sess.setProxy.mockRejectedValueOnce(new Error('transient proxy failure')) - - await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined() - - expect(sess.setProxy).toHaveBeenCalledTimes(2) - }) - - it('forgets failed startup credentials and restores them after recovery', async () => { - setBrowserNetworkProxySettingsResolver(() => ({ - httpProxyUrl: 'http://alice:secret@proxy.example:8080', - httpProxyBypassRules: '' - })) - const profile = nextProfile() - const sess = fromPartitionMock(profile.partition) - sess.closeAllConnections.mockRejectedValue(new Error('proxy settlement failed')) - - await expect(installBrowserSessionPartitionPolicies(profile)).rejects.toThrow( - 'proxy settlement failed' - ) - const failedCallback = vi.fn() - handleElectronProxyLogin( - { preventDefault: vi.fn() } as never, - { session: sess } as never, - {} as never, - { isProxy: true, host: 'proxy.example', port: 8080 }, - failedCallback - ) - expect(failedCallback).not.toHaveBeenCalled() - - sess.closeAllConnections.mockResolvedValue(undefined) - await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined() - const recoveredCallback = vi.fn() - handleElectronProxyLogin( - { preventDefault: vi.fn() } as never, - { session: sess } as never, - {} as never, - { isProxy: true, host: 'proxy.example', port: 8080 }, - recoveredCallback - ) - expect(recoveredCallback).toHaveBeenCalledWith('alice', 'secret') - }) - - it('cancels an in-flight policy retry when partition policies are cleared', async () => { - let proxyUrl = 'http://old.example:8080' - let finishWrite: (() => void) | undefined - setBrowserNetworkProxySettingsResolver(() => ({ httpProxyUrl: proxyUrl })) - const profile = nextProfile() - const sess = fromPartitionMock(profile.partition) - sess.setProxy.mockImplementationOnce( - () => new Promise((resolve) => (finishWrite = resolve)) - ) - - const installation = installBrowserSessionPartitionPolicies(profile) - await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1)) - clearBrowserSessionPartitionPolicies(profile.partition, sess as never) - proxyUrl = 'http://new.example:8080' - await applyBrowserSessionProxies([], { httpProxyUrl: proxyUrl }) - finishWrite?.() - await installation - - expect(sess.setProxy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/browser/site-settings/display/browser-site-display-controller.test.ts b/src/main/browser/site-settings/display/browser-site-display-controller.test.ts index 8ef505bf2d9..c9fc9e8eebd 100644 --- a/src/main/browser/site-settings/display/browser-site-display-controller.test.ts +++ b/src/main/browser/site-settings/display/browser-site-display-controller.test.ts @@ -4,10 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' -import { - browserPageZoomLevelToPercent, - normalizeBrowserPageZoomLevel -} from '../../../../shared/browser-page-zoom' +import { browserPageZoomLevelToPercent } from '../../../../shared/browser-page-zoom' import { BrowserSiteDisplayController } from './browser-site-display-controller' import { configureBrowserSiteDisplayStore, @@ -55,12 +52,6 @@ function fakeGuest(url: string): FakeGuest { return guest } -function commit(controller: BrowserSiteDisplayController, guest: FakeGuest, url: string): void { - guest.url = url - controller.onMainFrameNavigationCommitted(guest.id) - guest.emit('dom-ready') -} - function uiStore(initial: Partial = {}): { getUI: () => PersistedUIState onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void @@ -89,29 +80,6 @@ beforeEach(() => { }) describe('BrowserSiteDisplayController zoom', () => { - it('keeps zoom per site and browser profile, and resets it to Default Zoom', () => { - const controller = new BrowserSiteDisplayController() - const ui = uiStore({ browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(0.5) }) - controller.followZoomSettings(ui) - const github = fakeGuest('https://github.com/a') - const githubOtherPort = fakeGuest('https://github.com:8443/b') - const githubWork = fakeGuest('https://github.com/c') - const docs = fakeGuest('https://docs.example.com/') - controller.onGuestRegistered(github, null) - controller.onGuestRegistered(githubOtherPort, 'default') - controller.onGuestRegistered(githubWork, 'work') - controller.onGuestRegistered(docs, null) - - controller.zoomPage(github.id, 'in') - - expect([github, githubOtherPort, githubWork, docs].map(percent)).toEqual([125, 125, 110, 110]) - commit(controller, docs, 'https://github.com/d') - expect(percent(docs)).toBe(125) - - expect(controller.zoomPage(github.id, 'reset')).toMatchObject({ siteHasZoom: false }) - expect([github, githubOtherPort, docs].map(percent)).toEqual([110, 110, 110]) - }) - it("restores the site's zoom after Orca's UI zoom is copied onto the page", () => { const controller = new BrowserSiteDisplayController() const ui = uiStore() @@ -141,41 +109,6 @@ describe('BrowserSiteDisplayController zoom', () => { }) describe('BrowserSiteDisplayController color scheme', () => { - it('uses chat media for document guests without changing zoom, and upgrades browser registration', () => { - const controller = new BrowserSiteDisplayController() - const preview = fakeGuest('orca-preview://grant/theme.html') - preview.level = 1 - controller.onGuestDocumentStart(preview, { - url: preview.url, - profileId: null, - colorScheme: 'dark' - }) - controller.onMainFrameNavigationCommitted(preview.id) - expect(preview.mediaSent).toEqual(['dark', 'dark']) - expect(preview.level).toBe(1) - expect(preview.listenerCount('dom-ready')).toBe(0) - - const local = fakeGuest('file:///chat-scratch/theme.html') - controller.onGuestDocumentStart(local, { - url: local.url, - profileId: 'work', - colorScheme: 'dark' - }) - controller.onGuestRegistered(local, 'work') - controller.setColorScheme(local.id, 'light') - controller.setGuestColorSchemeFallback(local, 'light') - controller.setGuestColorSchemeFallback(local, 'dark') - expect(local.mediaSent.at(-1)).toBe('light') - expect(controller.getPageDisplay(local.id)?.colorScheme).toBe('light') - controller.setColorScheme(local.id, null) - expect(local.mediaSent.at(-1)).toBe('dark') - controller.setGuestColorSchemeFallback(local, undefined) - commit(controller, local, 'https://ordinary.example/') - expect(local.mediaSent.slice(-2)).toEqual(['dark', '']) - controller.onGuestRetired(preview.id) - expect(preview.debugger.isAttached()).toBe(false) - }) - it.each([ { order: 'read-first', profileId: 'work', colorScheme: 'light', saved: 'dark' }, { order: 'registration-first', profileId: 'work', colorScheme: 'light', saved: 'dark' } diff --git a/src/main/browser/site-settings/permissions/browser-site-permission-prompt.test.ts b/src/main/browser/site-settings/permissions/browser-site-permission-prompt.test.ts index 80141f91612..59b43a07dc8 100644 --- a/src/main/browser/site-settings/permissions/browser-site-permission-prompt.test.ts +++ b/src/main/browser/site-settings/permissions/browser-site-permission-prompt.test.ts @@ -20,19 +20,12 @@ import { BROWSER_PAGE_PROMPT_TIMEOUT_MS, respondToBrowserPagePrompt } from '../../guest-lifecycle/prompts/browser-page-prompts' -import { - decideBrowserSitePermissions, - isBrowserSitePermissionAllowed -} from './browser-site-permission-prompt' +import { decideBrowserSitePermissions } from './browser-site-permission-prompt' import { savedBrowserSitePermissions, - sessionBrowserSitePermissions, type BrowserSitePermissionMemory } from './browser-site-permission-memory' -import { - configureBrowserSitePermissionStore, - listBrowserSitePermissions -} from './browser-site-permission-store' +import { configureBrowserSitePermissionStore } from './browser-site-permission-store' type MockGuest = Electron.WebContents & EventEmitter @@ -83,20 +76,6 @@ function ask( return decideBrowserSitePermissions({ requester: guest, frameOrigin, memory, permissions }) } -function allowedOnCheck( - permissions: BrowserSitePermission[], - { - memory = savedBrowserSitePermissions('default'), - frameOrigin = 'https://meet.example.com' - }: { memory?: BrowserSitePermissionMemory; frameOrigin?: string } = {} -): boolean { - return isBrowserSitePermissionAllowed({ - source: { pageUrl: 'https://meet.example.com/room', frameOrigin }, - memory, - permissions - }) -} - async function answerLatest(value: unknown): Promise { const request = sentRequests(renderer).at(-1) if (!request) { @@ -140,57 +119,4 @@ describe('browser site permission prompts', () => { await expect(ask(['camera', 'microphone'])).resolves.toBe(false) expect(sentRequests(renderer)).toHaveLength(2) }) - - it("keeps a paired server's answers for its session only, where checks still see them", async () => { - const pairedSession = sessionBrowserSitePermissions('persist:paired-a') - const call = ask(['notifications'], { memory: pairedSession }) - await answerLatest('allow') - await expect(call).resolves.toBe(true) - - expect(allowedOnCheck(['notifications'], { memory: pairedSession })).toBe(true) - await expect(ask(['notifications'], { memory: pairedSession })).resolves.toBe(true) - expect(sentRequests(renderer)).toHaveLength(1) - expect(listBrowserSitePermissions('default')).toEqual([]) - void ask(['notifications'], { memory: sessionBrowserSitePermissions('persist:paired-b') }) - expect(sentRequests(renderer)).toHaveLength(2) - }) - - it("gives another site's frame only its own notification answer, and never asks for it", async () => { - const tab = ask(['notifications']) - await answerLatest('allow') - await tab - const widgetPage = ask(['notifications'], { guest: mockGuest('https://widget.example.net/') }) - await answerLatest('allow') - await widgetPage - - expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://widget.example.net' })).toBe( - true - ) - await expect( - ask(['notifications'], { frameOrigin: 'https://widget.example.net' }) - ).resolves.toBe(true) - expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://ads.example.net' })).toBe( - false - ) - await expect(ask(['notifications'], { frameOrigin: 'https://ads.example.net' })).resolves.toBe( - false - ) - expect(sentRequests(renderer)).toHaveLength(2) - }) - - it('remembers answers across a restart', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-site-permissions-')) - configureBrowserSitePermissionStore(directory) - const call = ask(['midi']) - await answerLatest('allow') - await call - - // Switch directories to evict the cache and verify the next read comes from disk. - configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))) - expect(listBrowserSitePermissions('default')).toEqual([]) - configureBrowserSitePermissionStore(directory) - expect(listBrowserSitePermissions('default')).toEqual([ - { origin: 'https://meet.example.com', decisions: { midi: 'allow' } } - ]) - }) }) diff --git a/src/main/crash-reporting/memory/renderer-heap-allocation-profile.ts b/src/main/crash-reporting/memory/renderer-heap-allocation-profile.ts index a09aa66bdd3..2fd412f59fc 100644 --- a/src/main/crash-reporting/memory/renderer-heap-allocation-profile.ts +++ b/src/main/crash-reporting/memory/renderer-heap-allocation-profile.ts @@ -14,8 +14,10 @@ import { const SAMPLING_INTERVAL_BYTES = 512 * 1024 const START_HEAP_RATIO = 0.6 const STOP_HEAP_RATIO = 0.5 -// Main refreshes independently because hidden renderers throttle memory samples. -const PROFILE_REFRESH_MS = 30_000 +// Each profile read forces a full renderer GC, so after the first read require this much growth. +const PROFILE_READ_GROWTH_RATIO = 0.05 +// Main checks the heap itself because hidden renderers throttle memory samples. +const HEAP_CHECK_MS = 30_000 export type ProfiledDebugger = { isAttached(): boolean @@ -61,8 +63,8 @@ export function profiledRendererFor(contents: WebContents): ProfiledRenderer { export type RendererHeapReading = { heapRatio: number + heapLimitBytes: number rendererSurface: string - isHighwaterMark: boolean } type SamplingSession = { @@ -70,9 +72,13 @@ type SamplingSession = { startedAtPct: number startedAtMs: number trustedScriptPrefix: string | null + heapLimitBytes: number + admittedHeapRatio: number + profileReadAdmitted: boolean // Wait for startSampling before requesting a profile. ready: boolean reading: boolean + checking: boolean stop: () => void } @@ -94,23 +100,31 @@ export function noteRendererHeapReading( } return } - if (reading.heapRatio < STOP_HEAP_RATIO) { + noteSessionHeapRatio(renderer, session, reading.heapRatio, false, record) +} + +function noteSessionHeapRatio( + renderer: ProfiledRenderer, + session: SamplingSession, + heapRatio: number, + fromHeapCheck: boolean, + record: (data: CrashReportBreadcrumbData) => void +): void { + if (heapRatio < STOP_HEAP_RATIO) { session.stop() return } - if (reading.isHighwaterMark) { - refreshProfile(renderer, session, record) - } -} - -function refreshProfile( - renderer: ProfiledRenderer, - session: SamplingSession, - record: (data: CrashReportBreadcrumbData) => void -): void { if (!session.ready || session.reading) { return } + // The first heap check reads even without growth so a steady high heap still has evidence. + const firstReadDue = fromHeapCheck && !session.profileReadAdmitted + if (!firstReadDue && heapRatio < session.admittedHeapRatio + PROFILE_READ_GROWTH_RATIO) { + return + } + // Admission, not success, consumes the allowance: a failed read may already have paid for its GC. + session.profileReadAdmitted = true + session.admittedHeapRatio = Math.max(session.admittedHeapRatio, heapRatio) session.reading = true void renderer.debugger .sendCommand('HeapProfiler.getSamplingProfile') @@ -137,7 +151,7 @@ function startSampling( record: (data: CrashReportBreadcrumbData) => void ): void { const debug = renderer.debugger - // Do not take over a debugger attached by DevTools or another diagnostic client. + // Only sees Orca's own debugger client (the lag capture); other CDP sessions are not visible here. if (debug.isAttached()) { return } @@ -159,7 +173,7 @@ function startSampling( debug.removeListener('detach', onDetach) unwatch() if (!renderer.isDestroyed() && debug.isAttached()) { - void debug.sendCommand('HeapProfiler.stopSampling').catch(() => undefined) + // Detaching ends the inspector session, which stops sampling without stopSampling's forced GC. try { debug.detach() } catch { @@ -175,8 +189,12 @@ function startSampling( startedAtPct: Math.round(reading.heapRatio * 100), startedAtMs: Date.now(), trustedScriptPrefix: trustedScriptPrefixFor(renderer.getURL()), + heapLimitBytes: reading.heapLimitBytes, + admittedHeapRatio: reading.heapRatio, + profileReadAdmitted: false, ready: false, reading: false, + checking: false, stop } sessions.set(renderer.id, session) @@ -192,15 +210,40 @@ function startSampling( return } session.ready = true - refreshTimer = setInterval( - () => refreshProfile(renderer, session, record), - PROFILE_REFRESH_MS - ) + refreshTimer = setInterval(() => checkHeap(renderer, session, record), HEAP_CHECK_MS) refreshTimer.unref?.() }) .catch(stop) } +function checkHeap( + renderer: ProfiledRenderer, + session: SamplingSession, + record: (data: CrashReportBreadcrumbData) => void +): void { + if (session.checking) { + return + } + session.checking = true + void renderer.debugger + .sendCommand('Runtime.getHeapUsage') + .then((result) => { + const used = + result && typeof result === 'object' && 'usedSize' in result ? result.usedSize : undefined + if ( + typeof used === 'number' && + Number.isFinite(used) && + sessions.get(renderer.id) === session + ) { + noteSessionHeapRatio(renderer, session, used / session.heapLimitBytes, true, record) + } + }) + .catch(() => undefined) + .finally(() => { + session.checking = false + }) +} + export function profileRendererMemoryBreadcrumb( renderer: ProfiledRenderer, args: { name?: unknown; data?: unknown } | undefined, @@ -217,21 +260,29 @@ export function profileRendererMemoryBreadcrumb( function readHeapReading( args: { name?: unknown; data?: unknown } | undefined ): RendererHeapReading | null { - const isHighwaterMark = args?.name === 'renderer_memory_highwater' const data = args?.data - if ((!isHighwaterMark && args?.name !== 'renderer_memory') || !data || typeof data !== 'object') { + if ( + (args?.name !== 'renderer_memory_highwater' && args?.name !== 'renderer_memory') || + !data || + typeof data !== 'object' + ) { return null } const used = 'usedHeapMB' in data ? data.usedHeapMB : undefined const limit = 'heapLimitMB' in data ? data.heapLimitMB : undefined - if (typeof used !== 'number' || typeof limit !== 'number' || !(limit > 0)) { + if ( + typeof used !== 'number' || + !Number.isFinite(used) || + typeof limit !== 'number' || + !(limit > 0) + ) { return null } const surface = 'rendererSurface' in data ? data.rendererSurface : undefined return { heapRatio: used / limit, - rendererSurface: typeof surface === 'string' ? surface : 'unknown', - isHighwaterMark + heapLimitBytes: limit * 1024 * 1024, + rendererSurface: typeof surface === 'string' ? surface : 'unknown' } } diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 7fcbbd801d3..09524f343cc 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -29,10 +29,12 @@ import { startDaemonScopeDeathWatch } from './process-scopes/daemon-scope-death- import { isWindowsProcessTableAvailable } from '../windows/windows-process-table' import { leaseAppImageGenerationForCurrentProcess } from '../cli/appimage/appimage-generation-lease' import { createDaemonShutdownDeadline } from './launch/daemon-shutdown-deadline' +import { setDaemonProcessName } from './daemon-process-name' async function main(): Promise { // The parent closes startup stderr after readiness; later EPIPE errors must not kill the daemon. process.stderr.on('error', () => {}) + setDaemonProcessName() const { socketPath, diff --git a/src/main/daemon/daemon-process-name.ts b/src/main/daemon/daemon-process-name.ts new file mode 100644 index 00000000000..188422f9650 --- /dev/null +++ b/src/main/daemon/daemon-process-name.ts @@ -0,0 +1,13 @@ +import { writeFileSync } from 'node:fs' + +export function setDaemonProcessName(): void { + if (process.platform !== 'linux') { + return + } + try { + // process.title overwrites argv, which daemon adoption and recovery use as identity evidence. + writeFileSync('/proc/self/comm', 'orca-agents') + } catch { + // Naming must not prevent terminals starting when procfs is unavailable or read-only. + } +} diff --git a/src/main/daemon/executable-host/macos/macos-daemon-bundle-retirement.ts b/src/main/daemon/executable-host/macos/macos-daemon-bundle-retirement.ts index 08ae78c820a..7982fc78158 100644 --- a/src/main/daemon/executable-host/macos/macos-daemon-bundle-retirement.ts +++ b/src/main/daemon/executable-host/macos/macos-daemon-bundle-retirement.ts @@ -2,7 +2,7 @@ import { readdir } from 'node:fs/promises' import { join } from 'node:path' import { runProcess } from '@orca/process-host' import { rm } from '../../../filesystem/asar-transparent-fs' -import { macDaemonCopyBundlePath } from './macos-daemon-bundle' +import { MAC_TERMINAL_HOST_BUNDLE } from './macos-daemon-bundle' const LSREGISTER = '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister' @@ -27,13 +27,20 @@ export async function retireUnusedMacDaemonCopy(directory: string): Promise {}) + // Old copies retain their original bundle name when the helper is renamed. + const parent = join(directory, 'app.noindex') + const bundles = await readdir(parent).catch(() => []) + for (const bundle of bundles) { + if (bundle !== MAC_TERMINAL_HOST_BUNDLE && bundle !== 'Orca Terminal Host.app') { + continue + } + await runProcess({ + program: LSREGISTER, + args: ['-u', join(parent, bundle)], + timeoutMs: 5_000, + maxOutputBytes: 8192 + }).catch(() => {}) + } await rm(directory, { recursive: true, force: true }) } catch { // Retain code when its use cannot be verified. diff --git a/src/main/daemon/executable-host/macos/macos-daemon-bundle.ts b/src/main/daemon/executable-host/macos/macos-daemon-bundle.ts index ecdcfe54fa1..65e8eb7a190 100644 --- a/src/main/daemon/executable-host/macos/macos-daemon-bundle.ts +++ b/src/main/daemon/executable-host/macos/macos-daemon-bundle.ts @@ -7,9 +7,9 @@ import { rm } from '../../../filesystem/asar-transparent-fs' import { ensurePrivateDir } from '../../endpoint/publication/daemon-private-file-modes' import { inspectMacProcessCodeIdentity } from './daemon-mac-code-identity' -/** Plain Node helper packaged by config/scripts/macos-terminal-host-bundle.cjs. */ -export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Terminal Host.app' -export const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host' +/** Plain Node helper packaged by config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs. */ +export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Agents.app' +export const MAC_TERMINAL_HOST_EXECUTABLE = 'Orca Agents' export const MAC_DAEMON_COPY_PREFIX = 'terminal-host-' export type MacDaemonBundle = { diff --git a/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle-retirement.test.ts b/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle-retirement.test.ts index 091f660c456..61988c9001e 100644 --- a/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle-retirement.test.ts +++ b/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle-retirement.test.ts @@ -29,9 +29,9 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -async function copy(name: string): Promise { +async function copy(name: string, bundle = 'Orca Agents.app'): Promise { const directory = join(root, name) - await mkdir(join(directory, 'app.noindex', 'Orca Terminal Host.app'), { recursive: true }) + await mkdir(join(directory, 'app.noindex', bundle), { recursive: true }) return directory } @@ -68,6 +68,27 @@ it.each([ expect(await exists(directory)).toBe(true) }) +it.each(['Orca Agents.app', 'Orca Terminal Host.app'])( + 'unregisters an unused %s before deleting its copy', + async (bundle) => { + const directory = await copy('terminal-host-old', bundle) + run.mockImplementation(async (spec) => { + if (spec.program === LSREGISTER) { + expect(await exists(directory)).toBe(true) + } + return unused + }) + await retireUnusedMacDaemonCopy(directory) + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + program: LSREGISTER, + args: ['-u', join(directory, 'app.noindex', bundle)] + }) + ) + expect(await exists(directory)).toBe(false) + } +) + it("retires every unused copy and staging dir except the current build's and this process's staging", async () => { const current = await copy('terminal-host-current') const oldUnused = await copy('terminal-host-old') diff --git a/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle.test.ts b/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle.test.ts index 28c8b3d75b4..450f0bbfac1 100644 --- a/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle.test.ts +++ b/src/main/daemon/executable-host/macos/testing/macos-daemon-bundle.test.ts @@ -35,13 +35,13 @@ let verifyCode: number let onCopy: (() => Promise) | null const signal = (): AbortSignal => new AbortController().signal -const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'orca-terminal-host') +const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'Orca Agents') beforeEach(async () => { root = await realpath(await mkdtemp(join(tmpdir(), 'orca-mac-runtime-'))) // Model an update that moved the running bundle and replaced its original path. source = join(root, 'com.stablyai.orca.ShipIt.old', 'Orca.app') - helper = join(source, 'Contents', 'Helpers', 'Orca Terminal Host.app') + helper = join(source, 'Contents', 'Helpers', 'Orca Agents.app') copies = join(root, 'profile', 'daemon-host', 'macos') await mkdir(join(source, 'Contents', 'MacOS'), { recursive: true }) await mkdir(join(helper, 'Contents', 'MacOS'), { recursive: true }) @@ -101,14 +101,7 @@ it('publishes one verified copy per helper build, from the running app rather th await rm(source, { recursive: true }) expect(copy.directory).toBe(join(copies, `terminal-host-${cdhash}`)) expect(copy.execPath).toBe( - join( - copy.directory, - 'app.noindex', - 'Orca Terminal Host.app', - 'Contents', - 'MacOS', - 'orca-terminal-host' - ) + join(copy.directory, 'app.noindex', 'Orca Agents.app', 'Contents', 'MacOS', 'Orca Agents') ) expect(await readFile(copy.execPath, 'utf8')).toBe('signed-node') expect(await readFile(copy.entryPath, 'utf8')).toBe('daemon-code') @@ -136,9 +129,9 @@ it("rejects a copy that does not satisfy the running app's requirement and publi it('keeps the winning copy when a concurrent publisher renames first', async () => { onCopy = async () => { onCopy = null - const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Terminal Host.app') + const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Agents.app') await cp(helper, winner, { recursive: true }) - await writeFile(join(winner, 'Contents', 'MacOS', 'orca-terminal-host'), 'winner') + await writeFile(join(winner, 'Contents', 'MacOS', 'Orca Agents'), 'winner') } const copy = await materializeMacDaemonBundle(copies, signal()) expect(await readFile(copy.execPath, 'utf8')).toBe('winner') @@ -155,7 +148,7 @@ it('stops at the shared deadline and removes the staging copy', async () => { return controller.signal.aborted ? { ...ok, code: null } : (signed?.(spec) ?? ok) }) await expect(materializeMacDaemonBundle(copies, controller.signal)).rejects.toThrow( - 'Could not copy Orca Terminal Host.app' + 'Could not copy Orca Agents.app' ) expect(await readdir(copies)).toEqual([]) }) diff --git a/src/main/daemon/executable-host/macos/testing/macos-daemon-launchd.test.ts b/src/main/daemon/executable-host/macos/testing/macos-daemon-launchd.test.ts index ae57f9a34a4..4422d64ff2e 100644 --- a/src/main/daemon/executable-host/macos/testing/macos-daemon-launchd.test.ts +++ b/src/main/daemon/executable-host/macos/testing/macos-daemon-launchd.test.ts @@ -92,7 +92,7 @@ const nativePlatform = process.platform const originalGetuid = Object.getOwnPropertyDescriptor(process, 'getuid') const originalGetSystemVersion = Object.getOwnPropertyDescriptor(process, 'getSystemVersion') const roomyDeadline = (): number => Date.now() + 60_000 -const helperExec = (): string => join(copyDir, 'MacOS', 'orca-terminal-host') +const helperExec = (): string => join(copyDir, 'MacOS', 'Orca Agents') const helperEntry = (): string => join(copyDir, 'daemon-entry.js') const service = (): string => `gui/501/${macDaemonJobLabel(state.root)}` const launchctlCalls = (verb: string): ProcessSpec[] => diff --git a/src/main/daemon/testing/daemon-process-name.integration.test.ts b/src/main/daemon/testing/daemon-process-name.integration.test.ts new file mode 100644 index 00000000000..67273230b47 --- /dev/null +++ b/src/main/daemon/testing/daemon-process-name.integration.test.ts @@ -0,0 +1,70 @@ +import { once } from 'node:events' +import { createInterface } from 'node:readline' +import { spawnProcess } from '@orca/process-host' +import { describe, expect, it } from 'vitest' +import { inspectDaemonProcessIdentity } from '../endpoint/identity/daemon-pid-identity' + +const namingModule = new URL('../daemon-process-name.ts', import.meta.url) + +describe.skipIf(process.platform !== 'linux')('Linux daemon process naming', () => { + it.each([false, true])( + 'preserves live daemon identity when naming writes are denied: %s', + async (denyWrites) => { + const socketPath = '/tmp/orca naming contract/daemon.sock' + const tokenPath = `/tmp/orca naming contract/${'long endpoint component/'.repeat(12)}daemon.token` + const script = ` + import { readFileSync } from 'node:fs' + import { setDaemonProcessName } from ${JSON.stringify(namingModule.href)} + const before = readFileSync('/proc/self/cmdline').toString('base64') + const beforeName = readFileSync('/proc/self/comm').toString('base64') + setDaemonProcessName() + process.stdin.resume() + console.log([ + before, + readFileSync('/proc/self/cmdline').toString('base64'), + beforeName, + readFileSync('/proc/self/comm').toString('base64') + ].join(' ')) + ` + const child = spawnProcess({ + program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath, + args: [ + ...(denyWrites ? ['--permission', '--allow-fs-read=*'] : []), + '--input-type=module', + '-e', + script, + '--', + 'daemon-entry.js', + '--socket', + socketPath, + '--token', + tokenPath + ] + }) + const exited = once(child, 'exit') + const lines = createInterface({ input: child.stdout }) + try { + const [line] = await Promise.race([ + once(lines, 'line'), + exited.then(() => { + throw new Error('Naming fixture exited before publishing its process identity') + }) + ]) + const [before, after, beforeName, afterName] = String(line).split(' ') + expect(Buffer.from(before, 'base64').toString()).toContain(tokenPath) + expect(after).toBe(before) + expect(Buffer.from(afterName, 'base64').toString()).toBe( + denyWrites ? Buffer.from(beforeName, 'base64').toString() : 'orca-agents\n' + ) + expect(child.pid).toBeDefined() + expect( + await inspectDaemonProcessIdentity(child.pid ?? 0, socketPath, tokenPath, null) + ).toBe('match') + } finally { + lines.close() + child.stdin.end() + await exited + } + } + ) +}) diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index d116f2f7b90..d4687d919fd 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -137,6 +137,9 @@ async function gitExecFileAsyncUnlocked( ) { await terminationState.current const wasMissing = invalidateMissingDirectWslGit(error, resolved) + if (options.canStart?.() === false) { + throw createAbortError() + } const fallback = resolveGitCommand(args, effectiveOptions, true, true) result = await capture(fallback) // Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient. diff --git a/src/main/git/refs/resolution/repo-default-base-ref.ts b/src/main/git/refs/resolution/repo-default-base-ref.ts index f811ffd7bb4..aec7701f864 100644 --- a/src/main/git/refs/resolution/repo-default-base-ref.ts +++ b/src/main/git/refs/resolution/repo-default-base-ref.ts @@ -5,12 +5,15 @@ import { resolveDefaultBaseRefViaExec } from '../../../../shared/git/refs/git-de export type LocalGitExecOptions = { wslDistro?: string admissionTier?: GitAdmissionTier + /** Checked at admission; a bounded optional read refuses git that would start past its deadline. */ + canStart?: () => boolean } export type LocalDefaultBaseRefGitOptions = { cwd: string wslDistro?: string admissionTier?: GitAdmissionTier + canStart?: () => boolean } export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 @@ -22,7 +25,8 @@ export function gitExecOptions( return { cwd, ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}), + ...(options.canStart ? { canStart: options.canStart } : {}) } } diff --git a/src/main/git/worktrees/creation/worktree-base-ref-probe.ts b/src/main/git/worktrees/creation/worktree-base-ref-probe.ts index 8d0f1c6b10a..25b67689b97 100644 --- a/src/main/git/worktrees/creation/worktree-base-ref-probe.ts +++ b/src/main/git/worktrees/creation/worktree-base-ref-probe.ts @@ -8,6 +8,7 @@ import { resolveWorktreeAddBaseRef } from '../../../../shared/worktree/base-refs type GitExecOptions = { wslDistro?: string admissionTier?: GitAdmissionTier + canStart?: () => boolean } /** Return the probe's commit OID so callers need not resolve it again. */ diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts index ae5ac61df6f..84a2d78e74d 100644 --- a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -10,13 +10,24 @@ import { hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../work import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' import { blockedWslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate' import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' +import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source' +import { readCreateSourceSetupHooks } from '../../workspace-hooks/create-source-setup-hooks' +import { getWorktreeCreatePrefetchGitOptions } from '../../workspaces/execution-host/project-runtime-git-options' export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void { - const { store } = context + const { store, runtime } = context ipcMain.handle( 'hooks:check', - async (_event, args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => { + async ( + _event, + args: { + repoId: string + hostId?: ExecutionHostId + background?: boolean + createSource?: SetupHooksCreateSource + } + ) => { const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) if (!repo) { const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) @@ -58,6 +69,23 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v } } + if (args.createSource && !args.background) { + const created = await readCreateSourceSetupHooks({ + repo, + source: args.createSource, + runtime, + gitOptions: getWorktreeCreatePrefetchGitOptions(store, repo) + }) + if (created) { + return { + status: 'ok', + hasHooks: created.hooks !== null, + hooks: created.hooks, + mayNeedUpdate: false + } + } + } + const has = hasHooksFile(repo.path) const hooks = scopeHooksToRepoKind(repo, has ? loadHooks(repo.path) : null) // Well-formed unknown keys may come from a newer Orca; suggest updating instead of parse repair. diff --git a/src/main/observability/git-execution-spans.ts b/src/main/observability/git-execution-spans.ts index 8f93bdd9619..8915b331af0 100644 --- a/src/main/observability/git-execution-spans.ts +++ b/src/main/observability/git-execution-spans.ts @@ -1,7 +1,7 @@ import { hasActiveTraceSink, startSpan, withSpan, type ActiveSpan } from './tracer' import { getGitOperationCause } from './git-operation-cause' import { resolveGitAdmissionTier } from '../git/command-runner/admission/git-operation-executor' -import type { GitAdmissionTier } from '../../shared/rpc-contract/git-admission-tier-params' +import type { GitAdmissionTier } from '../../shared/rpc-contract/source-control/git-admission-tier-params' const GIT_ACTION_NAMES: Record = { worktree: ['list', 'add', 'remove', 'move', 'prune', 'lock', 'unlock', 'repair'], diff --git a/src/main/runtime/client/events/runtime-notifier-contract.ts b/src/main/runtime/client/events/runtime-notifier-contract.ts index a91a15a9255..299078fa642 100644 --- a/src/main/runtime/client/events/runtime-notifier-contract.ts +++ b/src/main/runtime/client/events/runtime-notifier-contract.ts @@ -1,4 +1,5 @@ import type { WorkspaceMetadataSet } from '../../../../shared/worktree/metadata/workspace-metadata-set' +import type { WorkspaceCleanupRunFinishedEvent } from '../../../../shared/workspace-cleanup/workspace-cleanup-run' import type { RuntimeEditorTabReveal, RuntimeFileOpenPosition @@ -55,6 +56,7 @@ export type RuntimeNotifier = { worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void browserProfilesChanged?(): void + workspaceCleanupRunFinished?(run: WorkspaceCleanupRunFinishedEvent['run']): void activateWorktree( repoId: string, worktreeId: string, diff --git a/src/main/runtime/construction/runtime-repository-command-state.ts b/src/main/runtime/construction/runtime-repository-command-state.ts index 59f39b3725f..9562eb18589 100644 --- a/src/main/runtime/construction/runtime-repository-command-state.ts +++ b/src/main/runtime/construction/runtime-repository-command-state.ts @@ -21,6 +21,8 @@ import { storeBackedBranchSwitchSources } from '../integrations/git/runtime-branch-switch-host' import { OrcaRuntimeWithFileCommands } from './runtime-file-command-state' +import { readCreateSourceSetupHooks } from '../../workspace-hooks/create-source-setup-hooks' +import { getWorktreeCreatePrefetchGitOptions } from '../../workspaces/execution-host/project-runtime-git-options' export abstract class OrcaRuntimeWithRepositoryCommands extends OrcaRuntimeWithFileCommands { protected readonly gitCommands = new RuntimeGitCommands({ @@ -156,7 +158,14 @@ export abstract class OrcaRuntimeWithRepositoryCommands extends OrcaRuntimeWithF protected readonly gitHubProjectCommands = new RuntimeGitHubProjectCommands() protected readonly repositoryHooks = new RuntimeRepositoryHooksCommands({ - resolveRepo: (selector) => this.resolveRepoSelector(selector) + resolveRepo: (selector) => this.resolveRepoSelector(selector), + readCreateSourceHooks: (repo, source) => + readCreateSourceSetupHooks({ + repo, + source, + runtime: this.remoteFetches, + gitOptions: getWorktreeCreatePrefetchGitOptions(this.requireStore(), repo) + }) }) protected readonly repositoryIssueCommand = new RuntimeRepositoryIssueCommand({ diff --git a/src/main/runtime/orchestration/db/federation/peer-mail-store.ts b/src/main/runtime/orchestration/db/federation/peer-mail-store.ts index 4fc7015d837..28948462d3b 100644 --- a/src/main/runtime/orchestration/db/federation/peer-mail-store.ts +++ b/src/main/runtime/orchestration/db/federation/peer-mail-store.ts @@ -75,17 +75,6 @@ export function listHomePeerMailEnvironments(this: OrchestrationDb): string[] { return rows.map((row) => row.environment_id) } -/** One server's check, for its pull loop: a seek on that server's home rows, not every server's. */ -export function hasHomePeerMailEnvironment(this: OrchestrationDb, environmentId: string): boolean { - return ( - this.db - .prepare( - `SELECT 1 FROM peer_mail_conversations WHERE role = 'home' AND environment_id = ? LIMIT 1` - ) - .get(environmentId) !== undefined - ) -} - /** Conversations a paired Orca opened here that hold replies it has not acknowledged yet. */ export function listPeerConversationIdsWithPendingReplies( this: OrchestrationDb, @@ -189,7 +178,6 @@ export type PeerMailStoreMethods = { openHomePeerMailConversation: typeof openHomePeerMailConversation getPeerMailConversation: typeof getPeerMailConversation listHomePeerMailEnvironments: typeof listHomePeerMailEnvironments - hasHomePeerMailEnvironment: typeof hasHomePeerMailEnvironment listPeerConversationIdsWithPendingReplies: typeof listPeerConversationIdsWithPendingReplies getPeerMailMessage: typeof getPeerMailMessage isPeerMailSenderLabel: typeof isPeerMailSenderLabel @@ -201,7 +189,6 @@ export function attachPeerMailStore(ctor: { prototype: object }): void { openHomePeerMailConversation, getPeerMailConversation, listHomePeerMailEnvironments, - hasHomePeerMailEnvironment, listPeerConversationIdsWithPendingReplies, getPeerMailMessage, isPeerMailSenderLabel, diff --git a/src/main/runtime/orchestration/federation/peer-mail/peer-mail-relay-environments.ts b/src/main/runtime/orchestration/federation/peer-mail/peer-mail-relay-environments.ts index d3cc5c2ea91..e83c38f56b9 100644 --- a/src/main/runtime/orchestration/federation/peer-mail/peer-mail-relay-environments.ts +++ b/src/main/runtime/orchestration/federation/peer-mail/peer-mail-relay-environments.ts @@ -6,24 +6,22 @@ export function peerMailRelayEnvironments(db: OrchestrationDb): Set { return new Set([...db.listHomePeerMailEnvironments(), ...listLiveRemoteRunLinkEnvironments(db)]) } -/** Whether a server's pull loop still has a reason to run; a store read error keeps it running. Runs - * every pull, so it asks about this one server only: closed links' history never enters it. */ +// Every pull asks about one server only, so closed links' history never enters it: an index seek +// for a home conversation, then one per live link state, stopping at the first row. One equality +// per state, because an IN list builds a temporary set on every call. +const RELAY_WANTS_SQL = ` +SELECT 1 FROM peer_mail_conversations WHERE role = 'home' AND environment_id = ?1 +UNION ALL +SELECT 1 FROM remote_run_links WHERE environment_id = ?1 AND state = 'open' +UNION ALL +SELECT 1 FROM remote_run_links WHERE environment_id = ?1 AND state = 'cancel_owed' +LIMIT 1` + +/** Whether a server's pull loop still has a reason to run; a store read error keeps it running. */ export function peerMailRelayWants(db: OrchestrationDb, environmentId: string): boolean { try { - return db.hasHomePeerMailEnvironment(environmentId) || hasLiveRemoteRunLink(db, environmentId) + return db.db.prepare(RELAY_WANTS_SQL).get(environmentId) !== undefined } catch { return true } } - -/** A seek on this server's own (environment_id, state) index rows, however many closed links it keeps. */ -function hasLiveRemoteRunLink(db: OrchestrationDb, environmentId: string): boolean { - return ( - db.db - .prepare( - `SELECT 1 FROM remote_run_links - WHERE environment_id = ? AND state IN ('open', 'cancel_owed') LIMIT 1` - ) - .get(environmentId) !== undefined - ) -} diff --git a/src/main/runtime/orchestration/workers/turn-stops/failed-worker-start-report.ts b/src/main/runtime/orchestration/workers/turn-stops/failed-worker-start-report.ts index 4b2fbfc7335..f9d21e1c70e 100644 --- a/src/main/runtime/orchestration/workers/turn-stops/failed-worker-start-report.ts +++ b/src/main/runtime/orchestration/workers/turn-stops/failed-worker-start-report.ts @@ -1,5 +1,5 @@ import type { OrchestrationDb } from '../../db/orchestration-db' -import type { WorkerDispatchRow } from '../../db/orchestration-records' +import type { DispatchContextRow, WorkerDispatchRow } from '../../db/orchestration-records' import { reportWorkerTurnStop } from './worker-turn-stop-report' /** What a failed start left behind, as its residual resources name it. */ @@ -28,6 +28,32 @@ function describeLeftover(residualResources: string): string | undefined { return described.length > 0 ? described.join(', ') : undefined } +function reusesTerminal(worker: WorkerDispatchRow): boolean { + try { + const options: unknown = JSON.parse(worker.start_options) + return ( + typeof options === 'object' && options !== null && 'terminal' in options && !!options.terminal + ) + } catch { + return false + } +} + +/** A start that failed after its terminal opened still has output to read; a local reuse start + * that failed before binding never ran in the terminal it named. Federated rows never bind here. */ +export function failedWorkerStartHasOutput( + db: OrchestrationDb, + worker: WorkerDispatchRow, + dispatch: DispatchContextRow | undefined +): boolean { + const unboundReuse = + reusesTerminal(worker) && + !dispatch?.assignee_handle && + !dispatch?.assignee_orca_session_id && + !db.getFederatedDispatch(worker.dispatch_id) + return Boolean(worker.agent_terminal_handle || dispatch?.assignee_handle) && !unboundReuse +} + /** A failed start leaves its task unowned, and the caller may never have read the start's receipt. */ export function reportFailedWorkerStart( db: OrchestrationDb, @@ -51,8 +77,7 @@ export function reportFailedWorkerStart( cause: `it never started: ${worker.last_error ?? 'its start failed'}`, lastOutput: undefined, leftover: describeLeftover(worker.residual_resources), - // A start that failed after its terminal opened still has output to read. - ...(worker.agent_terminal_handle || dispatch.assignee_handle ? {} : { neverStarted: true }), + ...(failedWorkerStartHasOutput(db, worker, dispatch) ? {} : { neverStarted: true }), ...(settled ? { settled } : {}) }, { getDb: () => db, isWaitingForMail: () => false, notify } diff --git a/src/main/runtime/repositories/runtime-repository-hooks-commands.ts b/src/main/runtime/repositories/runtime-repository-hooks-commands.ts index 844dc691151..c5c8016f0a4 100644 --- a/src/main/runtime/repositories/runtime-repository-hooks-commands.ts +++ b/src/main/runtime/repositories/runtime-repository-hooks-commands.ts @@ -19,9 +19,14 @@ import { inspectSetupScriptImportCandidates } from '../../../shared/workspace-se import { joinWorktreeRelativePath } from '../files/paths/runtime-relative-paths' import { blockedWslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate' import { hashOrcaHookTrustContent } from '../../workspace-hooks/created-worktree-setup-trust' +import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source' type RuntimeRepositoryHooksCommandsDeps = { resolveRepo: (selector: string) => Promise + readCreateSourceHooks: ( + repo: Repo, + source: SetupHooksCreateSource + ) => Promise<{ hooks: OrcaHooks | null } | undefined> } export class RuntimeRepositoryHooksCommands { @@ -60,7 +65,10 @@ export class RuntimeRepositoryHooksCommands { } } - async checkRepoHooks(repoSelector: string, options: { background?: boolean } = {}) { + async checkRepoHooks( + repoSelector: string, + options: { background?: boolean; createSource?: SetupHooksCreateSource } = {} + ) { const repo = await this.deps.resolveRepo(repoSelector) if (isFolderRepo(repo) && !isRepoOnCatalogHost(repo)) { return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } @@ -83,6 +91,17 @@ export class RuntimeRepositoryHooksCommands { : ('wsl-distro-unverifiable' as const) } } + if (options.createSource && !options.background) { + const created = await this.deps.readCreateSourceHooks(repo, options.createSource) + if (created) { + return { + status: 'ok' as const, + hasHooks: created.hooks !== null, + hooks: created.hooks, + mayNeedUpdate: false + } + } + } const has = hasHooksFile(repo.path) const hooks = scopeHooksToRepoKind(repo, has ? loadHooks(repo.path) : null) return { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/start/failed-worker-start-report.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/start/failed-worker-start-report.test.ts index efa444c6ec9..ba0dceae158 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/start/failed-worker-start-report.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/start/failed-worker-start-report.test.ts @@ -51,4 +51,32 @@ describe('a worker-start that fails', () => { expect(lost?.from_handle).toMatch(/^dispatch:/) expect(h.runtime.orchestrationSenderNames.sender(lost!.from_handle).agent).toBe('claude') }) + + it('treats a reuse start that failed before binding as never started', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_reused', + condition: 'tui-idle', + satisfied: false, + status: 'running', + exitCode: null + }) + const task = h.db.createTask({ spec: 'follow up on the PR', runId: h.activeRunId }) + const receipt = await h.call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + terminal: 'term_reused' + }) + expect(receipt).toMatchObject({ state: 'failed' }) + const dispatchId = h.db.getDispatchContext(task.id)?.id + + const [lost] = h.db.getUnreadRunMailbox(h.activeRunId, 100, ['escalation']) + expect(lost?.body).not.toContain('worker-read') + await expect( + h.call('orchestration.workerRead', { dispatch: dispatchId }) + ).rejects.toMatchObject({ + code: 'worker_never_started', + message: expect.stringContaining('never started: Agent did not become ready') + }) + }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts index 9ea94c34645..71ba02a4de5 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-control.ts @@ -18,6 +18,7 @@ import { sessionIdFromStructuredWorkerIncarnation } from '../../../../orchestrat import { readExactWorkerOutput } from './output/worker-output' import { exposeWorkerTerminalResource } from './release/worker-terminal-resource-presentation' import { liveWorkerTerminalAutoReleaseAt } from '../../../../orchestration/terminal-authority/release/worker-terminal-ownership' +import { failedWorkerStartHasOutput } from '../../../../orchestration/workers/turn-stops/failed-worker-start-report' import { readFederatedWorkerOutput } from '../../../../orchestration/legacy-remote-home/federated-worker-read' import { showFederatedWorker } from '../../../../orchestration/legacy-remote-home/federated-worker-show' import { @@ -93,8 +94,8 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS = [ const dispatch = db.getDispatchContextById(params.dispatch) const worker = db.getWorkerDispatch(params.dispatch) const terminalHandle = worker?.agent_terminal_handle ?? dispatch?.assignee_handle - // A start that never got a terminal has only its failure, on any host. - if (worker?.state === 'failed' && !terminalHandle) { + // A start that never ran in a terminal has only its failure, on any host. + if (worker?.state === 'failed' && !failedWorkerStartHasOutput(db, worker, dispatch)) { throw new OrchestrationError( 'worker_never_started', `Worker Dispatch ${params.dispatch} never started: ${worker.last_error ?? 'its start failed'}` diff --git a/src/main/runtime/rpc/methods/projects/repo.ts b/src/main/runtime/rpc/methods/projects/repo.ts index 21672047b84..da9c4181b46 100644 --- a/src/main/runtime/rpc/methods/projects/repo.ts +++ b/src/main/runtime/rpc/methods/projects/repo.ts @@ -233,7 +233,10 @@ export const REPO_METHODS = [ permission: 'workspace', params: RepoHooksCheck, handler: async (params, { runtime }) => - runtime.checkRepoHooks(params.repo, { background: repoHooksCheckIsBackground(params) }) + runtime.checkRepoHooks(params.repo, { + background: repoHooksCheckIsBackground(params), + createSource: params.createSource + }) }), defineMethod({ name: 'repo.setupScriptImports', diff --git a/src/main/runtime/workspaces/catalog/runtime-workspace-change-events.ts b/src/main/runtime/workspaces/catalog/runtime-workspace-change-events.ts index 90226e7b05e..9169085e2f3 100644 --- a/src/main/runtime/workspaces/catalog/runtime-workspace-change-events.ts +++ b/src/main/runtime/workspaces/catalog/runtime-workspace-change-events.ts @@ -69,6 +69,7 @@ export abstract class RuntimeWorkspaceChangeEvents extends NativeChatLaunchDraft /** The cleanup runner finished a run on this host; clients may toast. */ notifyWorkspaceCleanupRunFinished(run: WorkspaceCleanupRunFinishedEvent['run']): void { + this.notifier?.workspaceCleanupRunFinished?.(run) this.emitClientEvent({ type: 'workspaceCleanupRunFinished', run }) } } diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 22c9b64e9ef..f1bc293bfc6 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -52,6 +52,7 @@ export function registerRuntimeWindowLifecycle( reposChanged: () => send('repos:changed'), browserProfilesChanged: () => send('browser:session:profilesChanged'), automationsChanged: (payload) => send('automations:changed', payload), + workspaceCleanupRunFinished: (run) => send('workspaceCleanup:runFinished', run), activateWorktree: ( repoId, worktreeId, diff --git a/src/main/workspace-hooks/create-source-setup-hooks-real-git.test.ts b/src/main/workspace-hooks/create-source-setup-hooks-real-git.test.ts new file mode 100644 index 00000000000..56dc35be59b --- /dev/null +++ b/src/main/workspace-hooks/create-source-setup-hooks-real-git.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { gitExecFileAsync } from '../git/command-runner/git-exec-file' +import { RuntimeRemoteFetchController } from '../runtime/repositories/runtime-remote-fetch-controller' +import { readCreateSourceSetupHooks } from './create-source-setup-hooks' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +const commitArgs = ['-c', 'user.name=Test', '-c', 'user.email=test@example.com'] + +async function git(cwd: string, ...args: string[]): Promise { + await gitExecFileAsync(args, { cwd }) +} + +async function commitOrcaYaml(cwd: string, setupLine: string, eol = '\n'): Promise { + const yaml = ['scripts:', ' setup: |', ` ${setupLine}`, ''].join(eol) + await writeFile(join(cwd, 'orca.yaml'), yaml) + await git(cwd, 'add', 'orca.yaml') + await git(cwd, ...commitArgs, 'commit', '-m', setupLine) +} + +// A project checkout left on old setup commands while origin/main moved on. +async function staleCheckout(): Promise<{ repo: Repo; upstream: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-create-source-hooks-')) + roots.push(root) + const upstream = join(root, 'upstream') + const checkout = join(root, 'checkout') + await git(root, 'init', '-b', 'main', upstream) + await git(upstream, 'config', 'core.autocrlf', 'false') + await commitOrcaYaml(upstream, 'node old-setup.mjs') + await git(root, 'clone', upstream, checkout) + await commitOrcaYaml(upstream, 'node new-setup.mjs', '\r\n') + const repo: Repo = { + id: 'repo-1', + path: checkout, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + return { repo, upstream } +} + +function read(repo: Repo, source: Parameters[0]['source']) { + return readCreateSourceSetupHooks({ + repo, + source, + runtime: new RuntimeRemoteFetchController(), + gitOptions: {} + }) +} + +it('reads the refreshed base instead of the stale project checkout', async () => { + const { repo } = await staleCheckout() + + const result = await read(repo, { kind: 'base', baseBranch: 'origin/main' }) + + expect(result?.hooks?.scripts.setup).toBe('node new-setup.mjs') +}) + +it('reads an explicitly reused local branch, and the base when that branch is absent', async () => { + const { repo } = await staleCheckout() + await git(repo.path, 'branch', 'feature', 'main') + + const reused = await read(repo, { kind: 'branch', ref: 'feature', baseBranch: 'origin/main' }) + const created = await read(repo, { kind: 'branch', ref: 'missing', baseBranch: 'origin/main' }) + + expect(reused?.hooks?.scripts.setup).toBe('node old-setup.mjs') + expect(created?.hooks?.scripts.setup).toBe('node new-setup.mjs') +}) diff --git a/src/main/workspace-hooks/create-source-setup-hooks.ts b/src/main/workspace-hooks/create-source-setup-hooks.ts new file mode 100644 index 00000000000..9d5656a70e1 --- /dev/null +++ b/src/main/workspace-hooks/create-source-setup-hooks.ts @@ -0,0 +1,166 @@ +import type { Repo } from '../../shared/repo-types' +import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' +import { parseOrcaYaml } from '../../shared/orca-yaml' +import { scopeHooksToRepoKind } from '../../shared/folder-workspace-hooks' +import { isFolderRepo } from '../../shared/repo-kind' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-refs/base-ref' +import type { SetupHooksCreateSource } from '../../shared/worktree/setup-hooks-create-source' +import { gitExecFileAsync } from '../git/command-runner/git-exec-file' +import { + getDefaultBaseRefAsync, + type LocalGitExecOptions +} from '../git/refs/resolution/repo-default-base-ref' +import { + hasLocalWorktreeBaseRef, + hasWorktreeBaseCommitRef +} from '../git/worktrees/creation/worktree-base-ref-probe' +import { resolveWorktreeCreateBase } from '../workspaces/creation/worktree-create-base' +import type { + RemoteFetchResult, + RemoteTrackingBase +} from '../runtime/repositories/runtime-remote-fetch-controller' + +// Leaves the 15 s hooks-check RPC room to fall back to the checkout read. +const CREATE_SOURCE_READ_DEADLINE_MS = 8_000 + +export type CreateSourceSetupHooksRuntime = { + resolveRemoteTrackingBase: ( + repoPath: string, + baseBranch: string, + options?: LocalGitExecOptions + ) => Promise + hasRemoteTrackingRef: ( + repoPath: string, + base: RemoteTrackingBase, + options?: LocalGitExecOptions + ) => Promise + getOrStartRemoteTrackingBaseRefresh: ( + repoPath: string, + base: RemoteTrackingBase, + options?: LocalGitExecOptions + ) => Promise +} + +class DeadlinePassed extends Error {} + +/** + * The orca.yaml hooks at the ref a create will check out. Undefined when that ref can't be read in + * time; callers then read the project checkout, as before, and the host's created-worktree check stays the backstop. + */ +export async function readCreateSourceSetupHooks(args: { + repo: Repo + source: SetupHooksCreateSource + runtime: CreateSourceSetupHooksRuntime + gitOptions: LocalGitExecOptions +}): Promise<{ hooks: OrcaHooks | null } | undefined> { + if (isFolderRepo(args.repo)) { + return undefined + } + const deadline = Date.now() + CREATE_SOURCE_READ_DEADLINE_MS + // Git admission refuses past the deadline, even for a step the race already abandoned. + const gated = { ...args.gitOptions, canStart: () => Date.now() < deadline } + const step = async (work: () => Promise): Promise => { + const remaining = deadline - Date.now() + if (remaining <= 0) { + throw new DeadlinePassed() + } + let timer: ReturnType | undefined + try { + return await Promise.race([ + work(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new DeadlinePassed()), remaining) + }) + ]) + } finally { + clearTimeout(timer) + } + } + try { + const ref = await step(() => resolveCreateSourceRef(args, gated, step)) + return ref ? await step(() => readOrcaYamlAtRef(args.repo, ref, gated)) : undefined + } catch { + return undefined + } +} + +async function resolveCreateSourceRef( + args: Parameters[0], + gitOptions: LocalGitExecOptions, + step: (work: () => Promise) => Promise +): Promise { + const { repo, source, runtime } = args + // Explicit reuse checks out exactly the local branch when it exists, otherwise creates it from the base. + if (source.kind === 'branch') { + const localRef = `refs/heads/${source.ref}` + if (await step(() => hasWorktreeBaseCommitRef(repo.path, localRef, gitOptions))) { + return localRef + } + } + const baseBranch = await step(() => + resolveWorktreeCreateBase({ + requestedBaseBranch: source.baseBranch, + repoWorktreeBaseRef: repo.worktreeBaseRef, + resolveDefaultBaseRef: () => getDefaultBaseRefAsync(repo.path, gitOptions), + isBaseUsable: async (candidate) => { + const remoteBase = await runtime.resolveRemoteTrackingBase(repo.path, candidate, gitOptions) + if (remoteBase && (await runtime.hasRemoteTrackingRef(repo.path, remoteBase, gitOptions))) { + return true + } + return hasLocalWorktreeBaseRef(repo.path, candidate, gitOptions) + } + }) + ) + if (!baseBranch) { + return null + } + // Refresh exactly when create would, sharing its in-flight fetch and freshness. + const remoteBase = await step(() => + runtime.resolveRemoteTrackingBase(repo.path, baseBranch, gitOptions) + ) + if (remoteBase) { + const [hasRemoteRef, hasLocalRef] = await step(() => + Promise.all([ + runtime.hasRemoteTrackingRef(repo.path, remoteBase, gitOptions), + hasLocalWorktreeBaseRef(repo.path, baseBranch, gitOptions) + ]) + ) + if (hasRemoteRef || !hasLocalRef) { + // Ungated and never aborted: past the deadline create joins this same fetch. + await step(() => + runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteBase, args.gitOptions) + ) + } + } + const qualified = await step(() => + resolveWorktreeAddBaseRef(baseBranch, (candidate) => + hasWorktreeBaseCommitRef(repo.path, candidate, gitOptions) + ) + ) + return (await step(() => hasWorktreeBaseCommitRef(repo.path, qualified, gitOptions))) + ? qualified + : null +} + +async function readOrcaYamlAtRef( + repo: Repo, + ref: string, + gitOptions: LocalGitExecOptions +): Promise<{ hooks: OrcaHooks | null } | undefined> { + const { stdout: entry } = await gitExecFileAsync(['ls-tree', ref, '--', 'orca.yaml'], { + cwd: repo.path, + ...gitOptions + }) + if (!entry.trim()) { + return { hooks: null } + } + // A symlink's blob is its target path; the checkout follows it, so read the checkout instead. + if (!entry.startsWith('100')) { + return undefined + } + const { stdout } = await gitExecFileAsync(['show', `${ref}:orca.yaml`], { + cwd: repo.path, + ...gitOptions + }) + return { hooks: scopeHooksToRepoKind(repo, parseOrcaYaml(stdout)) } +} diff --git a/src/main/workspaces/cleanup/workspace-cleanup-run-summary.ts b/src/main/workspaces/cleanup/workspace-cleanup-run-summary.ts index b865a5ea5b1..494c39c996a 100644 --- a/src/main/workspaces/cleanup/workspace-cleanup-run-summary.ts +++ b/src/main/workspaces/cleanup/workspace-cleanup-run-summary.ts @@ -1,6 +1,12 @@ -import type { WorkspaceCleanupEvaluation } from '../../../shared/workspace-cleanup/workspace-cleanup-evaluation' +import type { + WorkspaceCleanupEvaluation, + WorkspaceCleanupEvaluationInput +} from '../../../shared/workspace-cleanup/workspace-cleanup-evaluation' +import { hasWorktreeAtRiskLocalFiles } from '../../../shared/worktree/removal/at-risk-local-files' import { WORKSPACE_CLEANUP_REPORT_ROW_LIMIT, + WORKSPACE_CLEANUP_ROW_IGNORED_FILE_LIMIT, + type WorkspaceCleanupRowDetails, WORKSPACE_CLEANUP_UNWATCHED_CAUSES, type WorkspaceCleanupRun, type WorkspaceCleanupRunOutcome, @@ -12,6 +18,26 @@ export type WorkspaceCleanupKeptOutcome = Pick< 'outcome' | 'reason' | 'keepCause' | 'waiting' > +/** What the evidence already says about a row, for its label; no new Git reads. */ +export function workspaceCleanupRowDetails( + input: Pick +): WorkspaceCleanupRowDetails { + const review = input.git.merge?.mergedReview + const files = input.git.atRiskLocalFiles + return { + ...(review?.containsHead ? { prNumber: review.number } : {}), + ...(files && hasWorktreeAtRiskLocalFiles(files) + ? { + ignoredFiles: { + count: files.count, + samplePaths: files.samplePaths.slice(0, WORKSPACE_CLEANUP_ROW_IGNORED_FILE_LIMIT), + complete: files.complete + } + } + : {}) + } +} + export function keptRunOutcome( evaluation: WorkspaceCleanupEvaluation ): WorkspaceCleanupKeptOutcome { diff --git a/src/main/workspaces/cleanup/workspace-cleanup-runner-preview.test.ts b/src/main/workspaces/cleanup/workspace-cleanup-runner-preview.test.ts new file mode 100644 index 00000000000..4ccc129a108 --- /dev/null +++ b/src/main/workspaces/cleanup/workspace-cleanup-runner-preview.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { RuntimeStore } from '../../runtime/construction/runtime-store-contract' +import { getDefaultWorkspaceCleanupRules } from '../../../shared/workspace-cleanup/workspace-cleanup-policy' + +const { collectWorkspaces } = vi.hoisted(() => ({ collectWorkspaces: vi.fn() })) + +vi.mock('./workspace-cleanup-run-evidence', () => ({ + collectWorkspaceCleanupWorkspaces: collectWorkspaces +})) +vi.mock('../space-analysis/workspace-space-analysis-snapshot', () => ({ + readWorkspaceSpaceAnalysisSnapshot: () => Promise.resolve(null) +})) + +const { WorkspaceCleanupRunner } = await import('./workspace-cleanup-runner') + +function createRunner(): InstanceType { + const store = { + getSettings: () => ({ workspaceCleanupPolicy: getDefaultWorkspaceCleanupRules() }) + } + return new WorkspaceCleanupRunner({ + // SAFETY: preview only reads settings from the store and passes the runtime through to mocked evidence. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above + store: store as unknown as RuntimeStore, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above + runtime: {} as OrcaRuntimeService, + historyDirectory: '/unused' + }) +} + +describe('workspace cleanup runner preview', () => { + afterEach(() => { + collectWorkspaces.mockReset() + }) + + it('shares one evaluation between concurrent previews of the same rules', async () => { + let finish: (value: []) => void = () => {} + collectWorkspaces.mockReturnValue(new Promise<[]>((resolve) => (finish = resolve))) + const runner = createRunner() + + const first = runner.preview() + const second = runner.preview(getDefaultWorkspaceCleanupRules()) + finish([]) + await Promise.all([first, second]) + + expect(collectWorkspaces).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/workspaces/cleanup/workspace-cleanup-runner.ts b/src/main/workspaces/cleanup/workspace-cleanup-runner.ts index 95cab81bd2e..f51f1243c95 100644 --- a/src/main/workspaces/cleanup/workspace-cleanup-runner.ts +++ b/src/main/workspaces/cleanup/workspace-cleanup-runner.ts @@ -16,6 +16,7 @@ import type { WorkspaceCleanupReport, WorkspaceCleanupRun, WorkspaceCleanupRunRow, + WorkspaceCleanupRowDetails, WorkspaceCleanupRunTrigger, WorkspaceCleanupWorkspaceRef } from '../../../shared/workspace-cleanup/workspace-cleanup-run' @@ -24,7 +25,11 @@ import { type WorkspaceCleanupWorkspace } from './workspace-cleanup-run-evidence' import { decideWorkspaceCleanup, removeWorkspaceForCleanup } from './workspace-cleanup-removal-step' -import { keptRunOutcome, summarizeWorkspaceCleanupRun } from './workspace-cleanup-run-summary' +import { + keptRunOutcome, + summarizeWorkspaceCleanupRun, + workspaceCleanupRowDetails +} from './workspace-cleanup-run-summary' import { appendWorkspaceCleanupRun, readWorkspaceCleanupRunHistory @@ -39,7 +44,7 @@ const SETTINGS_DEBOUNCE_MS = 10 * 1000 type DecidedWorkspace = { workspace: WorkspaceCleanupWorkspace - ref: WorkspaceCleanupWorkspaceRef & { sizeBytes?: number } + ref: WorkspaceCleanupWorkspaceRef & WorkspaceCleanupRowDetails & { sizeBytes?: number } evaluation: WorkspaceCleanupEvaluation } @@ -54,6 +59,8 @@ export type WorkspaceCleanupRunnerDeps = { export class WorkspaceCleanupRunner { private readonly deps: WorkspaceCleanupRunnerDeps private current: Promise | null = null + // Every caller asking for the same rules while one evaluation runs shares it: reopened pages, several clients. + private readonly previews = new Map>() private timer: ReturnType | null = null private nextRunAt: number | null = null private enabledRules = new Set() @@ -107,9 +114,28 @@ export class WorkspaceCleanupRunner { } /** What a run would do now, removing nothing; `rules` previews other global rules. */ - async preview(rules?: WorkspaceCleanupRules): Promise { + preview(rules?: WorkspaceCleanupRules): Promise { + const effective = rules ?? this.globalRules() + const key = JSON.stringify(effective) + const shared = this.previews.get(key) + if (shared) { + return shared + } + const evaluation = this.evaluatePreview(effective) + this.previews.set(key, evaluation) + void evaluation + .catch(() => undefined) + .finally(() => { + if (this.previews.get(key) === evaluation) { + this.previews.delete(key) + } + }) + return evaluation + } + + private async evaluatePreview(rules: WorkspaceCleanupRules): Promise { const evaluatedAt = Date.now() - const decided = await this.evaluate(rules ?? this.globalRules(), evaluatedAt) + const decided = await this.evaluate(rules, evaluatedAt) return { evaluatedAt, rows: decided.map(({ ref, evaluation }) => ({ ...ref, evaluation })) } } @@ -193,7 +219,11 @@ export class WorkspaceCleanupRunner { const sizeBytes = sizes.get(workspace.ref.worktreeId) decided.push({ workspace, - ref: { ...workspace.ref, ...(sizeBytes !== undefined ? { sizeBytes } : {}) }, + ref: { + ...workspace.ref, + ...workspaceCleanupRowDetails(workspace.input), + ...(sizeBytes !== undefined ? { sizeBytes } : {}) + }, evaluation: await decideWorkspaceCleanup(this.deps.runtime, workspace, now) }) } diff --git a/src/preload/api/agent-hooks/agent-hook-api.ts b/src/preload/api/agent-hooks/agent-hook-api.ts index 37579bff473..2aa5c83398e 100644 --- a/src/preload/api/agent-hooks/agent-hook-api.ts +++ b/src/preload/api/agent-hooks/agent-hook-api.ts @@ -1,10 +1,16 @@ import type { OrcaHooks } from '../../../shared/orca-yaml-hook-types' import type { ExecutionHostId } from '../../../shared/execution-host' import type { SetupScriptImportCandidate } from '../../../shared/workspace-setup/setup-script-imports' +import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source' export type HooksApi = { - /** `background`: a render-driven probe, refused (status 'error') rather than booting a stopped WSL distro. */ - check: (args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => Promise<{ + /** `background`: a render-driven probe, refused (status 'error') rather than booting a stopped WSL distro. `createSource`: read orca.yaml at the ref a create checks out. */ + check: (args: { + repoId: string + hostId?: ExecutionHostId + background?: boolean + createSource?: SetupHooksCreateSource + }) => Promise<{ status?: 'ok' | 'error' hasHooks: boolean hooks: OrcaHooks | null diff --git a/src/preload/api/agent-hooks/hooks-bridge.ts b/src/preload/api/agent-hooks/hooks-bridge.ts index be35b2d12ad..1a8ca98eef0 100644 --- a/src/preload/api/agent-hooks/hooks-bridge.ts +++ b/src/preload/api/agent-hooks/hooks-bridge.ts @@ -1,10 +1,15 @@ import { ipcRenderer } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import type { PreloadApi } from '../../api-types' +import type { SetupHooksCreateSource } from '../../../shared/worktree/setup-hooks-create-source' export const hooksApi = { - check: (args: { repoId: string; hostId?: ExecutionHostId; background?: boolean }) => - ipcRenderer.invoke('hooks:check', args), + check: (args: { + repoId: string + hostId?: ExecutionHostId + background?: boolean + createSource?: SetupHooksCreateSource + }) => ipcRenderer.invoke('hooks:check', args), inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), diff --git a/src/preload/api/workspaces/workspace-cleanup-api.ts b/src/preload/api/workspaces/workspace-cleanup-api.ts index e286bbd2913..a16c8d7b7b2 100644 --- a/src/preload/api/workspaces/workspace-cleanup-api.ts +++ b/src/preload/api/workspaces/workspace-cleanup-api.ts @@ -12,6 +12,7 @@ import type { WorkspaceSpaceScanProgress } from '../../../shared/workspace-disk-space/workspace-space-types' import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceCleanupRunFinishedEvent } from '../../../shared/workspace-cleanup/workspace-cleanup-run' import type { WorkspaceSpaceChange } from '../../../shared/workspace-disk-space/workspace-space-change' export type WorkspaceCleanupApi = { @@ -27,6 +28,8 @@ export type WorkspaceCleanupApi = { beginRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise finishRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise + /** This app's own cleanup runner finished a run. */ + onRunFinished: (callback: (run: WorkspaceCleanupRunFinishedEvent['run']) => void) => () => void } export type WorkspaceSpaceApi = { diff --git a/src/preload/api/workspaces/workspace-cleanup-bridge.ts b/src/preload/api/workspaces/workspace-cleanup-bridge.ts index 4c979e55e84..f7d47eb08b0 100644 --- a/src/preload/api/workspaces/workspace-cleanup-bridge.ts +++ b/src/preload/api/workspaces/workspace-cleanup-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { WorkspaceCleanupScanProgress } from '../../../shared/workspace-cleanup/workspace-cleanup' +import type { WorkspaceCleanupRunFinishedEvent } from '../../../shared/workspace-cleanup/workspace-cleanup-run' import type { PreloadApi } from '../../api-types' export const workspaceCleanupApi = { @@ -30,5 +31,13 @@ export const workspaceCleanupApi = { recordRemovalSnapshotPrune: (args) => ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), finishRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) + ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args), + onRunFinished: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + run: WorkspaceCleanupRunFinishedEvent['run'] + ): void => callback(run) + ipcRenderer.on('workspaceCleanup:runFinished', listener) + return () => ipcRenderer.removeListener('workspaceCleanup:runFinished', listener) + } } satisfies PreloadApi['workspaceCleanup'] diff --git a/src/renderer/src/components/automations/authoring/automation-orca-save.ts b/src/renderer/src/components/automations/authoring/automation-orca-save.ts index e2dd43ee641..9eb54531ce9 100644 --- a/src/renderer/src/components/automations/authoring/automation-orca-save.ts +++ b/src/renderer/src/components/automations/authoring/automation-orca-save.ts @@ -17,7 +17,6 @@ import type { AutomationUpdateInput } from '../../../../../shared/automations/automation-definition' import { getRepoExecutionHostId } from '../../../../../shared/execution-host' -import { ensureHooksApproved } from '@/runtime/repo-hooks/ensure-hooks-confirmed' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store/index' import { listAutomationsForTarget } from '../hosts/automation-host-client' @@ -28,7 +27,7 @@ import { import { buildDraftPrecheck, buildDraftSchedule } from './automation-draft-model' import { buildAutomationRunContextForRepo } from './automation-run-context' import { resolveAutomationSetupDecisionForSave } from './automation-setup-decision' -import { reconfirmSkippedSetup } from './automation-setup-review' +import { confirmAutomationSaveSetup } from './automation-setup-review' import { createAutomationOnDestination, moveAutomationToDestination, @@ -135,14 +134,17 @@ export async function saveOrcaAutomation( draftSetupDecision: draft.setupDecision }) : currentAutomation.setupDecision - const trust = - setupDecision === 'run' && ((targetEdited && !recordedContext) || edited('setupDecision')) - ? await ensureHooksApproved(useAppStore.getState(), draft.projectId, 'setup', setupHostId) - : null - // Stored with `run` so later runs execute only this content. - const setupTrustHash = - trust?.approvedContentHash ?? - (await reconfirmSkippedSetup(context, trust, setupDecision, currentAutomation, setupHostId)) + const { trust, setupTrustHash } = await confirmAutomationSaveSetup(context, { + // A new base can bring different orca.yaml setup, so it needs its own approval. + prompt: + setupDecision === 'run' && + ((targetEdited && !recordedContext) || edited('setupDecision') || edited('baseBranch')), + repoId: draft.projectId, + hostId: setupHostId, + baseBranch: edited('baseBranch') ? draft.baseBranch : currentAutomation?.baseBranch, + setupDecision, + automation: currentAutomation + }) if (trust?.decision === 'skip') { setupDecision = 'skip' } diff --git a/src/renderer/src/components/automations/authoring/automation-setup-review.ts b/src/renderer/src/components/automations/authoring/automation-setup-review.ts index 280510157b4..3e01a078725 100644 --- a/src/renderer/src/components/automations/authoring/automation-setup-review.ts +++ b/src/renderer/src/components/automations/authoring/automation-setup-review.ts @@ -13,6 +13,25 @@ import { getAutomationRunRepoId } from '../../../../../shared/automations/histor import type { SetupDecision, SetupTrustMismatch } from '../../../../../shared/worktree/create-types' import type { ExecutionHostId } from '../../../../../shared/execution-host' +/** Runs create from the automation's base, so approve the orca.yaml there, not the project checkout's. */ +export function approveAutomationSetup( + repoId: string, + hostId: ExecutionHostId | undefined, + baseBranch: string | null | undefined +): Promise { + const createSource = { kind: 'base' as const, baseBranch: baseBranch?.trim() || undefined } + return ensureHooksApproved( + useAppStore.getState(), + repoId, + 'setup', + hostId, + undefined, + undefined, + undefined, + createSource + ) +} + /** A skipped setup still waiting for approval; null once the automation approves that content. */ export type PendingAutomationSetupReview = { content: SetupTrustMismatch | null } @@ -71,15 +90,48 @@ export async function reviewAutomationSkippedSetup( } /** - * Saving an automation whose last run skipped changed setup re-confirms that setup: the skipped - * content when the host recorded it, else the repo's current one. Returns the hash to store. + * A save's setup approval: prompt when the target or setup choice changed, else re-review a skipped run. + * The hash is stored with `run` so later runs execute only this content. */ -export async function reconfirmSkippedSetup( +export async function confirmAutomationSaveSetup( + context: Pick, + args: { + prompt: boolean + repoId: string + hostId: ExecutionHostId | undefined + /** The base this save persists, which an edit may have just changed. */ + baseBranch: string | null | undefined + setupDecision: SetupDecision | undefined + automation: Automation | null + } +): Promise<{ trust: HookTrustApproval | null; setupTrustHash: string | undefined }> { + const trust = args.prompt + ? await approveAutomationSetup(args.repoId, args.hostId, args.baseBranch) + : null + const setupTrustHash = + trust?.approvedContentHash ?? + (await reconfirmSkippedSetup( + context, + trust, + args.setupDecision, + args.automation, + args.hostId, + args.baseBranch + )) + return { trust, setupTrustHash } +} + +/** + * Saving an automation whose last run skipped changed setup re-confirms that setup: the skipped + * content when the host recorded it, else the content at the automation's base. Returns the hash to store. + */ +async function reconfirmSkippedSetup( context: Pick, trust: HookTrustApproval | null, setupDecision: SetupDecision | undefined, automation: Automation | null, - hostId: ExecutionHostId | undefined + hostId: ExecutionHostId | undefined, + baseBranch: string | null | undefined ): Promise { // A changed target or setup choice was just confirmed by the regular save prompt. if (trust || context.local.targetEdited || setupDecision !== 'run' || !automation) { @@ -100,7 +152,7 @@ export async function reconfirmSkippedSetup( hostId, 'automation' ) - : await ensureHooksApproved(useAppStore.getState(), repoId, 'setup', hostId) + : await approveAutomationSetup(repoId, hostId, baseBranch) return approval.decision === 'run' && approval.approvedContentHash !== automation.setupTrustHash ? approval.approvedContentHash : undefined diff --git a/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-view.test.tsx b/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-view.test.tsx index 850b37187a7..9e7638cdb71 100644 --- a/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-view.test.tsx +++ b/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-message-sender-view.test.tsx @@ -4,7 +4,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { render } from '../actions/native-chat-tooltip-test-harness' import { useAppStore } from '@/store' -import { makeTab } from '@/store/terminals/terminal-tab.test-fixture' +import { makeTab } from '@/store/terminals/testing/terminal-tab.test-fixture' import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture' import { TEST_REPO } from '@/store/repos/repo.test-fixture' import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' @@ -13,7 +13,6 @@ import '@/components/sidebar/workspace-card/activity/worktree-agent-row-selector import { useAgentMessageSenderView } from './native-chat-agent-message-sender-view' import { initialStore, status, resetSenderStore } from './native-chat-agent-sender.test-fixture' -const renders = new Map() function Probe({ id, workspace = 'workspace', @@ -41,7 +40,6 @@ function Probe({ remote ? { kind: 'environment', environmentId: 'remote' } : { kind: 'local' }, proof === 'terminal' ? undefined : `${proof === 'gone' ? 'closed' : 'source'}-${sourceId}` ) - renders.set(id, (renders.get(id) ?? 0) + 1) return null } let publicationRevision = 0 @@ -77,7 +75,6 @@ function write(i: number, update: Partial) { beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(2_000_000) - renders.clear() resetSenderStore() publishSources(1) publishSources(10, 'runtime:remote', 'remote') @@ -126,7 +123,7 @@ it.each(['run:coordinator', 'dispatch:past'])( } ) -it('shows only a proved Dispatch invocation and withdraws proof without changing unrelated sender rows', () => { +it('shows only a proved Dispatch invocation and withdraws missing proof', () => { write(0, { agentType: 'codex' }) const saved = { name: 'Worker', @@ -182,15 +179,14 @@ it('shows only a proved Dispatch invocation and withdraws proof without changing const single = renderHook(() => useAgentMessageSenderView(saved, 'workspace', { kind: 'local' })) expect(single.result.current).toMatchObject({ agentType: 'codex', dot: 'working' }) expect(alias.result.current).toEqual({ agentType: 'codex' }) - const before = new Map(renders) act(() => publish(false, 2)) - expect(renders).toEqual(before) + expect(single.result.current.agentType).toBe('codex') expect(single.result.current.dot).toBeUndefined() expect(alias.result.current.agentType).toBe('codex') expect(alias.result.current.dot).toBeUndefined() act(() => publish(false, 3)) - expect(renders).toEqual(before) + expect(alias.result.current.dot).toBeUndefined() // The current terminal alias must not bypass missing source proof. write(0, { state: 'waiting' }) diff --git a/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-sender.test-fixture.ts b/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-sender.test-fixture.ts index 127fd6a14c1..cde81b4c7cb 100644 --- a/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-sender.test-fixture.ts +++ b/src/renderer/src/components/native-chat/messages/agent-senders/native-chat-agent-sender.test-fixture.ts @@ -1,7 +1,7 @@ import { structuredAgentSessionPaneKey } from '../../../../../../shared/agent-session/view/structured-agent-session-projection' import type { OrcaSessionId } from '../../../../../../shared/orca-session-address' import { useAppStore } from '@/store' -import { makeTab } from '@/store/terminals/terminal-tab.test-fixture' +import { makeTab } from '@/store/terminals/testing/terminal-tab.test-fixture' import { makeUnifiedTab } from '@/store/slices/tabs/tab-group.test-fixture' import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' import type { AgentMessageSource } from '../../../../../../shared/agent-session/agent-session-message-source' diff --git a/src/renderer/src/components/native-chat/messages/rows/NativeChatAgentMessageCard.fixture.tsx b/src/renderer/src/components/native-chat/messages/rows/NativeChatAgentMessageCard.fixture.tsx index 0f591dddcce..fbcb49999fe 100644 --- a/src/renderer/src/components/native-chat/messages/rows/NativeChatAgentMessageCard.fixture.tsx +++ b/src/renderer/src/components/native-chat/messages/rows/NativeChatAgentMessageCard.fixture.tsx @@ -1,7 +1,7 @@ import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' import type { NativeChatMessage } from '../../../../../../shared/native-chat/native-chat-types' import { defineFixture } from '../../../../component-fixtures/component-fixture' -import { makeTab } from '@/store/terminals/terminal-tab.test-fixture' +import { makeTab } from '@/store/terminals/testing/terminal-tab.test-fixture' import type { AgentMessageSource } from '../../../../../../shared/agent-session/agent-session-message-source' import { NativeChatAgentMessageCard } from './NativeChatAgentMessageCard' import { MessageRow } from './NativeChatMessageRow' diff --git a/src/renderer/src/components/native-chat/pane/routing/native-chat-layout-actions.ts b/src/renderer/src/components/native-chat/pane/routing/native-chat-layout-actions.ts index ceb92e18243..d70bc3608a7 100644 --- a/src/renderer/src/components/native-chat/pane/routing/native-chat-layout-actions.ts +++ b/src/renderer/src/components/native-chat/pane/routing/native-chat-layout-actions.ts @@ -3,7 +3,7 @@ import { translate } from '@/i18n/i18n' import type { AppState } from '@/store/types' import { useAppStore } from '@/store' import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split' -import { openNewTerminalTabInWorkspace } from '@/store/terminals/terminal-active-workspace-creation' +import { openNewTerminalTabInWorkspace } from '@/store/terminals/tabs/creation/terminal-active-workspace-creation' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' import type { NativeChatSplitDirection } from './native-chat-pane-shortcut' diff --git a/src/renderer/src/components/settings/form-controls/SettingsFormControls.tsx b/src/renderer/src/components/settings/form-controls/SettingsFormControls.tsx index 995a0a52a82..4aa0610a117 100644 --- a/src/renderer/src/components/settings/form-controls/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/form-controls/SettingsFormControls.tsx @@ -253,7 +253,7 @@ type ColorFieldProps = { type NumberFieldProps = { label: string - description: string + description?: string /** undefined renders the field empty — pair it with `placeholder` and `onClear` for an unset state. */ value: number | undefined defaultValue?: number @@ -369,15 +369,17 @@ export function NumberField({ className={className} label={label} description={ - <> - {description} - {defaultValue !== undefined ? ( - - {t('auto.components.settings.SettingsFormControls.b661b034ec', '· Default:')}{' '} - {defaultValue} - - ) : null} - + description || defaultValue !== undefined ? ( + <> + {description} + {defaultValue !== undefined ? ( + + {t('auto.components.settings.SettingsFormControls.b661b034ec', '· Default:')}{' '} + {defaultValue} + + ) : null} + + ) : undefined } control={
diff --git a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts index c90336ad724..798d0163f16 100644 --- a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts +++ b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts @@ -15,6 +15,7 @@ export type SettingsNavInstallStatus = const SETTINGS_NAV_TARGETS = [ 'general', + 'workspace-cleanup', 'integrations', 'accounts', 'browser', diff --git a/src/renderer/src/components/settings/page/settings-page-renderer.tsx b/src/renderer/src/components/settings/page/settings-page-renderer.tsx index 376049e9d6a..df2ec7f741f 100644 --- a/src/renderer/src/components/settings/page/settings-page-renderer.tsx +++ b/src/renderer/src/components/settings/page/settings-page-renderer.tsx @@ -25,7 +25,8 @@ import { renderMobileSettingsSection, renderOrcaAccountSettingsSection, renderSetupGuideSettingsSection, - renderShareSkillsSettingsSection + renderShareSkillsSettingsSection, + renderWorkspaceCleanupSettingsSection } from './settings-setup-workflow-section-renderers' import { renderGitSettingsSection, @@ -169,6 +170,7 @@ export function renderSettingsPage( {renderOrcaAccountSettingsSection(context, t)} {renderSetupGuideSettingsSection(context, t)} {renderGeneralSettingsSection(context, t)} + {renderWorkspaceCleanupSettingsSection(context, t)} {renderIntegrationsSettingsSection(context, t)} {renderMobileSettingsSection(context, t)} {renderAutomationsSettingsSection(context, t)} diff --git a/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx index e70d30dc142..5f8715f59d5 100644 --- a/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-setup-workflow-section-renderers.tsx @@ -13,6 +13,8 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' import type { SettingsRenderContext } from './settings-render-context' import { SettingsGroup } from '../section-layout/SettingsGroup' +import { WorkspaceCleanupSettingsPane } from '../workspace-cleanup/WorkspaceCleanupSettingsPane' +import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target' export function renderOrcaAccountSettingsSection( context: SettingsRenderContext, @@ -236,3 +238,32 @@ export function renderSessionHistorySettingsSection( ) } + +export function renderWorkspaceCleanupSettingsSection( + context: SettingsRenderContext, + t: Translate = translate +): React.JSX.Element | null { + const { model, navigation, view } = context + // A paired browser's settings are its own; the cleanup rules live on the host it pairs with. + return model.showDesktopOnlySettings ? ( + + {view.isSectionMounted('workspace-cleanup') ? ( + + ) : null} + + ) : null +} diff --git a/src/renderer/src/components/settings/section-layout/SettingsGroupDisclosure.tsx b/src/renderer/src/components/settings/section-layout/SettingsGroupDisclosure.tsx index 9a51102871f..612a79198f9 100644 --- a/src/renderer/src/components/settings/section-layout/SettingsGroupDisclosure.tsx +++ b/src/renderer/src/components/settings/section-layout/SettingsGroupDisclosure.tsx @@ -8,6 +8,8 @@ import { SETTINGS_GROUP_NESTED_INSET_CLASS } from './SettingsGroup' type SettingsGroupDisclosureProps = { label?: string + /** A muted hint after the label naming what the disclosure holds. */ + description?: string forceOpen?: boolean children: React.ReactNode } @@ -15,6 +17,7 @@ type SettingsGroupDisclosureProps = { /** Collapsible rows inside a SettingsGroup card, one fixed step in from the card's rows. Search force-opens it so matches remain reachable. */ export function SettingsGroupDisclosure({ label, + description, forceOpen = false, children }: SettingsGroupDisclosureProps): React.JSX.Element { @@ -40,6 +43,9 @@ export function SettingsGroupDisclosure({ )} /> {label ?? t('auto.components.settings.AppearanceAdvancedDisclosure.advanced', 'Advanced')} + {description ? ( + {description} + ) : null} {/* Stays mounted while collapsed so a settings jump can find and reveal its rows. */}