mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions
This commit is contained in:
@@ -63,9 +63,10 @@ CI enforces this for `src/preload/` and `src/shared/`.
|
||||
|
||||
Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular:
|
||||
|
||||
- if you are an outside contributor, link the issue your PR addresses
|
||||
- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner)
|
||||
- explain what changed and why, and stay focused on a single topic when possible
|
||||
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why
|
||||
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual or interaction change, write `N/A` and briefly explain why
|
||||
- include high-quality tests when behavior changes or bug fixes warrant them
|
||||
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
|
||||
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
|
||||
|
||||
@@ -13,9 +13,8 @@ function readRootEntries(sha) {
|
||||
return stdout.split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
// Why: the Cloud workspace import is the one reviewed root addition; it stays
|
||||
// listed until it lands on main, after which the base tree carries it.
|
||||
const REVIEWED_ROOT_ENTRIES = new Set(['cloud'])
|
||||
// These reviewed additions stay listed until the base tree carries them.
|
||||
const REVIEWED_ROOT_ENTRIES = new Set(['cloud', 'opencode.json'])
|
||||
|
||||
function checkRootDirectoryEntries(argv) {
|
||||
if (argv.length !== 2) {
|
||||
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
production:production-gce-c30) ;;
|
||||
production:production-gce-c31) ;;
|
||||
production:production-gce-c32,production-gce-c33) target_region=us-central1 ;;
|
||||
production:production-gce-c34) ;;
|
||||
*) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "TARGET_REGION=${target_region}" >> "${GITHUB_ENV}"
|
||||
|
||||
@@ -194,7 +194,7 @@ jobs:
|
||||
EXPECTED_REGION=us-central1
|
||||
EXPECTED_DATABASE_POOL_MAX=
|
||||
;;
|
||||
c27|c28|c29|c30|c31)
|
||||
c27|c28|c29|c30|c31|c34)
|
||||
EXPECTED_HARD_CAP=3000
|
||||
EXPECTED_REGION=asia-east2
|
||||
EXPECTED_DATABASE_POOL_MAX=16
|
||||
|
||||
@@ -120,8 +120,6 @@ jobs:
|
||||
background: true
|
||||
run: node node_modules/typescript/bin/tsc --noEmit
|
||||
|
||||
- wait: production-types
|
||||
|
||||
# Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until
|
||||
# tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had
|
||||
# drifted. This fails when a test file that checks today stops checking, when a test leaves
|
||||
@@ -129,6 +127,8 @@ jobs:
|
||||
- name: Typecheck tests (ratchet)
|
||||
run: node scripts/check-tests-typecheck-ratchet.mjs
|
||||
|
||||
- wait: production-types
|
||||
|
||||
# This includes the bridged replay of the whole recording corpus, which used to be a second
|
||||
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
|
||||
# it fails when a divergence class grows, when a divergence lands in no class at all, or when
|
||||
|
||||
+56
-17
@@ -440,8 +440,12 @@ jobs:
|
||||
|
||||
git_compatibility:
|
||||
name: Git compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.git_compatibility == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.git_compatibility == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -509,8 +513,12 @@ jobs:
|
||||
# repair stops happening. Pinned because the binary is the thing expected to drift.
|
||||
codex_index_heal_contract:
|
||||
name: Codex index-heal contract
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.codex_index_heal_contract == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.codex_index_heal_contract == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: @openai/codex ships @openai/codex-linux-arm64.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
env:
|
||||
@@ -575,8 +583,12 @@ jobs:
|
||||
|
||||
xterm_patch_sync:
|
||||
name: xterm patch sync
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.xterm_patch_sync == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the
|
||||
# checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on
|
||||
# darwin-arm64, so the output is neither host-arch nor host-OS dependent.
|
||||
@@ -604,8 +616,12 @@ jobs:
|
||||
|
||||
shell_contracts:
|
||||
name: shell contracts
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.shell_contracts == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.shell_contracts == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
# Why: this job's cost is almost entirely package download, and a stalled mirror has
|
||||
@@ -758,8 +774,12 @@ jobs:
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
orcad_browser:
|
||||
name: orcad browser provider
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.orcad_browser == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.orcad_browser == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -811,7 +831,7 @@ jobs:
|
||||
# in this job loads node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
native-runtime: none
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
@@ -872,8 +892,12 @@ jobs:
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.cross-version-wire == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.cross-version-wire == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
@@ -916,8 +940,12 @@ jobs:
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.managed_hook_node18 == 'true' &&
|
||||
needs.preflight.result == 'success'
|
||||
# Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
@@ -1263,8 +1291,19 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e
|
||||
needs: code_paths
|
||||
if: needs.code_paths.outputs.e2e_should_run == 'true'
|
||||
needs: [code_paths, preflight]
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.e2e_should_run == 'true' &&
|
||||
(
|
||||
needs.preflight.result == 'success' ||
|
||||
(
|
||||
needs.preflight.result == 'skipped' &&
|
||||
needs.code_paths.outputs.static_analysis == 'false' &&
|
||||
needs.code_paths.outputs.typecheck == 'false'
|
||||
)
|
||||
)
|
||||
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -84,6 +84,17 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Keep complete server/test trees; missing future imports fail the unchanged builds.
|
||||
sparse-checkout: |
|
||||
.github
|
||||
config
|
||||
native
|
||||
resources
|
||||
tests
|
||||
src/main
|
||||
src/shared
|
||||
src/relay
|
||||
src/types
|
||||
- name: Self-test the provisioning scripts before touching the machine
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -5,12 +5,21 @@ on:
|
||||
# change is worth a real ibus session. A pull_request trigger here would run it on every PR.
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
diagnose_wayland_input:
|
||||
description: Capture passive Wayland input diagnostics while running both native lanes
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
|
||||
jobs:
|
||||
linux-x11:
|
||||
name: Linux X11 terminal IME
|
||||
@@ -98,6 +107,7 @@ jobs:
|
||||
- name: Run native Wayland Hangul terminating digit
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_WAYLAND_INPUT_DIAGNOSTICS: ${{ inputs.diagnose_wayland_input && '1' || '' }}
|
||||
run: node config/scripts/run-terminal-ibus-hangul-e2e.mjs --nested-wayland
|
||||
- name: Upload Wayland terminal IME evidence
|
||||
if: always()
|
||||
|
||||
@@ -16,6 +16,7 @@ function stubStore(overrides: Partial<AssignmentCleanupStore> = {}) {
|
||||
abortExpiredRegionalRehomes: method('abortExpiredRegionalRehomes'),
|
||||
reapRegionalRehomeAttempts: method('reapRegionalRehomeAttempts'),
|
||||
releaseExpiredActivityLeases: method('releaseExpiredActivityLeases'),
|
||||
pruneReleasedControlReservations: method('pruneReleasedControlReservations'),
|
||||
releaseExpiredActivity: method('releaseExpiredActivity'),
|
||||
releaseExpiredRegionPreferences: method('releaseExpiredRegionPreferences'),
|
||||
evacuateDeadCells: method('evacuateDeadCells'),
|
||||
@@ -43,6 +44,7 @@ describe('assignment cleanup steps', () => {
|
||||
'abortExpiredRegionalRehomes',
|
||||
'reapRegionalRehomeAttempts',
|
||||
'releaseExpiredActivityLeases',
|
||||
'pruneReleasedControlReservations',
|
||||
'releaseExpiredActivity',
|
||||
'releaseExpiredRegionPreferences',
|
||||
'evacuateDeadCells'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { runRelayBackgroundOperation } from './relay-background-operation.js'
|
||||
|
||||
// The eleven periodic assignment sweeps the director runs every 30s. Each step
|
||||
// The twelve periodic assignment sweeps the director runs every 30s. Each step
|
||||
// re-derives its state from the database and is idempotent, so they carry no
|
||||
// intra-tick ordering dependency — which is what makes per-step isolation
|
||||
// sound: one failing sweep costs one tick of itself, never the other ten.
|
||||
// sound: one failing sweep costs one tick of itself, never the other eleven.
|
||||
// (A single poisoned rehome row once silenced the whole chained form
|
||||
// fleet-wide.) Sweep failures are logged, never fed into the rehome worker's
|
||||
// dispatch-failure budget: a sweep exception is not a dispatch failure and
|
||||
@@ -17,6 +17,7 @@ export type AssignmentCleanupStore = {
|
||||
abortExpiredRegionalRehomes(): Promise<unknown>
|
||||
reapRegionalRehomeAttempts(): Promise<unknown>
|
||||
releaseExpiredActivityLeases(): Promise<unknown>
|
||||
pruneReleasedControlReservations(): Promise<unknown>
|
||||
releaseExpiredActivity(): Promise<unknown>
|
||||
releaseExpiredRegionPreferences(): Promise<unknown>
|
||||
evacuateDeadCells(): Promise<unknown>
|
||||
@@ -34,6 +35,10 @@ function assignmentCleanupSteps(
|
||||
['abort-expired-regional-rehomes', () => assignments.abortExpiredRegionalRehomes()],
|
||||
['reap-regional-rehome-attempts', () => assignments.reapRegionalRehomeAttempts()],
|
||||
['release-expired-activity-leases', () => assignments.releaseExpiredActivityLeases()],
|
||||
[
|
||||
'prune-released-control-reservations',
|
||||
() => assignments.pruneReleasedControlReservations()
|
||||
],
|
||||
['release-expired-activity', () => assignments.releaseExpiredActivity()],
|
||||
['release-expired-region-preferences', () => assignments.releaseExpiredRegionPreferences()],
|
||||
['evacuate-dead-cells', () => assignments.evacuateDeadCells()]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js'
|
||||
import { HeapWindowReaper } from './heap-window-reaper.js'
|
||||
import {
|
||||
selectIdleRegionalRehomes,
|
||||
type IdleRegionalRehomeCandidate,
|
||||
@@ -498,6 +499,19 @@ export class RelayHomeCellUnavailableError extends Error {
|
||||
// never return otherwise starves connection headroom fleet-wide and turns
|
||||
// every placement into relay_capacity_exhausted.
|
||||
const LATE_ARRIVAL_DEBT_RETENTION_MS = 10 * 60 * 1_000
|
||||
// A released reservation is read by nothing: every reader filters it out by state, and the only
|
||||
// statement that still touches it is the per-host lock, which just makes that lock set longer. A day
|
||||
// is margin for forensics, not for reads.
|
||||
export const RELEASED_CONTROL_RESERVATION_RETENTION_MS = 24 * 60 * 60 * 1_000
|
||||
// ~27 rows per page, so a statement deletes a few hundred rows at most. With ~9 director ticks a
|
||||
// minute the row cap is ~5M rows a day: the 13.7M-row backlog drains over about three days, and a
|
||||
// walk that finds nothing reads ~1 MB a tick.
|
||||
const RELEASED_CONTROL_RESERVATION_REAP_BUDGET = {
|
||||
pagesPerStatement: 16,
|
||||
maxPagesPerTick: 128,
|
||||
maxRowsPerTick: 400,
|
||||
budgetMs: 250
|
||||
}
|
||||
const CELL_FENCE_TTL_MS = 5 * 60 * 1_000
|
||||
const CELL_FENCE_ATTEMPT_TTL_MS = 60 * 60 * 1_000
|
||||
const CELL_DRAIN_SEND_PERMIT_MS = 30_000
|
||||
@@ -541,6 +555,11 @@ export class RelayAssignmentStore {
|
||||
private readonly admissionSelector: RelayCellAdmissionSelector
|
||||
private readonly migrationCellRegistrar: RelayMigrationCellRegistrar
|
||||
private readonly activityQueue = new AssignmentIdentityQueue()
|
||||
private readonly releasedReservationReaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
`state = 'released' AND released_at <= ?`,
|
||||
RELEASED_CONTROL_RESERVATION_REAP_BUDGET
|
||||
)
|
||||
private assignmentTail: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor(
|
||||
@@ -2944,6 +2963,10 @@ export class RelayAssignmentStore {
|
||||
async evacuateDeadCells(limit = 100): Promise<number> {
|
||||
if (!this.requireLiveCells) return 0
|
||||
const cutoff = this.now() - this.heartbeatTtlMs
|
||||
const cellIds = await this.deadCellEvacuationCandidates(cutoff)
|
||||
// Why: without a candidate cell the host query below walks every assignment by primary key to
|
||||
// return nothing (574 ms per call in production, from stale existing-only cells it can never act on).
|
||||
if (cellIds.length === 0) return 0
|
||||
const rows = await this.database.query(
|
||||
`SELECT assignment.user_id, assignment.relay_host_id, assignment.cell_id
|
||||
FROM relay_assignments assignment
|
||||
@@ -3006,8 +3029,9 @@ export class RelayAssignmentStore {
|
||||
AND fence.expires_at > ?
|
||||
)
|
||||
)
|
||||
AND assignment.cell_id IN (${cellIds.map(() => '?').join(', ')})
|
||||
ORDER BY assignment.user_id, assignment.relay_host_id LIMIT ?`,
|
||||
[1, cutoff, this.now(), this.now(), limit]
|
||||
[1, cutoff, this.now(), this.now(), ...cellIds, limit]
|
||||
)
|
||||
let moved = 0
|
||||
for (const row of rows) {
|
||||
@@ -3034,6 +3058,43 @@ export class RelayAssignmentStore {
|
||||
return moved
|
||||
}
|
||||
|
||||
// The cell-level half of evacuateDeadCells' predicate, so it is a superset: every cell the host
|
||||
// query could act on is here, and only the per-host pin checks are left out.
|
||||
private async deadCellEvacuationCandidates(cutoff: number): Promise<string[]> {
|
||||
const now = this.now()
|
||||
const rows = await this.database.query(
|
||||
`SELECT cell.cell_id
|
||||
FROM relay_cells cell
|
||||
LEFT JOIN relay_cell_committed_fences committed ON committed.cell_id = cell.cell_id
|
||||
LEFT JOIN relay_cell_fence_attempts attempt ON attempt.attempt_id = committed.attempt_id
|
||||
LEFT JOIN relay_cell_fences fence ON fence.cell_id = cell.cell_id
|
||||
LEFT JOIN relay_cell_runtime runtime ON runtime.cell_id = cell.cell_id
|
||||
WHERE (runtime.cell_id IS NULL OR runtime.ready != ? OR runtime.last_heartbeat_at <= ?)
|
||||
AND (
|
||||
(
|
||||
cell.enabled = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM relay_cell_connection_limits limits
|
||||
WHERE limits.cell_id = cell.cell_id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
cell.enabled = 0
|
||||
AND attempt.completed_at IS NOT NULL
|
||||
AND attempt.aborted_at IS NULL
|
||||
AND committed.cell_incarnation = runtime.cell_incarnation
|
||||
AND fence.cell_incarnation = committed.cell_incarnation
|
||||
AND committed.attested_at >= runtime.last_heartbeat_at
|
||||
AND committed.expires_at > ?
|
||||
AND fence.expires_at > ?
|
||||
)
|
||||
)
|
||||
ORDER BY cell.cell_id`,
|
||||
[1, cutoff, now, now]
|
||||
)
|
||||
return rows.map((row) => text(row, 'cell_id'))
|
||||
}
|
||||
|
||||
async configureCell(
|
||||
cell: RelayCellConfig,
|
||||
admission: boolean | CellAdmissionState
|
||||
@@ -7158,6 +7219,12 @@ export class RelayAssignmentStore {
|
||||
return aborted
|
||||
}
|
||||
|
||||
async pruneReleasedControlReservations(): Promise<number> {
|
||||
return await this.releasedReservationReaper.reap(this.database, [
|
||||
this.now() - RELEASED_CONTROL_RESERVATION_RETENTION_MS
|
||||
])
|
||||
}
|
||||
|
||||
async releaseExpiredActivityLeases(): Promise<number> {
|
||||
const now = this.now()
|
||||
await this.database.query(
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { RelayCredentialStore, type RelayIdentity } from './credential-store.js'
|
||||
import {
|
||||
AUDIT_EVENT_RETENTION_MS,
|
||||
CONFIRM_RESULT_RETENTION_MS,
|
||||
RelayCredentialStore,
|
||||
type RelayIdentity
|
||||
} from './credential-store.js'
|
||||
import { openRelayDatabase, type RelayDatabase } from './database.js'
|
||||
|
||||
// The outage this guards against: the credential cleanup ran every 30s in all 23 cells and both
|
||||
@@ -169,9 +174,9 @@ describePostgres('credential cleanup against PostgreSQL', () => {
|
||||
await database.query(`ANALYZE relay_connection_bases`)
|
||||
|
||||
const reaper = await plan(
|
||||
`DELETE FROM relay_connection_bases WHERE ctid IN (
|
||||
`DELETE FROM relay_connection_bases WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM relay_connection_bases WHERE active = ? AND deadline <= ? LIMIT 5000
|
||||
)`,
|
||||
))`,
|
||||
[0, NOW - DAY_MS]
|
||||
)
|
||||
|
||||
@@ -280,4 +285,55 @@ describePostgres('credential cleanup against PostgreSQL', () => {
|
||||
{ state: 'invalidated', total: '3' }
|
||||
])
|
||||
})
|
||||
|
||||
it('plans the audit reaper off relay_audit_events_at when most rows are past retention', async () => {
|
||||
// Unordered, a LIMIT with this many matches is cheapest as a sequential scan from page 0, which
|
||||
// in production would reread every retained row once the oldest pages are reaped.
|
||||
await database.query(
|
||||
`INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json)
|
||||
SELECT 'old-' || n, ?, 'resume-confirmed', ?, ?, '{}' FROM generate_series(1, 20000) AS n`,
|
||||
[NOW - AUDIT_EVENT_RETENTION_MS - DAY_MS, identity.userId, identity.relayHostId]
|
||||
)
|
||||
await database.query(`ANALYZE relay_audit_events`)
|
||||
|
||||
const reaper = await plan(
|
||||
`DELETE FROM relay_audit_events WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM relay_audit_events WHERE at <= ? ORDER BY at LIMIT 5000
|
||||
))`,
|
||||
[NOW - AUDIT_EVENT_RETENTION_MS]
|
||||
)
|
||||
|
||||
expect(reaper).toContain('relay_audit_events_at')
|
||||
expect(reaper).not.toContain('Seq Scan on relay_audit_events')
|
||||
})
|
||||
|
||||
it('reaps old confirm results and audit events and keeps the ones inside retention', async () => {
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
SELECT ?, ?, 'old-' || n, 'basis-1', '{}', '{}', ? FROM generate_series(1, 200) AS n`,
|
||||
[identity.userId, identity.relayHostId, NOW - CONFIRM_RESULT_RETENTION_MS - 1]
|
||||
)
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
VALUES (?, ?, 'recent', 'basis-1', '{}', '{}', ?)`,
|
||||
[identity.userId, identity.relayHostId, NOW - DAY_MS]
|
||||
)
|
||||
await database.query(
|
||||
`INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json)
|
||||
VALUES ('old', ?, 'resume-confirmed', ?, ?, '{}'), ('recent', ?, 'resume-confirmed', ?, ?, '{}')`,
|
||||
[
|
||||
NOW - AUDIT_EVENT_RETENTION_MS - 1, identity.userId, identity.relayHostId,
|
||||
NOW - DAY_MS, identity.userId, identity.relayHostId
|
||||
]
|
||||
)
|
||||
|
||||
await store.cleanup()
|
||||
|
||||
expect(await database.query(`SELECT req_id FROM relay_confirm_results`)).toEqual([
|
||||
{ req_id: 'recent' }
|
||||
])
|
||||
expect(await database.query(`SELECT id FROM relay_audit_events`)).toEqual([{ id: 'recent' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RelayCredentialStore, type RelayIdentity } from './credential-store.js'
|
||||
import {
|
||||
AUDIT_EVENT_RETENTION_MS,
|
||||
CONFIRM_RESULT_RETENTION_MS,
|
||||
RelayCredentialStore,
|
||||
type RelayIdentity
|
||||
} from './credential-store.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
|
||||
|
||||
const identity: RelayIdentity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
|
||||
@@ -71,6 +76,29 @@ async function insertDirectAuthorization(
|
||||
)
|
||||
}
|
||||
|
||||
async function insertConfirmResult(
|
||||
database: RelayDatabase,
|
||||
result: { reqId: string; committedAt: number }
|
||||
): Promise<void> {
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
VALUES (?, ?, ?, 'basis-1', '{}', '{}', ?)`,
|
||||
[identity.userId, identity.relayHostId, result.reqId, result.committedAt]
|
||||
)
|
||||
}
|
||||
|
||||
async function insertAuditEvent(
|
||||
database: RelayDatabase,
|
||||
event: { id: string; at: number }
|
||||
): Promise<void> {
|
||||
await database.query(
|
||||
`INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json)
|
||||
VALUES (?, ?, 'resume-confirmed', ?, ?, '{}')`,
|
||||
[event.id, event.at, identity.userId, identity.relayHostId]
|
||||
)
|
||||
}
|
||||
|
||||
async function remainingIds(database: RelayDatabase, table: string, column: string): Promise<string[]> {
|
||||
const rows = await database.query(`SELECT ${column} FROM ${table} ORDER BY ${column}`)
|
||||
return rows.map((row) => String(row[column]))
|
||||
@@ -294,4 +322,46 @@ describe('credential cleanup invite reaper', () => {
|
||||
expect(await remainingIds(database, 'relay_direct_authorizations', 'direct_auth_id')).toEqual([])
|
||||
await database.close()
|
||||
})
|
||||
|
||||
it('reaps confirm results and audit events only past their retention windows', async () => {
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
const store = new RelayCredentialStore(database, () => NOW)
|
||||
await insertConfirmResult(database, { reqId: 'confirm-old', committedAt: NOW - CONFIRM_RESULT_RETENTION_MS })
|
||||
await insertConfirmResult(database, {
|
||||
reqId: 'confirm-recent',
|
||||
committedAt: NOW - CONFIRM_RESULT_RETENTION_MS + 1
|
||||
})
|
||||
await insertAuditEvent(database, { id: 'audit-old', at: NOW - AUDIT_EVENT_RETENTION_MS })
|
||||
await insertAuditEvent(database, { id: 'audit-recent', at: NOW - AUDIT_EVENT_RETENTION_MS + 1 })
|
||||
|
||||
await store.cleanup()
|
||||
|
||||
expect(await remainingIds(database, 'relay_confirm_results', 'req_id')).toEqual(['confirm-recent'])
|
||||
expect(await remainingIds(database, 'relay_audit_events', 'id')).toEqual(['audit-recent'])
|
||||
await database.close()
|
||||
})
|
||||
|
||||
it('still replays a confirm result inside retention', async () => {
|
||||
// The one reader: a retried confirm on the same basis gets the stored answer back.
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
const store = new RelayCredentialStore(database, () => NOW)
|
||||
await database.query(
|
||||
`INSERT INTO relay_confirm_results
|
||||
(user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at)
|
||||
VALUES (?, ?, 'confirm-1', 'basis-1', '{}', ?, ?)`,
|
||||
[identity.userId, identity.relayHostId, JSON.stringify({ v: 1, reqId: 'confirm-1' }), NOW - DAY_MS]
|
||||
)
|
||||
|
||||
await store.cleanup()
|
||||
|
||||
expect(
|
||||
await store.confirmResume({
|
||||
...identity,
|
||||
reqId: 'confirm-1',
|
||||
basisConnId: 'basis-1',
|
||||
owningControlGeneration: 1
|
||||
})
|
||||
).toEqual({ v: 1, reqId: 'confirm-1' })
|
||||
await database.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DeviceResumeConfirmed
|
||||
} from '@orca-cloud/relay-contract'
|
||||
import type { RelayDatabase, SqlRow } from './database.js'
|
||||
import { HeapWindowReaper } from './heap-window-reaper.js'
|
||||
|
||||
const CREDENTIAL_GRACE_MS = 24 * 60 * 60 * 1000
|
||||
// Released desktops validate invite expiry against their own clock with zero
|
||||
@@ -20,9 +21,23 @@ const TERMINAL_INVITE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// active/unconsumed AND inside its deadline, and every deadline is set at most 30s past insert, so
|
||||
// a settled row can never authorize anything again. A day is margin for forensics, not for reads.
|
||||
const INACTIVE_AUTHORIZATION_RETENTION_MS = 24 * 60 * 60 * 1000
|
||||
// A stored confirm result only answers a retry of the same request on the same connection basis,
|
||||
// and a different basis is refused as a tuple mismatch; a basis lives for one phone connection. A
|
||||
// week matches the pairing support window above.
|
||||
export const CONFIRM_RESULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// Nothing in the relay reads audit events back; 90 days covers support and incident questions.
|
||||
export const AUDIT_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000
|
||||
// Bounded so one cycle cannot hold row locks or grow WAL without limit; the backlog drains over
|
||||
// however many cycles it takes.
|
||||
const REAP_BATCH_ROWS = 5000
|
||||
// ~14 rows per page. With ~9 director ticks a minute the row cap is ~3M rows a day, so the 7.4M-row
|
||||
// backlog drains over two to three days; a walk that finds nothing reads ~1 MB a tick.
|
||||
const CONFIRM_RESULT_REAP_BUDGET = {
|
||||
pagesPerStatement: 16,
|
||||
maxPagesPerTick: 128,
|
||||
maxRowsPerTick: 250,
|
||||
budgetMs: 250
|
||||
}
|
||||
|
||||
export type RelayIdentity = { userId: string; relayHostId: string }
|
||||
export type CredentialReservation = RelayIdentity & {
|
||||
@@ -81,6 +96,13 @@ function string(row: SqlRow, field: string): string {
|
||||
}
|
||||
|
||||
export class RelayCredentialStore {
|
||||
// committed_at has no index, so a LIMIT delete would plan as a sequential scan of the whole table.
|
||||
private readonly confirmResultReaper = new HeapWindowReaper(
|
||||
'relay_confirm_results',
|
||||
'committed_at <= ?',
|
||||
CONFIRM_RESULT_REAP_BUDGET
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly database: RelayDatabase,
|
||||
private readonly now: () => number = Date.now
|
||||
@@ -680,16 +702,29 @@ export class RelayCredentialStore {
|
||||
'consumed_at IS NOT NULL AND consumed_at <= ?',
|
||||
[now - INACTIVE_AUTHORIZATION_RETENTION_MS]
|
||||
)
|
||||
await this.confirmResultReaper.reap(this.database, [now - CONFIRM_RESULT_RETENTION_MS])
|
||||
// Ordered so the plan walks relay_audit_events_at from its oldest entry: unordered, a LIMIT
|
||||
// can plan as a sequential scan that rereads the retained rows before reaching the old ones.
|
||||
await this.reapBatch('relay_audit_events', 'at <= ?', [now - AUDIT_EVENT_RETENTION_MS], 'at')
|
||||
}
|
||||
|
||||
// ctid/rowid, not the primary key: the physical address lets the delete re-find exactly the batch
|
||||
// the subquery located instead of re-matching the predicate per row.
|
||||
private async reapBatch(table: string, predicate: string, params: unknown[]): Promise<void> {
|
||||
const address = this.database.dialect === 'sqlite' ? 'rowid' : 'ctid'
|
||||
// the subquery located instead of re-matching the predicate per row. On Postgres an array of TIDs,
|
||||
// not `IN`: IN can plan as a hash join over a sequential scan of the whole table.
|
||||
private async reapBatch(
|
||||
table: string,
|
||||
predicate: string,
|
||||
params: unknown[],
|
||||
orderBy?: string
|
||||
): Promise<void> {
|
||||
const sqlite = this.database.dialect === 'sqlite'
|
||||
const address = sqlite ? 'rowid' : 'ctid'
|
||||
const order = orderBy ? `ORDER BY ${orderBy} ` : ''
|
||||
const batch = `SELECT ${address} FROM ${table} WHERE ${predicate} ${order}LIMIT ${REAP_BATCH_ROWS}`
|
||||
await this.database.query(
|
||||
`DELETE FROM ${table} WHERE ${address} IN (
|
||||
SELECT ${address} FROM ${table} WHERE ${predicate} LIMIT ${REAP_BATCH_ROWS}
|
||||
)`,
|
||||
sqlite
|
||||
? `DELETE FROM ${table} WHERE rowid IN (${batch})`
|
||||
: `DELETE FROM ${table} WHERE ctid = ANY(ARRAY(${batch}))`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ describe('relay database', () => {
|
||||
'relay_cell_connection_runtime',
|
||||
'relay_cell_connection_snapshots',
|
||||
'relay_cell_drain_attempt_states',
|
||||
'relay_cell_drain_attempts',
|
||||
'relay_cell_drain_recovery_attempts',
|
||||
'relay_cell_fence_apply_invocations',
|
||||
'relay_cell_fence_attempts',
|
||||
@@ -80,7 +79,6 @@ describe('relay database', () => {
|
||||
'relay_cell_runtime',
|
||||
'relay_cells',
|
||||
'relay_confirm_results',
|
||||
'relay_confirmable_splices',
|
||||
'relay_connection_bases',
|
||||
'relay_control_capabilities',
|
||||
'relay_control_connection_reservations',
|
||||
@@ -88,7 +86,6 @@ describe('relay database', () => {
|
||||
'relay_direct_authorizations',
|
||||
'relay_install_results',
|
||||
'relay_invites',
|
||||
'relay_migration_leases',
|
||||
'relay_post_drain_migration_pins',
|
||||
'relay_rate_windows',
|
||||
'relay_region_decisions',
|
||||
|
||||
@@ -94,6 +94,10 @@ export interface RelayDatabase {
|
||||
// Constraint swaps are matched by NAME in pg_constraint, never by body, because the CHECK list is
|
||||
// generated from REGION_LIST. Changing a constraint's definition under the same name therefore does
|
||||
// nothing on boot: an operator drops it, and the next boot adds the current definition back.
|
||||
// relay_confirmable_splices, relay_cell_drain_attempts and relay_migration_leases are no longer
|
||||
// created; nothing ever wrote them. Databases that have them keep them empty until a drop is safe:
|
||||
// an older image still creates them at boot, and a drop racing that CREATE can fail its schema step.
|
||||
// Account erasure in orca-cloud must be deployed with retired-table support (orca-cloud#493) first.
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS relay_invites (
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -154,19 +158,6 @@ CREATE TABLE IF NOT EXISTS relay_install_results (
|
||||
PRIMARY KEY (user_id, relay_host_id, relay_device_id, req_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_confirmable_splices (
|
||||
basis_conn_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
relay_host_id TEXT NOT NULL,
|
||||
owning_control_generation BIGINT NOT NULL,
|
||||
relay_device_id TEXT NOT NULL,
|
||||
accepted_credential_version BIGINT NOT NULL,
|
||||
accepted_as TEXT NOT NULL,
|
||||
confirm_deadline BIGINT NOT NULL,
|
||||
active BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_connection_bases (
|
||||
basis_conn_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -507,15 +498,6 @@ CREATE TABLE IF NOT EXISTS relay_cell_fence_apply_invocations (
|
||||
CREATE INDEX IF NOT EXISTS relay_cell_fence_apply_invocations_attempt
|
||||
ON relay_cell_fence_apply_invocations(attempt_id, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_cell_drain_attempts (
|
||||
cell_id TEXT PRIMARY KEY,
|
||||
cell_incarnation TEXT NOT NULL,
|
||||
planned_grace_ms BIGINT NOT NULL,
|
||||
attempted_at BIGINT NOT NULL,
|
||||
retry_after BIGINT NOT NULL,
|
||||
recover_forward_attempted_at BIGINT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_cell_drain_attempt_states (
|
||||
attempt_id TEXT PRIMARY KEY,
|
||||
cell_id TEXT NOT NULL,
|
||||
@@ -605,17 +587,6 @@ CREATE TABLE IF NOT EXISTS relay_rate_windows (
|
||||
CREATE INDEX IF NOT EXISTS relay_rate_windows_started
|
||||
ON relay_rate_windows(window_started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_migration_leases (
|
||||
user_id TEXT NOT NULL,
|
||||
relay_host_id TEXT NOT NULL,
|
||||
source_cell_id TEXT NOT NULL,
|
||||
target_cell_id TEXT NOT NULL,
|
||||
assignment_epoch BIGINT NOT NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
completed_at BIGINT,
|
||||
PRIMARY KEY (user_id, relay_host_id, assignment_epoch)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_assignment_migrations (
|
||||
user_id TEXT NOT NULL,
|
||||
relay_host_id TEXT NOT NULL,
|
||||
@@ -741,7 +712,6 @@ const POSTGRES_TRANSACTION_PHASES = [
|
||||
['relay_region_rehome_', 'regional-rehome'],
|
||||
['relay_assignment_activity_leases', 'activity-lease'],
|
||||
['relay_assignment_migration', 'migration'],
|
||||
['relay_migration_leases', 'migration'],
|
||||
['relay_post_drain_migration_pins', 'migration'],
|
||||
['relay_cell_connection_runtime', 'cell-runtime'],
|
||||
['relay_cell_connection_snapshots', 'cell-runtime'],
|
||||
@@ -753,7 +723,6 @@ const POSTGRES_TRANSACTION_PHASES = [
|
||||
['relay_admission_selector', 'admission'],
|
||||
['relay_cell_admission', 'admission'],
|
||||
['relay_control_connection_reservations', 'connection'],
|
||||
['relay_confirmable_splices', 'connection'],
|
||||
['relay_connection_bases', 'connection'],
|
||||
['relay_direct_authorizations', 'connection'],
|
||||
['relay_confirm_results', 'connection'],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, afterEach, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from './assignment-store.js'
|
||||
import type { RelayCellConfig } from './config.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
openRelayDatabase,
|
||||
type RelayDatabase,
|
||||
type SqlRow
|
||||
} from './database.js'
|
||||
|
||||
// The sweep's host query walked every assignment by primary key to return nothing while stale
|
||||
// existing-only cells sat in the fleet. These pin that a fleet with nothing to evacuate never
|
||||
// reaches it, and that a cell the sweep can act on still does, on both dialects.
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const schema = 'relay_dead_cell_precheck_test'
|
||||
const HOST_QUERY = 'FROM relay_assignments assignment'
|
||||
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 }
|
||||
]
|
||||
|
||||
class RecordingDatabase implements RelayDatabase {
|
||||
readonly statements: string[] = []
|
||||
|
||||
constructor(private readonly delegate: RelayDatabase) {}
|
||||
|
||||
get dialect() {
|
||||
return this.delegate.dialect
|
||||
}
|
||||
|
||||
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
|
||||
this.statements.push(sql)
|
||||
return await this.delegate.query(sql, params)
|
||||
}
|
||||
|
||||
async queryLocked(...args: Parameters<RelayDatabase['queryLocked']>): Promise<SqlRow[]> {
|
||||
return await this.delegate.queryLocked(...args)
|
||||
}
|
||||
|
||||
async transaction<T>(operation: (transaction: RelayDatabase) => Promise<T>): Promise<T> {
|
||||
return await this.delegate.transaction(operation)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.delegate.close()
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
let database: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close()
|
||||
database = undefined
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
})
|
||||
|
||||
async function setup(now: () => number) {
|
||||
database = await open()
|
||||
const recording = new RecordingDatabase(database)
|
||||
const store = new RelayAssignmentStore(recording, now, {
|
||||
requireLiveCells: true,
|
||||
heartbeatTtlMs: 45_000
|
||||
})
|
||||
await store.reconcileCells(CELLS)
|
||||
return { store, recording }
|
||||
}
|
||||
|
||||
async function heartbeat(store: RelayAssignmentStore, cell: RelayCellConfig): Promise<void> {
|
||||
await store.recordCellHeartbeat({
|
||||
cellId: cell.id,
|
||||
cellUrl: cell.url,
|
||||
cellIncarnation: '11111111-1111-4111-8111-111111111111',
|
||||
startedAt: 50,
|
||||
ready: true,
|
||||
observedRequests: 0
|
||||
})
|
||||
}
|
||||
|
||||
it('skips the host query when the only dead cell is unfenced and existing-only', async () => {
|
||||
let now = 100
|
||||
const { store, recording } = await setup(() => now)
|
||||
for (const cell of CELLS) await heartbeat(store, cell)
|
||||
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
|
||||
await store.setCellEnabled('cell-b', false)
|
||||
expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' })
|
||||
await store.setCellEnabled('cell-b', true)
|
||||
await store.setCellEnabled('cell-a', false)
|
||||
now += 45_001
|
||||
await heartbeat(store, CELLS[1]!)
|
||||
recording.statements.length = 0
|
||||
|
||||
expect(await store.evacuateDeadCells()).toBe(0)
|
||||
expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(false)
|
||||
expect(
|
||||
await database!.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [
|
||||
identity.userId
|
||||
])
|
||||
).toEqual([{ cell_id: 'cell-a' }])
|
||||
})
|
||||
|
||||
it('still evacuates hosts from a dead uncapped cell that admits', async () => {
|
||||
let now = 100
|
||||
const { store, recording } = await setup(() => now)
|
||||
for (const cell of CELLS) await heartbeat(store, cell)
|
||||
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
|
||||
await store.setCellEnabled('cell-b', false)
|
||||
expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' })
|
||||
await store.setCellEnabled('cell-b', true)
|
||||
now += 45_001
|
||||
await heartbeat(store, CELLS[1]!)
|
||||
recording.statements.length = 0
|
||||
|
||||
expect(await store.evacuateDeadCells()).toBe(1)
|
||||
expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(true)
|
||||
expect((await store.resolve(identity))?.cellId).toBe('cell-b')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { openRelayDatabase } from './database.js'
|
||||
|
||||
// Three tables were created at every boot and never written. The schema stops creating them, and
|
||||
// a database that already has them must still boot, because they stay until a separate drop.
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
const schema = 'relay_dead_tables_test'
|
||||
const DEAD_TABLES = [
|
||||
'relay_confirmable_splices',
|
||||
'relay_cell_drain_attempts',
|
||||
'relay_migration_leases'
|
||||
]
|
||||
|
||||
function scopedUrl(): string {
|
||||
const url = new URL(databaseUrl!)
|
||||
url.searchParams.set('options', `-c search_path=${schema}`)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function onScoped<T>(operation: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: scopedUrl() })
|
||||
await client.connect()
|
||||
try {
|
||||
return await operation(client)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSchema(): Promise<void> {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
try {
|
||||
await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await client.query(`CREATE SCHEMA ${schema}`)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function existingDeadTables(): Promise<string[]> {
|
||||
return await onScoped(async (client) => {
|
||||
const rows = await client.query(
|
||||
`SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = $1 AND table_name = ANY($2) ORDER BY table_name`,
|
||||
[schema, DEAD_TABLES]
|
||||
)
|
||||
return rows.rows.map((row) => String(row.table_name))
|
||||
})
|
||||
}
|
||||
|
||||
describePostgres('removed relay tables against PostgreSQL', () => {
|
||||
afterAll(async () => {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await client.end()
|
||||
})
|
||||
|
||||
it('does not create the removed tables on a fresh database', async () => {
|
||||
await resetSchema()
|
||||
const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
await database.close()
|
||||
|
||||
expect(await existingDeadTables()).toEqual([])
|
||||
})
|
||||
|
||||
it('boots on a database an older image created, leaving those tables alone', async () => {
|
||||
await resetSchema()
|
||||
await onScoped(async (client) => {
|
||||
await client.query(`CREATE TABLE relay_confirmable_splices (basis_conn_id TEXT PRIMARY KEY)`)
|
||||
await client.query(`CREATE TABLE relay_cell_drain_attempts (cell_id TEXT PRIMARY KEY)`)
|
||||
await client.query(`CREATE TABLE relay_migration_leases (user_id TEXT NOT NULL)`)
|
||||
})
|
||||
|
||||
const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
await database.close()
|
||||
|
||||
expect(await existingDeadTables()).toEqual([...DEAD_TABLES].sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { RelayDatabase, SqlRow } from './database.js'
|
||||
|
||||
export type HeapWindowReapBudget = {
|
||||
// Pages one DELETE may visit, so its row locks and WAL stay a few hundred rows.
|
||||
pagesPerStatement: number
|
||||
// Pages one tick may visit while it finds nothing to delete.
|
||||
maxPagesPerTick: number
|
||||
// Rows one tick deletes before it stops; this is what paces a backlog over days.
|
||||
maxRowsPerTick: number
|
||||
budgetMs: number
|
||||
}
|
||||
|
||||
// Why a TID range and not `WHERE <retention> LIMIT n`: these tables have no index on their
|
||||
// retention column, so the planner answers LIMIT with a sequential scan from page 0 (production
|
||||
// EXPLAIN, 2026-10-04). That scan gets longer every tick as the reaped head of the heap empties.
|
||||
// A TID range bounds each statement to its own pages whatever the table holds.
|
||||
export class HeapWindowReaper {
|
||||
private nextPage: number | undefined
|
||||
|
||||
constructor(
|
||||
private readonly table: string,
|
||||
private readonly predicate: string,
|
||||
private readonly budget: HeapWindowReapBudget,
|
||||
private readonly random: () => number = Math.random,
|
||||
private readonly clock: () => number = () => performance.now()
|
||||
) {}
|
||||
|
||||
async reap(database: RelayDatabase, params: unknown[]): Promise<number> {
|
||||
if (database.dialect !== 'postgres') {
|
||||
return changes(
|
||||
await database.query(
|
||||
`DELETE FROM ${this.table} WHERE rowid IN (
|
||||
SELECT rowid FROM ${this.table} WHERE ${this.predicate}
|
||||
LIMIT ${this.budget.maxRowsPerTick}
|
||||
)`,
|
||||
params
|
||||
)
|
||||
)
|
||||
}
|
||||
const pages = await heapPages(database, this.table)
|
||||
if (pages === 0) return 0
|
||||
// A random first page: every director runs this sweep, and walks that all start at page 0
|
||||
// after a rollout would read the same pages in lockstep.
|
||||
let page = this.nextPage ?? Math.floor(this.random() * pages)
|
||||
const startedAt = this.clock()
|
||||
let scanned = 0
|
||||
let deleted = 0
|
||||
while (
|
||||
scanned < this.budget.maxPagesPerTick &&
|
||||
deleted < this.budget.maxRowsPerTick &&
|
||||
this.clock() - startedAt < this.budget.budgetMs
|
||||
) {
|
||||
if (page >= pages) page = 0
|
||||
const end = Math.min(page + this.budget.pagesPerStatement, pages)
|
||||
// SKIP LOCKED: a row some request holds is left for a later pass rather than waited on.
|
||||
// `= ANY(ARRAY(...))`, not `IN (...)`: IN can plan as a hash join over a sequential scan of
|
||||
// the whole table; an array of TIDs is always a TID scan.
|
||||
deleted += changes(
|
||||
await database.query(
|
||||
`DELETE FROM ${this.table} WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM ${this.table}
|
||||
WHERE ctid >= CAST(? AS tid) AND ctid < CAST(? AS tid) AND ${this.predicate}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
))`,
|
||||
[`(${page},0)`, `(${end},0)`, ...params]
|
||||
)
|
||||
)
|
||||
scanned += end - page
|
||||
page = end
|
||||
}
|
||||
this.nextPage = page
|
||||
return deleted
|
||||
}
|
||||
}
|
||||
|
||||
async function heapPages(database: RelayDatabase, table: string): Promise<number> {
|
||||
const row = (
|
||||
await database.query(
|
||||
`SELECT pg_relation_size(CAST(? AS regclass)) / current_setting('block_size')::bigint
|
||||
AS pages`,
|
||||
[table]
|
||||
)
|
||||
)[0]
|
||||
return Number(row?.pages ?? 0)
|
||||
}
|
||||
|
||||
function changes(rows: SqlRow[]): number {
|
||||
return Number(rows[0]?.changes ?? 0)
|
||||
}
|
||||
@@ -42,9 +42,6 @@ describePostgres('PostgreSQL drain-send locking', () => {
|
||||
`DELETE FROM relay_control_connection_reservations WHERE user_id = ?`,
|
||||
[identity.userId]
|
||||
)
|
||||
await database.query(`DELETE FROM relay_migration_leases WHERE user_id = ?`, [
|
||||
identity.userId
|
||||
])
|
||||
await database.query(`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, [
|
||||
identity.userId
|
||||
])
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RelayAssignmentStore,
|
||||
RELEASED_CONTROL_RESERVATION_RETENTION_MS
|
||||
} from './assignment-store.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
openRelayDatabase,
|
||||
type RelayDatabase
|
||||
} from './database.js'
|
||||
import { HeapWindowReaper } from './heap-window-reaper.js'
|
||||
|
||||
// Released reservations were never deleted: 13.7M rows and 9 GB in production, every one of them
|
||||
// still locked by each placement of its host. These pin what the prune may take and how it walks.
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
const schema = 'relay_released_reservation_prune_test'
|
||||
const NOW = 100 * 24 * 60 * 60 * 1_000
|
||||
const STALE = NOW - RELEASED_CONTROL_RESERVATION_RETENTION_MS
|
||||
const PREDICATE = `state = 'released' AND released_at <= ?`
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
async function openPostgres(): Promise<RelayDatabase> {
|
||||
await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await onAdmin(`CREATE SCHEMA ${schema}`)
|
||||
return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' })
|
||||
}
|
||||
|
||||
async function insertReservation(
|
||||
database: RelayDatabase,
|
||||
id: string,
|
||||
state: string,
|
||||
releasedAt: number | null
|
||||
): Promise<void> {
|
||||
await database.query(
|
||||
`INSERT INTO relay_control_connection_reservations
|
||||
(reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch,
|
||||
cell_id, state, created_at, timeout_at, released_at, updated_at)
|
||||
VALUES (?, ?, 'user-1', 'host000000000001', 1, 'cell-a', ?, 1, 1, ?, 1)`,
|
||||
[id, id, state, releasedAt]
|
||||
)
|
||||
}
|
||||
|
||||
async function remainingIds(database: RelayDatabase): Promise<string[]> {
|
||||
const rows = await database.query(
|
||||
`SELECT reservation_id FROM relay_control_connection_reservations ORDER BY reservation_id`
|
||||
)
|
||||
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) => {
|
||||
let database: RelayDatabase | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close()
|
||||
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()
|
||||
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)
|
||||
await insertReservation(database, 'd-reserved', 'reserved', null)
|
||||
await insertReservation(database, 'e-debt', 'late-arrival-debt', null)
|
||||
await insertReservation(database, 'f-claimed', 'claimed', null)
|
||||
// A row that once was released and came back is judged by its state, not its timestamp.
|
||||
await insertReservation(database, 'g-claimed-old-release', 'claimed', STALE - 1)
|
||||
const store = new RelayAssignmentStore(database, () => NOW)
|
||||
|
||||
expect(await store.pruneReleasedControlReservations()).toBe(2)
|
||||
expect(await remainingIds(database)).toEqual([
|
||||
'c-fresh-released',
|
||||
'd-reserved',
|
||||
'e-debt',
|
||||
'f-claimed',
|
||||
'g-claimed-old-release'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describePostgres('heap window reaper against PostgreSQL', () => {
|
||||
let database: RelayDatabase
|
||||
// 200 rows of this shape fill about two pages; 6,000 spread the table over ~60 pages.
|
||||
const ROWS = 6_000
|
||||
|
||||
afterEach(async () => {
|
||||
await database?.close()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
})
|
||||
|
||||
async function seed(): Promise<number> {
|
||||
database = await openPostgres()
|
||||
// Every third row is still live, so each page holds rows the walk must keep.
|
||||
await database.query(
|
||||
`INSERT INTO relay_control_connection_reservations
|
||||
(reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch,
|
||||
cell_id, state, created_at, timeout_at, released_at, updated_at)
|
||||
SELECT 'r-' || lpad(n::text, 6, '0'), 'r-' || n, 'user-1', 'host000000000001', 1,
|
||||
'cell-a', CASE WHEN n % 3 = 0 THEN 'claimed' ELSE 'released' END,
|
||||
1, 1, CASE WHEN n % 3 = 0 THEN NULL ELSE 1 END, 1
|
||||
FROM generate_series(1, ?) AS n`,
|
||||
[ROWS]
|
||||
)
|
||||
const pages = (
|
||||
await database.query(
|
||||
`SELECT pg_relation_size('relay_control_connection_reservations') / 8192 AS pages`
|
||||
)
|
||||
)[0]!
|
||||
return Number(pages.pages)
|
||||
}
|
||||
|
||||
it('stops each tick at its row cap and drains the backlog over later ticks', async () => {
|
||||
const pages = await seed()
|
||||
expect(pages).toBeGreaterThan(20)
|
||||
const reaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
PREDICATE,
|
||||
{ pagesPerStatement: 2, maxPagesPerTick: 1_000, maxRowsPerTick: 300, budgetMs: 60_000 },
|
||||
() => 0.5
|
||||
)
|
||||
|
||||
const first = await reaper.reap(database, [NOW])
|
||||
// One statement past the cap at most: two pages of this shape hold well under 300 rows.
|
||||
expect(first).toBeGreaterThanOrEqual(300)
|
||||
expect(first).toBeLessThan(600)
|
||||
|
||||
let ticks = 1
|
||||
while ((await reaper.reap(database, [NOW])) > 0) ticks += 1
|
||||
expect(ticks).toBeGreaterThan(5)
|
||||
const left = await database.query(
|
||||
`SELECT state, COUNT(*) AS rows FROM relay_control_connection_reservations GROUP BY state`
|
||||
)
|
||||
expect(left).toEqual([{ state: 'claimed', rows: String(ROWS / 3) }])
|
||||
})
|
||||
|
||||
it('wraps from the end of the heap back to its first page', async () => {
|
||||
const pages = await seed()
|
||||
// Starts on the last page, so every other page is reached only after the wrap.
|
||||
const reaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
PREDICATE,
|
||||
{ pagesPerStatement: 4, maxPagesPerTick: pages + 4, maxRowsPerTick: 1_000_000, budgetMs: 60_000 },
|
||||
() => (pages - 1) / pages
|
||||
)
|
||||
|
||||
await reaper.reap(database, [NOW])
|
||||
|
||||
expect(
|
||||
await database.query(
|
||||
`SELECT COUNT(*) AS rows FROM relay_control_connection_reservations
|
||||
WHERE state = 'released'`
|
||||
)
|
||||
).toEqual([{ rows: '0' }])
|
||||
})
|
||||
|
||||
it('skips a row a request holds instead of waiting for it', async () => {
|
||||
await seed()
|
||||
const holder = new pg.Client({ connectionString: scopedUrl() })
|
||||
await holder.connect()
|
||||
try {
|
||||
await holder.query('BEGIN')
|
||||
await holder.query(
|
||||
`SELECT reservation_id FROM relay_control_connection_reservations
|
||||
WHERE reservation_id = 'r-000001' FOR UPDATE`
|
||||
)
|
||||
const reaper = new HeapWindowReaper(
|
||||
'relay_control_connection_reservations',
|
||||
PREDICATE,
|
||||
{ pagesPerStatement: 1_000, maxPagesPerTick: 1_000, maxRowsPerTick: 1_000_000, budgetMs: 60_000 },
|
||||
() => 0
|
||||
)
|
||||
|
||||
// The pool's lock_timeout would fail this statement if it waited on the held row.
|
||||
expect(await reaper.reap(database, [NOW])).toBe((ROWS * 2) / 3 - 1)
|
||||
expect(
|
||||
await database.query(
|
||||
`SELECT reservation_id FROM relay_control_connection_reservations
|
||||
WHERE state = 'released'`
|
||||
)
|
||||
).toEqual([{ reservation_id: 'r-000001' }])
|
||||
} finally {
|
||||
await holder.query('ROLLBACK')
|
||||
await holder.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('plans each statement as a TID range scan, never a sequential scan', async () => {
|
||||
await seed()
|
||||
await database.query('ANALYZE relay_control_connection_reservations')
|
||||
const client = new pg.Client({ connectionString: scopedUrl() })
|
||||
await client.connect()
|
||||
try {
|
||||
const plan = await client.query(
|
||||
`EXPLAIN DELETE FROM relay_control_connection_reservations WHERE ctid = ANY(ARRAY(
|
||||
SELECT ctid FROM relay_control_connection_reservations
|
||||
WHERE ctid >= CAST($1 AS tid) AND ctid < CAST($2 AS tid) AND ${PREDICATE.replace('?', '$3')}
|
||||
FOR UPDATE SKIP LOCKED))`,
|
||||
['(0,0)', '(16,0)', NOW]
|
||||
)
|
||||
const text = plan.rows.map((row) => String(row['QUERY PLAN'])).join('\n')
|
||||
expect(text).toContain('Tid Range Scan')
|
||||
expect(text).not.toContain('Seq Scan')
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ const SHAPES = {
|
||||
domain: 'relay.onorca.dev',
|
||||
allCells: [
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30',
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34'
|
||||
],
|
||||
// The launch set was registered together; each later cell registers alone beside it.
|
||||
registrationWaves: [
|
||||
@@ -29,8 +29,10 @@ const SHAPES = {
|
||||
['production-gce-c30'],
|
||||
['production-gce-c31'],
|
||||
['production-gce-c32'],
|
||||
['production-gce-c33']
|
||||
['production-gce-c33'],
|
||||
['production-gce-c34']
|
||||
],
|
||||
// C34 is a migration-only spare: no promotion wave until a reviewed change adds one.
|
||||
promotionWaves: [
|
||||
['production-gce-c27'],
|
||||
['production-gce-c28', 'production-gce-c29'],
|
||||
|
||||
@@ -553,11 +553,13 @@ test('accepts only reviewed Asia admission waves', () => {
|
||||
['rollback', 'production-gce-c30'],
|
||||
['rollback', 'production-gce-c31'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'],
|
||||
...['production-gce-c32', 'production-gce-c33'].flatMap((cellId) => [
|
||||
'inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback'
|
||||
].map((mode) => [mode, cellId])),
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33']
|
||||
...['inspect', 'verify', 'register', 'registered', 'rollback']
|
||||
.map((mode) => [mode, 'production-gce-c34']),
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34']
|
||||
]
|
||||
for (const [mode, cellIds] of accepted) {
|
||||
assert.deepEqual(
|
||||
@@ -586,7 +588,12 @@ test('accepts only reviewed Asia admission waves', () => {
|
||||
['promote', 'production-gce-c27,production-gce-c30'],
|
||||
['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['promote', 'production-gce-c30,production-gce-c31'],
|
||||
// The C34 spare stays migration-only: no reviewed promotion wave names it.
|
||||
['promote', 'production-gce-c34'],
|
||||
['recover-promotion', 'production-gce-c34'],
|
||||
['register', 'production-gce-c33,production-gce-c34'],
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'],
|
||||
['rollback', 'production-gce-c27,production-gce-c30'],
|
||||
['rollback', 'production-gce-c30,production-gce-c31'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
@@ -648,6 +655,29 @@ test('registers C31 alone beside the general C27-C30', async () => {
|
||||
assert.deepEqual(subject.selector().membership.general, general)
|
||||
})
|
||||
|
||||
test('registers the C34 spare alone as migration-only in asia-east2', async () => {
|
||||
const general = [...launchCells, 'production-gce-c30', 'production-gce-c31']
|
||||
const subject = harness({
|
||||
generation: 17,
|
||||
membership: {
|
||||
existingOnly: [],
|
||||
migrationOnly: [],
|
||||
general: [...general, 'production-gce-c32', 'production-gce-c33']
|
||||
}
|
||||
})
|
||||
const result = await operateRelayAsiaAdmission({
|
||||
environment: 'production', mode: 'register', cells: ['production-gce-c34'],
|
||||
expectedGeneration: 17, imageDigest: digest, attemptId: 'asia_register_c34', token: 'not-logged'
|
||||
}, subject)
|
||||
const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells'))
|
||||
assert.deepEqual(request.body.cells, [{
|
||||
cellId: 'production-gce-c34', cellUrl: 'https://c34.relay.onorca.dev', region: 'asia-east2',
|
||||
capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60
|
||||
}])
|
||||
assert.deepEqual(result.states, { 'production-gce-c34': 'migration-only' })
|
||||
assert.equal(subject.selector().membership.general.includes('production-gce-c34'), false)
|
||||
})
|
||||
|
||||
const usRegions = { 'production-gce-c32': 'us-central1', 'production-gce-c33': 'us-central1' }
|
||||
|
||||
test('registers C32 and C33 one at a time in us-central1 at the Asia shape', async () => {
|
||||
|
||||
@@ -20,7 +20,8 @@ const SHAPES = {
|
||||
'production-gce-c30': 'asia-east2-a',
|
||||
'production-gce-c31': 'asia-east2-b',
|
||||
'production-gce-c32': 'us-central1-a',
|
||||
'production-gce-c33': 'us-central1-b'
|
||||
'production-gce-c33': 'us-central1-b',
|
||||
'production-gce-c34': 'asia-east2-c'
|
||||
},
|
||||
// The launch set, then each later additive cell; a plan targets one wave, never live cells.
|
||||
waves: [
|
||||
@@ -28,7 +29,8 @@ const SHAPES = {
|
||||
['production-gce-c30'],
|
||||
['production-gce-c31'],
|
||||
// Declared together, so they plan together: a lone C32 plan would hit C33's missing template.
|
||||
['production-gce-c32', 'production-gce-c33']
|
||||
['production-gce-c32', 'production-gce-c33'],
|
||||
['production-gce-c34']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ const productionCells = () => Object.fromEntries([
|
||||
[30, 'asia-east2-a'],
|
||||
[31, 'asia-east2-b'],
|
||||
[32, 'us-central1-a'],
|
||||
[33, 'us-central1-b']
|
||||
[33, 'us-central1-b'],
|
||||
[34, 'asia-east2-c']
|
||||
].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, {
|
||||
hostname: `c${ordinal}`, region: zone.slice(0, -2), zone,
|
||||
machine_type: 'e2-standard-4', boot_disk_gb: 30,
|
||||
@@ -57,6 +58,14 @@ test('accepts the additive C31 wave in the next zone of the rotation', () => {
|
||||
assert.equal(result.relay_gce_cells['production-gce-c31'].zone, 'asia-east2-b')
|
||||
})
|
||||
|
||||
test('accepts the additive C34 spare wave in asia-east2-c at the Asia pool', () => {
|
||||
const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(),
|
||||
existingAdditionalRegions: additionalRegions, environment: 'production',
|
||||
cellIds: 'production-gce-c34', image })
|
||||
assert.equal(result.relay_gce_cells['production-gce-c34'].zone, 'asia-east2-c')
|
||||
assert.equal(result.relay_gce_cells['production-gce-c34'].database_pool_max, 16)
|
||||
})
|
||||
|
||||
test('accepts the additive US C32+C33 wave at the default pool only', () => {
|
||||
const cellIds = 'production-gce-c32,production-gce-c33'
|
||||
for (const [cellId, zone] of [
|
||||
@@ -103,7 +112,8 @@ test('matches every committed production Asia cell entry', () => {
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production-gce-c30',
|
||||
'production-gce-c31',
|
||||
'production-gce-c32,production-gce-c33'
|
||||
'production-gce-c32,production-gce-c33',
|
||||
'production-gce-c34'
|
||||
]) {
|
||||
const committedImage = committed[wave.split(',')[0]].image
|
||||
assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({
|
||||
@@ -116,8 +126,8 @@ test('matches every committed production Asia cell entry', () => {
|
||||
environment: 'production', cellIds: 'production-gce-c30',
|
||||
image
|
||||
}), /differs from the reviewed topology/)
|
||||
// The US cells launch on the newest digest, the one C31 launched on.
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33']) {
|
||||
// The US cells and the C34 spare launch on the newest cell digest, the one C31 launched on.
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) {
|
||||
assert.equal(committed[cellId].image, committed['production-gce-c31'].image, cellId)
|
||||
}
|
||||
})
|
||||
@@ -152,7 +162,8 @@ test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drif
|
||||
'production-gce-c30,production-gce-c31',
|
||||
'production-gce-c32',
|
||||
'production-gce-c33',
|
||||
'production-gce-c34'
|
||||
'production-gce-c33,production-gce-c34',
|
||||
'production-gce-c35'
|
||||
]) {
|
||||
assert.throws(() => prepareRelayAsiaTopologyInput({
|
||||
existingCells: productionCells(), existingAdditionalRegions: additionalRegions,
|
||||
|
||||
@@ -101,7 +101,9 @@ describe('production Relay capacity cell admission', () => {
|
||||
// Migration-only canaries: the US-only capacity rollout never touches them either.
|
||||
'production-gce-c17', 'production-gce-c18',
|
||||
// US cells at the Asia shape: the 1,000-cap capacity rollout never touches them.
|
||||
'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c32', 'production-gce-c33',
|
||||
// The migration-only Asia spare.
|
||||
'production-gce-c34'
|
||||
]) {
|
||||
const hostname = cellId.slice('production-gce-'.length)
|
||||
assert.deepEqual(parseProductionCapacityCellArguments([
|
||||
@@ -118,7 +120,7 @@ describe('production Relay capacity cell admission', () => {
|
||||
paceWindowMs: 0
|
||||
})
|
||||
}
|
||||
for (const cellId of ['production-gce-c12', 'production-gce-c34']) {
|
||||
for (const cellId of ['production-gce-c12', 'production-gce-c35']) {
|
||||
const hostname = cellId.slice('production-gce-'.length)
|
||||
assert.throws(() => parseProductionCapacityCellArguments([
|
||||
'--director-origin', 'https://relay.onorca.dev',
|
||||
|
||||
@@ -2,9 +2,9 @@ import { pathToFileURL } from 'node:url'
|
||||
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||
|
||||
// Every cell that carries the rehome identity: the eighteen US cells and the
|
||||
// five asia-east2 cells that drain mis-homed hosts back the other way.
|
||||
// six asia-east2 cells that drain mis-homed hosts back the other way.
|
||||
const PRODUCTION_CELL =
|
||||
/^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33)$/
|
||||
/^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34)$/
|
||||
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
|
||||
|
||||
export function parseRehomeTrustProbeArguments(argv, environment = process.env) {
|
||||
|
||||
@@ -116,7 +116,7 @@ test('fails when both trust-probe attempts return a transient 503', async () =>
|
||||
test('approves the asia-east2 and US 3,000 rehome sources and still rejects unlisted cells', () => {
|
||||
for (const cellId of [
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30',
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34'
|
||||
]) {
|
||||
const parsed = parseRehomeTrustProbeArguments(
|
||||
argv.map((value) => (value === 'production-gce-c7' ? cellId : value)),
|
||||
@@ -124,7 +124,7 @@ test('approves the asia-east2 and US 3,000 rehome sources and still rejects unli
|
||||
)
|
||||
assert.equal(parsed.cellId, cellId)
|
||||
}
|
||||
for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c34']) {
|
||||
for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c35']) {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRehomeTrustProbeArguments(
|
||||
@@ -183,7 +183,7 @@ test('approves exactly the committed production rehome source cells', () => {
|
||||
const sources = new Set(
|
||||
[...tfvars.slice(start, tfvars.indexOf(']', start)).matchAll(/"([^"]+)"/g)].map(([, cell]) => cell)
|
||||
)
|
||||
assert.ok(sources.has('production-gce-c33'))
|
||||
assert.ok(sources.has('production-gce-c34'))
|
||||
for (let ordinal = 1; ordinal <= 40; ordinal++) {
|
||||
const cellId = `production-gce-c${ordinal}`
|
||||
const approved = (() => {
|
||||
|
||||
@@ -50,7 +50,8 @@ test('accepts only the reviewed Asia topology waves', () => {
|
||||
'production:production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production:production-gce-c30',
|
||||
'production:production-gce-c31',
|
||||
'production:production-gce-c32,production-gce-c33'
|
||||
'production:production-gce-c32,production-gce-c33',
|
||||
'production:production-gce-c34'
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from './relay-cloud-sql-connection-budget.mjs'
|
||||
|
||||
test('production shared consumers keep allowance and reserve below the ceiling', () => {
|
||||
// cells: 22 pools at 10 (220, C32/C33 included) + the five asia-east2 pools at 16 (80).
|
||||
// cells: 22 pools at 10 (220, C32/C33 included) + the six asia-east2 pools at 16 (96).
|
||||
const report = readRelayCloudSqlConnectionBudget()
|
||||
|
||||
assert.deepEqual(report.consumers, { cells: 300, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 5, poolMax: 16 })
|
||||
assert.equal(report.configuredMaximum, 385)
|
||||
assert.deepEqual(report.consumers, { cells: 316, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 6, poolMax: 16 })
|
||||
assert.equal(report.configuredMaximum, 401)
|
||||
assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30)
|
||||
assert.equal(report.rolloutOverlap.apiCandidate, 65)
|
||||
assert.equal(report.rolloutOverlap.authCandidate, 35)
|
||||
@@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling',
|
||||
assert.equal(report.maintenanceAdminAllowance, 5)
|
||||
assert.equal(report.explicitReserve, 10)
|
||||
assert.equal(report.usableCeiling, 490)
|
||||
assert.equal(report.operatingMaximum, 455)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 35)
|
||||
assert.equal(report.budgetedTotal, 465)
|
||||
assert.equal(report.unallocated, 35)
|
||||
assert.equal(report.operatingMaximum, 471)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 19)
|
||||
assert.equal(report.budgetedTotal, 481)
|
||||
assert.equal(report.unallocated, 19)
|
||||
assert.equal(report.withinBudget, true)
|
||||
})
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs'
|
||||
|
||||
// Migration-only by policy: zero hosts and no reservation, so a wave rolls one without
|
||||
// displacing anybody. It enters and must leave migration-only, never general.
|
||||
// C32 and C33 stay here until each one's canary promotes it; that follow-up moves it to general.
|
||||
// C34 is an Asia spare that stays migration-only by policy.
|
||||
export const SAME_CAP_MIGRATION_ONLY_CELLS = [
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33'
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c34'
|
||||
]
|
||||
|
||||
export const SAME_CAP_CELLS = [
|
||||
@@ -15,7 +15,7 @@ export const SAME_CAP_CELLS = [
|
||||
'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22',
|
||||
'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26',
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30',
|
||||
'production-gce-c31',
|
||||
'production-gce-c31', 'production-gce-c32', 'production-gce-c33',
|
||||
...SAME_CAP_MIGRATION_ONLY_CELLS
|
||||
]
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ test('requires one canary or a bounded reviewed batch', () => {
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31`
|
||||
}).cells, ['production-gce-c31'])
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33']) {
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) {
|
||||
assert.deepEqual(validateSameCapWave({
|
||||
mode: 'canary-apply',
|
||||
cellIds: cellId,
|
||||
@@ -74,10 +74,10 @@ test('requires one canary or a bounded reviewed batch', () => {
|
||||
}
|
||||
assert.throws(() => validateSameCapWave({
|
||||
mode: 'canary-apply',
|
||||
cellIds: 'production-gce-c34',
|
||||
cellIds: 'production-gce-c35',
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c34`
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c35`
|
||||
}), /cells/)
|
||||
})
|
||||
|
||||
@@ -123,12 +123,16 @@ test('the wave workflow chains exactly ten serial cell jobs', () => {
|
||||
assert.doesNotMatch(dispatch, /\n cell_11:/)
|
||||
})
|
||||
|
||||
test('lists C32 and C33 as migration-only beside C17 and C18 until their canaries promote them', () => {
|
||||
assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, [
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33'
|
||||
])
|
||||
assert.equal(SAME_CAP_CELLS.includes('production-gce-c30'), true)
|
||||
assert.equal(SAME_CAP_CELLS.includes('production-gce-c31'), true)
|
||||
test('lists the C34 spare as migration-only beside C17 and C18, and C30-C33 as general', () => {
|
||||
assert.deepEqual(
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS,
|
||||
['production-gce-c17', 'production-gce-c18', 'production-gce-c34']
|
||||
)
|
||||
for (const cellId of [
|
||||
'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33'
|
||||
]) {
|
||||
assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId)
|
||||
}
|
||||
})
|
||||
|
||||
test('rolls the migration-only cells but never mixes the two classes in one wave', () => {
|
||||
@@ -194,15 +198,28 @@ test('rolls the migration-only cells but never mixes the two classes in one wave
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c31Mixed}`,
|
||||
canaryRunId: '42'
|
||||
}), /all general or all migration-only/)
|
||||
// Until its canary promotes it, a same-cap restore must hand a US 3,000 cell back isolated.
|
||||
assert.equal(entryAdmission('production-gce-c32'), 'migration-only')
|
||||
const usUnpromoted = 'production-gce-c26,production-gce-c32'
|
||||
assert.throws(() => validateSameCapWave({
|
||||
// C32 and C33 are general since their 2026-10-01 promotions: they roll beside US 1k cells,
|
||||
// isolate and restore (delta 2), and never share a wave with C17/C18.
|
||||
for (const cellId of ['production-gce-c32', 'production-gce-c33']) {
|
||||
assert.equal(entryAdmission(cellId), 'general', cellId)
|
||||
assert.equal(selectorWaveDelta(cellId), 2, cellId)
|
||||
}
|
||||
const usPromoted = 'production-gce-c26,production-gce-c32,production-gce-c33'
|
||||
assert.deepEqual(validateSameCapWave({
|
||||
mode: 'batch-apply',
|
||||
cellIds: usUnpromoted,
|
||||
cellIds: usPromoted,
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usUnpromoted}`,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usPromoted}`,
|
||||
canaryRunId: '42'
|
||||
}).cells, ['production-gce-c26', 'production-gce-c32', 'production-gce-c33'])
|
||||
const usMixed = 'production-gce-c32,production-gce-c17'
|
||||
assert.throws(() => validateSameCapWave({
|
||||
mode: 'batch-apply',
|
||||
cellIds: usMixed,
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usMixed}`,
|
||||
canaryRunId: '42'
|
||||
}), /all general or all migration-only/)
|
||||
// A mixed wave has no single selector delta for its later cells to offset from.
|
||||
|
||||
@@ -263,7 +263,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId)
|
||||
}
|
||||
assert.equal(resolveCellShape('production-gce-c12').status, 1)
|
||||
assert.equal(resolveCellShape('production-gce-c34').status, 1)
|
||||
assert.equal(resolveCellShape('production-gce-c35').status, 1)
|
||||
})
|
||||
|
||||
|
||||
@@ -275,11 +275,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell))
|
||||
// Only a declared rehome source may roll at a trusted protocol at all; the job refuses
|
||||
// the rest before it plans, and the next test covers them at protocol 0.
|
||||
// C32 and C33 are already rehome sources but stay migration-only until their canaries.
|
||||
const unpromotedSources = ['production-gce-c32', 'production-gce-c33']
|
||||
// The C34 spare is a rehome source that stays migration-only.
|
||||
assert.deepEqual(
|
||||
SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)),
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => !unpromotedSources.includes(cell))
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c34')
|
||||
)
|
||||
for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) {
|
||||
const { cap, pool } = cellShape(cellId)
|
||||
|
||||
@@ -24,14 +24,16 @@ const CELL_SHAPES = {
|
||||
'production-gce-c30': 'asia-east2-a',
|
||||
'production-gce-c31': 'asia-east2-b',
|
||||
'production-gce-c32': 'us-central1-a',
|
||||
'production-gce-c33': 'us-central1-b'
|
||||
'production-gce-c33': 'us-central1-b',
|
||||
'production-gce-c34': 'asia-east2-c'
|
||||
},
|
||||
waves: [
|
||||
['production-gce-c27', 'production-gce-c28', 'production-gce-c29'],
|
||||
['production-gce-c30'],
|
||||
['production-gce-c31'],
|
||||
// Declared together, so they plan together: a lone C32 plan would hit C33's missing template.
|
||||
['production-gce-c32', 'production-gce-c33']
|
||||
['production-gce-c32', 'production-gce-c33'],
|
||||
['production-gce-c34']
|
||||
]
|
||||
},
|
||||
staging: {
|
||||
|
||||
@@ -161,6 +161,18 @@ test('accepts the additive production C31 wave only in asia-east2-b', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts the additive production C34 spare wave only in asia-east2-c', () => {
|
||||
const c34Config = { ...productionConfig, cells: ['production-gce-c34'] }
|
||||
assert.deepEqual(
|
||||
validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34', 'asia-east2-c') }, c34Config),
|
||||
{ environment: 'production', cells: ['production-gce-c34'], changes: 4 }
|
||||
)
|
||||
assert.throws(
|
||||
() => validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34') }, c34Config),
|
||||
/fixed-one Asia MIG shape/
|
||||
)
|
||||
})
|
||||
|
||||
// A US cell joins the root region: no additional-region network, no region label or line, and
|
||||
// the default pool emits no line, exactly as the startup template renders a root-region cell.
|
||||
function usCellPlan(hostname, zone) {
|
||||
@@ -269,7 +281,8 @@ test('accepts only a reviewed Asia topology wave', () => {
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production-gce-c29,production-gce-c27,production-gce-c28',
|
||||
'production-gce-c30',
|
||||
'production-gce-c31'
|
||||
'production-gce-c31',
|
||||
'production-gce-c34'
|
||||
]) {
|
||||
assert.doesNotThrow(
|
||||
() => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)),
|
||||
@@ -286,7 +299,8 @@ test('accepts only a reviewed Asia topology wave', () => {
|
||||
'production-gce-c32',
|
||||
'production-gce-c33',
|
||||
'production-gce-c32,production-gce-c33,production-gce-c34',
|
||||
'production-gce-c34'
|
||||
'production-gce-c33,production-gce-c34',
|
||||
'production-gce-c35'
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)),
|
||||
|
||||
@@ -215,7 +215,19 @@ promote while a same-cap restore has just returned an empty general US cell: the
|
||||
would land there and the canary would roll the new cell back. Both cells are declared rehome
|
||||
sources and sit in the same-cap migration-only list until each one's canary promotes it, then move
|
||||
to the general list. The shadow gate's fleet pool list tracks the 16-connection Asia pools, so
|
||||
whether a US cell belongs there is decided at promotion, not assumed.
|
||||
whether a US cell belongs there is decided at promotion, not assumed. Both were promoted to general
|
||||
on 2026-10-01, so the same-cap job now rolls them as general cells. They stay out of the fleet pool
|
||||
list because their pool is the US default of 10.
|
||||
|
||||
C34 is an Asia spare at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It is
|
||||
its own topology wave and registers alone as migration-only, then the director is configured with
|
||||
`cell-ids` set to C34. It has no promotion wave: the Asia admission script and workflow refuse
|
||||
`promote` for it, and placement and regional rehome select only general cells. It is a
|
||||
migration-only landing zone that only an explicit evacuation or migration naming it can target.
|
||||
Do not name it in the multi-target `promote-general-cell` or `retire-migration-cell` modes, which
|
||||
accept any migration-only cell. It is a declared rehome source, sits in the same-cap migration-only
|
||||
list, and stays out of the fleet pool list. Promoting it later takes its own reviewed change adding a
|
||||
promotion wave and canary entry.
|
||||
Rollback returns
|
||||
Asia cells to migration-only; it does not destroy the network or use
|
||||
existing-only. The production topology dispatch remains unavailable until the
|
||||
|
||||
@@ -332,7 +332,7 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It
|
||||
rejects deletes, replacements, loss of an existing host route, US-resource
|
||||
changes, and unrelated drift. Do not add production C27-C29 until the
|
||||
compatible image has been published and each entry can pin its immutable
|
||||
digest. A later cell, such as C30 or C31, is its own reviewed wave. The shared URL map
|
||||
digest. A later cell, such as C30, C31 or the C34 spare, is its own reviewed wave. The shared URL map
|
||||
pulls every live cell into its plan, so the workflow plans each live cell at the
|
||||
image its state template already serves, and the validator rejects any change
|
||||
to a cell outside the wave. US C32 and C33 use the same workflow at the same
|
||||
|
||||
@@ -450,6 +450,21 @@ relay_gce_cells = {
|
||||
connection_hard_cap = 3000
|
||||
connection_unobserved_bound = 60
|
||||
}
|
||||
# Asia spare: registered migration-only as a drain landing zone; c completes the 2/2/2 zone spread.
|
||||
"production-gce-c34" = {
|
||||
hostname = "c34"
|
||||
region = "asia-east2"
|
||||
zone = "asia-east2-c"
|
||||
machine_type = "e2-standard-4"
|
||||
boot_disk_gb = 30
|
||||
boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21"
|
||||
capacity_requests = 6000
|
||||
database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters).
|
||||
image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269"
|
||||
initially_enabled = false
|
||||
connection_hard_cap = 3000
|
||||
connection_unobserved_bound = 60
|
||||
}
|
||||
}
|
||||
|
||||
relay_region_rehome_source_cell_ids = [
|
||||
@@ -476,7 +491,8 @@ relay_region_rehome_source_cell_ids = [
|
||||
"production-gce-c30",
|
||||
"production-gce-c31",
|
||||
"production-gce-c32",
|
||||
"production-gce-c33"
|
||||
"production-gce-c33",
|
||||
"production-gce-c34"
|
||||
]
|
||||
|
||||
# Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply
|
||||
|
||||
@@ -95,9 +95,9 @@ not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is
|
||||
small (10 reviewed entries — one test fixture title, five non-English
|
||||
language-name search keywords, and four reviewed product-name search
|
||||
keywords): new candidates fail the check and must be localized or added with
|
||||
small (11 reviewed entries — six non-English language-name search keywords,
|
||||
four reviewed product-name search keywords, and one non-UI `label` placement
|
||||
prop): new candidates fail the check and must be localized or added with
|
||||
a reviewed reason in the same change.
|
||||
|
||||
The script scans `src/renderer/src` by default. That is the primary UI surface.
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
[
|
||||
{
|
||||
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Nightly #1",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts",
|
||||
"kind": "object-property:label",
|
||||
"text": "Hermes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
"text": "sidebar",
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Idioma",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "语言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "語言",
|
||||
"text": "Langue",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
@@ -51,24 +30,17 @@
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Idioma",
|
||||
"text": "語言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Langue",
|
||||
"text": "语言",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
@@ -77,11 +49,11 @@
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
@@ -91,317 +63,16 @@
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:title",
|
||||
"text": "Default shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:description",
|
||||
"text": "Shell used for new terminal panes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "shell",
|
||||
"text": "Ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "terminal",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "fish",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "bash",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "nushell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "default",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:title",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:description",
|
||||
"text": "Choose what Orca opens for new local terminal panes.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:ariaLabel",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "System shell (",
|
||||
"dynamic": true,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "Custom shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:placeholder",
|
||||
"text": "fish, nu, or /bin/zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:aria-label",
|
||||
"text": "Custom shell executable",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Enter a shell name on PATH or an executable path. Orca starts it as a login shell.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": ". Switch to System shell or choose an executable on this host.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "arguments",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "args",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "login",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "wrapper",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "rcfile",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Advanced",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Shell arguments",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-expression",
|
||||
"text": "Starts the shell as a login shell with -l.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-expression",
|
||||
"text": "Enter one argument per line. Leave it empty to pass no arguments.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:ariaLabel",
|
||||
"text": "Shell argument mode",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "-l (default)",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "Custom args",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:placeholder",
|
||||
"text": "--rcfile /path/to/rcfile",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:aria-label",
|
||||
"text": "Shell arguments, one per line",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:title",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:description",
|
||||
"text": "Shell and arguments used for new local interactive terminal panes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "terminal",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "fish",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "bash",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "nushell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "arguments",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "args",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "login",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "wrapper",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/terminal-search.ts",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "rcfile",
|
||||
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
|
||||
"kind": "jsx-attribute:label",
|
||||
"text": "sidebar",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
"anti-slop/no-module-mocking": "off"
|
||||
}
|
||||
},
|
||||
// Test-only side effect for explicit RPC registries; real catalog tests never import it.
|
||||
{
|
||||
"files": ["**/src/main/runtime/rpc/unused-default-rpc-methods.test-fixture.ts"],
|
||||
"rules": {
|
||||
"anti-slop/no-module-mocking": "off"
|
||||
}
|
||||
},
|
||||
// The exemptions below are file-scoped rather than inline `oxlint-disable` comments
|
||||
// because the root lint scan does not load this plugin, so an inline directive naming
|
||||
// an anti-slop rule always reads back as an unused directive there.
|
||||
|
||||
@@ -948,7 +948,7 @@
|
||||
"invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.",
|
||||
"oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
@@ -963,6 +963,7 @@
|
||||
"src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx",
|
||||
"src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx",
|
||||
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx",
|
||||
"src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx",
|
||||
"src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"mobile/src/session/mobile-native-chat-image-attachment.test.ts",
|
||||
"mobile/src/session/use-mobile-native-chat-image-attachments.test.ts",
|
||||
@@ -1033,13 +1034,13 @@
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
"date": "2026-10-04",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 22.1,
|
||||
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests."
|
||||
"durationSeconds": 42.3,
|
||||
"summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file."
|
||||
},
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
@@ -16958,15 +16959,38 @@
|
||||
"invariant": "Hidden-output restore, snapshot replay, metadata-only replay, and clear-before-replay must preserve order and never overlay stale bytes on newer live terminal output; restoring a snapshot onto an already-dirty pane must yield a buffer exactly equal to the snapshot frame.",
|
||||
"oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts"
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts",
|
||||
"assertions": [
|
||||
"repaints a pushed image exactly and keeps the history the TUI covers",
|
||||
"keeps the pane's history when the host screen has another grid",
|
||||
"keeps history unduplicated when a pushed image also carries history",
|
||||
"paints the whole image when the TUI started while hidden",
|
||||
"reads the pane buffer after a queued alt-screen entry parses",
|
||||
"repaints from the normal buffer once the host TUI has exited",
|
||||
"clears a raw byte replay in place on the alt screen",
|
||||
"paints a requested image from an exited TUI exactly over an alt screen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts",
|
||||
"assertions": [
|
||||
"keeps the covered history for a live TUI exactly as the host holds it",
|
||||
"repaints from the normal buffer once the host TUI has exited"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"assertions": [
|
||||
|
||||
+29
-27
@@ -8,10 +8,14 @@
|
||||
* Counting passes patch `Map`/`Set`/`Object.assign`/`Object.values`, which deoptimizes them, so
|
||||
* counts and timings are taken in separate passes and never from the same run.
|
||||
*
|
||||
* Run: ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts
|
||||
*
|
||||
* Scale mirrors the reporting user rather than the 100-worktree fixture in
|
||||
* docs/reference/renderer-agent-status-performance.md: 423 worktrees, 634 terminal tabs.
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { AgentStatusBatchUpdate } from '@/store/slices/agent-status'
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
TEST_REPO
|
||||
} from '@/store/slices/store-test-helpers'
|
||||
import { makePaneKey } from '../../src/shared/stable-pane-id'
|
||||
import { getDefaultSettings } from '../../src/shared/constants'
|
||||
import {
|
||||
createAgentStatusPaneRoutingIndex,
|
||||
resolvePaneKeyFromRoutingIndex
|
||||
@@ -39,25 +44,24 @@ const counters = { maps: 0, sets: 0, tabComparisons: 0 }
|
||||
|
||||
const NativeMap = globalThis.Map
|
||||
const NativeSet = globalThis.Set
|
||||
const nativeArrayIterator = Array.prototype[Symbol.iterator]
|
||||
|
||||
function withAllocationCounting<T>(run: () => T): T {
|
||||
class CountingMap<K, V> extends NativeMap<K, V> {
|
||||
constructor(entries?: readonly (readonly [K, V])[] | null) {
|
||||
constructor(entries?: Iterable<readonly [K, V]> | null) {
|
||||
super(entries)
|
||||
counters.maps += 1
|
||||
}
|
||||
}
|
||||
class CountingSet<V> extends NativeSet<V> {
|
||||
constructor(values?: readonly V[] | null) {
|
||||
constructor(values?: Iterable<V> | null) {
|
||||
super(values)
|
||||
counters.sets += 1
|
||||
}
|
||||
}
|
||||
counters.maps = 0
|
||||
counters.sets = 0
|
||||
globalThis.Map = CountingMap as unknown as MapConstructor
|
||||
globalThis.Set = CountingSet as unknown as SetConstructor
|
||||
globalThis.Map = CountingMap
|
||||
globalThis.Set = CountingSet
|
||||
try {
|
||||
return run()
|
||||
} finally {
|
||||
@@ -68,34 +72,33 @@ function withAllocationCounting<T>(run: () => T): T {
|
||||
|
||||
/** Tab list whose iteration is observable, so the nested-loop resolver's comparisons are countable. */
|
||||
class CountingTabList<T> extends Array<T> {
|
||||
[Symbol.iterator](): IterableIterator<T> {
|
||||
const inner = nativeArrayIterator.call(this) as IterableIterator<T>
|
||||
const wrapped: IterableIterator<T> = {
|
||||
next: () => {
|
||||
const result = inner.next()
|
||||
if (!result.done) {
|
||||
counters.tabComparisons += 1
|
||||
}
|
||||
return result
|
||||
},
|
||||
[Symbol.iterator]: () => wrapped
|
||||
[Symbol.iterator](): ArrayIterator<T> {
|
||||
const iterator = super[Symbol.iterator]()
|
||||
const next = iterator.next.bind(iterator)
|
||||
iterator.next = (...args) => {
|
||||
const result = next(...args)
|
||||
if (!result.done) {
|
||||
counters.tabComparisons += 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
return wrapped
|
||||
return iterator
|
||||
}
|
||||
}
|
||||
|
||||
function buildFixture(countTabIteration: boolean) {
|
||||
const store = createTestStore()
|
||||
const settings = store.getState().settings ?? getDefaultSettings(tmpdir())
|
||||
const tabsByWorktree: AppState['tabsByWorktree'] = {}
|
||||
const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = {}
|
||||
const worktrees = []
|
||||
const worktrees: ReturnType<typeof makeWorktree>[] = []
|
||||
const paneKeys: string[] = []
|
||||
const owners: { tabId: string; worktreeId: string }[] = []
|
||||
for (let index = 0; index < WORKTREES; index += 1) {
|
||||
const worktreeId = `wt-${index}`
|
||||
worktrees.push(makeWorktree({ id: worktreeId, repoId: TEST_REPO.id }))
|
||||
const tabs = []
|
||||
const unified = []
|
||||
const tabs: ReturnType<typeof makeTab>[] = []
|
||||
const unified: ReturnType<typeof makeUnifiedTab>[] = []
|
||||
for (let tab = 0; tab < (index % 2 === 0 ? 1 : 2); tab += 1) {
|
||||
const tabId = `tab-${index}-${tab}`
|
||||
tabs.push(makeTab({ id: tabId, worktreeId, title: `Terminal ${index}-${tab}` }))
|
||||
@@ -110,9 +113,7 @@ function buildFixture(countTabIteration: boolean) {
|
||||
paneKeys.push(makePaneKey(tabId, LEAF_ID))
|
||||
owners.push({ tabId, worktreeId })
|
||||
}
|
||||
tabsByWorktree[worktreeId] = countTabIteration
|
||||
? (CountingTabList.from(tabs) as unknown as typeof tabs)
|
||||
: tabs
|
||||
tabsByWorktree[worktreeId] = countTabIteration ? CountingTabList.from(tabs) : tabs
|
||||
unifiedTabsByWorktree[worktreeId] = unified
|
||||
}
|
||||
store.setState({
|
||||
@@ -122,8 +123,8 @@ function buildFixture(countTabIteration: boolean) {
|
||||
unifiedTabsByWorktree,
|
||||
terminalLayoutsByTabId: {},
|
||||
setGeneratedTabTitlesFromAgentPrompts: () => {},
|
||||
settings: { ...store.getState().settings, tabAutoGenerateTitle: false }
|
||||
} as Partial<AppState>)
|
||||
settings: { ...settings, tabAutoGenerateTitle: false }
|
||||
})
|
||||
return { store, paneKeys, owners }
|
||||
}
|
||||
|
||||
@@ -141,7 +142,7 @@ function per1k(value: number): number {
|
||||
function runIndexedRouting(store: ReturnType<typeof createTestStore>, paneKeys: string[]): void {
|
||||
for (let event = 0; event < EVENTS; event += 1) {
|
||||
if (event % BATCH_SIZE === 0) {
|
||||
store.setState({ agentStatusEpoch: event } as Partial<AppState>)
|
||||
store.setState({ agentStatusEpoch: event })
|
||||
}
|
||||
const index = createAgentStatusPaneRoutingIndex(store.getState())
|
||||
resolvePaneKeyFromRoutingIndex(index, paneKeys[event % paneKeys.length])
|
||||
@@ -287,7 +288,8 @@ describe('agent-status hot path benchmark', () => {
|
||||
}
|
||||
|
||||
const outputPath =
|
||||
process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ?? '/tmp/agent-status-hot-path-benchmark.json'
|
||||
process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ??
|
||||
join(tmpdir(), 'agent-status-hot-path-benchmark.json')
|
||||
writeFileSync(
|
||||
outputPath,
|
||||
`${JSON.stringify({ worktrees: WORKTREES, events: EVENTS, report }, null, 2)}\n`
|
||||
@@ -114,6 +114,17 @@ describe('root directory guard', () => {
|
||||
expect(result.status).toBe(0)
|
||||
})
|
||||
|
||||
it('allows the reviewed repository OpenCode permission config', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [
|
||||
['opencode.json', '{"permission":{"*":{"*":"allow"}}}\n']
|
||||
])
|
||||
|
||||
const result = runGuard({ ...fixture, head })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a new top-level directory', () => {
|
||||
const fixture = makeFixture()
|
||||
const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']])
|
||||
|
||||
@@ -96,13 +96,13 @@ describe('CI background step barriers', () => {
|
||||
expect(steps.findIndex((step) => step.id === 'changed-code-quality')).toBeGreaterThan(install)
|
||||
})
|
||||
|
||||
it('serializes mobile pnpm entrypoints before allocating test workers', () => {
|
||||
it('joins independent mobile typechecks before allocating test workers', () => {
|
||||
const steps = mobile.jobs.verify.steps
|
||||
assertJoinedBefore(steps, 'production-types', (step) => step.name === 'Test')
|
||||
const ratchet = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)')
|
||||
const join = steps.findIndex((step) => step.wait === 'production-types')
|
||||
expect(steps[ratchet].background).toBeUndefined()
|
||||
expect(ratchet).toBeGreaterThan(join)
|
||||
expect(ratchet).toBeLessThan(join)
|
||||
})
|
||||
|
||||
it('waits for WebKit and the bundle before any browser tests', () => {
|
||||
|
||||
@@ -5375,7 +5375,7 @@
|
||||
"src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 552,
|
||||
"src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 59,
|
||||
"src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 2238,
|
||||
"src/renderer/src/components/editor/csv-parse.test.ts": 122,
|
||||
"src/renderer/src/components/editor/csv/csv-parse.test.ts": 122,
|
||||
"src/renderer/src/components/editor/details-markdown-html.test.ts": 99,
|
||||
"src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 35,
|
||||
"src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 48,
|
||||
|
||||
@@ -2342,9 +2342,6 @@
|
||||
"auto.components.settings.MobileEmulatorSettingsPane.ae1612c58c": {
|
||||
"ko": "가용성"
|
||||
},
|
||||
"auto.components.settings.MobilePane.35100bca5d": {
|
||||
"ko": "휴대폰에서 terminals을 사용하는 동안 Orca는 휴대폰 화면에 맞게 terminals을 축소합니다. 앱을 닫거나 다른 곳으로 전환하면 terminals이 휴대폰 크기로 유지되는지(대화형 CLI 도구가 리플로우되지 않음) 또는 데스크탑으로 다시 크기가 조정되는지 여부가 제어됩니다. 배너에서 '이 terminals 복원' 또는 '모든 terminals 복원'을 사용해 언제든지 수동으로 크기를 조정할 수 있습니다."
|
||||
},
|
||||
"auto.components.settings.MobileSettingsPane.b0088412a1": {
|
||||
"ko": "또는 다음에서 Android APK를 받으세요"
|
||||
},
|
||||
@@ -4886,12 +4883,6 @@
|
||||
"auto.components.terminal.pane.CloseTerminalDialog.stop_command_title": {
|
||||
"ko": "실행 중인 명령을 중지할까요?"
|
||||
},
|
||||
"auto.components.terminal.pane.MobileDriverOverlay.54f7d6f69d": {
|
||||
"ko": "모든 terminals 제어권 가져오기"
|
||||
},
|
||||
"auto.components.terminal.pane.MobileDriverOverlay.faa367dc74": {
|
||||
"ko": "휴대폰이 이 화면을 휴대폰 크기로 남겨 두었습니다"
|
||||
},
|
||||
"auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d": {
|
||||
"ko": "삽입"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('mobile verification command ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('finishes installation and joins production types before checking test types', () => {
|
||||
it('finishes installation and joins both independent typechecks before tests', () => {
|
||||
const installIndex = steps.findIndex((step) => step.name === 'Install dependencies')
|
||||
const productionIndex = steps.findIndex((step) => step.name === 'Typecheck')
|
||||
const ratchetIndex = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)')
|
||||
@@ -36,7 +36,10 @@ describe('mobile verification command ownership', () => {
|
||||
expect(steps[productionIndex].background).toBe(true)
|
||||
expect(productionIndex).toBeLessThan(ratchetIndex)
|
||||
expect(waitIndex).toBeGreaterThan(productionIndex)
|
||||
expect(waitIndex).toBeLessThan(ratchetIndex)
|
||||
expect(ratchetIndex).toBeLessThan(testIndex)
|
||||
expect(ratchetIndex).toBeLessThan(waitIndex)
|
||||
expect(waitIndex).toBeLessThan(testIndex)
|
||||
expect(steps[waitIndex].if).toBeUndefined()
|
||||
expect(steps[productionIndex]['continue-on-error'] ?? false).toBe(false)
|
||||
expect(steps[ratchetIndex]['continue-on-error'] ?? false).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
|
||||
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
|
||||
import {
|
||||
createBundleServer,
|
||||
installShellDouble,
|
||||
projectDir,
|
||||
readBridgeFaultGrant,
|
||||
readBridgeProtocolVersion,
|
||||
readShellCsp
|
||||
} from './mobile-web-app-render-harness.mjs'
|
||||
import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs'
|
||||
|
||||
/**
|
||||
* The working rings keep turning on the page. Each is a one-second rotation loop; a loop that
|
||||
* stops after its first turn reads `rotate(360deg)` from then on.
|
||||
*/
|
||||
|
||||
const ROUTE = `/${MOBILE_WEB_APP_ROUTE_ROOT}/working-spinner-probe`
|
||||
const SAMPLE_SECONDS = [1.3, 1.8, 2.4]
|
||||
const bundles = mobileWebAppDependenciesPresent()
|
||||
const describeRender = bundles ? describe : describe.skip
|
||||
|
||||
function probeRouteSource({ spinnerModule, dotModule }) {
|
||||
return `import { View } from 'react-native'
|
||||
import { AgentSpinner } from ${JSON.stringify(spinnerModule)}
|
||||
import { AgentStateDot } from ${JSON.stringify(dotModule)}
|
||||
|
||||
export default function WorkingSpinnerProbeRoute() {
|
||||
globalThis.__orcaWorkingSpinnerProbe = true
|
||||
return (
|
||||
<View style={{ padding: 24, gap: 24 }}>
|
||||
<View nativeID="working-spinner"><AgentSpinner status="working" /></View>
|
||||
<View nativeID="working-dot"><AgentStateDot state="working" /></View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
let browser = null
|
||||
let origin = null
|
||||
let scratch = null
|
||||
let server = null
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!bundles) {
|
||||
return
|
||||
}
|
||||
const componentsDir = join(projectDir, 'mobile', 'src', 'components')
|
||||
const cspHeader = await readShellCsp()
|
||||
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-working-spinner-'))
|
||||
const appDir = join(scratch, 'app')
|
||||
const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT)
|
||||
await mkdir(routeDir, { recursive: true })
|
||||
await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE)
|
||||
await writeFile(
|
||||
join(routeDir, 'working-spinner-probe.tsx'),
|
||||
probeRouteSource({
|
||||
spinnerModule: join(componentsDir, 'AgentSpinner'),
|
||||
dotModule: join(componentsDir, 'AgentStateDot')
|
||||
})
|
||||
)
|
||||
const built = await buildMobileWebAppBundle({
|
||||
appDir,
|
||||
outDir: join(scratch, 'bundle'),
|
||||
pageRoutes: [{ pathname: ROUTE, grants: [] }]
|
||||
})
|
||||
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
|
||||
server = served.server
|
||||
origin = served.origin
|
||||
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
|
||||
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
|
||||
}, 600_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
server?.close()
|
||||
if (scratch) {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function openProbe() {
|
||||
const faultGrant = await readBridgeFaultGrant()
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
||||
await page.addInitScript(installShellDouble, {
|
||||
version: await readBridgeProtocolVersion(),
|
||||
sessionId: 'working-spinner-session',
|
||||
buildId: 'working-spinner-build',
|
||||
route: { pathname: ROUTE, params: {} },
|
||||
host: { id: 'spinner-host', name: 'Spinner Host', endpoint: 'ws://spinner', lastConnected: 1 },
|
||||
storage: {},
|
||||
faultGrant,
|
||||
grants: [faultGrant],
|
||||
pageRoutes: [ROUTE],
|
||||
replies: {}
|
||||
})
|
||||
const errors = []
|
||||
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
|
||||
await page.goto(`${origin}/`, { waitUntil: 'load' })
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
globalThis.__orcaWorkingSpinnerProbe !== undefined ||
|
||||
(globalThis.__orcaRenderCheckFaults ?? []).length > 0,
|
||||
{ timeout: 60_000, polling: 100 }
|
||||
)
|
||||
expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([])
|
||||
return { errors, page }
|
||||
}
|
||||
|
||||
/** The rotating ring is the only element under each wrapper that carries an inline transform. */
|
||||
const readRotations = (page) =>
|
||||
page.evaluate(() =>
|
||||
['working-spinner', 'working-dot'].map(
|
||||
(id) =>
|
||||
[...document.querySelectorAll(`#${id} *`)].find((node) => node.style.transform)?.style
|
||||
.transform ?? null
|
||||
)
|
||||
)
|
||||
|
||||
describeRender('working rings on the page', () => {
|
||||
it('keeps both rings turning past the first second', async () => {
|
||||
const { errors, page } = await openProbe()
|
||||
const started = Date.now()
|
||||
const samples = []
|
||||
for (const seconds of SAMPLE_SECONDS) {
|
||||
await page.waitForTimeout(Math.max(0, started + seconds * 1000 - Date.now()))
|
||||
samples.push(await readRotations(page))
|
||||
}
|
||||
for (const ring of [0, 1]) {
|
||||
const angles = samples.map((sample) => sample[ring])
|
||||
expect(new Set(angles).size, `ring ${ring} after 1.3 s: ${angles.join(', ')}`).toBe(
|
||||
angles.length
|
||||
)
|
||||
}
|
||||
expect(errors).toEqual([])
|
||||
await page.close()
|
||||
}, 300_000)
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { classifyPrJobs } from './pr-code-change-scope.mjs'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const routes = [
|
||||
['git_compatibility', 'src/shared/git-capability-cache.ts'],
|
||||
['codex_index_heal_contract', 'src/main/codex/codex-session-index-heal.ts'],
|
||||
['xterm_patch_sync', 'config/patches/xterm-upstream.json'],
|
||||
['shell_contracts', 'src/main/pty/pty-manager.ts'],
|
||||
['orcad_browser', 'src/main/orcad/orcad-browser-provider.ts'],
|
||||
['cross-version-wire', 'src/shared/orchestration-rpc-contract.ts'],
|
||||
['managed_hook_node18', 'src/shared/agent-hook-status.ts']
|
||||
]
|
||||
|
||||
function evaluate(expression, context) {
|
||||
return runInNewContext(
|
||||
expression
|
||||
.replace(/^\$\{\{\s*|\s*\}\}$/g, '')
|
||||
.replaceAll('.cross-version-wire', '["cross-version-wire"]'),
|
||||
context
|
||||
)
|
||||
}
|
||||
|
||||
function routedOutputs(files, reused) {
|
||||
const scope = classifyPrJobs(files)
|
||||
const context = {
|
||||
steps: {
|
||||
readiness: { outputs: { reused } },
|
||||
filter: {
|
||||
outputs: Object.fromEntries(
|
||||
Object.entries(scope).map(([key, value]) => [key, String(value)])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
['test', 'static_analysis', 'typecheck', ...routes.map(([job]) => job)].map((name) => [
|
||||
name,
|
||||
String(evaluate(workflow.jobs.code_paths.outputs[name], context))
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
describe.each(routes)('%s preflight admission', (jobName, changedFile) => {
|
||||
const job = workflow.jobs[jobName]
|
||||
|
||||
it('waits for the detector and physical preflight job', () => {
|
||||
expect(job.needs).toEqual(['code_paths', 'preflight'])
|
||||
expect(job.if).toContain('!cancelled()')
|
||||
expect(job['continue-on-error']).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'all prerequisites pass', admitted: true },
|
||||
{ name: 'preflight fails', preflight: 'failure', admitted: false },
|
||||
{ name: 'preflight is cancelled', preflight: 'cancelled', admitted: false },
|
||||
{ name: 'preflight skips', preflight: 'skipped', admitted: false },
|
||||
{ name: 'preflight result is missing', preflight: '', admitted: false },
|
||||
{ name: 'preflight is unfinished', preflight: null, admitted: false },
|
||||
{ name: 'detector fails', detector: 'failure', admitted: false },
|
||||
{ name: 'detector is cancelled', detector: 'cancelled', admitted: false },
|
||||
{ name: 'detector skips', detector: 'skipped', admitted: false },
|
||||
{ name: 'detector result is missing', detector: '', admitted: false },
|
||||
{ name: 'detector is unfinished', detector: null, admitted: false },
|
||||
{ name: 'route is unselected', selected: 'false', admitted: false },
|
||||
{ name: 'route is missing', selected: '', admitted: false },
|
||||
{ name: 'route is absent', selected: null, admitted: false },
|
||||
{ name: 'route is unknown', selected: 'unknown', admitted: false },
|
||||
{ name: 'workflow is cancelled', cancelled: true, admitted: false }
|
||||
])('evaluates the actual workflow condition: $name', (scenario) => {
|
||||
const admitted = evaluate(job.if, {
|
||||
cancelled: () => scenario.cancelled ?? false,
|
||||
needs: {
|
||||
code_paths: {
|
||||
result: scenario.detector === undefined ? 'success' : (scenario.detector ?? undefined),
|
||||
outputs: {
|
||||
[jobName]: scenario.selected === undefined ? 'true' : (scenario.selected ?? undefined)
|
||||
}
|
||||
},
|
||||
preflight: {
|
||||
result: scenario.preflight === undefined ? 'success' : (scenario.preflight ?? undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(admitted).toBe(scenario.admitted)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ files: [] },
|
||||
{ files: ['package.json'] },
|
||||
{ files: ['.github/workflows/pr.yml'] },
|
||||
{ files: [changedFile] }
|
||||
])(
|
||||
'requires unit work and preflight whenever the real classifier selects $files',
|
||||
({ files }) => {
|
||||
const selected = routedOutputs(files, 'false')
|
||||
expect(selected[jobName]).toBe('true')
|
||||
expect(selected.test).toBe('true')
|
||||
expect(selected.static_analysis).toBe('true')
|
||||
expect(selected.typecheck).toBe('true')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['true', 'false', undefined])(
|
||||
'honors the actual readiness-masked output when reuse is %s',
|
||||
(reused) => {
|
||||
const outputs = routedOutputs([changedFile], reused)
|
||||
expect(outputs[jobName]).toBe(String(reused !== 'true'))
|
||||
expect(outputs.test).toBe(String(reused !== 'true'))
|
||||
const admitted = evaluate(job.if, {
|
||||
cancelled: () => false,
|
||||
needs: { code_paths: { result: 'success', outputs }, preflight: { result: 'success' } }
|
||||
})
|
||||
expect(admitted).toBe(reused !== 'true')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ files: ['README.md'] },
|
||||
{ files: ['mobile/src/App.tsx'] },
|
||||
{ files: ['mobile/package.json'] },
|
||||
{ files: ['cloud/apps/relay/src/index.ts'] }
|
||||
])('does not add preflight consumers to a no-unit diff: $files', ({ files }) => {
|
||||
const outputs = routedOutputs(files, 'false')
|
||||
expect(outputs.test).toBe('false')
|
||||
for (const [name] of routes) {
|
||||
expect(outputs[name], name).toBe('false')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the mobile-only bundle job in the first wave', () => {
|
||||
const mobile = workflow.jobs.mobile_web_app
|
||||
expect(mobile.needs).toEqual(['code_paths'])
|
||||
expect(mobile.if).toBe("needs.code_paths.outputs.mobile_web_app == 'true'")
|
||||
expect(classifyPrJobs(['mobile/src/App.tsx'])).toMatchObject({
|
||||
test: false,
|
||||
mobile_web_app: true
|
||||
})
|
||||
})
|
||||
@@ -626,12 +626,8 @@ describe('PR Checks skip wiring', () => {
|
||||
for (const jobName of expensiveJobs.filter(
|
||||
(jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName)
|
||||
)) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(
|
||||
['package', 'package_windows'].includes(jobName)
|
||||
? ['code_paths', 'preflight']
|
||||
: ['code_paths']
|
||||
)
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths', 'preflight'])
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toContain(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ describe('PR E2E gate contract', () => {
|
||||
// Why: without this the job could lose its filter and run on every PR — the
|
||||
// cost the path filter exists to avoid — while the gate assertions above
|
||||
// stay green.
|
||||
expect(prWorkflow.jobs.e2e.needs).toBe('code_paths')
|
||||
expect(prWorkflow.jobs.e2e.if).toBe("needs.code_paths.outputs.e2e_should_run == 'true'")
|
||||
expect(prWorkflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight'])
|
||||
expect(prWorkflow.jobs.e2e.if).toContain("needs.code_paths.outputs.e2e_should_run == 'true'")
|
||||
expect(prWorkflow.jobs.code_paths.outputs.e2e_should_run).toBe(
|
||||
'${{ steps.e2e_filter.outputs.should_run }}'
|
||||
)
|
||||
|
||||
@@ -69,6 +69,113 @@ it('requires physical preflight success before publishing shards and admitting c
|
||||
expect(workflow.jobs.verify.needs).not.toContain('typecheck')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'both phases succeed', phases: ['true', 'true'], result: 'success', admitted: true },
|
||||
{ name: 'static-only succeeds', phases: ['true', 'false'], result: 'success', admitted: true },
|
||||
{ name: 'type-only succeeds', phases: ['false', 'true'], result: 'success', admitted: true },
|
||||
{ name: 'static-only fails', phases: ['true', 'false'], result: 'failure', admitted: false },
|
||||
{ name: 'type-only fails', phases: ['false', 'true'], result: 'failure', admitted: false },
|
||||
{ name: 'both phases fail', phases: ['true', 'true'], result: 'failure', admitted: false },
|
||||
{ name: 'preflight is cancelled', result: 'cancelled', admitted: false },
|
||||
{ name: 'preflight is unfinished', result: undefined, admitted: false },
|
||||
{ name: 'both phases unselected', phases: ['false', 'false'], result: 'skipped', admitted: true },
|
||||
{ name: 'required static skips', phases: ['true', 'false'], result: 'skipped', admitted: false },
|
||||
{
|
||||
name: 'required compiler skips',
|
||||
phases: ['false', 'true'],
|
||||
result: 'skipped',
|
||||
admitted: false
|
||||
},
|
||||
{ name: 'both required phases skip', result: 'skipped', admitted: false },
|
||||
{
|
||||
name: 'unselected preflight fails',
|
||||
phases: ['false', 'false'],
|
||||
result: 'failure',
|
||||
admitted: false
|
||||
},
|
||||
{
|
||||
name: 'static selection missing',
|
||||
phases: [undefined, 'false'],
|
||||
result: 'skipped',
|
||||
admitted: false
|
||||
},
|
||||
{
|
||||
name: 'compiler selection missing',
|
||||
phases: ['false', undefined],
|
||||
result: 'skipped',
|
||||
admitted: false
|
||||
},
|
||||
{ name: 'selection unknown', phases: ['', 'false'], result: 'skipped', admitted: false },
|
||||
{
|
||||
name: 'docs have no route',
|
||||
phases: ['false', 'false'],
|
||||
result: 'skipped',
|
||||
route: 'false',
|
||||
admitted: false
|
||||
},
|
||||
{ name: 'source has no route', result: 'success', route: 'false', admitted: false },
|
||||
{ name: 'route missing', result: 'success', route: '', admitted: false },
|
||||
{ name: 'path detection fails', result: 'success', pathsResult: 'failure', admitted: false },
|
||||
{ name: 'path detection skips', result: 'skipped', pathsResult: 'skipped', admitted: false },
|
||||
{ name: 'workflow cancelled after success', result: 'success', cancelled: true, admitted: false },
|
||||
{
|
||||
name: 'workflow cancelled with unselected phases',
|
||||
phases: ['false', 'false'],
|
||||
result: 'skipped',
|
||||
cancelled: true,
|
||||
admitted: false
|
||||
}
|
||||
])('admits advisory E2E only after eligible preflight: $name', (scenario) => {
|
||||
const [static_analysis, typecheck] = scenario.phases ?? ['true', 'true']
|
||||
expect(workflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight'])
|
||||
const admitted = runInNewContext(workflow.jobs.e2e.if, {
|
||||
cancelled: () => scenario.cancelled ?? false,
|
||||
needs: {
|
||||
code_paths: {
|
||||
result: scenario.pathsResult ?? 'success',
|
||||
outputs: { static_analysis, typecheck, e2e_should_run: scenario.route ?? 'true' }
|
||||
},
|
||||
preflight: { result: scenario.result }
|
||||
}
|
||||
})
|
||||
expect(admitted).toBe(scenario.admitted)
|
||||
})
|
||||
|
||||
it.each(['true', 'false'])(
|
||||
'preserves advisory E2E on ready-for-review only with proven required-check reuse: %s',
|
||||
(reused) => {
|
||||
const scope = classifyPrJobs(['src/main/runtime/orca-runtime.ts'])
|
||||
const context = {
|
||||
steps: {
|
||||
readiness: { outputs: { reused } },
|
||||
filter: {
|
||||
outputs: Object.fromEntries(
|
||||
Object.entries(scope).map(([key, value]) => [key, String(value)])
|
||||
)
|
||||
},
|
||||
e2e_filter: { outputs: { should_run: 'true' } }
|
||||
}
|
||||
}
|
||||
const outputs = Object.fromEntries(
|
||||
['static_analysis', 'typecheck', 'e2e_should_run'].map((name) => [
|
||||
name,
|
||||
String(runInNewContext(workflow.jobs.code_paths.outputs[name].slice(3, -2), context))
|
||||
])
|
||||
)
|
||||
expect(outputs).toEqual({
|
||||
static_analysis: reused === 'true' ? 'false' : 'true',
|
||||
typecheck: reused === 'true' ? 'false' : 'true',
|
||||
e2e_should_run: 'true'
|
||||
})
|
||||
const needs = { code_paths: { result: 'success', outputs }, preflight: { result: 'skipped' } }
|
||||
expect(runInNewContext(preflight.if, { needs })).toBe(reused !== 'true')
|
||||
expect(runInNewContext(workflow.jobs.e2e.if, { needs, cancelled: () => false })).toBe(
|
||||
reused === 'true'
|
||||
)
|
||||
expect(workflow.jobs.verify.needs).not.toContain('e2e')
|
||||
}
|
||||
)
|
||||
|
||||
it('pins every foreground and background step to its selected phase', () => {
|
||||
const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'"
|
||||
const typePhase = "needs.code_paths.outputs.typecheck == 'true'"
|
||||
|
||||
@@ -178,7 +178,10 @@ async function runInsideSession(evidenceDir) {
|
||||
['--nested', '--wayland', `--wayland-display=${process.env.WAYLAND_DISPLAY}`],
|
||||
{
|
||||
detached: true,
|
||||
env: process.env,
|
||||
env:
|
||||
process.env.ORCA_E2E_WAYLAND_INPUT_DIAGNOSTICS === '1'
|
||||
? { ...process.env, WAYLAND_DEBUG: 'server' }
|
||||
: process.env,
|
||||
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
|
||||
}
|
||||
)
|
||||
@@ -275,10 +278,9 @@ async function runInsideSession(evidenceDir) {
|
||||
: {}),
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1',
|
||||
ORCA_E2E_NATIVE_IBUS_HANGUL: '1',
|
||||
ORCA_E2E_NATIVE_IBUS_XVFB: '1',
|
||||
[IME_ENGAGEMENT_RECEIPT_ENV]: receiptPath,
|
||||
// Why: native IBus key injection only reaches a window the window manager
|
||||
// has focused, so this run opts out of the background-launch policy.
|
||||
ORCA_E2E_FOREGROUND: '1'
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
|
||||
@@ -12,8 +12,13 @@ describe('terminal IME e2e workflow', () => {
|
||||
|
||||
it('runs only on schedule or manual dispatch', () => {
|
||||
expect(workflow.on.pull_request).toBeUndefined()
|
||||
expect(workflow.on.workflow_dispatch).toBeNull()
|
||||
expect(workflow.on.workflow_dispatch.inputs.diagnose_wayland_input).toMatchObject({
|
||||
required: false,
|
||||
type: 'boolean',
|
||||
default: false
|
||||
})
|
||||
expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }])
|
||||
expect(workflow.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
|
||||
})
|
||||
|
||||
it('installs native IBus Hangul and X11 input tools', () => {
|
||||
|
||||
@@ -14,7 +14,8 @@ export const IME_ENGAGEMENT_RECEIPT_ENV = 'ORCA_E2E_IME_ENGAGEMENT_RECEIPT'
|
||||
export const EXPECTED_NATIVE_IME_TESTS = [
|
||||
'forwards the issue exact-byte sequence without loss or duplication',
|
||||
'forwards the issue sentence stress sequence without leaked ASCII',
|
||||
'a digit typed right after a Hangul syllable reaches the pty'
|
||||
'a digit typed right after a Hangul syllable reaches the pty',
|
||||
'confirms native Hangul notes before a deliberate Enter saves'
|
||||
]
|
||||
|
||||
function parseReceipts(text) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
verifyImeEngagementReceipts
|
||||
} from './terminal-ime-engagement-receipt.mjs'
|
||||
|
||||
const [firstTest, secondTest, thirdTest] = EXPECTED_NATIVE_IME_TESTS
|
||||
const [firstTest, secondTest, thirdTest, notesTest] = EXPECTED_NATIVE_IME_TESTS
|
||||
|
||||
function receipt(test, overrides = {}) {
|
||||
return JSON.stringify({
|
||||
@@ -20,7 +20,7 @@ describe('verifyImeEngagementReceipts', () => {
|
||||
it('accepts a run where every expected test observed real composition', () => {
|
||||
expect(
|
||||
verifyImeEngagementReceipts(
|
||||
`${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n`
|
||||
`${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n`
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
@@ -38,19 +38,34 @@ describe('verifyImeEngagementReceipts', () => {
|
||||
it('rejects a partial run where only one test reached the engine', () => {
|
||||
expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n`)).toEqual([
|
||||
`no engagement receipt for "${secondTest}" — it was skipped, filtered out, or renamed`,
|
||||
`no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed`
|
||||
`no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed`,
|
||||
`no engagement receipt for "${notesTest}" — it was skipped, filtered out, or renamed`
|
||||
])
|
||||
})
|
||||
|
||||
it('requires the digit receipt even when both original native tests passed', () => {
|
||||
expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n${receipt(secondTest)}\n`)).toEqual([
|
||||
expect(
|
||||
verifyImeEngagementReceipts(
|
||||
`${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n`
|
||||
)
|
||||
).toEqual([
|
||||
`no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed`
|
||||
])
|
||||
})
|
||||
|
||||
it('requires the Notes receipt even when the terminal scenarios passed', () => {
|
||||
expect(
|
||||
verifyImeEngagementReceipts(
|
||||
`${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n`
|
||||
)
|
||||
).toEqual([
|
||||
`no engagement receipt for "${notesTest}" — it was skipped, filtered out, or renamed`
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a run that typed keys but never opened a composition', () => {
|
||||
const problems = verifyImeEngagementReceipts(
|
||||
`${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n`
|
||||
`${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n`
|
||||
)
|
||||
expect(problems).toEqual([
|
||||
`"${firstTest}" recorded no compositionstart — the IME never engaged`
|
||||
@@ -59,7 +74,7 @@ describe('verifyImeEngagementReceipts', () => {
|
||||
|
||||
it('rejects a composition that produced no Hangul, which a latin passthrough would satisfy', () => {
|
||||
const problems = verifyImeEngagementReceipts(
|
||||
`${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n`
|
||||
`${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n`
|
||||
)
|
||||
expect(problems).toEqual([
|
||||
`"${firstTest}" recorded no Hangul composition data — the engine produced no syllables`
|
||||
@@ -68,7 +83,7 @@ describe('verifyImeEngagementReceipts', () => {
|
||||
|
||||
it('rejects a renamed test rather than counting it toward coverage', () => {
|
||||
const problems = verifyImeEngagementReceipts(
|
||||
`${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt('some new scenario')}\n`
|
||||
`${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n${receipt('some new scenario')}\n`
|
||||
)
|
||||
expect(problems).toEqual([
|
||||
'unexpected engagement receipt for "some new scenario" — update EXPECTED_NATIVE_IME_TESTS'
|
||||
@@ -77,7 +92,7 @@ describe('verifyImeEngagementReceipts', () => {
|
||||
|
||||
it('reports a truncated receipt rather than parsing around it', () => {
|
||||
const problems = verifyImeEngagementReceipts(
|
||||
`${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n`
|
||||
`${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n`
|
||||
)
|
||||
expect(problems).toEqual(['malformed receipt line: {"test":"trunc'])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import baseConfig from './vitest.config'
|
||||
|
||||
export default defineConfig({
|
||||
...baseConfig,
|
||||
test: {
|
||||
...baseConfig.test,
|
||||
include: ['config/scripts/agent-status-hot-path-benchmark.ts'],
|
||||
fileParallelism: false,
|
||||
retry: 0
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 91m">
|
||||
<title>downloads: 91m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 93m">
|
||||
<title>downloads: 93m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">91m</text>
|
||||
<text x="90" y="14">91m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">93m</text>
|
||||
<text x="90" y="14">93m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -2383,3 +2383,203 @@ These screens reject the examined changes; they do not establish that every
|
||||
future optimization is exhausted. Shorter admitted jobs and one shared preflight
|
||||
reduce demand on the existing runner allowance. They do not increase that
|
||||
allowance or prove lower queue delay under different account traffic.
|
||||
|
||||
## October 5 remaining import, diagnostic and checkout work
|
||||
|
||||
The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37247027814)
|
||||
used three alternating pairs per treatment at source
|
||||
`b18b174cd463f852051dc22adc8478b82dee4d1f`. Focused tests ran as fresh one-worker
|
||||
processes on the four-core, 16 GB Linux ARM runner with Node 24.21.0, pnpm 12.8.1
|
||||
and Vitest 4.1.11; persisted transforms and Node compile caches were disabled.
|
||||
|
||||
| Work | Original median | Candidate median | Median paired saving |
|
||||
| ------------------------------------------------- | --------------- | ---------------- | -------------------- |
|
||||
| Real incumbent-process test file | 16.478s | 5.976s | 10.520s |
|
||||
| Commentable-line lifecycle test file | 9.595s | 6.880s | 2.714s |
|
||||
| Agent-status diagnostic plus semantic test cohort | 11.547s | 8.235s | 3.361s |
|
||||
| Windows x64 SSH checkout | 22.052s | 16.884s | 5.020s |
|
||||
| Windows ARM SSH checkout | 46.758s | 33.910s | 13.456s |
|
||||
|
||||
The incumbent cases own distinct sockets, shim directories and process groups.
|
||||
Running them concurrently preserves all nine outcomes and every real five-second
|
||||
lsof deadline. Both original and candidate still reject an unreaped helper and
|
||||
false claims of clean enumeration. Fault receipts prove the actual modified probe
|
||||
was imported and all owned helpers, groups, sockets and directories were cleaned.
|
||||
The Windows platform gate retains all nine skips.
|
||||
|
||||
The renderer lifecycle tests keep their six original bodies and real decorator,
|
||||
zone and model behavior. Only the unrelated saved-note delivery menu is replaced
|
||||
by a typed throwing facade. Its cleanup assertion rejects unexpected use. Actual
|
||||
faults in memoization, value-equal refreshes and model replacement still fail their
|
||||
original assertions; a real menu call fails the facade and the cleanup guard.
|
||||
|
||||
The agent-status benchmark reports counters and timings but asserts only a
|
||||
nonempty status map and positive elapsed time. It remains available through
|
||||
`ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts`.
|
||||
Ordinary discovery removes exactly that reporting test. Its 15 meaningful routing,
|
||||
index-retention and batch cases remain unchanged. Three real production faults
|
||||
pass the old reporting test and fail those retained contracts. Manual execution
|
||||
preserves its JSON schema and all nine deterministic counters at 423 worktrees,
|
||||
634 tabs and 1,000 events.
|
||||
|
||||
Windows SSH hosts retain complete main, shared, relay, type, configuration,
|
||||
native, resource and test trees, plus root files. Six actual checkouts per
|
||||
architecture pinned candidate source `ce69b84d675b0a8b4763b03329c9d6549ac723f1`;
|
||||
all 16,568 retained files match the full checkout's contents, modes and index.
|
||||
The original 5,209 required inputs and six further inputs added by the source
|
||||
rebase are present. Local full/sparse builds match 54 generated artifacts;
|
||||
explicit test discovery is identical. Missing imported source, a named test or a
|
||||
required descriptor still fails. Full Windows native/provisioning lanes remain
|
||||
a separate qualification gate.
|
||||
|
||||
Test timings exclude dependency setup, checkout and queues. Checkout timings
|
||||
include the action and shell/runner observation boundaries but exclude later
|
||||
builds and provisioning. These scoped savings must not be summed or treated as
|
||||
measured changes to full-shard occupancy or PR latency.
|
||||
|
||||
## October 5 mobile typecheck overlap
|
||||
|
||||
The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37249591578)
|
||||
ran three alternating pairs on the four-core, 16 GB Linux ARM runner, with Node
|
||||
24.21.0 and TypeScript 6.0.3. The production compiler and test-typecheck ratchet
|
||||
use installed tools after dependency installation. Both resolved compiler
|
||||
programs have `noEmit: true`, with no incremental, composite or build-info writes.
|
||||
The change moves the existing unconditional production join after the foreground
|
||||
ratchet and before tests; it changes no compiler command or failure policy.
|
||||
|
||||
| Complete typecheck stage | Original serial | Overlapped |
|
||||
| ------------------------ | --------------- | ---------- |
|
||||
| Pair 1 | 68.284s | 41.582s |
|
||||
| Pair 2, reversed order | 65.385s | 41.042s |
|
||||
| Pair 3 | 64.240s | 40.418s |
|
||||
| Median | 65.385s | 41.042s |
|
||||
|
||||
The median paired saving is 24.343 seconds, or 37.2% of this stage. The stage
|
||||
bracket includes native background/wait boundaries and observation overhead,
|
||||
but excludes dependency installation and the later test suite. All six stages
|
||||
preserve the three workers' compiler/ratchet verdicts and stdout/stderr hashes.
|
||||
The test compiler's existing diagnostics remain subject to the unchanged
|
||||
ratchet. Neither compiler attempted native loading or recorded filesystem
|
||||
mutations. Maximum aggregate owned-process RSS sampled every 200 milliseconds
|
||||
was 5.137 GiB; this is a sampled value rather than a kernel peak. The ratchet now
|
||||
runs even when the concurrently running production compiler later fails.
|
||||
|
||||
Two real, independent type faults still prevent tests from starting. The
|
||||
production fault preserves the unaffected ratchet and child output; the test
|
||||
fault preserves the production output and fails the ratchet. Their explicit
|
||||
control receipts pass even though their intentionally failing jobs are allowed
|
||||
to finish collecting evidence.
|
||||
|
||||
The separate [external cancellation control](https://github.com/stablyai/orca/actions/runs/37251289372)
|
||||
held the two real installed compiler entrypoints before checking, while retaining
|
||||
the production-background/ratchet-foreground topology. All three owned workers
|
||||
were observed alive 14.275 seconds before the cancellation request. Both native
|
||||
step outcomes became cancelled, tests did not start, and the runner's final
|
||||
cleanup log names all three exact worker PIDs. The attempted earlier assertion
|
||||
of PID absence failed: GitHub performs orphan cleanup after the always-tail
|
||||
observer and artifact upload. Post-cleanup absence was not observed and is not
|
||||
claimed. This control supplies no compiler-completion or timing measurement.
|
||||
|
||||
## October 5 explicit RPC registry test setup
|
||||
|
||||
The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37251277897)
|
||||
ran three alternating pairs of the complete 156-file mixed cohort on source
|
||||
`d1e08ddd666e3099fc51c79f01f0305f5162186d`, using four workers on the four-core
|
||||
Linux ARM runner, Node 24.21.0, pnpm 12.8.1 and Vitest 4.1.11. Persisted module
|
||||
transforms and Node compile caches were disabled. All 153 candidate test files
|
||||
pass explicit method lists to their dispatchers. A shared throwing fixture
|
||||
prevents those tests from loading the unused default method catalog and rejects
|
||||
any accidental iteration of it. The three real catalog subjects remain
|
||||
unchanged and run beside the candidate subjects in every arm.
|
||||
|
||||
| Complete cohort process | Original | Candidate | Paired saving |
|
||||
| ----------------------- | -------- | --------- | ------------- |
|
||||
| Pair 1 | 142.184s | 89.238s | 52.946s |
|
||||
| Pair 2, reversed order | 142.833s | 88.933s | 53.900s |
|
||||
| Pair 3 | 141.274s | 90.186s | 51.088s |
|
||||
| Median | 142.184s | 89.238s | 52.946s |
|
||||
|
||||
The median paired saving is 37.2% of this fixed cohort. All six arms preserve
|
||||
the complete ordered ledger, including duplicate parameterized case names:
|
||||
1,469 passes and one existing setup-dependent skip, totaling 1,470 outcomes.
|
||||
The real catalog subjects contribute 63 of those cases. Candidate bodies and
|
||||
assertions are unchanged. One file at its line limit uses the dispatch method's
|
||||
parameter type in place of its equivalent type-only import; its emitted code
|
||||
matches the measured candidate.
|
||||
|
||||
Seventeen control invocations preserve actual terminal-handler fault detection,
|
||||
reject unexpected default-catalog consumption, and still detect a missing real
|
||||
catalog registration. A plain consumption counter prevents global mock-history
|
||||
resets from erasing the guard; actual `clearAllMocks` and `resetAllMocks` controls
|
||||
demonstrate that distinction and preserve unrelated call history. The isolated
|
||||
driver restores all source files after success, faults and a real cancellation.
|
||||
Independent review also confirms all 153 candidate sources, the fixture, three
|
||||
catalog subjects and declared qualification inputs survive the rebase unchanged.
|
||||
|
||||
These are fresh-process wall times for this cohort, excluding dependency setup,
|
||||
queues and other test shards. They do not measure full-shard balance, total PR
|
||||
runner demand, or a change to the dashboard's PR runtime percentiles.
|
||||
|
||||
## October 5 fused terminal cursor row scans
|
||||
|
||||
Readiness checks repeatedly read terminal rows to recognize composer text.
|
||||
The shared reader now collects undimmed text and the first visible glyph's style
|
||||
in one pass. The cursor suffix remains a separate scan; dim glyph attributes,
|
||||
empty cells, wide characters and wrapped spaces keep their existing behavior.
|
||||
No grid data is retained between calls.
|
||||
|
||||
The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37263358478)
|
||||
used source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM,
|
||||
Node 24.21.0, pnpm 12.8.1 and one worker. All six fresh processes ran the same
|
||||
49-case readiness census module, with separate empty Vite caches, filesystem
|
||||
transform caching disabled and Node compile caching disabled.
|
||||
|
||||
| Complete focused process | Original | Candidate | Paired saving |
|
||||
| ------------------------ | -------- | --------- | ------------- |
|
||||
| Pair 1 | 66.412s | 60.464s | 5.948s |
|
||||
| Pair 2, reversed order | 66.880s | 61.061s | 5.819s |
|
||||
| Pair 3 | 68.056s | 65.821s | 2.234s |
|
||||
|
||||
The median paired saving is 5.819 seconds, or 8.7% of this fixed workload.
|
||||
All 294 timed case outcomes passed. Separate qualification preserves complete
|
||||
context/composer outputs on 98 recordings and the logical cursor/projection
|
||||
outputs of all 192 Runtime census cases. Nine actual scanner faults fail the
|
||||
intended assertions; the 89-case IME/composer slice also passes. Blank-row tests
|
||||
bound cell reads to 72 instead of the original 132 for a 12-column, five-row grid,
|
||||
with and without a reusable cell adapter.
|
||||
|
||||
The raw timer-driven polling trace differed and was excluded from equivalence
|
||||
evidence. Logical per-frame output captures match; no raw polling-count equality
|
||||
is claimed. These measurements exclude setup, queues and other modules and do
|
||||
not establish a change in full-shard balance, PR percentiles or runner demand.
|
||||
|
||||
## October 5 Qoder test import guards
|
||||
|
||||
The direct Qoder Runtime tests now import the existing unused-default-RPC guard
|
||||
before their Runtime fixture. Their complete test bodies remain unchanged.
|
||||
The guard rejects an unexpected registry access instead of loading the full
|
||||
default-method graph. The three real registry catalogs remain unmocked.
|
||||
|
||||
The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37266875139)
|
||||
used fixed source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM,
|
||||
Node 24.21.0, pnpm 12.8.1 and four isolated fork workers. Every fresh process ran
|
||||
both Qoder files and all three catalogs: 85 cases across five files. Each invocation
|
||||
used a distinct empty Vite cache, with results, filesystem transform and Node
|
||||
compile caching disabled. All 510 timed outcomes passed.
|
||||
|
||||
| Complete five-file process | Original | Candidate | Paired saving |
|
||||
| -------------------------- | -------- | --------- | ------------- |
|
||||
| Pair 1 | 17.677s | 16.323s | 1.354s |
|
||||
| Pair 2, reversed order | 16.827s | 16.021s | 0.806s |
|
||||
| Pair 3 | 17.324s | 16.211s | 1.113s |
|
||||
|
||||
The median paired saving is 1.113 seconds, or 6.4% of this fixed workload.
|
||||
Separate actual faults in retained launch recipes, Qoder command selection and
|
||||
method registration fail the same intended assertions before and after the
|
||||
imports. Generated launch IDs and shifted stack lines differ in the raw failure
|
||||
messages; they were preserved and are not claimed byte-identical.
|
||||
|
||||
The single hosted trial occupied 134 runner-seconds including all six samples,
|
||||
shared setup and upload. That is trial cost, not a production saving. These
|
||||
focused process measurements do not establish full-shard savings, PR runtime
|
||||
percentiles, queue relief or a change in the organization's runner allowance.
|
||||
|
||||
@@ -100,3 +100,47 @@ Each fix must pass all of these checks:
|
||||
regenerate its bundle patch and lockfile together.
|
||||
- Prefer deterministic replay or state-transition tests. Native evidence is a
|
||||
second layer, never a substitute for regression coverage.
|
||||
|
||||
## Enter in application text fields (#25035)
|
||||
|
||||
Use `Input`, `Textarea`, or `CommandInput` for styled fields. Existing unstyled
|
||||
fields with keyboard actions use `ImeInput` / `ImeTextarea` from
|
||||
`lib/ime-text-field.tsx`; those preserve the DOM element, styles, refs, and
|
||||
composition callbacks. They share `useImeEnterGestureOwnership` and keep
|
||||
IME-owned keys out of both field actions and bubbling form/menu shortcuts.
|
||||
Overlay primitives also reject IME-marked Escape in document capture, where
|
||||
field-level propagation guards cannot intercept dismissal.
|
||||
Do not add a second tracker at a call site already using a guarded field.
|
||||
Native Chat and the File Explorer inline name field retain their existing
|
||||
trackers because they also own specialized composition or element lifetimes.
|
||||
|
||||
Required cases:
|
||||
|
||||
- `isComposing`, `keyCode: 229` without `isComposing`, and `Process/229` must
|
||||
never submit, choose a suggestion, or dismiss the field.
|
||||
- The unmarked Enter redispatch stays owned on either side of keyup, including
|
||||
a `Process/229` release. A
|
||||
subsequent ordinary typing/navigation key ends that carry immediately;
|
||||
hidden renderers may defer animation frames, and typing a filename suffix
|
||||
must not cause the next deliberate Enter to disappear.
|
||||
- Composition callbacks, blur, refs, and keyed remount cleanup still work.
|
||||
Normal Enter, modifier submits, and Shift+Enter newlines remain available.
|
||||
- Test the actual shared field when a consumer delegates IME handling to it;
|
||||
a mock that replaces `CommandInput` with a raw input removes the protection.
|
||||
|
||||
`ime-text-field.test.tsx` covers primitives, raw fields, parent handlers, and
|
||||
command selection. File Explorer component tests cover all three operations
|
||||
and input replacement. `file-explorer-ime-enter.spec.ts` drives Chromium
|
||||
composition in New File, New Folder, and Rename in a folder workspace, then
|
||||
checks the complete name in the Explorer and on disk, with both continued typing
|
||||
and a redispatch followed by deliberate Enter. Overlay tests cover IME Escape
|
||||
and ordinary dismissal; Markdown tests preserve an unmarked save shortcut while
|
||||
composition state lingers. These are CDP event
|
||||
contracts, not native OS keyboard evidence.
|
||||
|
||||
The audit also covers settings and title fields, issue/review creation and
|
||||
pickers, comments and annotations, search fields, Native Chat questions,
|
||||
notebook execution shortcuts, and Markdown menu handlers. Terminal input keeps
|
||||
its existing xterm/PTY ownership; mobile native fields use `onSubmitEditing`
|
||||
instead of desktop DOM keydown actions. Remote workspaces use the same renderer
|
||||
fields; file-operation routing and mixed-version wire contracts are unchanged.
|
||||
|
||||
@@ -44,7 +44,7 @@ orca orchestration worker-start --task <taskId> --worktree new-child --name bill
|
||||
orca orchestration worker-start --task <taskId> --worktree current --agent claude --model <opaque-model-id> --effort high --json
|
||||
```
|
||||
|
||||
`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); other agents, including opencode, run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support.
|
||||
`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); OpenCode accepts a per-launch model only in an existing worktree, when the execution host verifies its CLI version and model availability; OpenCode effort remains unsupported. Other agents run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support.
|
||||
|
||||
Wait for completions (process every message in a Delivery, then ack):
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function TerminalSettingsScreen() {
|
||||
While you're using a terminal on your phone, Orca shrinks it to fit your screen. When
|
||||
you close the app or switch away, this controls whether it stays at phone size (so
|
||||
interactive CLI tools don't reflow) or resizes back to your desktop. You can always
|
||||
use Restore this terminal or Restore all terminals on the banner to resize manually.
|
||||
use Restore on the desktop banner to resize manually.
|
||||
</Text>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { optionalSettingsRead } from '../transport/settings-read-operations'
|
||||
import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message'
|
||||
import { rpcPayloadMember } from '../transport/rpc-reader-payload'
|
||||
import { readAcceptedResumeList } from './resume-metadata-lists'
|
||||
import {
|
||||
resumeFolderWorkspaceListRead,
|
||||
resumeProjectGroupListRead,
|
||||
resumeRepoListRead,
|
||||
resumeWorktreeListRead
|
||||
} from './mobile-agent-history-operations'
|
||||
import { loadMobileResumeMetadata } from './mobile-agent-history-resume-metadata'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
@@ -15,22 +6,17 @@ import { useRouteHandoff } from '../navigation/route-handoff'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
import { worktreeCatalogRead } from '../worktree/worktree-catalog-operations'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileAiVaultResumeHost,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
type MobileAiVaultResumeSettings
|
||||
resumeAiVaultSessionInTerminal
|
||||
} from '../session/ai-vault-resume-launch'
|
||||
import {
|
||||
prepareMobileAiVaultSessionResume,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
} from '../session/ai-vault-resume-preparation'
|
||||
import { prepareMobileAiVaultSessionResume } from '../session/ai-vault-resume-preparation'
|
||||
import { triggerError, triggerSuccess } from '../platform/haptics'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
@@ -38,15 +24,11 @@ import { useMobileAgentHistoryState } from './use-mobile-agent-history-state'
|
||||
import { buildMobileAgentHistorySections } from './agent-history-sections'
|
||||
import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge'
|
||||
import { MobileAgentSessionHistoryList } from './MobileAgentSessionHistoryList'
|
||||
import {
|
||||
resolveMobileAiVaultSessionResumeTarget,
|
||||
type MobileAiVaultResumeFolderWorkspace,
|
||||
type MobileAiVaultResumeProjectGroup,
|
||||
type MobileAiVaultResumeRepo
|
||||
} from './agent-history-resume-target'
|
||||
import { resolveMobileAiVaultSessionResumeTarget } from './agent-history-resume-target'
|
||||
import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card'
|
||||
import { styles } from './agent-history-styles'
|
||||
import { useNow } from '../hooks/use-now'
|
||||
import { useMobileResumeOperationOwnership } from './use-mobile-resume-operation-ownership'
|
||||
|
||||
export type MobileAgentSessionHistoryPanelProps = {
|
||||
hostId: string
|
||||
@@ -69,6 +51,12 @@ export function MobileAgentSessionHistoryPanel({
|
||||
// screen, and the session it resumes into is a native route the shell has to push.
|
||||
const router = useRouteHandoff()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const claimResumeOwnership = useMobileResumeOperationOwnership(
|
||||
hostId,
|
||||
worktreeId,
|
||||
client,
|
||||
connState
|
||||
)
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>([])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -147,8 +135,8 @@ export function MobileAgentSessionHistoryPanel({
|
||||
[sessions, query, scope, scopeFilterPaths, activeWorktreePath, now]
|
||||
)
|
||||
|
||||
const hostPlatform = useMemo(
|
||||
() => readMobileRuntimeHostPlatform(hostStatusResult),
|
||||
const resumeHost = useMemo(
|
||||
() => readMobileAiVaultResumeHost(hostStatusResult),
|
||||
[hostStatusResult]
|
||||
)
|
||||
const hostTerminalWindowsShell = useMemo(
|
||||
@@ -177,6 +165,7 @@ export function MobileAgentSessionHistoryPanel({
|
||||
return
|
||||
}
|
||||
|
||||
const assertCurrentOwner = claimResumeOwnership()
|
||||
resumeLaunchInFlightRef.current = true
|
||||
setResumingSessionId(session.id)
|
||||
setResumeMessage(null)
|
||||
@@ -188,6 +177,7 @@ export function MobileAgentSessionHistoryPanel({
|
||||
settings,
|
||||
worktrees: freshWorktrees
|
||||
} = await loadMobileResumeMetadata(client)
|
||||
assertCurrentOwner()
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session,
|
||||
activeWorktreeId: worktreeId,
|
||||
@@ -207,7 +197,7 @@ export function MobileAgentSessionHistoryPanel({
|
||||
|
||||
const platform = resolveMobileAiVaultResumePlatform(
|
||||
target.targetStatus,
|
||||
hostPlatform,
|
||||
resumeHost.platform,
|
||||
target.workspacePath,
|
||||
target.terminalPlatform
|
||||
)
|
||||
@@ -218,18 +208,31 @@ export function MobileAgentSessionHistoryPanel({
|
||||
}
|
||||
|
||||
const preparedSession = await prepareMobileAiVaultSessionResume(client, session)
|
||||
assertCurrentOwner()
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session: preparedSession,
|
||||
hostPlatform: platform,
|
||||
hostTerminalWindowsShell,
|
||||
settings
|
||||
})
|
||||
await resumeAiVaultSessionInTerminal(client, target.worktreeId, {
|
||||
...launch,
|
||||
clientMutationId: resumeMutationRegistryRef.current.claim(session.id)
|
||||
})
|
||||
await resumeAiVaultSessionInTerminal(
|
||||
client,
|
||||
target.worktreeId,
|
||||
{
|
||||
...launch,
|
||||
hostCapabilities: resumeHost.capabilities,
|
||||
clientMutationId: resumeMutationRegistryRef.current.claim(session.id)
|
||||
},
|
||||
assertCurrentOwner
|
||||
)
|
||||
resumeMutationRegistryRef.current.releaseOnSuccess(session.id)
|
||||
triggerSuccess()
|
||||
// The host accepted the resume; a cutover now only stops navigation.
|
||||
try {
|
||||
assertCurrentOwner()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setResumeMessage('Agent session queued.')
|
||||
router.push(
|
||||
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters<
|
||||
@@ -248,11 +251,12 @@ export function MobileAgentSessionHistoryPanel({
|
||||
client,
|
||||
connState,
|
||||
hostId,
|
||||
hostPlatform,
|
||||
resumeHost,
|
||||
hostTerminalWindowsShell,
|
||||
router,
|
||||
worktreeId,
|
||||
worktrees
|
||||
worktrees,
|
||||
claimResumeOwnership
|
||||
]
|
||||
)
|
||||
|
||||
@@ -377,57 +381,6 @@ export function MobileAgentSessionHistoryPanel({
|
||||
const EMPTY_SESSIONS: AiVaultSession[] = []
|
||||
const EMPTY_ISSUES: { agent: AiVaultSession['agent']; path: string; message: string }[] = []
|
||||
|
||||
async function loadMobileResumeMetadata(client: RpcClient): Promise<{
|
||||
repos: MobileAiVaultResumeRepo[]
|
||||
folderWorkspaces: MobileAiVaultResumeFolderWorkspace[]
|
||||
projectGroups: MobileAiVaultResumeProjectGroup[]
|
||||
settings: MobileAiVaultResumeSettings | null
|
||||
worktrees: Worktree[] | null
|
||||
}> {
|
||||
// Why: repo.list can enrich repo remote identities, so fetch resume-only
|
||||
// metadata after explicit user intent instead of delaying history browsing.
|
||||
// timeoutMs: without it a socket drop parks these on the reconnect waiter
|
||||
// for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS).
|
||||
const [repoReply, folderWorkspaceReply, projectGroupReply, settingsReply, worktreeReply] =
|
||||
await Promise.all([
|
||||
resumeRepoListRead.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }),
|
||||
resumeFolderWorkspaceListRead
|
||||
.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
resumeProjectGroupListRead
|
||||
.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
optionalSettingsRead
|
||||
.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
resumeWorktreeListRead
|
||||
.request(client, { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null)
|
||||
])
|
||||
const repoResult = interpretOrThrowRefusalMessage(
|
||||
() => resumeRepoListRead.interpret(repoReply),
|
||||
'Unable to load workspace metadata.'
|
||||
)
|
||||
const folderWorkspaceResult =
|
||||
folderWorkspaceReply && resumeFolderWorkspaceListRead.interpret(folderWorkspaceReply)
|
||||
const projectGroupResult =
|
||||
projectGroupReply && resumeProjectGroupListRead.interpret(projectGroupReply)
|
||||
const settingsResult = settingsReply ? optionalSettingsRead.interpret(settingsReply) : null
|
||||
const settings = settingsResult?.accepted
|
||||
? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
(settingsResult.value as MobileAiVaultResumeSettings | null | undefined)
|
||||
: null
|
||||
const worktreeResult = worktreeReply && resumeWorktreeListRead.interpret(worktreeReply)
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
repos: (rpcPayloadMember(repoResult, 'repos') as MobileAiVaultResumeRepo[] | undefined) ?? [],
|
||||
folderWorkspaces: readAcceptedResumeList(folderWorkspaceResult, 'folderWorkspaces') ?? [],
|
||||
projectGroups: readAcceptedResumeList(projectGroupResult, 'groups') ?? [],
|
||||
settings: settings ?? null,
|
||||
worktrees: readAcceptedResumeList(worktreeResult, 'worktrees') ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function createMobileAiVaultResumeMutationId(sessionId: string): string {
|
||||
const sessionPart = sessionId.replace(/[^a-zA-Z0-9_.:-]/g, '_').slice(0, 64) || 'session'
|
||||
const randomPart = Math.random().toString(36).slice(2, 10)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcResponse } from '../transport/types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability'
|
||||
|
||||
const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionState } => ({
|
||||
client: null,
|
||||
state: 'connected'
|
||||
}))
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => connection,
|
||||
useForceReconnect: () => vi.fn()
|
||||
}))
|
||||
import {
|
||||
useMobileAgentHistoryState,
|
||||
type MobileAgentHistoryState
|
||||
} from './use-mobile-agent-history-state'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
readMobileAiVaultResumeHost,
|
||||
resumeAiVaultSessionInTerminal
|
||||
} from '../session/ai-vault-resume-launch'
|
||||
|
||||
function fakeClient(sendRequest: RpcClient['sendRequest'], generation?: () => number): RpcClient {
|
||||
return {
|
||||
sendRequest,
|
||||
subscribe: () => () => {},
|
||||
updateTerminalSubscriptionViewport: () => {},
|
||||
getState: () => connection.state,
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: () => () => {},
|
||||
notifyForeground: () => {},
|
||||
close: () => {},
|
||||
...(generation ? { getGeneration: generation } : {})
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
'host/client replacement',
|
||||
'host replacement',
|
||||
'same-client generation',
|
||||
'legacy-client reconnect'
|
||||
])('requires current host evidence before owned resume: %s', async (scenario) => {
|
||||
const cap = QODER_OWNED_TERMINAL_CREATE_CAPABILITY
|
||||
const worktrees: Worktree[] = []
|
||||
const observed: { current: MobileAgentHistoryState | null } = { current: null }
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
let generation = 1
|
||||
let hostId = 'owned-host-a'
|
||||
const read = () => {
|
||||
if (!observed.current) {
|
||||
throw new Error('missing history state')
|
||||
}
|
||||
return observed.current
|
||||
}
|
||||
function Probe() {
|
||||
observed.current = useMobileAgentHistoryState({
|
||||
hostId,
|
||||
worktreeId: 'owned-worktree',
|
||||
worktrees,
|
||||
worktreesLoaded: true
|
||||
})
|
||||
return null
|
||||
}
|
||||
const firstStatus: RpcResponse = {
|
||||
id: 'owned-reply',
|
||||
ok: true,
|
||||
result: { hostPlatform: 'darwin', capabilities: ['aiVault.v1', cap] }
|
||||
}
|
||||
const initialSend = vi.fn<RpcClient['sendRequest']>(async (method) =>
|
||||
method === 'status.get'
|
||||
? firstStatus
|
||||
: { id: 'owned-reply', ok: true, result: { sessions: [], issues: [] } }
|
||||
)
|
||||
const initialClient = fakeClient(
|
||||
initialSend,
|
||||
scenario === 'legacy-client reconnect' ? undefined : () => generation
|
||||
)
|
||||
connection.client = initialClient
|
||||
connection.state = 'connected'
|
||||
await act(async () => {
|
||||
rendered.tree = create(createElement(Probe))
|
||||
})
|
||||
try {
|
||||
expect(read().screenState.kind).toBe('ready')
|
||||
expect(readMobileAiVaultResumeHost(read().hostStatusResult).capabilities).toContain(cap)
|
||||
let resolveRetired: (reply: RpcResponse) => void = () => {}
|
||||
const retiredStatus = new Promise<RpcResponse>((resolve) => {
|
||||
resolveRetired = resolve
|
||||
})
|
||||
initialSend.mockImplementation(async () => retiredStatus)
|
||||
let refresh: Promise<void> | undefined
|
||||
await act(async () => {
|
||||
refresh = read().onRefresh()
|
||||
})
|
||||
let resolveStatus: (reply: RpcResponse) => void = () => {}
|
||||
const pendingStatus = new Promise<RpcResponse>((resolve) => {
|
||||
resolveStatus = resolve
|
||||
})
|
||||
const currentSend = vi.fn<RpcClient['sendRequest']>(async (method) => {
|
||||
if (method === 'status.get') {
|
||||
return pendingStatus
|
||||
}
|
||||
if (method === 'session.tabs.createTerminal') {
|
||||
return {
|
||||
id: 'owned-reply',
|
||||
ok: true,
|
||||
result: {
|
||||
tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'terminal.send') {
|
||||
return { id: 'owned-reply', ok: true, result: { send: { accepted: true } } }
|
||||
}
|
||||
return { id: 'owned-reply', ok: true, result: { sessions: [], issues: [] } }
|
||||
})
|
||||
let currentClient = initialClient
|
||||
if (scenario === 'host/client replacement') {
|
||||
currentClient = fakeClient(currentSend, () => 2)
|
||||
connection.client = currentClient
|
||||
hostId = 'owned-host-b'
|
||||
} else {
|
||||
initialSend.mockImplementation(currentSend)
|
||||
if (scenario === 'host replacement') {
|
||||
hostId = 'owned-host-b'
|
||||
} else if (scenario === 'same-client generation') {
|
||||
generation = 2
|
||||
} else {
|
||||
connection.state = 'reconnecting'
|
||||
await act(async () => {
|
||||
rendered.tree?.update(createElement(Probe))
|
||||
})
|
||||
connection.state = 'connected'
|
||||
}
|
||||
}
|
||||
await act(async () => {
|
||||
rendered.tree?.update(createElement(Probe))
|
||||
})
|
||||
expect(read().screenState.kind).toBe('ready')
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session: {
|
||||
agent: 'qoder',
|
||||
sessionId: 'owned-session',
|
||||
cwd: '/owned/workspace',
|
||||
codexHome: null
|
||||
},
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
await act(async () => {
|
||||
resolveRetired(firstStatus)
|
||||
await refresh
|
||||
})
|
||||
const pendingHost = readMobileAiVaultResumeHost(read().hostStatusResult)
|
||||
expect(pendingHost.capabilities?.includes(cap) ?? false).toBe(false)
|
||||
await resumeAiVaultSessionInTerminal(currentClient, 'owned-worktree', {
|
||||
...launch,
|
||||
hostCapabilities: pendingHost.capabilities,
|
||||
clientMutationId: 'pending-probe-resume'
|
||||
})
|
||||
const firstCreate = currentSend.mock.calls.find(
|
||||
([method]) => method === 'session.tabs.createTerminal'
|
||||
)
|
||||
expect(firstCreate?.[1]).not.toHaveProperty('command')
|
||||
expect(currentSend.mock.calls.map(([method]) => method)).toContain('terminal.send')
|
||||
await act(async () => {
|
||||
resolveStatus({
|
||||
id: 'owned-reply',
|
||||
ok: true,
|
||||
result: { hostPlatform: 'linux', capabilities: ['aiVault.v1', cap] }
|
||||
})
|
||||
})
|
||||
expect(readMobileAiVaultResumeHost(read().hostStatusResult)).toEqual({
|
||||
platform: 'linux',
|
||||
capabilities: ['aiVault.v1', cap]
|
||||
})
|
||||
currentSend.mockClear()
|
||||
await resumeAiVaultSessionInTerminal(currentClient, 'owned-worktree', {
|
||||
...launch,
|
||||
hostCapabilities: readMobileAiVaultResumeHost(read().hostStatusResult).capabilities,
|
||||
clientMutationId: 'current-probe-resume'
|
||||
})
|
||||
expect(
|
||||
currentSend.mock.calls.find(([method]) => method === 'session.tabs.createTerminal')?.[1]
|
||||
).toEqual(expect.objectContaining({ command: launch.command }))
|
||||
expect(currentSend.mock.calls.map(([method]) => method)).not.toContain('terminal.send')
|
||||
} finally {
|
||||
await act(async () => {
|
||||
rendered.tree?.unmount()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,350 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import { CreateTerminalTab } from '../../../src/shared/rpc-contract/session-tabs-schemas-params'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcResponse } from '../transport/types'
|
||||
import { createFakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
const state = vi.hoisted(
|
||||
(): {
|
||||
generation: number
|
||||
client: RpcClient | null
|
||||
connection: ConnectionState
|
||||
legacy: boolean
|
||||
completedBoundary: boolean
|
||||
session: AiVaultSession | null
|
||||
onResume?: (session: AiVaultSession) => Promise<void>
|
||||
} => ({
|
||||
generation: 1,
|
||||
client: null,
|
||||
connection: 'connected',
|
||||
legacy: false,
|
||||
completedBoundary: false,
|
||||
session: null
|
||||
})
|
||||
)
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Pressable: 'Pressable',
|
||||
RefreshControl: 'RefreshControl',
|
||||
SectionList: 'SectionList',
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View',
|
||||
Platform: { OS: 'web', select: (choices: Record<string, unknown>) => choices.web },
|
||||
AppState: { currentState: 'active', addEventListener: () => ({ remove() {} }) },
|
||||
StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 }
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: 'SafeAreaView',
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }))
|
||||
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Play: 'Icon', RefreshCw: 'Icon' }))
|
||||
const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), push: vi.fn() }))
|
||||
vi.mock('../platform/haptics', () => ({
|
||||
triggerError: notifications.error,
|
||||
triggerSuccess: notifications.success
|
||||
}))
|
||||
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
|
||||
vi.mock('../navigation/route-handoff', () => ({
|
||||
useRouteHandoff: () => ({ push: notifications.push, back: vi.fn(), canGoBack: () => false })
|
||||
}))
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: state.client, state: state.connection }),
|
||||
useForceReconnect: () => vi.fn()
|
||||
}))
|
||||
vi.mock('./use-mobile-agent-history-state', () => ({
|
||||
useMobileAgentHistoryState: () => ({
|
||||
scope: 'workspace',
|
||||
screenState: { kind: 'ready', sessions: [state.session], issues: [] },
|
||||
refreshing: false,
|
||||
hostStatusResult:
|
||||
state.generation === 1
|
||||
? {
|
||||
hostPlatform: 'darwin',
|
||||
capabilities: state.legacy ? [] : ['aiVault.v1', 'session.tabs.qoderOwnedCreate.v1']
|
||||
}
|
||||
: state.completedBoundary
|
||||
? { hostPlatform: 'darwin', capabilities: [] }
|
||||
: null,
|
||||
activeWorktreePath: '/owned/workspace',
|
||||
scopeFilterPaths: ['/owned/workspace'],
|
||||
onSelectScope: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
retry: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('./MobileAgentSessionHistoryList', () => ({
|
||||
MobileAgentSessionHistoryList: (props: {
|
||||
onResume: (session: AiVaultSession) => Promise<void>
|
||||
}) => {
|
||||
state.onResume = props.onResume
|
||||
return null
|
||||
}
|
||||
}))
|
||||
import { MobileAgentSessionHistoryPanel } from './MobileAgentSessionHistoryPanel'
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
it.each([
|
||||
['stable', 'metadata'],
|
||||
['generation', 'metadata'],
|
||||
['generation without render', 'metadata'],
|
||||
['client', 'metadata'],
|
||||
['host', 'metadata'],
|
||||
['legacy reconnect', 'metadata'],
|
||||
['unmount', 'metadata'],
|
||||
['stable', 'preparation'],
|
||||
['generation', 'preparation'],
|
||||
['disconnect', 'preparation'],
|
||||
['stable', 'terminal creation'],
|
||||
['generation', 'terminal creation'],
|
||||
['stable', 'accepted send'],
|
||||
['generation', 'accepted send'],
|
||||
['generation without render', 'accepted send'],
|
||||
['host', 'accepted send'],
|
||||
['unmount', 'accepted send'],
|
||||
['generation', 'owned create accepted']
|
||||
])('pending resume keeps its owner: %s during %s', async (scenario, boundary) => {
|
||||
const cutover = scenario !== 'stable'
|
||||
let hostId = 'owned-host'
|
||||
notifications.error.mockClear()
|
||||
notifications.success.mockClear()
|
||||
notifications.push.mockClear()
|
||||
state.legacy = boundary === 'accepted send'
|
||||
state.completedBoundary = state.legacy || boundary === 'owned create accepted'
|
||||
state.generation = 1
|
||||
state.connection = 'connected'
|
||||
state.session = {
|
||||
id: 'qoder:owned-session',
|
||||
executionHostId: 'local',
|
||||
agent: 'qoder',
|
||||
sessionId: 'owned-session',
|
||||
title: 'Owned session',
|
||||
cwd: '/owned/workspace',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: '/owned/qoder-session.json',
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: '2026-10-04T00:00:00.000Z',
|
||||
messageCount: 2,
|
||||
totalTokens: 10,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: '',
|
||||
subagent: null
|
||||
}
|
||||
if (boundary === 'preparation' || boundary === 'terminal creation') {
|
||||
state.session.agent = 'codex'
|
||||
state.session.codexHome = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
|
||||
}
|
||||
const worktrees = [
|
||||
{
|
||||
worktreeId: 'owned-worktree',
|
||||
repoId: 'owned-repo',
|
||||
repo: 'Owned',
|
||||
path: '/owned/workspace',
|
||||
branch: 'main',
|
||||
displayName: 'Owned',
|
||||
liveTerminalCount: 0,
|
||||
hasAttachedPty: false,
|
||||
preview: '',
|
||||
unread: false,
|
||||
isPinned: false,
|
||||
linkedPR: null
|
||||
}
|
||||
]
|
||||
const calls: { method: string; params: unknown; generation: number }[] = []
|
||||
let rejectOptional: (error: Error) => void = () => {}
|
||||
let resolveOptional: (reply: RpcResponse) => void = () => {}
|
||||
const pendingOptional = new Promise<RpcResponse>((resolve, reject) => {
|
||||
rejectOptional = reject
|
||||
resolveOptional = resolve
|
||||
})
|
||||
const sendRequest: RpcClient['sendRequest'] = vi.fn<RpcClient['sendRequest']>(
|
||||
async (method, params) => {
|
||||
calls.push({ method, params, generation: state.generation })
|
||||
if (method === 'repo.list') {
|
||||
return {
|
||||
id: 'owned-reply',
|
||||
ok: true,
|
||||
result: { repos: [{ id: 'owned-repo', path: '/owned/workspace', connectionId: null }] }
|
||||
}
|
||||
}
|
||||
if (method === 'folderWorkspace.list') {
|
||||
return boundary === 'metadata'
|
||||
? pendingOptional
|
||||
: { id: 'owned-reply', ok: true, result: { workspaces: [] } }
|
||||
}
|
||||
if (method === 'aiVault.prepareSessionResume') {
|
||||
return boundary === 'preparation'
|
||||
? pendingOptional
|
||||
: { id: 'owned-reply', ok: true, result: { useRealCodexHome: true } }
|
||||
}
|
||||
if (method === 'projectGroup.list') {
|
||||
return { id: 'owned-reply', ok: true, result: { groups: [] } }
|
||||
}
|
||||
if (method === 'worktree.ps') {
|
||||
return { id: 'owned-reply', ok: true, result: { worktrees } }
|
||||
}
|
||||
if (method === 'settings.get') {
|
||||
return { id: 'owned-reply', ok: true, result: {} }
|
||||
}
|
||||
if (method === 'session.tabs.createTerminal') {
|
||||
return boundary === 'terminal creation' || boundary === 'owned create accepted'
|
||||
? pendingOptional
|
||||
: {
|
||||
id: 'owned-reply',
|
||||
ok: true,
|
||||
result: {
|
||||
tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (method === 'terminal.send') {
|
||||
if (boundary === 'accepted send') {
|
||||
return pendingOptional
|
||||
}
|
||||
return { id: 'owned-reply', ok: true, result: { send: { accepted: true } } }
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`)
|
||||
}
|
||||
)
|
||||
const client = {
|
||||
...createFakeRpcClient(
|
||||
scenario === 'legacy reconnect' ? {} : { getGeneration: () => state.generation }
|
||||
),
|
||||
sendRequest
|
||||
}
|
||||
state.client = client
|
||||
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
|
||||
let resume: Promise<void> | undefined
|
||||
const panel = () =>
|
||||
React.createElement(
|
||||
React.StrictMode,
|
||||
null,
|
||||
React.createElement(MobileAgentSessionHistoryPanel, { hostId, worktreeId: 'owned-worktree' })
|
||||
)
|
||||
await act(async () => {
|
||||
rendered.tree = create(panel())
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
if (!rendered.tree) {
|
||||
throw new Error('missing panel')
|
||||
}
|
||||
if (!state.session || !state.onResume) {
|
||||
throw new Error('missing resume callback')
|
||||
}
|
||||
resume = state.onResume(state.session)
|
||||
})
|
||||
expect(calls.some((call) => call.method === 'repo.list')).toBe(true)
|
||||
expect(calls.some((call) => call.method === 'folderWorkspace.list')).toBe(true)
|
||||
if (
|
||||
boundary === 'terminal creation' ||
|
||||
boundary === 'owned create accepted' ||
|
||||
boundary === 'accepted send'
|
||||
) {
|
||||
expect(calls.some((call) => call.method === 'session.tabs.createTerminal')).toBe(true)
|
||||
}
|
||||
if (boundary === 'preparation') {
|
||||
expect(calls.some((call) => call.method === 'aiVault.prepareSessionResume')).toBe(true)
|
||||
}
|
||||
if (scenario === 'generation' || scenario === 'generation without render') {
|
||||
state.generation = 2
|
||||
}
|
||||
if (scenario === 'client') {
|
||||
state.client = { ...createFakeRpcClient(), sendRequest }
|
||||
}
|
||||
if (scenario === 'host') {
|
||||
hostId = 'replacement-host'
|
||||
}
|
||||
if (scenario === 'legacy reconnect' || scenario === 'disconnect') {
|
||||
state.connection = 'reconnecting'
|
||||
await act(async () => {
|
||||
rendered.tree?.update(panel())
|
||||
})
|
||||
if (scenario === 'legacy reconnect') {
|
||||
state.connection = 'connected'
|
||||
}
|
||||
}
|
||||
if (scenario === 'unmount') {
|
||||
await act(async () => {
|
||||
rendered.tree?.unmount()
|
||||
})
|
||||
} else if (scenario !== 'generation without render' && cutover) {
|
||||
await act(async () => {
|
||||
rendered.tree?.update(panel())
|
||||
})
|
||||
}
|
||||
await act(async () => {
|
||||
if (boundary === 'terminal creation' || boundary === 'owned create accepted') {
|
||||
resolveOptional({
|
||||
id: 'owned-reply',
|
||||
ok: true,
|
||||
result: {
|
||||
tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' }
|
||||
}
|
||||
})
|
||||
} else if (boundary === 'accepted send') {
|
||||
resolveOptional({ id: 'owned-reply', ok: true, result: { send: { accepted: true } } })
|
||||
} else if (boundary === 'preparation' && !cutover) {
|
||||
resolveOptional({ id: 'owned-reply', ok: true, result: { useRealCodexHome: true } })
|
||||
} else {
|
||||
rejectOptional(new Error('client_disconnected'))
|
||||
}
|
||||
await resume
|
||||
})
|
||||
const createCall = calls.find((call) => call.method === 'session.tabs.createTerminal')
|
||||
if (boundary === 'accepted send' || boundary === 'owned create accepted') {
|
||||
expect(createCall).toBeDefined()
|
||||
expect(calls.filter((call) => call.method === 'terminal.send')).toHaveLength(
|
||||
state.legacy ? 1 : 0
|
||||
)
|
||||
const completion = {
|
||||
errors: notifications.error.mock.calls.length,
|
||||
successes: notifications.success.mock.calls.length,
|
||||
navigations: notifications.push.mock.calls.length
|
||||
}
|
||||
if (scenario !== 'unmount') {
|
||||
await act(async () => {
|
||||
if (!state.session || !state.onResume) {
|
||||
throw new Error('missing retry')
|
||||
}
|
||||
await state.onResume(state.session)
|
||||
})
|
||||
const creates = calls.filter((call) => call.method === 'session.tabs.createTerminal')
|
||||
expect(creates).toHaveLength(2)
|
||||
const originalCreate = CreateTerminalTab.parse(createCall?.params)
|
||||
const retryCreate = CreateTerminalTab.parse(creates[1].params)
|
||||
expect(originalCreate.clientMutationId).toEqual(expect.any(String))
|
||||
expect(retryCreate.clientMutationId).toEqual(expect.any(String))
|
||||
expect(retryCreate.clientMutationId).not.toBe(originalCreate.clientMutationId)
|
||||
}
|
||||
expect(completion).toEqual({ errors: 0, successes: 1, navigations: cutover ? 0 : 1 })
|
||||
} else if (cutover) {
|
||||
if (boundary === 'terminal creation') {
|
||||
expect(createCall).toBeDefined()
|
||||
} else {
|
||||
expect(createCall).toBeUndefined()
|
||||
}
|
||||
expect(calls.some((call) => call.method === 'terminal.send')).toBe(false)
|
||||
} else {
|
||||
expect(createCall, JSON.stringify(calls)).toBeDefined()
|
||||
if (boundary === 'metadata') {
|
||||
expect(createCall?.params).toEqual(
|
||||
expect.objectContaining({ command: expect.stringContaining('owned-session') })
|
||||
)
|
||||
} else {
|
||||
expect(createCall?.params).not.toHaveProperty('command')
|
||||
expect(calls.some((call) => call.method === 'terminal.send')).toBe(true)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await act(async () => rendered.tree?.unmount())
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { optionalSettingsRead } from '../transport/settings-read-operations'
|
||||
import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message'
|
||||
import { rpcPayloadMember } from '../transport/rpc-reader-payload'
|
||||
import { readAcceptedResumeList } from './resume-metadata-lists'
|
||||
import {
|
||||
resumeFolderWorkspaceListRead,
|
||||
resumeProjectGroupListRead,
|
||||
resumeRepoListRead,
|
||||
resumeWorktreeListRead
|
||||
} from './mobile-agent-history-operations'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import type { MobileAiVaultResumeSettings } from '../session/ai-vault-resume-launch'
|
||||
import { RESUME_RPC_TIMEOUT_MS } from '../session/ai-vault-resume-preparation'
|
||||
import type {
|
||||
MobileAiVaultResumeRepo,
|
||||
MobileAiVaultResumeFolderWorkspace,
|
||||
MobileAiVaultResumeProjectGroup
|
||||
} from './agent-history-resume-target'
|
||||
|
||||
export async function loadMobileResumeMetadata(client: RpcClient): Promise<{
|
||||
repos: MobileAiVaultResumeRepo[]
|
||||
folderWorkspaces: MobileAiVaultResumeFolderWorkspace[]
|
||||
projectGroups: MobileAiVaultResumeProjectGroup[]
|
||||
settings: MobileAiVaultResumeSettings | null
|
||||
worktrees: Worktree[] | null
|
||||
}> {
|
||||
// Why: repo.list can enrich repo remote identities, so fetch resume-only
|
||||
// metadata after explicit user intent instead of delaying history browsing.
|
||||
// timeoutMs: without it a socket drop parks these on the reconnect waiter
|
||||
// for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS).
|
||||
const [repoReply, folderWorkspaceReply, projectGroupReply, settingsReply, worktreeReply] =
|
||||
await Promise.all([
|
||||
resumeRepoListRead.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }),
|
||||
resumeFolderWorkspaceListRead
|
||||
.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
resumeProjectGroupListRead
|
||||
.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
optionalSettingsRead
|
||||
.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
resumeWorktreeListRead
|
||||
.request(client, { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null)
|
||||
])
|
||||
const repoResult = interpretOrThrowRefusalMessage(
|
||||
() => resumeRepoListRead.interpret(repoReply),
|
||||
'Unable to load workspace metadata.'
|
||||
)
|
||||
const folderWorkspaceResult =
|
||||
folderWorkspaceReply && resumeFolderWorkspaceListRead.interpret(folderWorkspaceReply)
|
||||
const projectGroupResult =
|
||||
projectGroupReply && resumeProjectGroupListRead.interpret(projectGroupReply)
|
||||
const settingsResult = settingsReply ? optionalSettingsRead.interpret(settingsReply) : null
|
||||
const settings = settingsResult?.accepted
|
||||
? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
(settingsResult.value as MobileAiVaultResumeSettings | null | undefined)
|
||||
: null
|
||||
const worktreeResult = worktreeReply && resumeWorktreeListRead.interpret(worktreeReply)
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
repos: (rpcPayloadMember(repoResult, 'repos') as MobileAiVaultResumeRepo[] | undefined) ?? [],
|
||||
folderWorkspaces: readAcceptedResumeList(folderWorkspaceResult, 'folderWorkspaces') ?? [],
|
||||
projectGroups: readAcceptedResumeList(projectGroupResult, 'groups') ?? [],
|
||||
settings: settings ?? null,
|
||||
worktrees: readAcceptedResumeList(worktreeResult, 'worktrees') ?? null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHostClient, useForceReconnect } from '../transport/client-context'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { connectionRetryAction } from '../transport/connection-retry-action'
|
||||
import type {
|
||||
AiVaultScanIssue,
|
||||
@@ -43,7 +44,20 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams
|
||||
const forceReconnect = useForceReconnect()
|
||||
const [scope, setScope] = useState<AiVaultScope>('workspace')
|
||||
const [screenState, setScreenState] = useState<AgentHistoryScreenState>({ kind: 'loading' })
|
||||
const [hostStatusResult, setHostStatusResult] = useState<unknown>(null)
|
||||
const clientGeneration = client?.getGeneration?.() ?? 0
|
||||
const [hostStatusProbe, setHostStatusProbe] = useState<{
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
generation: number
|
||||
result: unknown
|
||||
} | null>(null)
|
||||
const hostStatusResult =
|
||||
connState === 'connected' &&
|
||||
hostStatusProbe?.hostId === hostId &&
|
||||
hostStatusProbe.client === client &&
|
||||
hostStatusProbe.generation === clientGeneration
|
||||
? hostStatusProbe.result
|
||||
: null
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const generationRef = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
@@ -72,10 +86,14 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams
|
||||
async (options: { scope: AiVaultScope; force: boolean }): Promise<void> => {
|
||||
const generation = generationRef.current + 1
|
||||
generationRef.current = generation
|
||||
const isCurrent = () => mountedRef.current && generationRef.current === generation
|
||||
const isCurrent = () =>
|
||||
mountedRef.current &&
|
||||
generationRef.current === generation &&
|
||||
(client?.getGeneration?.() ?? 0) === clientGeneration
|
||||
|
||||
if (!client || connState !== 'connected') {
|
||||
if (isCurrent()) {
|
||||
setHostStatusProbe(null)
|
||||
// Why: keep the stale list visible through transient reconnects
|
||||
// (connState flips re-run the load effect) instead of tearing it
|
||||
// down to a full-screen error, matching the host list screen.
|
||||
@@ -98,7 +116,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams
|
||||
() => agentHistoryHostStatusRead.interpret(statusReply),
|
||||
'Unable to reach host'
|
||||
)
|
||||
setHostStatusResult(status)
|
||||
setHostStatusProbe({ hostId, client, generation: clientGeneration, result: status })
|
||||
if (!status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY)) {
|
||||
setScreenState({ kind: 'unsupported' })
|
||||
return
|
||||
@@ -140,11 +158,11 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load agent sessions'
|
||||
setHostStatusResult(null)
|
||||
setHostStatusProbe(null)
|
||||
setScreenState({ kind: 'error', message })
|
||||
}
|
||||
},
|
||||
[activeWorktree, client, connState, worktrees, worktreesLoaded]
|
||||
[activeWorktree, client, clientGeneration, connState, hostId, worktrees, worktreesLoaded]
|
||||
)
|
||||
|
||||
// Initial + reconnect load. Why: scope switches reuse the host's 15s cache
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
export function useMobileResumeOperationOwnership(
|
||||
hostId: string,
|
||||
worktreeId: string,
|
||||
client: RpcClient | null,
|
||||
connection: ConnectionState
|
||||
) {
|
||||
const generation = client?.getGeneration?.()
|
||||
const owner = useMemo(
|
||||
() => ({ hostId, worktreeId, client, connection, generation }),
|
||||
[hostId, worktreeId, client, connection, generation]
|
||||
)
|
||||
const currentOwner = useRef<typeof owner | null>(owner)
|
||||
useLayoutEffect(() => {
|
||||
currentOwner.current = owner
|
||||
return () => {
|
||||
currentOwner.current = null
|
||||
}
|
||||
}, [owner])
|
||||
return useCallback(
|
||||
() => () => {
|
||||
if (
|
||||
currentOwner.current !== owner ||
|
||||
owner.connection !== 'connected' ||
|
||||
!owner.client ||
|
||||
owner.client.getState() !== 'connected' ||
|
||||
owner.client.getGeneration?.() !== owner.generation
|
||||
) {
|
||||
throw new Error('Host connection changed. Retry resume.')
|
||||
}
|
||||
},
|
||||
[owner]
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Activity } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import { Animated, StyleSheet, View } from 'react-native'
|
||||
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
|
||||
import { useWorkingRingRotation } from './use-working-ring-rotation'
|
||||
|
||||
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
|
||||
@@ -26,24 +26,8 @@ export function AgentSpinner({
|
||||
status: WorktreeStatus
|
||||
workingMode?: AgentWorkingMode
|
||||
}) {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
const monitoring = status === 'working' && workingMode === 'monitoring'
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'working' && !monitoring) {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(spinValue, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true
|
||||
})
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}
|
||||
spinValue.setValue(0)
|
||||
}, [monitoring, status, spinValue])
|
||||
const rotate = useWorkingRingRotation(status === 'working' && !monitoring)
|
||||
|
||||
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
|
||||
|
||||
@@ -56,10 +40,6 @@ export function AgentSpinner({
|
||||
}
|
||||
|
||||
if (status === 'working') {
|
||||
const rotate = spinValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg']
|
||||
})
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Activity } from 'lucide-react-native'
|
||||
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
||||
import { Animated, StyleSheet, View } from 'react-native'
|
||||
import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useWorkingRingRotation } from './use-working-ring-rotation'
|
||||
|
||||
// Per-agent state indicator, 1:1 with desktop AgentStateDot
|
||||
// (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working',
|
||||
@@ -22,27 +22,9 @@ const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, strin
|
||||
const WORKING_COLOR = '#eab308'
|
||||
|
||||
export function AgentStateDot({ state }: { state: AgentDotState }) {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'working') {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(spinValue, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true
|
||||
})
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}
|
||||
spinValue.setValue(0)
|
||||
return undefined
|
||||
}, [state, spinValue])
|
||||
const rotate = useWorkingRingRotation(state === 'working')
|
||||
|
||||
if (state === 'working') {
|
||||
const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Animated.View style={[styles.spinner, { transform: [{ rotate }] }]} />
|
||||
|
||||
@@ -7,6 +7,7 @@ const openURL = vi.fn(() => Promise.resolve())
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: (url: string) => openURL(url) },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
|
||||
import { INLINE_TEXT_SELECTION } from './inline-text-selection'
|
||||
import { MobileSelectableText } from './MobileSelectableText'
|
||||
import {
|
||||
Fragment,
|
||||
@@ -33,6 +34,8 @@ type Props = {
|
||||
fallback?: string
|
||||
/** Enables iOS range selection for native-chat transcript prose. */
|
||||
rangeSelectable?: boolean
|
||||
/** Forward long presses from interactive Android transcript spans to the message. */
|
||||
onLongPress?: () => void
|
||||
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
|
||||
* the chat view passes >1 so agent prose reads larger than the compact base. */
|
||||
textScale?: number
|
||||
@@ -47,11 +50,28 @@ const MAX_TABLE_ROWS = 40
|
||||
const MAX_TABLE_COLUMNS = 8
|
||||
/** Prose base size — passed to MermaidDiagram fallback mono text. */
|
||||
const MERMAID_BASE = 13
|
||||
const MarkdownTextContext = createContext<ComponentType<TextProps>>(NativeText)
|
||||
type MarkdownTextSetup = {
|
||||
TextComponent: ComponentType<TextProps>
|
||||
/** Disable native selection only within the Android transcript. */
|
||||
androidTranscript: boolean
|
||||
onLongPress?: () => void
|
||||
}
|
||||
const MarkdownTextContext = createContext<MarkdownTextSetup>({
|
||||
TextComponent: NativeText,
|
||||
androidTranscript: false
|
||||
})
|
||||
|
||||
function MarkdownText(props: TextProps): React.JSX.Element {
|
||||
const TextComponent = useContext(MarkdownTextContext)
|
||||
return createElement(TextComponent, props)
|
||||
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
|
||||
if (!androidTranscript) {
|
||||
return createElement(TextComponent, props)
|
||||
}
|
||||
// Override selection without changing the nested spans' inherited behavior.
|
||||
return createElement(TextComponent, {
|
||||
...props,
|
||||
...(props.selectable === true ? { selectable: false } : {}),
|
||||
...(onLongPress && props.onPress ? { onLongPress } : {})
|
||||
})
|
||||
}
|
||||
|
||||
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
|
||||
@@ -215,6 +235,9 @@ function MobileMarkdownContent({
|
||||
textScale = 1,
|
||||
onOpenFile
|
||||
}: Props) {
|
||||
// Interactive children own their touches and must forward the row action.
|
||||
const setup = useContext(MarkdownTextContext)
|
||||
const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined
|
||||
const text = content?.trim() ?? ''
|
||||
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
|
||||
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
|
||||
@@ -288,6 +311,7 @@ function MobileMarkdownContent({
|
||||
key={index}
|
||||
style={styles.imageFrame}
|
||||
onPress={() => openMarkdownHref(block.url, onOpenFile)}
|
||||
onLongPress={rowLongPress}
|
||||
>
|
||||
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
|
||||
<NativeText style={styles.imageCaption} numberOfLines={1}>
|
||||
@@ -380,9 +404,19 @@ function MobileMarkdownContent({
|
||||
}
|
||||
|
||||
function MobileMarkdownInner(props: Props): React.JSX.Element | null {
|
||||
const TextComponent = props.rangeSelectable ? MobileSelectableText : NativeText
|
||||
const { rangeSelectable = false, onLongPress } = props
|
||||
// Other Markdown surfaces retain their existing selection behavior.
|
||||
const androidTranscript = rangeSelectable && !INLINE_TEXT_SELECTION
|
||||
const setup = useMemo<MarkdownTextSetup>(
|
||||
() => ({
|
||||
TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText,
|
||||
androidTranscript,
|
||||
...(onLongPress ? { onLongPress } : {})
|
||||
}),
|
||||
[rangeSelectable, androidTranscript, onLongPress]
|
||||
)
|
||||
return (
|
||||
<MarkdownTextContext.Provider value={TextComponent}>
|
||||
<MarkdownTextContext.Provider value={setup}>
|
||||
<MobileMarkdownContent {...props} />
|
||||
</MarkdownTextContext.Provider>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PickerOption<T extends string = string> = {
|
||||
type Props<T extends string = string> = {
|
||||
visible: boolean
|
||||
title: string
|
||||
subtitle?: string
|
||||
options: PickerOption<T>[]
|
||||
selected: T
|
||||
onSelect: (value: T) => void
|
||||
@@ -32,6 +33,7 @@ type PickerModalContentProps<T extends string = string> = Pick<
|
||||
export function PickerModal<T extends string = string>({
|
||||
visible,
|
||||
title,
|
||||
subtitle,
|
||||
options,
|
||||
selected,
|
||||
onSelect,
|
||||
@@ -44,6 +46,7 @@ export function PickerModal<T extends string = string>({
|
||||
<BottomDrawer visible={visible} onClose={onClose} onAfterClose={onAfterClose} zIndex={zIndex}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
|
||||
</View>
|
||||
|
||||
<PickerModalContent
|
||||
@@ -130,6 +133,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500',
|
||||
color: colors.textMuted
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
group: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('react-native', () => ({
|
||||
timing: animationTiming
|
||||
},
|
||||
Easing: { linear: 'linear' },
|
||||
Platform: { OS: 'ios' },
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
View: 'View'
|
||||
}))
|
||||
@@ -87,6 +88,22 @@ describe('mobile monitoring indicators', () => {
|
||||
expect(animationLoop).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the native driver for both working rings on native', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement('View', null, [
|
||||
createElement(AgentSpinner, { key: 'spinner', status: 'working' }),
|
||||
createElement(AgentStateDot, { key: 'dot', state: 'working' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
expect(animationTiming).toHaveBeenCalledTimes(2)
|
||||
for (const call of animationTiming.mock.calls) {
|
||||
expect(call).toEqual([expect.anything(), expect.objectContaining({ useNativeDriver: true })])
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
// A user's Stop is not news: muted, never the fault red a failure draws.
|
||||
['interrupted', colors.textMuted],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }))
|
||||
|
||||
const { INLINE_TEXT_SELECTION, inlineTextSelectionAllowed } =
|
||||
await import('./inline-text-selection')
|
||||
|
||||
describe('inline text selection policy', () => {
|
||||
it('keeps inline selection everywhere but Android', () => {
|
||||
expect(inlineTextSelectionAllowed('ios')).toBe(true)
|
||||
expect(inlineTextSelectionAllowed('web')).toBe(true)
|
||||
expect(inlineTextSelectionAllowed('android')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves the running platform once', () => {
|
||||
expect(INLINE_TEXT_SELECTION).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
// Android double-tap selection can mistake successive scroll flicks for word selection.
|
||||
export function inlineTextSelectionAllowed(os: string): boolean {
|
||||
return os !== 'android'
|
||||
}
|
||||
|
||||
export const INLINE_TEXT_SELECTION = inlineTextSelectionAllowed(Platform.OS)
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
// Why Android here: the sibling selectable test runs as iOS and proves prose IS selectable there;
|
||||
// this one proves the transcript carries no selectable span on Android, where a selectable
|
||||
// TextView selects words while the transcript scrolls — and that other surfaces are untouched.
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: () => Promise.resolve() },
|
||||
Platform: { OS: 'android' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
|
||||
|
||||
const CONTENT = [
|
||||
'# Heading',
|
||||
'A paragraph with **bold**, `code` and https://example.com/link.',
|
||||
'> quoted',
|
||||
'- item one',
|
||||
'| a | b |',
|
||||
'| - | - |',
|
||||
'| 1 | 2 |',
|
||||
'```',
|
||||
'fenced()',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
describe('MobileMarkdown on Android', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function render(props: Parameters<typeof MobileMarkdown>[0]): ReactTestInstance[] {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, props))
|
||||
})
|
||||
return renderer!.root.findAll((node) => String(node.type) === 'Text')
|
||||
}
|
||||
|
||||
const tappable = (nodes: ReactTestInstance[]) =>
|
||||
nodes.filter((node) => typeof node.props.onPress === 'function')
|
||||
|
||||
it('renders the transcript with no selectable span, leaving untouched spans alone', () => {
|
||||
const all = render({ content: CONTENT, rangeSelectable: true })
|
||||
expect(all.length).toBeGreaterThan(5)
|
||||
expect(all.filter((node) => node.props.selectable === true)).toHaveLength(0)
|
||||
// Inline spans (bold, code, links) never asked for selection; writing `false` onto them
|
||||
// would map to `userSelect: none` on the web, so the gate must leave them alone.
|
||||
const links = tappable(all)
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
for (const node of links) {
|
||||
expect(node.props.selectable).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes a long press on a tappable span or image to the row, so neither swallows it', () => {
|
||||
const onLongPress = vi.fn()
|
||||
const content = `${CONTENT}\n\n`
|
||||
const all = render({ content, rangeSelectable: true, onLongPress })
|
||||
const links = tappable(all)
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
for (const node of links) {
|
||||
expect(node.props.onLongPress).toBe(onLongPress)
|
||||
}
|
||||
expect(all.filter((node) => !node.props.onPress && node.props.onLongPress)).toHaveLength(0)
|
||||
const images = renderer!.root.findAll(
|
||||
(node) => String(node.type) === 'Pressable' && typeof node.props.onPress === 'function'
|
||||
)
|
||||
expect(images.length).toBeGreaterThan(0)
|
||||
for (const node of images) {
|
||||
expect(node.props.onLongPress).toBe(onLongPress)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps other surfaces (task comments, previews) selectable as before', () => {
|
||||
const all = render({ content: CONTENT })
|
||||
expect(all.filter((node) => node.props.selectable === true).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: vi.fn() },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: () => Promise.resolve() },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Animated, Easing, Platform } from 'react-native'
|
||||
|
||||
/** The working ring's rotation: one turn per second while `active`, reset to 0 otherwise. */
|
||||
export function useWorkingRingRotation(active: boolean): Animated.AnimatedInterpolation<string> {
|
||||
const spinValue = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(spinValue, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
// Why: web has no native driver, so a loop over a native-driver timing plays it once.
|
||||
useNativeDriver: Platform.OS !== 'web'
|
||||
})
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}
|
||||
spinValue.setValue(0)
|
||||
return undefined
|
||||
}, [active, spinValue])
|
||||
|
||||
return spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
|
||||
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
|
||||
import {
|
||||
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
|
||||
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS,
|
||||
WORKSPACE_VIEW_SHARED_NOTE
|
||||
} from '../worktree/workspace-list-picker-options'
|
||||
import { isWorktreePinned } from '../worktree/workspace-list-sections'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
@@ -37,6 +38,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
<PickerModal
|
||||
visible={state.showSortPicker}
|
||||
title="Sort By"
|
||||
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
|
||||
options={SORT_OPTIONS}
|
||||
selected={state.sortMode}
|
||||
onSelect={settings.handleSortChange}
|
||||
@@ -46,6 +48,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
<PickerModal
|
||||
visible={state.showGroupPicker}
|
||||
title="Group By"
|
||||
subtitle={WORKSPACE_VIEW_SHARED_NOTE}
|
||||
options={GROUP_OPTIONS}
|
||||
selected={state.groupMode}
|
||||
onSelect={settings.handleGroupChange}
|
||||
@@ -54,7 +57,10 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
|
||||
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
|
||||
<View style={styles.filterModalHeader}>
|
||||
<Text style={styles.filterModalTitle}>Filter</Text>
|
||||
<View style={styles.filterModalHeading}>
|
||||
<Text style={styles.filterModalTitle}>Filter</Text>
|
||||
<Text style={styles.filterModalSubtitle}>{WORKSPACE_VIEW_SHARED_NOTE}</Text>
|
||||
</View>
|
||||
{settings.activeFilterCount > 0 && (
|
||||
<Pressable onPress={settings.clearFilters}>
|
||||
<Text style={styles.clearFiltersText}>Clear filters</Text>
|
||||
|
||||
@@ -47,11 +47,19 @@ export const hostScreenSecondaryStyles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
filterModalHeading: {
|
||||
flexShrink: 1
|
||||
},
|
||||
filterModalTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
filterModalSubtitle: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
marginTop: 2
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.textSecondary
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
|
||||
vi.mock('../cache/worktree-cache', () => ({
|
||||
getCachedWorktrees: () => null,
|
||||
setCachedWorktrees: () => {}
|
||||
}))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadPinnedIds: async () => new Set(),
|
||||
savePinnedIds: async () => {}
|
||||
}))
|
||||
vi.mock('../transport/host-store', () => ({
|
||||
loadHosts: async () => [],
|
||||
updateLastConnected: async () => {}
|
||||
}))
|
||||
vi.mock('../worktree/host-worktree-refresh', () => ({ startHostWorktreeRefresh: () => () => {} }))
|
||||
vi.mock('../transport/use-worktree-resync', () => ({
|
||||
useWorktreeResync: () => ({ refreshing: false, onRefresh: async () => {} })
|
||||
}))
|
||||
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useHostScreenIdentity } from './use-host-screen-identity'
|
||||
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
|
||||
|
||||
function settingsReply(settings: unknown): RpcResponse {
|
||||
return { id: 'reply', ok: true, result: { settings }, _meta: { runtimeId: 'runtime' } }
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
type ClientRef = { current: RpcClient | null }
|
||||
|
||||
function screenState(clientRef: ClientRef, applied: boolean[]) {
|
||||
return {
|
||||
clientRef,
|
||||
fetchWorktreesInFlightRef: { current: false },
|
||||
newWorktreeModalVisibleRef: { current: false },
|
||||
repoMetadataFetchedAtRef: { current: 0 },
|
||||
worktreeCatalogRef: { current: null },
|
||||
setShowPinnedInGroups: (show: boolean) => applied.push(show),
|
||||
...Object.fromEntries(
|
||||
[
|
||||
'setActionError',
|
||||
'setCatalogError',
|
||||
'setError',
|
||||
'setHostLabelById',
|
||||
'setHostName',
|
||||
'setHostPlatform',
|
||||
'setHostStoredDescriptor',
|
||||
'setLastKnownWorktrees',
|
||||
'setOptimisticActiveWorktreeIdentity',
|
||||
'setPinnedIds',
|
||||
'setRepoColorsByName',
|
||||
'setRepoHostIdByRepoId',
|
||||
'setRepoIconsByName',
|
||||
'setSleptIds',
|
||||
'setWorktrees',
|
||||
'setWorktreesLoaded'
|
||||
].map((setter) => [setter, noop])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mounting the embedded catalog runs its refresh, which issues the placement read.
|
||||
async function refreshWith(
|
||||
reply: Promise<RpcResponse>,
|
||||
onSent: (clientRef: ClientRef) => void = noop
|
||||
): Promise<boolean[]> {
|
||||
const client = new FakeSession('connected')
|
||||
const clientRef: ClientRef = { current: client }
|
||||
client.sendRequest.mockImplementation(() => {
|
||||
onSent(clientRef)
|
||||
return reply
|
||||
})
|
||||
const applied: boolean[] = []
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mount-time refresh reads clientRef and setShowPinnedInGroups; the other members are inert stubs.
|
||||
const args = {
|
||||
client,
|
||||
connState: 'connected',
|
||||
embedded: true,
|
||||
fetchRepoMetadata: async () => {},
|
||||
hostId: 'host-1',
|
||||
state: screenState(clientRef, applied),
|
||||
syncViewSettingsFromDesktop: async () => {}
|
||||
} as unknown as Parameters<typeof useHostWorktreeCatalog>[0]
|
||||
function Probe(): null {
|
||||
useHostWorktreeCatalog(args)
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
create(createElement(Probe))
|
||||
await reply.catch(noop)
|
||||
})
|
||||
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual(['settings.get'])
|
||||
return applied
|
||||
}
|
||||
|
||||
describe('the host list mirrors the desktop pinned-placement setting', () => {
|
||||
it('applies the setting the host reports on refresh, absent reading as off', async () => {
|
||||
const on = settingsReply({ showPinnedWorktreesInGroups: true })
|
||||
expect(await refreshWith(Promise.resolve(on))).toEqual([true])
|
||||
expect(await refreshWith(Promise.resolve(settingsReply({})))).toEqual([false])
|
||||
})
|
||||
|
||||
it('drops a reply that lands after the screen moved to another client', async () => {
|
||||
const on = settingsReply({ showPinnedWorktreesInGroups: true })
|
||||
const moveOn = (clientRef: ClientRef) => {
|
||||
clientRef.current = new FakeSession('connected')
|
||||
}
|
||||
expect(await refreshWith(Promise.resolve(on), moveOn)).toEqual([])
|
||||
})
|
||||
|
||||
it('resets to off when the screen is reused for another host', async () => {
|
||||
const applied: boolean[] = []
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the identity hook reads only the refs and setters screenState provides.
|
||||
const identityState = screenState({ current: null }, applied) as unknown as Parameters<
|
||||
typeof useHostScreenIdentity
|
||||
>[0]['state']
|
||||
function Probe({ hostId }: { hostId: string }): null {
|
||||
useHostScreenIdentity({ client: null, hostId, state: identityState })
|
||||
return null
|
||||
}
|
||||
const renderer = create(createElement(Probe, { hostId: 'host-1' }))
|
||||
await act(async () => {})
|
||||
applied.length = 0
|
||||
await act(async () => {
|
||||
renderer.update(createElement(Probe, { hostId: 'host-2' }))
|
||||
})
|
||||
expect(applied).toEqual([false])
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,7 @@ export function useHostScreenController({
|
||||
search: state.search,
|
||||
groupMode: state.groupMode,
|
||||
pinnedIds: state.pinnedIds,
|
||||
showPinnedInGroups: state.showPinnedInGroups,
|
||||
repoIdsByName: state.repoIdsByName,
|
||||
repoColorsByName: state.repoColorsByName,
|
||||
collapsedGroups: state.collapsedGroups,
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useHostScreenIdentity(args: {
|
||||
setRepoColorsByName,
|
||||
setRepoHostIdByRepoId,
|
||||
setRepoIconsByName,
|
||||
setShowPinnedInGroups,
|
||||
setWorktrees,
|
||||
setWorktreesLoaded
|
||||
} = state
|
||||
@@ -62,6 +63,7 @@ export function useHostScreenIdentity(args: {
|
||||
setRepoHostIdByRepoId(new Map())
|
||||
setHostLabelById(new Map())
|
||||
setHostPlatform(null)
|
||||
setShowPinnedInGroups(false)
|
||||
repoMetadataFetchedAtRef.current = 0
|
||||
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
|
||||
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
|
||||
@@ -84,6 +84,8 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
const [sleptIds, setSleptIds] = useState<Set<string>>(new Set())
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
// Desktop's "Also show pinned worktrees in their original lists"; off by default.
|
||||
const [showPinnedInGroups, setShowPinnedInGroups] = useState(false)
|
||||
// Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change.
|
||||
const viewStateRef = useRef<MobileViewState>({
|
||||
groupMode: 'repo',
|
||||
@@ -149,6 +151,7 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
setRouteActionState,
|
||||
setSearch,
|
||||
setShowFilterModal,
|
||||
setShowPinnedInGroups,
|
||||
setShowGroupPicker,
|
||||
setShowSearch,
|
||||
setShowSortPicker,
|
||||
@@ -158,6 +161,7 @@ export function useHostScreenState(hostId: string | undefined, action: string |
|
||||
setWorktrees,
|
||||
setWorktreesLoaded,
|
||||
showFilterModal,
|
||||
showPinnedInGroups,
|
||||
showGroupPicker,
|
||||
showSearch,
|
||||
showSortPicker,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { setCachedWorktrees } from '../cache/worktree-cache'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { RpcIncompatibleReplyError } from '../transport/rpc-incompatible-reply-error'
|
||||
import { showPinnedWorktreesInGroupsRead } from '../transport/settings-read-operations'
|
||||
import { useWorktreeResync } from '../transport/use-worktree-resync'
|
||||
import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh'
|
||||
import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot'
|
||||
@@ -42,6 +43,7 @@ export function useHostWorktreeCatalog(args: {
|
||||
setLastKnownWorktrees,
|
||||
setOptimisticActiveWorktreeIdentity,
|
||||
setPinnedIds,
|
||||
setShowPinnedInGroups,
|
||||
setSleptIds,
|
||||
setWorktrees,
|
||||
setWorktreesLoaded,
|
||||
@@ -145,13 +147,36 @@ export function useHostWorktreeCatalog(args: {
|
||||
}, [])
|
||||
)
|
||||
|
||||
const syncShowPinnedInGroups = useCallback(async (requestClient: RpcClient) => {
|
||||
try {
|
||||
const reply = await showPinnedWorktreesInGroupsRead.request(requestClient)
|
||||
if (clientRef.current !== requestClient) {
|
||||
return
|
||||
}
|
||||
const read = showPinnedWorktreesInGroupsRead.interpret(reply)
|
||||
if (read.accepted) {
|
||||
setShowPinnedInGroups(read.value)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: keep the current placement until the next focus/connect.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startWorktreeRefresh = useCallback(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void syncViewSettingsFromDesktop()
|
||||
void syncShowPinnedInGroups(client)
|
||||
return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
|
||||
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
}, [
|
||||
client,
|
||||
connState,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata,
|
||||
syncViewSettingsFromDesktop,
|
||||
syncShowPinnedInGroups
|
||||
])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
// Why a separate file: the sibling suite runs as iOS, where the row has no long press at all.
|
||||
// This one runs as Android and pins the wiring from the bubble's long press to the actions sheet.
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Animated: {
|
||||
Text,
|
||||
Value: class {
|
||||
setValue(): void {}
|
||||
},
|
||||
loop: (animation: unknown) => animation,
|
||||
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
Image: 'Image',
|
||||
Platform: { OS: 'android' },
|
||||
Pressable: 'Pressable',
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement('View', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}
|
||||
})
|
||||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
ChevronDown: 'ChevronDown',
|
||||
Copy: 'Copy',
|
||||
SquareChevronRight: 'SquareChevronRight',
|
||||
SquareTerminal: 'SquareTerminal',
|
||||
Wrench: 'Wrench',
|
||||
ChevronRight: 'ChevronRight'
|
||||
}))
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
||||
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
|
||||
}))
|
||||
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
|
||||
const message: NativeChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'Read https://example.com then reply.' }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
|
||||
describe('MobileNativeChatMessage on Android', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function byType(type: string): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => String(node.type) === type)
|
||||
}
|
||||
|
||||
it('opens the actions sheet from a long press on the bubble, and closes it again', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message }))
|
||||
})
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(0)
|
||||
|
||||
const [bubble] = byType('Pressable')
|
||||
expect(typeof bubble!.props.onLongPress).toBe('function')
|
||||
// The markdown gets the same handler, so a link span or image under the finger opens the sheet.
|
||||
const [markdown] = byType('MobileMarkdown')
|
||||
expect(markdown!.props.onLongPress).toBe(bubble!.props.onLongPress)
|
||||
expect(markdown!.props.rangeSelectable).toBe(true)
|
||||
|
||||
act(() => bubble!.props.onLongPress())
|
||||
const [sheet] = byType('MessageActionsSheet')
|
||||
expect(sheet!.props.message).toBe(message)
|
||||
|
||||
act(() => sheet!.props.onClose())
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disables inline selection for host notices too', () => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatMessage, {
|
||||
message: {
|
||||
...message,
|
||||
role: 'system',
|
||||
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
|
||||
expect(texts).toHaveLength(1)
|
||||
expect(texts[0]!.props.selectable).toBe(false)
|
||||
act(() => byType('Pressable')[0]!.props.onLongPress())
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders the user bubble without inline selection', () => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatMessage, { message: { ...message, id: 'u1', role: 'user' } })
|
||||
)
|
||||
})
|
||||
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
|
||||
expect(texts.length).toBeGreaterThan(0)
|
||||
for (const node of texts) {
|
||||
expect(node.props.selectable).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,7 @@ vi.mock('react-native', async () => {
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
Image: 'Image',
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: unknown }) =>
|
||||
@@ -40,6 +41,9 @@ vi.mock('lucide-react-native', () => ({
|
||||
ChevronRight: 'ChevronRight'
|
||||
}))
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
||||
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
|
||||
}))
|
||||
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
|
||||
import { memo } from 'react'
|
||||
import { Image, Text as NativeText, View } from 'react-native'
|
||||
import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { Image, Text as NativeText, Pressable, View } from 'react-native'
|
||||
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
|
||||
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
|
||||
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
|
||||
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
|
||||
@@ -20,17 +22,23 @@ function Prose({
|
||||
block,
|
||||
invert,
|
||||
fontScale,
|
||||
onOpenFile
|
||||
onOpenFile,
|
||||
onLongPress
|
||||
}: {
|
||||
block: NativeChatBlock
|
||||
invert?: boolean
|
||||
fontScale: number
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
/** Android only: routes a long press on a link span to the row's actions sheet. */
|
||||
onLongPress?: () => void
|
||||
}): React.JSX.Element | null {
|
||||
if (isTextBlock(block)) {
|
||||
if (isAgentSessionHostStatusPresentation(block.presentation)) {
|
||||
return (
|
||||
<Text selectable style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}>
|
||||
<Text
|
||||
selectable={INLINE_TEXT_SELECTION}
|
||||
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
|
||||
>
|
||||
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
|
||||
</Text>
|
||||
)
|
||||
@@ -39,7 +47,10 @@ function Prose({
|
||||
// markdown renderer's light-on-dark palette.
|
||||
if (invert) {
|
||||
return (
|
||||
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
|
||||
<Text
|
||||
selectable={INLINE_TEXT_SELECTION}
|
||||
style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}
|
||||
>
|
||||
{block.text}
|
||||
</Text>
|
||||
)
|
||||
@@ -50,6 +61,7 @@ function Prose({
|
||||
rangeSelectable
|
||||
textScale={1.25 * fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -76,6 +88,25 @@ function Prose({
|
||||
return null
|
||||
}
|
||||
|
||||
// Keep the existing responder hierarchy on platforms with inline selection.
|
||||
function Content({
|
||||
onLongPress,
|
||||
style,
|
||||
children
|
||||
}: {
|
||||
onLongPress?: () => void
|
||||
style: ComponentProps<typeof View>['style']
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
return onLongPress ? (
|
||||
<Pressable onLongPress={onLongPress} style={style}>
|
||||
{children}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={style}>{children}</View>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNativeChatMessageImpl({
|
||||
message,
|
||||
toolsExpanded = false,
|
||||
@@ -129,6 +160,11 @@ function MobileNativeChatMessageImpl({
|
||||
!turnExpanded &&
|
||||
!toolsExpanded
|
||||
const showToolRun = tools.length > 0 && !settledToolsHidden
|
||||
// Mount selection UI only for the message being copied.
|
||||
const [actionsOpen, setActionsOpen] = useState(false)
|
||||
// Keep the memoized Markdown context stable as the message streams.
|
||||
const openActions = useCallback(() => setActionsOpen(true), [])
|
||||
const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions
|
||||
|
||||
const statusRow = turnStatus ? (
|
||||
<MobileNativeChatTurnStatus
|
||||
@@ -144,7 +180,8 @@ function MobileNativeChatMessageImpl({
|
||||
{/* A turn with no user bubble carries its bar above its first row. */}
|
||||
{turnStatusAbove ? statusRow : null}
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
<View
|
||||
<Content
|
||||
onLongPress={onLongPress}
|
||||
style={[styles.content, isUser && styles.userBubble, isReasoning && styles.reasoning]}
|
||||
>
|
||||
{prose.map((block, index) => (
|
||||
@@ -154,6 +191,7 @@ function MobileNativeChatMessageImpl({
|
||||
invert={isUser}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
))}
|
||||
{showToolRun ? (
|
||||
@@ -168,8 +206,14 @@ function MobileNativeChatMessageImpl({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Content>
|
||||
</View>
|
||||
{actionsOpen ? (
|
||||
<MobileNativeChatMessageActionsSheet
|
||||
message={message}
|
||||
onClose={() => setActionsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{turnStatusAbove ? null : statusRow}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
|
||||
|
||||
const { writeText, alert } = vi.hoisted(() => ({ writeText: vi.fn(), alert: vi.fn() }))
|
||||
vi.mock('react-native', () => ({
|
||||
Alert: { alert },
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Modal: 'Modal',
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
Text: 'Text',
|
||||
View: 'View',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 24, bottom: 16, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Copy: 'Copy',
|
||||
TextSelect: 'TextSelect',
|
||||
X: 'X',
|
||||
Edit3: 'Edit3',
|
||||
Trash2: 'Trash2'
|
||||
}))
|
||||
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText }) }))
|
||||
// Keep the real drawer lifecycle; only native animation completion is driven by the test.
|
||||
vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedDrawer' }))
|
||||
|
||||
const message: NativeChatMessage = {
|
||||
id: 'reply',
|
||||
source: 'transcript',
|
||||
role: 'assistant',
|
||||
timestamp: null,
|
||||
blocks: [{ type: 'text', text: 'First words' }]
|
||||
}
|
||||
|
||||
describe('message selection actions', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const onClose = vi.fn()
|
||||
const nodes = (type: string) => renderer!.root.findAll((node) => String(node.type) === type)
|
||||
const render = (value = message) => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatMessageActionsSheet, { message: value, onClose })
|
||||
)
|
||||
})
|
||||
}
|
||||
const pressAction = (index: number) => act(() => nodes('Pressable')[index]!.props.onPress())
|
||||
const finishClosing = () => act(() => nodes('MountedDrawer')[0]!.props.onHidden())
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
writeText.mockResolvedValue(undefined)
|
||||
})
|
||||
afterEach(() => act(() => renderer?.unmount()))
|
||||
|
||||
it('waits for drawer removal, then selects a stable snapshot while the reply streams', () => {
|
||||
render()
|
||||
act(() =>
|
||||
renderer!.update(
|
||||
createElement(MobileNativeChatMessageActionsSheet, {
|
||||
message: {
|
||||
...message,
|
||||
blocks: [{ type: 'text', text: 'First words plus streamed text' }]
|
||||
},
|
||||
onClose
|
||||
})
|
||||
)
|
||||
)
|
||||
pressAction(1)
|
||||
expect(nodes('MountedDrawer')[0]!.props.visible).toBe(false)
|
||||
expect(nodes('Modal')).toHaveLength(0)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
finishClosing()
|
||||
expect(nodes('MountedDrawer')).toHaveLength(0)
|
||||
expect(nodes('Modal')).toHaveLength(1)
|
||||
const selected = nodes('Text').find((node) => node.props.selectable)
|
||||
expect(selected?.props.children).toBe('First words')
|
||||
act(() => nodes('Modal')[0]!.props.onRequestClose())
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes selection with the accessible close button', () => {
|
||||
render()
|
||||
pressAction(1)
|
||||
finishClosing()
|
||||
const close = nodes('Pressable').find((node) => node.props.accessibilityLabel === 'Close')
|
||||
expect(close?.props.accessibilityRole).toBe('button')
|
||||
act(() => close!.props.onPress())
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('copies prose and dismisses only after the drawer closes', () => {
|
||||
render()
|
||||
pressAction(0)
|
||||
expect(writeText).toHaveBeenCalledWith('First words')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
finishClosing()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(nodes('Modal')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces clipboard rejection', async () => {
|
||||
writeText.mockRejectedValue(new Error('Clipboard unavailable'))
|
||||
render()
|
||||
await act(async () => pressAction(0))
|
||||
expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable')
|
||||
})
|
||||
|
||||
it('dismisses without selecting when the drawer is cancelled', () => {
|
||||
render()
|
||||
act(() => nodes('MountedDrawer')[0]!.props.onClose())
|
||||
finishClosing()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(nodes('Modal')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disables actions when the message has no copyable prose', () => {
|
||||
render({ ...message, blocks: [{ type: 'image-ref', path: '/image.png' }] })
|
||||
expect(nodes('Pressable').map((node) => node.props.disabled)).toEqual([true, true])
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Copy, TextSelect, X } from 'lucide-react-native'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { ActionSheetContent } from '../components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import { useClipboardWriter } from '../platform/clipboard'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
|
||||
|
||||
type Props = {
|
||||
/** The long-pressed message. The owner mounts this only while the sheet is open. */
|
||||
message: NativeChatMessage
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MobileNativeChatMessageActionsSheet({
|
||||
message,
|
||||
onClose
|
||||
}: Props): React.JSX.Element {
|
||||
const clipboard = useClipboardWriter()
|
||||
const [sheetVisible, setSheetVisible] = useState(true)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
// Wait for the drawer to unmount before presenting another native modal.
|
||||
const selectRequested = useRef(false)
|
||||
// Streaming updates must not reset an active native text selection.
|
||||
const [text] = useState(() => nativeChatMessagePlainText(message))
|
||||
const closeSheet = () => setSheetVisible(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer
|
||||
visible={sheetVisible}
|
||||
onClose={closeSheet}
|
||||
onAfterClose={() => (selectRequested.current ? setSelecting(true) : onClose())}
|
||||
dragContentToDismiss
|
||||
>
|
||||
<ActionSheetContent
|
||||
onClose={closeSheet}
|
||||
actions={[
|
||||
{
|
||||
label: 'Copy message',
|
||||
icon: Copy,
|
||||
disabled: text.length === 0,
|
||||
onPress: () => {
|
||||
void clipboard.writeText(text).catch((error: unknown) => {
|
||||
Alert.alert(
|
||||
'Copy failed',
|
||||
error instanceof Error ? error.message : 'The clipboard rejected the text.'
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Select text',
|
||||
icon: TextSelect,
|
||||
disabled: text.length === 0,
|
||||
onPress: () => {
|
||||
selectRequested.current = true
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</BottomDrawer>
|
||||
{selecting ? <SelectTextScreen text={text} onClose={onClose} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTextScreen({
|
||||
text,
|
||||
onClose
|
||||
}: {
|
||||
text: string
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose}>
|
||||
<View style={[styles.screen, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Select text</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Close"
|
||||
accessibilityRole="button"
|
||||
style={styles.close}
|
||||
>
|
||||
<X size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text selectable style={styles.text}>
|
||||
{text}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: colors.bgBase },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
title: { color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '600' },
|
||||
close: { padding: spacing.xs },
|
||||
body: { padding: spacing.lg },
|
||||
text: { color: colors.textPrimary, fontSize: typography.bodySize, lineHeight: 22 }
|
||||
})
|
||||
@@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
expect(onToggleExpanded).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => {
|
||||
// The crash's notice row explains the cut; the turn bar reads like any finished turn.
|
||||
it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => {
|
||||
const crashed = render({
|
||||
startedAt: Date.now(),
|
||||
workedSeconds: 12,
|
||||
verdict: 'interruption',
|
||||
onToggleExpanded: vi.fn()
|
||||
})
|
||||
expect(labels(crashed.root)).toEqual(['Failed after 12s'])
|
||||
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
|
||||
act(() => crashed.unmount())
|
||||
const stopped = render({
|
||||
startedAt: Date.now(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
buildMobileAiVaultResumeCommand,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileAiVaultResumeHost,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal
|
||||
@@ -242,6 +243,75 @@ describe('buildMobileAiVaultResumeLaunch', () => {
|
||||
})
|
||||
|
||||
describe('resumeAiVaultSessionInTerminal', () => {
|
||||
it.each(['darwin', 'linux', 'win32'] as const)(
|
||||
'lets the %s execution host select the Qoder resume command at creation',
|
||||
async (hostPlatform) => {
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session: session({ agent: 'qoder', sessionId: 'same-qoder-session' }),
|
||||
hostPlatform
|
||||
})
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } })
|
||||
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', {
|
||||
...launch,
|
||||
clientMutationId: 'qoder-resume',
|
||||
hostCapabilities: ['session.tabs.qoderOwnedCreate.v1']
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'session.tabs.createTerminal',
|
||||
expect.objectContaining({
|
||||
command: launch.command,
|
||||
launchAgent: 'qoder',
|
||||
launchConfig: launch.launchConfig
|
||||
}),
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
expect(launch.command).toContain('same-qoder-session')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(
|
||||
[undefined, [], ['aiVault.v1']].flatMap((hostCapabilities) =>
|
||||
(['darwin', 'linux', 'win32'] as const).map((hostPlatform) => ({
|
||||
hostCapabilities,
|
||||
hostPlatform
|
||||
}))
|
||||
)
|
||||
)(
|
||||
'keeps the acknowledged-create fallback on an older $hostPlatform host',
|
||||
async ({ hostCapabilities, hostPlatform }) => {
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session: session({ agent: 'qoder', sessionId: 'legacy-session' }),
|
||||
hostPlatform
|
||||
})
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } })
|
||||
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', {
|
||||
...launch,
|
||||
hostCapabilities,
|
||||
clientMutationId: 'same-legacy-resume'
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest.mock.calls[0]?.[1]).not.toHaveProperty('command')
|
||||
expect(sendRequest.mock.calls[1]).toEqual([
|
||||
'terminal.send',
|
||||
{ terminal: 'pty-1', text: launch.command, enter: true },
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('creates a fresh terminal and sends the command with Enter', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
@@ -401,3 +471,19 @@ describe('resume platform helpers', () => {
|
||||
expect(resolveMobileAiVaultResumePlatform('runtime', 'linux')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it.each([null, {}, { capabilities: 'session.tabs.qoderOwnedCreate.v1' }, { capabilities: [1] }])(
|
||||
'does not infer safe Qoder creation from unreadable host capabilities',
|
||||
(status) => {
|
||||
expect(readMobileAiVaultResumeHost(status).capabilities).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('reads the execution platform and advertised safe-create capability together', () => {
|
||||
expect(
|
||||
readMobileAiVaultResumeHost({
|
||||
hostPlatform: 'win32',
|
||||
capabilities: ['session.tabs.qoderOwnedCreate.v1']
|
||||
})
|
||||
).toEqual({ platform: 'win32', capabilities: ['session.tabs.qoderOwnedCreate.v1'] })
|
||||
})
|
||||
|
||||
@@ -21,6 +21,17 @@ import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message
|
||||
import { reviewTerminalCreateRun, reviewTerminalSendRun } from './mobile-review-terminal-operations'
|
||||
import type { MobileReviewTerminalTab } from './review-terminal-reply-schema'
|
||||
import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target'
|
||||
import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability'
|
||||
import { agentHistoryHostStatusSchema } from '../agent-history/agent-history-reply-schema'
|
||||
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
|
||||
|
||||
export function readMobileAiVaultResumeHost(statusResult: unknown) {
|
||||
const status = agentHistoryHostStatusSchema.safeParse(statusResult)
|
||||
return {
|
||||
platform: readMobileRuntimeHostPlatform(statusResult),
|
||||
capabilities: status.success ? status.data.capabilities : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMobileAiVaultResumeCommand(args: {
|
||||
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
|
||||
@@ -151,14 +162,25 @@ function normalizeMobileAiVaultResumeCommandOverrides(
|
||||
export async function resumeAiVaultSessionInTerminal(
|
||||
client: RpcOperationSender,
|
||||
worktreeId: string,
|
||||
launch: MobileAiVaultResumeLaunch & { clientMutationId?: string }
|
||||
launch: MobileAiVaultResumeLaunch & {
|
||||
clientMutationId?: string
|
||||
hostCapabilities?: readonly string[]
|
||||
},
|
||||
assertCurrentOwner?: () => void
|
||||
): Promise<MobileReviewTerminalTab> {
|
||||
assertCurrentOwner?.()
|
||||
// Qoder's execution host must select its installed executable before the resume starts.
|
||||
const launchAtCreate =
|
||||
launch.launchAgent === 'qoder' &&
|
||||
Boolean(launch.clientMutationId) &&
|
||||
launch.hostCapabilities?.includes(QODER_OWNED_TERMINAL_CREATE_CAPABILITY) === true
|
||||
// Each request is awaited outside its catch so a transport drop propagates as the original error
|
||||
// object; only a refusal is rewritten into this step's own copy.
|
||||
const created = await reviewTerminalCreateRun.request(
|
||||
client,
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
...(launchAtCreate ? { command: launch.command } : {}),
|
||||
...(launch.env ? { env: launch.env } : {}),
|
||||
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
|
||||
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
|
||||
@@ -175,6 +197,10 @@ export async function resumeAiVaultSessionInTerminal(
|
||||
() => reviewTerminalCreateRun.interpret(created),
|
||||
'Failed to create terminal'
|
||||
)
|
||||
if (launchAtCreate) {
|
||||
return terminalTab
|
||||
}
|
||||
assertCurrentOwner?.()
|
||||
const sent = await reviewTerminalSendRun.request(
|
||||
client,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
|
||||
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
|
||||
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
|
||||
|
||||
describe('nativeChatMessagePlainText', () => {
|
||||
it('joins the prose blocks, keeps their whitespace, and leaves tool calls out', () => {
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{ type: 'text', text: ' indented first line\n' },
|
||||
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
|
||||
{ type: 'text', text: ' \n' },
|
||||
{ type: 'text', text: 'Second, with `code`.' }
|
||||
]
|
||||
expect(nativeChatMessagePlainText({ blocks })).toBe(
|
||||
' indented first line\n\n\nSecond, with `code`.'
|
||||
)
|
||||
})
|
||||
|
||||
it('copies the displayed host notice instead of its fallback wire text', () => {
|
||||
expect(
|
||||
nativeChatMessagePlainText({
|
||||
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
|
||||
})
|
||||
).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
|
||||
})
|
||||
|
||||
it('is empty for a message with no prose', () => {
|
||||
expect(
|
||||
nativeChatMessagePlainText({ blocks: [{ type: 'tool-call', name: 'Read', input: {} }] })
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
AGENT_SESSION_HOST_STATUS_COPY,
|
||||
isAgentSessionHostStatusPresentation
|
||||
} from '../../../src/shared/agent-session-host-status-rows'
|
||||
import { isTextBlock } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** Copy the displayed prose, excluding tool activity and image attachments. */
|
||||
export function nativeChatMessagePlainText(message: Pick<NativeChatMessage, 'blocks'>): string {
|
||||
// Preserve indentation in code and nested lists.
|
||||
return message.blocks
|
||||
.filter(isTextBlock)
|
||||
.map((block) =>
|
||||
isAgentSessionHostStatusPresentation(block.presentation)
|
||||
? AGENT_SESSION_HOST_STATUS_COPY[block.presentation]
|
||||
: block.text
|
||||
)
|
||||
.filter((text) => text.trim().length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }
|
||||
}))
|
||||
|
||||
const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' }
|
||||
const ITEMS: AgentJournalRenderItem[] = [
|
||||
{
|
||||
itemId: 'user-1',
|
||||
revision: 0,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
turnScope: { kind: 'thread' },
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }
|
||||
},
|
||||
{
|
||||
itemId: 'turn-1',
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 2,
|
||||
turnScope: { kind: 'thread' },
|
||||
body: {
|
||||
kind: 'turn',
|
||||
turnId: 'turn-1',
|
||||
userItemId: 'user-1',
|
||||
state: 'interrupted',
|
||||
startedAt: 2,
|
||||
completedAt: 9
|
||||
}
|
||||
},
|
||||
{
|
||||
itemId: 'reply-1',
|
||||
revision: 0,
|
||||
sequence: 3,
|
||||
observedAt: 3,
|
||||
turnScope: SCOPE,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] }
|
||||
}
|
||||
]
|
||||
|
||||
function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-1',
|
||||
fence: 3,
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
fence: 3,
|
||||
direction: 'tail',
|
||||
items,
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: {
|
||||
oldest: { epoch: 'epoch-1', sequence: 1 },
|
||||
newest: { epoch: 'epoch-1', sequence: 3 },
|
||||
nextCursor: { epoch: 'epoch-1', sequence: 4 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 3 },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMobileStructuredAgentSession transcript', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => {
|
||||
let listener: ((value: unknown) => void) | null = null
|
||||
const client: Pick<RpcClient, 'sendRequest' | 'subscribe'> = {
|
||||
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
|
||||
id: 'response-1',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})),
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
|
||||
listener = onData
|
||||
return () => {}
|
||||
})
|
||||
}
|
||||
const rendered: { hook: ReturnType<typeof useMobileStructuredAgentSession> | null } = {
|
||||
hook: null
|
||||
}
|
||||
function Harness(): null {
|
||||
rendered.hook = useMobileStructuredAgentSession({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided.
|
||||
client: client as RpcClient,
|
||||
sessionId: 'session-1',
|
||||
enabled: true,
|
||||
connected: true,
|
||||
hostSupport: null,
|
||||
agent: 'codex',
|
||||
onSendError: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
|
||||
act(() => listener?.(snapshot(ITEMS)))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(rendered.hook?.session.messages.at(-1)).toMatchObject({
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{
|
||||
text: 'Codex stopped while this response was in progress. You can continue in this conversation.',
|
||||
tone: 'error'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
// The turn bars place rows by the same items, so the notice joins the cut turn.
|
||||
expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react'
|
||||
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
|
||||
import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice'
|
||||
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
|
||||
import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnId,
|
||||
@@ -157,12 +159,28 @@ export function useMobileStructuredAgentSession(args: {
|
||||
onSendError
|
||||
})
|
||||
|
||||
// What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no
|
||||
// row gets.
|
||||
const transcriptItems = useMemo(
|
||||
() =>
|
||||
withNativeChatCutTurnNotices(state.items, {
|
||||
agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude']
|
||||
}),
|
||||
[agent, state.items]
|
||||
)
|
||||
const messages = useMemo(
|
||||
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
|
||||
[state.items, state.submissions]
|
||||
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
|
||||
() =>
|
||||
projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, {
|
||||
rejectedInPlace: false
|
||||
}),
|
||||
[transcriptItems, state.submissions]
|
||||
)
|
||||
const turnId = activeStructuredAgentSessionTurnId(state.items)
|
||||
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
|
||||
const turnTiming = useMobileStructuredAgentTurnTiming(
|
||||
{ ...state, items: transcriptItems },
|
||||
turnId
|
||||
)
|
||||
const activityText =
|
||||
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
|
||||
const thinking = isStructuredAgentSessionThinking(state.items)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user