Merge current main to include the corrected git admission import

This commit is contained in:
Brennan Benson
2026-11-13 14:25:58 -08:00
376 changed files with 4145 additions and 8124 deletions
@@ -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> = {}): RelayConfig {
@@ -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<void> {
const client = new pg.Client({ connectionString: databaseUrl })
await client.connect()
try {
await client.query(sql)
} finally {
await client.end()
}
}
const dialects: [string, () => Promise<RelayDatabase>][] = [
['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<RelayDatabase>]
]
: [])
]
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
@@ -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)))
@@ -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 {
@@ -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()
@@ -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) {
@@ -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)
})
})
@@ -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' })
})
})
@@ -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 [
@@ -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<ReturnType<typeof setup>>
@@ -251,18 +187,6 @@ async function activatePreferredSource(
return control
}
async function activateSource(
context: Context,
identity: { userId: string; relayHostId: string }
): Promise<string> {
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,
@@ -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([])
})
})
@@ -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(
@@ -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> = {}): 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<string, unknown>) =>
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<string, unknown> }
}
function config(overrides: Partial<RelayConfig> = {}): 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
}
}
@@ -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<SqlRow[]> {
return await this.delegate.query(sql, params)
}
async queryLocked(
sql: string,
params?: unknown[],
options?: RelayLockOptions
): Promise<SqlRow[]> {
return await this.delegate.queryLocked(sql, params, options)
}
async transaction<T>(operation: (transaction: RelayDatabase) => Promise<T>): Promise<T> {
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<void> {
await this.delegate.close()
}
}
class AfterTransactionDatabase implements RelayDatabase {
private afterTransaction: (() => Promise<void>) | 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({
@@ -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<void> => {
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
})
})
})
@@ -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)
@@ -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)
})
@@ -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()
@@ -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<Response>,
options: { identityFails?: boolean; cells?: SeatFeedCell[]; neverServed?: ReadonlySet<string> } = {}
options: { cells?: SeatFeedCell[]; neverServed?: ReadonlySet<string> } = {}
) {
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] })
@@ -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 }),
@@ -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<boolean>, timeoutMs = 10_000): Promise<void> {
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> | 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<ReturnType<typeof cell>>) =>
(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<ReturnType<typeof cell>>
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<string, Rollback> = {
'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()
@@ -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<void> {
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<void>
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<T>(): {
promise: Promise<T>
resolve(value: T): void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((complete) => {
resolve = complete
})
return { promise, resolve }
}
function createRegistry(
activateControl: RelayAssignmentStore['activateControl'],
store: Partial<RelayCredentialStore> = {},
verifyRelayToken: (token: string) => Promise<RelayTokenClaims | null> = vi.fn()
): {
registry: HostSessionRegistry
activate: ActivateSession
acquireActivity: ReturnType<typeof vi.fn>
renewControlActivities: ReturnType<typeof vi.fn<RelayAssignmentStore['renewControlActivities']>>
releaseActivity: ReturnType<typeof vi.fn>
observer: {
recordAuth: ReturnType<typeof vi.fn>
recordControlClose: ReturnType<typeof vi.fn>
recordPostProofClose: ReturnType<typeof vi.fn>
recordSpliceClose: ReturnType<typeof vi.fn>
}
} {
const acquireActivity = vi.fn().mockResolvedValue(undefined)
const releaseActivity = vi.fn().mockResolvedValue(true)
const renewControlActivities = vi
.fn<RelayAssignmentStore['renewControlActivities']>()
.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<void>
}
).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<string>()
const activateControl = vi
.fn<RelayAssignmentStore['activateControl']>()
.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<ControlRenewalOutcome[]>((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' })
})
})
@@ -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<RelayDatabase> }[] = [
{ name: 'sqlite', open: openInMemoryRelayDatabase },
...(databaseUrl
? [
{
name: 'postgres',
open: () => openRelayDatabase({ databaseUrl, dataDir: '' })
}
]
: [])
]
const databases: RelayDatabase[] = []
async function removeScopedRows(database: RelayDatabase): Promise<void> {
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<RelayDatabase>,
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, {
@@ -63,12 +63,7 @@ async function remainingIds(database: RelayDatabase): Promise<string[]> {
return rows.map((row) => String(row.reservation_id))
}
const dialects: [string, () => Promise<RelayDatabase>][] = [
['sqlite', openInMemoryRelayDatabase],
...(databaseUrl ? [['postgres', openPostgres] as [string, () => Promise<RelayDatabase>]] : [])
]
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)
@@ -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()
}
})
})
@@ -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<SqlRow[]> =>
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()
})
})
@@ -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<void>((resolve) => (release = resolve))
let held!: () => void
const holding = new Promise<void>((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])
}
})
})
@@ -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('-', '')}`
@@ -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 () => {
-11
View File
@@ -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()
})
})
@@ -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<typeof AdminTokenVerifier>()
const identities = new Map<string, Parameters<typeof actual.relayAdminIdentityMayAccess>[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<unknown>,
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'
}
}
@@ -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(
@@ -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<string, unknown>): RequestInit {
return {
method: 'POST',
headers: {
authorization: `Bearer ${relayHostId}`,
'content-type': 'application/json'
},
body: JSON.stringify({ v: 1, relayHostId, ...extra })
}
}
function config(overrides: Partial<RelayConfig> = {}): 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
}
}
@@ -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<number> {
const server = createServer()
await new Promise<void>((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<void>((resolveClose) => server.close(() => resolveClose()))
return address.port
}
async function startRelay(port: number): Promise<ChildProcess> {
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<void>((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<Socket> {
const socket = new WebSocket(`ws://127.0.0.1:${port}/v1/connect/abcdefghijklmnop`)
socket.on('error', () => undefined)
await new Promise<void>((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<void> {
const deadline = Date.now() + 4_000
while (Date.now() < deadline) {
const accepted = await new Promise<boolean>((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<void>((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<void>((resolveClose) => peer.once('close', () => resolveClose()))
const startedAt = Date.now()
const exited = new Promise<number | null>((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)
-2
View File
@@ -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
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -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
@@ -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) {
@@ -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}`)
}
@@ -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([
@@ -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',
@@ -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
)
},
@@ -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',
+1 -1
View File
@@ -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
@@ -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-<cdhash>/app.noindex/Orca Terminal Host.app`. The helper
`userData/daemon-host/macos/terminal-host-<cdhash>/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-<pid>-*` 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:
@@ -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 })
@@ -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<typeof WorktreeScanCacheModule>()),
listWorktrees: facade.listWorktrees
}
})
vi.mock('../../../git/worktrees/catalog/worktree-listing', async (importOriginal) => {
const facade = getWorktreeListingMocks()
return {
...(await importOriginal<typeof WorktreeListingModule>()),
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<typeof createWorktreeListingMocks> | 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<string, unknown>) => {
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<string, ((...args: unknown[]) => 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<string, unknown>) {
for (const handler of debuggerListeners.get('message') ?? []) {
handler({}, method, params)
}
}
}
}
async function sendRequest(
endpoint: string,
request: Record<string, unknown>
): Promise<Record<string, unknown>> {
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<string, unknown>)
})
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<typeof createMockGuest>
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<string, unknown>) {
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')
})
})
@@ -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()
@@ -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')
})
})
@@ -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<typeof createMockScreencastWebContents>,
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
})
})
@@ -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<typeof createGuest>, 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
})
})
@@ -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 })
@@ -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)
@@ -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')
})
})
@@ -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?')
@@ -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')
})
})
@@ -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<string, unknown> {
return {
id: PROFILE_ID,
scope: 'isolated',
partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, PROFILE_ID),
label: 'Existing',
source: null,
userAgentMode: mode
}
}
function unhydratableProfile(extra: Record<string, unknown> = {}): Record<string, unknown> {
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
})
})
})
@@ -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<ProbeResult> {
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<ReturnType<typeof chromium.connectOverCDP>> | 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<void> {
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<Record<string, boolean>> {
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<number> {
const server = createServer()
await new Promise<void>((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<void>((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 }))
})
}
@@ -1,214 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { fromPartitionMock } = vi.hoisted(() => {
const sessionsByPartition = new Map<string, Record<string, ReturnType<typeof vi.fn>>>()
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<void> | 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<void>((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<void>((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)
})
})
@@ -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<PersistedUIState> = {}): {
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' }
@@ -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<void> {
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' } }
])
})
})
@@ -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'
}
}
+2
View File
@@ -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<void> {
// The parent closes startup stderr after readiness; later EPIPE errors must not kill the daemon.
process.stderr.on('error', () => {})
setDaemonProcessName()
const {
socketPath,
+13
View File
@@ -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.
}
}
@@ -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<void
if (!unused) {
return
}
// Direct execution may register this copy with LaunchServices.
await runProcess({
program: LSREGISTER,
args: ['-u', macDaemonCopyBundlePath(directory)],
timeoutMs: 5_000,
maxOutputBytes: 8192
}).catch(() => {})
// 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.
@@ -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 = {
@@ -29,9 +29,9 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
async function copy(name: string): Promise<string> {
async function copy(name: string, bundle = 'Orca Agents.app'): Promise<string> {
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')
@@ -35,13 +35,13 @@ let verifyCode: number
let onCopy: (() => Promise<void>) | 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([])
})
@@ -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[] =>
@@ -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
}
}
)
})
@@ -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.
@@ -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 } : {})
}
}
@@ -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. */
@@ -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.
@@ -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<string, readonly string[]> = {
worktree: ['list', 'add', 'remove', 'move', 'prune', 'lock', 'unlock', 'repair'],
@@ -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,
@@ -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({
@@ -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,
@@ -6,24 +6,22 @@ export function peerMailRelayEnvironments(db: OrchestrationDb): Set<string> {
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
)
}
@@ -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 }
@@ -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<Repo>
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 {
@@ -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')
})
})
})
@@ -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'}`
@@ -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',
@@ -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 })
}
}
@@ -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,
@@ -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<void> {
await gitExecFileAsync(args, { cwd })
}
async function commitOrcaYaml(cwd: string, setupLine: string, eol = '\n'): Promise<void> {
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<typeof readCreateSourceSetupHooks>[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')
})
@@ -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<RemoteTrackingBase | null>
hasRemoteTrackingRef: (
repoPath: string,
base: RemoteTrackingBase,
options?: LocalGitExecOptions
) => Promise<boolean>
getOrStartRemoteTrackingBaseRefresh: (
repoPath: string,
base: RemoteTrackingBase,
options?: LocalGitExecOptions
) => Promise<RemoteFetchResult>
}
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 <T>(work: () => Promise<T>): Promise<T> => {
const remaining = deadline - Date.now()
if (remaining <= 0) {
throw new DeadlinePassed()
}
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
work(),
new Promise<never>((_, 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<typeof readCreateSourceSetupHooks>[0],
gitOptions: LocalGitExecOptions,
step: <T>(work: () => Promise<T>) => Promise<T>
): Promise<string | null> {
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)) }
}
@@ -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<WorkspaceCleanupEvaluationInput, 'git'>
): 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 {
@@ -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<typeof WorkspaceCleanupRunner> {
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)
})
})
@@ -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<WorkspaceCleanupRun> | null = null
// Every caller asking for the same rules while one evaluation runs shares it: reopened pages, several clients.
private readonly previews = new Map<string, Promise<WorkspaceCleanupPreview>>()
private timer: ReturnType<typeof setTimeout> | null = null
private nextRunAt: number | null = null
private enabledRules = new Set<string>()
@@ -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<WorkspaceCleanupPreview> {
preview(rules?: WorkspaceCleanupRules): Promise<WorkspaceCleanupPreview> {
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<WorkspaceCleanupPreview> {
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)
})
}
@@ -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
+7 -2
View File
@@ -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),
@@ -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<void>
recordRemovalSnapshotPrune?: (args: WorkspaceCleanupSnapshotPruneRecordArgs) => Promise<void>
finishRemovalSnapshotPruneBatch?: (args: WorkspaceCleanupSnapshotPruneBatchArgs) => Promise<void>
/** This app's own cleanup runner finished a run. */
onRunFinished: (callback: (run: WorkspaceCleanupRunFinishedEvent['run']) => void) => () => void
}
export type WorkspaceSpaceApi = {
@@ -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']
@@ -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'
}
@@ -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<HookTrustApproval> {
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<AutomationSaveContext, 'local' | 'list'>,
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<AutomationSaveContext, 'local' | 'list'>,
trust: HookTrustApproval | null,
setupDecision: SetupDecision | undefined,
automation: Automation | null,
hostId: ExecutionHostId | undefined
hostId: ExecutionHostId | undefined,
baseBranch: string | null | undefined
): Promise<string | undefined> {
// 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

Some files were not shown because too many files have changed in this diff Show More