fix(editor): bound reference-link fence lookahead on blank runs

Merge origin/main so CI picks up the TabBar.context-menu fix (#20367).

- replaceReferenceLinks sliced the suffix and ran /^\s*(```|~~~)/ on every
  blank line, so a reference definition after a long blank run made rich
  mode quadratic again (~7s at 100k blank lines). Reuse the same cached
  non-whitespace probe the rich encoder uses, keeping cross-line fence
  semantics.
- Add a deadline-bounded regression test for the blank-run + reference
  definition shape in raw-markdown-html.blank-run.test.ts.
This commit is contained in:
Neil
2026-09-12 18:03:14 -07:00
290 changed files with 26869 additions and 5205 deletions
@@ -10,6 +10,10 @@ inputs:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
cache-dependency-path:
description: Lockfiles for the pnpm download store; include mobile/pnpm-lock.yaml only when the job installs mobile dependencies.
required: false
default: pnpm-lock.yaml
persist-native-cache:
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
required: false
@@ -39,9 +43,7 @@ runs:
with:
install: false
# Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so
# jobs that also install mobile restored a store with none of the React Native tree
# in it and re-downloaded the lot on every run.
# Desktop-only jobs should not miss their download cache when mobile dependencies change.
- name: Setup Node.js
id: default-node
if: inputs.node-version == ''
@@ -49,9 +51,7 @@ runs:
with:
node-version-file: package.json
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- name: Setup requested Node.js
id: requested-node
@@ -60,9 +60,7 @@ runs:
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
cache-dependency-path: ${{ inputs.cache-dependency-path }}
- name: Validate native runtime
shell: bash
+2 -4
View File
@@ -160,16 +160,14 @@ jobs:
- name: Checkout the requested ref
uses: actions/checkout@v6
env:
# Full-history checkout must also preserve case-twin branch and tag names.
GIT_DEFAULT_REF_FORMAT: reftable
with:
# Why an input at all rather than just github.ref: the whole point is to
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
ref: ${{ steps.vetted.outputs.sha }}
fetch-depth: 0
# Version helpers only read HEAD; published versions come from the release API.
fetch-depth: 1
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc repo through a minted App token passed by env. Not
# persisting the checkout credential shrinks the blast radius if a build
@@ -62,7 +62,7 @@ on:
required: false
type: string
canary-run-id:
description: Successful same-commit canary run required for batch-apply
description: Successful same-code canary in this rehome control generation; reusable across batches
required: false
type: string
confirmation:
+2 -1
View File
@@ -90,7 +90,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Version helpers only read HEAD; published versions come from the release API.
fetch-depth: 1
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the daily repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
+27 -1
View File
@@ -170,8 +170,34 @@ jobs:
# artifact instead of starting five concurrent electron-vite builds.
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
# launches but never creates a BrowserWindow.
- name: Balance E2E shard from timing evidence
env:
ORCA_BACKGROUND_LAUNCH: '1'
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_E2E_WEB_CLIENT: '1'
ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay
run: |
mkdir -p ci-shards
pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --list --reporter=json > ci-shards/discovery.json
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
node config/scripts/ci-e2e-shard-plan.mjs ci-shards/discovery.json '${{ matrix.shard }}' ci-shards
pnpm exec playwright test --config tests/playwright.config.ts --project=electron-headless --test-list=ci-shards/selected.txt --list --reporter=json > ci-shards/selected-discovery.json
node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json
- name: Run E2E tests (${{ matrix.shard_name }})
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --shard=${{ matrix.shard }}
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt
- name: Upload E2E shard assignment
if: always()
# Diagnostic upload outages must not change the test verdict.
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: e2e-shard-${{ matrix.shard_name }}-attempt-${{ github.run_attempt }}
path: ci-shards/
retention-days: 14
if-no-files-found: warn
# The frame benchmark needs a mapped window, which the headless shards exclude.
- name: Run worktree first-paint benchmark
+2 -1
View File
@@ -137,7 +137,8 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.head_sha }}
fetch-depth: 0
# Version helpers only read HEAD; published versions come from the release API.
fetch-depth: 1
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the hourly repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
+2
View File
@@ -94,6 +94,8 @@ jobs:
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
- name: Expo prebuild
env:
ORCA_IOS_APS_ENVIRONMENT: production
run: npx expo prebuild --platform ios --no-install
- name: Install CocoaPods
+4
View File
@@ -41,6 +41,10 @@ jobs:
uses: actions/checkout@v6
- uses: ./.github/actions/install-node-dependencies
with:
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
# bundler-cache installs mobile/Gemfile.lock, so this job is also what
# proves the pinned fastlane the release workflow depends on still
+4 -10
View File
@@ -126,6 +126,9 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
cache-dependency-path: |
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Lint
run: pnpm exec oxlint --format github
@@ -775,18 +778,9 @@ jobs:
[[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; }
- name: Verify headless serve signal shutdown
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
- name: Verify extracted launcher serve signal shutdown
run: >-
node config/scripts/run-headless-serve-shutdown-docker.mjs
--appimage dist/orca-linux.AppImage --entrypoint launcher
- name: Verify AppImage CLI registration and serve signal shutdown
run: >-
node config/scripts/run-headless-serve-shutdown-docker.mjs
--appimage dist/orca-linux.AppImage --entrypoint appimage
--signal-target serving-electron --int-delivery pid
--appimage dist/orca-linux.AppImage --all-entrypoints
# A default container reproduces the hostile AppImage launch environment.
- name: Verify Linux CLI launch contract
+15
View File
@@ -45,7 +45,11 @@ jobs:
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build
- name: Test shard
env:
ORCA_BALANCE_UNIT_SHARDS: '1'
ORCA_BACKGROUND_LAUNCH: '1'
run: |
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
@@ -65,3 +69,14 @@ jobs:
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
- name: Upload unit shard assignment
if: always()
# Diagnostic upload outages must not change the test verdict.
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
path: ci-shards/
retention-days: 14
if-no-files-found: warn
+1
View File
@@ -104,6 +104,7 @@ docs/**
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/agent-pty-transcript-capture.md
!docs/reference/agent-session-search-query-tuning.md
!docs/reference/agent-status-store.md
!docs/reference/antigravity-readiness-evidence.md
!docs/reference/git-compatibility.md
@@ -273,6 +273,26 @@ describe('incident monitor evaluator', () => {
)
})
it('allows at most three unexpected director errors per five minutes without relaxing other gates', () => {
for (const errors of [1, 2, 3]) {
const sample = healthySample()
sample.sources['cloud-monitoring']!.signals['director.errors'] = signal(errors)
expect(evaluateIncidentSample(sample, startedAt).status).toBe('green')
}
const excess = healthySample()
excess.sources['cloud-monitoring']!.signals['director.errors'] = signal(4)
expect(evaluateIncidentSample(excess, startedAt).failures).toContainEqual(
expect.objectContaining({ signal: 'director.errors', observed: 4, threshold: 3 })
)
const auth = healthySample()
auth.sources['cloud-monitoring']!.signals['auth.errors'] = signal(1)
expect(evaluateIncidentSample(auth, startedAt).status).toBe('freeze')
const pressure = healthySample()
pressure.sources['cloud-monitoring']!.signals['director.errors'] = signal(1)
pressure.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81)
expect(evaluateIncidentSample(pressure, startedAt).status).toBe('freeze')
})
it('freezes on SQL, director, relay pool, heartbeat, and migration breaches', () => {
const sample = healthySample()
sample.sources['cloud-monitoring']!.signals['cloud_sql.cpu'] = signal(0.81)
+2 -1
View File
@@ -101,7 +101,8 @@ export const INCIDENT_MONITOR_THRESHOLDS = {
directorCpuUtilization: 0.8,
directorMemoryUtilization: 0.8,
directorConcurrency: 64,
directorErrors: 0,
// Sparse connection timeouts must not block a healthy rollout; four/5min still freezes.
directorErrors: 3,
authErrors: 0,
// Why: 800 exceeded the 600 hard cap, so this could never trigger on a capped cell. 500 is
// the ordinary admission limit a cell actually stops at (600 cap - 100 control-rebind reserve).
+20 -3
View File
@@ -38,7 +38,7 @@ export function parseRehomeTrustProbeArguments(argv, environment = process.env)
export async function probeRehomeTrust(config, dependencies = {}) {
const fetchImpl = dependencies.fetch ?? fetch
const response = await fetchAdminOnceMore(
const request = () => fetchAdminOnceMore(
fetchImpl,
`${config.directorOrigin}/v1/admin/regional-rehome-trust-probe`,
{
@@ -55,9 +55,26 @@ export async function probeRehomeTrust(config, dependencies = {}) {
},
{ wait: dependencies.wait }
)
const body = await response.json().catch(() => ({}))
let response = await request()
let body = await response.json().catch(() => ({}))
// The director wraps source HTTP failures in 409; retry only explicit transient statuses.
if (response.status === 409 && /^regional_rehome_trust_probe_source_(500|502|503|504)$/.test(body?.error ?? '')) {
await (dependencies.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(2_000)
response = await request()
body = await response.json().catch(() => ({}))
}
if (!response.ok) {
throw new Error(`application-mediated rehome trust probe returned ${response.status}`)
const safeReasons = new Set([
'invalid_token', 'director_only', 'invalid_request',
'regional_rehome_trust_not_configured',
'regional_rehome_trust_probe_source_unavailable',
'regional_rehome_trust_probe_source_invalid_response',
'regional_rehome_trust_probe_not_proven',
...[400, 401, 403, 404, 409, 429, 500, 502, 503, 504]
.map((status) => `regional_rehome_trust_probe_source_${status}`)
])
const reason = safeReasons.has(body?.error) ? body.error : 'unrecognized_error'
throw new Error(`application-mediated rehome trust probe returned ${response.status}: ${reason}`)
}
if (
body.v !== 1 ||
@@ -131,3 +131,40 @@ test('approves the asia-east2 rehome sources and still rejects unlisted cells',
)
}
})
test('retries one director-wrapped source 503 without relaxing the proof', async () => {
let calls = 0
const result = await probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), {
wait: async () => {},
fetch: async () => ++calls === 1
? Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 })
: Response.json(provenProbe)
})
assert.equal(calls, 2)
assert.equal(result.proven, true)
})
test('reports safe trust reasons, keeps rejection final, and redacts arbitrary error text', async () => {
for (const reason of ['regional_rehome_trust_probe_source_403', 'secret-token-example']) {
let calls = 0
await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), {
wait: async () => { throw new Error('must not retry') },
fetch: async () => { calls++; return Response.json({ error: reason }, { status: 409 }) }
}), error => {
assert.match(error.message, /returned 409/)
assert.ok(!error.message.includes('secret-token-example'))
if (reason.endsWith('_403')) assert.match(error.message, /source_403/)
return true
})
assert.equal(calls, 1)
}
})
test('stops after the second wrapped transient failure', async () => {
let calls = 0
await assert.rejects(probeRehomeTrust(parseRehomeTrustProbeArguments(argv, environment), {
wait: async () => {},
fetch: async () => { calls++; return Response.json({ error: 'regional_rehome_trust_probe_source_503' }, { status: 409 }) }
}), /returned 409.*source_503/)
assert.equal(calls, 2)
})
@@ -87,18 +87,21 @@ export function canaryAuthority(input) {
}
export function verifyCanaryAuthority(authority, expected, repositoryRoot) {
const selectorGeneration = Number(expected.selectorGeneration)
if (
authority?.v !== 1 ||
!/^[0-9a-f]{40}$/.test(authority.commitSha ?? '') ||
authority.runId !== expected.runId ||
authority.targetDigest !== expected.targetDigest ||
authority.rollbackDigest !== expected.rollbackDigest ||
authority.selectorGeneration !== Number(expected.selectorGeneration) ||
!Number.isSafeInteger(authority.selectorGeneration) ||
authority.selectorGeneration < 0 ||
!Number.isSafeInteger(selectorGeneration) ||
selectorGeneration < authority.selectorGeneration ||
authority.rehomeGeneration !== Number(expected.rehomeGeneration) ||
!SAME_CAP_CELLS.includes(authority.cellId)
) throw new Error('canary authority does not match this batch')
// The batch dispatch resolves main after the canary sealed, so bind to the same code, not the
// same SHA; every field above still pins this batch to that exact canary.
// Each cell checks exact live selector state; later batches may reuse this control epoch's canary.
requireSameEvidenceCode({
sealedSha: authority.commitSha,
currentSha: expected.commitSha,
@@ -109,6 +109,41 @@ test('seals and verifies canary authority for later batches', () => {
}), /does not match/)
})
test('reuses a canary across selector advances only within the same control epoch', () => {
const authority = canaryAuthority({
cellIds: 'production-gce-c7', targetDigest, rollbackDigest,
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c7`,
commitSha: 'c'.repeat(40), runId: '42', selectorGeneration: '11', rehomeGeneration: '4'
})
const expected = {
commitSha: 'c'.repeat(40), runId: '42', targetDigest, rollbackDigest,
selectorGeneration: '21', rehomeGeneration: '4'
}
for (const generation of ['13', '14', '21', '29']) {
assert.equal(verifyCanaryAuthority(authority, {
...expected, selectorGeneration: generation
}), authority)
}
for (const generation of ['12', '-1', 'NaN', 'Infinity', '13.5', '9007199254740992']) {
assert.throws(() => verifyCanaryAuthority(authority, {
...expected, selectorGeneration: generation
}), /does not match/)
}
for (const generation of [-1, NaN, Infinity, 13.5, '13', Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(() => verifyCanaryAuthority({
...authority, selectorGeneration: generation
}, expected), /does not match/)
}
for (const mismatch of [
{ rehomeGeneration: '3' }, { rehomeGeneration: '5' },
{ targetDigest: rollbackDigest }, { rollbackDigest: targetDigest }, { runId: '43' }
]) {
assert.throws(() => verifyCanaryAuthority(authority, {
...expected, ...mismatch
}), /does not match/)
}
})
function gitIn(root, ...args) {
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8' }).trim()
}
@@ -158,7 +193,7 @@ test('a batch trusts a canary sealed by identical code at an ancestor commit', a
runId: '42',
targetDigest,
rollbackDigest,
selectorGeneration: '13',
selectorGeneration: '21',
rehomeGeneration: '4'
}, repositoryRoot)
assert.equal(verifyAt(repository.sameCode, repository.root).cellId, 'production-gce-c7')
+6 -1
View File
@@ -112,7 +112,8 @@ durably marked consumed before mutation and cannot authorize another run.
| Director instances | outside 5–6 |
| Director CPU or memory | over 80% |
| Director concurrency | over 64 |
| Unexpected director 5xx or auth 5xx in five minutes | over 0 |
| Unexpected director 5xx in five minutes (excludes 503) | over 3 |
| Auth 5xx in five minutes | over 0 |
| Connections per cell process | over 500 |
| Queued bytes per cell process | over 48 MiB |
| Blocked or expired/unregistered migration | over 0 |
@@ -276,3 +277,7 @@ without its segment is a compile error in relay-contract, not a silent gap.
load the director's three-connection database pool.
- Added private atomic state, idempotent JSONL checkpoints, and secret-safe Markdown evidence.
- Added the manual production workflow. It has not been dispatched.
### Director error allowance (2026-09-12)
The serving-cell rollout observed three unexpected director 500 responses among approximately 33,600 responses in an hour, all two-second PostgreSQL connection timeouts. CPU remained near 30–37% and the zero-error bar repeatedly prevented any cell mutation. The five-minute allowance is now three non-503 director 5xx; four freezes. Auth errors, data freshness, active probes, SQL/pool pressure and other limits are unchanged. This is a bounded operational allowance, not a calibrated SLO or proof that intermittent failures are resolved; persistent low-frequency errors below this limit still require diagnosis.
@@ -0,0 +1,30 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const read = (path) => parse(readFileSync(path, 'utf8'))
const workflow = (name) => read(`.github/workflows/${name}.yml`)
const action = read('.github/actions/install-node-dependencies/action.yml')
describe('CI dependency download caches', () => {
it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => {
expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml')
for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) {
expect(step.with.cache).toBe('pnpm')
expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}')
}
const install = action.runs.steps.find((step) => step.name === 'Install dependencies')
expect(install.if).toBeUndefined()
expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts')
expect(install.run).toContain(
'diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml'
)
const mobile = workflow('mobile').jobs.verify.steps.find((step) =>
step.uses?.includes('install-node-dependencies')
)
expect(mobile.with['cache-dependency-path'].trim().split('\n')).toEqual([
'pnpm-lock.yaml',
'mobile/pnpm-lock.yaml'
])
})
})
+97
View File
@@ -0,0 +1,97 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
balanceFiles,
compareIds,
readTimingBaseline,
writeAssignment
} from './ci-shard-assignment.mjs'
export function discoverE2eFiles(report) {
if (report.errors?.length) {
throw new Error('Playwright discovery reported errors')
}
const files = new Map()
function visit(suite) {
for (const spec of suite.specs ?? []) {
const file = spec.file.replaceAll('\\', '/')
if (file.startsWith('/') || file.split('/').includes('..') || /[\n\r>›]/.test(file)) {
throw new Error(`Unsafe test-list path: ${file}`)
}
for (const test of spec.tests) {
const id = `${test.projectName}:${spec.id}`
const ids = files.get(file) ?? []
ids.push(id)
files.set(file, ids)
}
}
for (const child of suite.suites ?? []) {
visit(child)
}
}
for (const suite of report.suites) {
visit(suite)
}
if (!files.size) {
throw new Error('Playwright discovered no tests')
}
const ids = [...files.values()].flat()
if (new Set(ids).size !== ids.length) {
throw new Error('Duplicate discovered test identity')
}
return Object.fromEntries([...files.entries()].sort(([a], [b]) => compareIds(a, b)))
}
export function planE2e(report, count, baseline) {
const testsByFile = discoverE2eFiles(report)
const timings = Object.fromEntries(
Object.entries(baseline.timings).map(([file, duration]) => [
file.replace(/^tests\/e2e\//, ''),
duration
])
)
const assignment = balanceFiles(Object.keys(testsByFile), count, timings)
return { ...assignment, testsByFile, baselineSha256: baseline.baselineSha256 }
}
export function verifyE2eSelection(assignment, report) {
const actual = Object.values(discoverE2eFiles(report)).flat().sort(compareIds)
const expected = assignment.shards[assignment.selectedShard - 1].files
.flatMap((file) => assignment.testsByFile[file])
.sort(compareIds)
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error('Native Playwright selection differs from shard assignment')
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (process.argv[2] === '--verify') {
verifyE2eSelection(
JSON.parse(readFileSync(process.argv[3], 'utf8')),
JSON.parse(readFileSync(process.argv[4], 'utf8'))
)
} else {
const [input, shard, directory] = process.argv.slice(2)
const match = shard?.match(/^(\d+)\/(\d+)$/)
if (!input || !directory || !match) {
throw new Error('Usage: ci-e2e-shard-plan.mjs DISCOVERY INDEX/COUNT OUTPUT_DIRECTORY')
}
const index = Number(match[1])
const count = Number(match[2])
if (index < 1 || index > count) {
throw new Error('Invalid shard index')
}
const assignment = planE2e(
JSON.parse(readFileSync(input, 'utf8')),
count,
readTimingBaseline('e2e')
)
const selected = assignment.shards[index - 1].files
if (!selected.length) {
throw new Error('Empty E2E shard')
}
writeAssignment(join(directory, 'assignment.json'), { ...assignment, selectedShard: index })
writeFileSync(join(directory, 'selected.txt'), `${selected.join('\n')}\n`)
}
}
@@ -0,0 +1,73 @@
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { expect, it } from 'vitest'
import { runProcess } from '../../src/shared/child-process/run-process'
import { planE2e, verifyE2eSelection } from './ci-e2e-shard-plan.mjs'
const require = createRequire(import.meta.url)
it('native Playwright test-list preserves full discovery, serial suites, skips and headful filtering', async () => {
const directory = realpathSync(mkdtempSync(join(tmpdir(), 'orca-playwright-shards-')))
const testPackage = JSON.stringify(require.resolve('@stablyai/playwright-test'))
const config = join(directory, 'playwright.config.cjs')
writeFileSync(
config,
`module.exports = { testDir: '.', fullyParallel: true, projects: [{ name: 'electron-headless', grepInvert: /@headful/ }] }`
)
for (let index = 0; index < 17; index++) {
writeFileSync(
join(directory, `file-${index}.spec.cjs`),
`
const { test } = require(${testPackage});
test('normal', () => {});
test.skip('skipped', () => {});
test('visible @headful', () => {});
test.describe.serial('serial', () => {
test('first', () => {});
test('second', () => {});
});
`
)
}
async function discover(extra = []) {
const result = await runProcess({
program: process.execPath,
cwd: directory,
args: [
join(dirname(require.resolve('playwright/package.json')), 'cli.js'),
'test',
'--config',
config,
'--list',
'--reporter=json',
...extra
],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs: 20000
})
expect(result.code, result.stderr).toBe(0)
return JSON.parse(result.stdout)
}
try {
const full = await discover()
const assignment = planE2e(full, 14, { timings: {} })
const ids = []
for (let index = 0; index < 14; index++) {
const path = join(directory, 'selected.txt')
writeFileSync(path, `${assignment.shards[index].files.join('\n')}\n`)
const selected = await discover(['--test-list', path])
verifyE2eSelection({ ...assignment, selectedShard: index + 1 }, selected)
for (const suite of selected.suites) {
expect(suite.specs.some((spec) => spec.title.includes('@headful'))).toBe(false)
}
ids.push(...assignment.shards[index].files.flatMap((file) => assignment.testsByFile[file]))
}
expect(ids).toHaveLength(17 * 4)
expect(new Set(ids).size).toBe(ids.length)
expect(() => verifyE2eSelection({ ...assignment, selectedShard: 1 }, full)).toThrow('differs')
} finally {
rmSync(directory, { recursive: true, force: true })
}
}, 60000)
+66
View File
@@ -0,0 +1,66 @@
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
export const compareIds = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
export function balanceFiles(files, count, timings, overheadMs = 0) {
if (!Number.isInteger(count) || count < 1) {
throw new Error('Invalid shard count')
}
if (new Set(files).size !== files.length) {
throw new Error('Duplicate discovered file')
}
const known = Object.values(timings).filter((value) => Number.isFinite(value) && value > 0)
known.sort((a, b) => a - b)
const fallbackMs = known[Math.floor(known.length / 2)] ?? 1000
const weighted = files.map((file) => ({
file,
durationMs:
(Number.isFinite(timings[file]) && timings[file] > 0 ? timings[file] : fallbackMs) +
overheadMs
}))
weighted.sort((a, b) => b.durationMs - a.durationMs || compareIds(a.file, b.file))
const shards = Array.from({ length: count }, () => ({ files: [], durationMs: 0 }))
for (const entry of weighted) {
const target = shards.reduce((best, shard) =>
shard.durationMs < best.durationMs ||
(shard.durationMs === best.durationMs && shard.files.length < best.files.length)
? shard
: best
)
target.files.push(entry.file)
target.durationMs += entry.durationMs
}
for (const shard of shards) {
shard.files.sort(compareIds)
}
const assigned = shards.flatMap((shard) => shard.files).sort(compareIds)
if (JSON.stringify(assigned) !== JSON.stringify([...files].sort(compareIds))) {
throw new Error('Shard coverage differs from discovery')
}
return { algorithm: 'file-lpt-v1', fallbackMs, overheadMs, shards }
}
export function readTimingBaseline(suite) {
const bytes = readFileSync(new URL('./ci-shard-timings.json', import.meta.url), 'utf8')
const baseline = JSON.parse(bytes)
return { ...baseline[suite], baselineSha256: createHash('sha256').update(bytes).digest('hex') }
}
export function writeAssignment(path, assignment) {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(
path,
`${JSON.stringify(
{
sourceSha: process.env.ORCA_SHARD_SOURCE_SHA ?? process.env.GITHUB_SHA ?? null,
runId: process.env.GITHUB_RUN_ID ?? null,
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null,
...assignment
},
null,
2
)}\n`
)
}
+124
View File
@@ -0,0 +1,124 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BaseSequencer } from 'vitest/node'
import { balanceFiles } from './ci-shard-assignment.mjs'
import { discoverE2eFiles, planE2e } from './ci-e2e-shard-plan.mjs'
import { parseTimingLog } from './ci-shard-timing-import.mjs'
import TimingSequencer from './ci-unit-sequencer.mjs'
const directories = []
afterEach(() => {
vi.unstubAllEnvs()
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('timing-weighted shard selection', () => {
it('distributes long files, includes unknowns exactly once, and ignores discovery order', () => {
const files = ['long', 'medium', 'short', 'unknown', 'new', 'zero', 'invalid']
const timings = { long: 100, medium: 80, short: 20, zero: 0, invalid: -1, deleted: 20 }
const plan = balanceFiles(files, 3, timings, 10)
expect(plan).toEqual(balanceFiles(files.toReversed(), 3, timings, 10))
expect(plan.fallbackMs).toBe(80)
expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([...files].sort())
expect(Math.max(...plan.shards.map((shard) => shard.durationMs))).toBeLessThan(250)
})
it('has a deterministic cold fallback and permits fewer files than shards', () => {
expect(balanceFiles(['b', 'a'], 3, {}).shards).toEqual([
{ files: ['a'], durationMs: 1000 },
{ files: ['b'], durationMs: 1000 },
{ files: [], durationMs: 0 }
])
expect(() => balanceFiles(['a', 'a'], 8, {})).toThrow('Duplicate')
expect(() => balanceFiles(['a'], 0, {})).toThrow('count')
})
it('uses the post-filter Vitest discovery unchanged across eight shards and retains default sort', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-unit-shards-'))
directories.push(directory)
vi.stubEnv('ORCA_SHARD_MANIFEST', join(directory, 'assignment.json'))
const specs = Array.from({ length: 37 }, (_, i) => ({
moduleId: resolve(`src/fixture-${i}.test.ts`)
}))
const selected = []
for (let index = 1; index <= 8; index++) {
const sequencer = new TimingSequencer({
config: { root: process.cwd(), shard: { index, count: 8 } }
})
expect(sequencer.sort).toBe(BaseSequencer.prototype.sort)
selected.push(...(await sequencer.shard(specs)))
const manifest = JSON.parse(readFileSync(join(directory, 'assignment.json'), 'utf8'))
expect(manifest.selectedShard).toBe(index)
expect(manifest.baselineSha256).toMatch(/^[a-f0-9]{64}$/)
}
expect(new Set(selected).size).toBe(specs.length)
expect(selected).toHaveLength(specs.length)
expect(new Set(selected)).toEqual(new Set(specs))
})
it('wires a constructor into the opt-in Vitest config', async () => {
vi.stubEnv('ORCA_BALANCE_UNIT_SHARDS', '1')
const { default: config } = await import('../vitest.config')
expect(config.test.sequence.sequencer).toBe(TimingSequencer)
})
it('keeps nested/serial E2E files atomic and fails closed on discovery errors', () => {
const spec = (id, file) => ({ id, file, tests: [{ projectName: 'electron-headless' }] })
const report = {
suites: [
{
specs: [spec('a', 'one.spec.ts')],
suites: [{ specs: [spec('b', 'one.spec.ts'), spec('c', 'two.spec.ts')] }]
}
]
}
const plan = planE2e(report, 14, { timings: { 'tests/e2e/one.spec.ts': 4000 } })
expect(plan.shards.flatMap((shard) => shard.files).sort()).toEqual([
'one.spec.ts',
'two.spec.ts'
])
expect(plan.testsByFile['one.spec.ts']).toHaveLength(2)
expect(() => discoverE2eFiles({ ...report, errors: [{}] })).toThrow('errors')
expect(() => discoverE2eFiles({ suites: [] })).toThrow('no tests')
expect(() =>
discoverE2eFiles({ suites: [{ specs: [spec('a', '../escape.spec.ts')] }] })
).toThrow('Unsafe')
expect(() =>
discoverE2eFiles({
suites: [{ specs: [spec('a', 'one.spec.ts'), spec('a', 'one.spec.ts')] }]
})
).toThrow('Duplicate')
})
it('imports ANSI unit timings and E2E failures without counting headful reruns', () => {
const parsed = parseTimingLog(
[
'\u001b[32m✓\u001b[39m src/a.test.ts (2 tests) 35ms',
'Duration 1s (transform 0.1s, setup 0.2s, import 0.3s, tests 0.04s, environment 0.4s)',
'✓ 1 [electron-headless] › tests/e2e/a.spec.ts:1:1 › works (2s)',
'✘ 2 [electron-headless] › tests/e2e/a.spec.ts:2:1 › fails (1.2m)',
'✓ 3 [electron-headful] › tests/e2e/a.spec.ts:3:1 › benchmark (9s)'
].join('\n')
)
expect(parsed).toEqual({
unit: { 'src/a.test.ts': 35 },
e2e: { 'tests/e2e/a.spec.ts': 74000 },
overheadMs: 1000
})
})
it('reads mixed units from captured Vitest output', () => {
const parsed = parseTimingLog(
'Duration 5.14s (transform 952ms, setup 449ms, import 1.18s, tests 9.41s, environment 1ms)'
)
expect(parsed.overheadMs).toBe(2582)
})
it('rejects incomplete unit evidence instead of silently dropping overhead', () => {
expect(() => parseTimingLog('✓ src/a.test.ts (2 tests) 35ms')).toThrow('Duration summary')
})
})
+86
View File
@@ -0,0 +1,86 @@
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { stripVTControlCharacters } from 'node:util'
import { pathToFileURL } from 'node:url'
export function parseTimingLog(text) {
const clean = stripVTControlCharacters(text)
const unit = {}
const e2e = {}
for (const match of clean.matchAll(
/[✓×❯] ([\w./-]+\.test\.(?:ts|tsx|mjs)) \([^\n]*?\)\s+([\d.]+)ms/g
)) {
unit[match[1]] = Number(match[2])
}
for (const match of clean.matchAll(
/[✓✘]\s+\d+ \[electron-headless\] › (tests\/e2e\/[^:]+):\d+:\d+ › .*? \(([\d.]+)(ms|s|m)\)/g
)) {
e2e[match[1]] = (e2e[match[1]] ?? 0) + Number(match[2]) * { ms: 1, s: 1000, m: 60000 }[match[3]]
}
const summary = clean.match(
/Duration\s+[\d.]+(?:ms|s) \(transform ([\d.]+(?:ms|s)), setup ([\d.]+(?:ms|s)), import ([\d.]+(?:ms|s)), tests [\d.]+(?:ms|s), environment ([\d.]+(?:ms|s))\)/
)
if (Object.keys(unit).length && !summary) {
throw new Error('Unit timing log has no supported Duration summary')
}
return {
unit,
e2e,
overheadMs: summary
? summary
.slice(1)
.reduce(
(sum, value) => sum + Number.parseFloat(value) * (value.endsWith('ms') ? 1 : 1000),
0
)
: 0
}
}
export function importTimingLogs(directory, unitRun, e2eRun) {
const baseline = {
unit: { runId: unitRun, jobIds: [], overheadMs: 0, timings: {} },
e2e: { runId: e2eRun, jobIds: [], overheadMs: 0, timings: {} }
}
for (const file of readdirSync(directory)
.filter((file) => /^log-\d+\.txt$/.test(file))
.sort()) {
const parsed = parseTimingLog(readFileSync(join(directory, file), 'utf8'))
for (const suite of ['unit', 'e2e']) {
if (!Object.keys(parsed[suite]).length) {
continue
}
baseline[suite].jobIds.push(file.match(/\d+/)[0])
for (const [name, duration] of Object.entries(parsed[suite])) {
if (suite === 'unit' && name in baseline.unit.timings) {
throw new Error(`Duplicate unit timing: ${name}`)
}
baseline[suite].timings[name] = (baseline[suite].timings[name] ?? 0) + duration
}
}
baseline.unit.overheadMs += parsed.overheadMs
}
for (const suite of ['unit', 'e2e']) {
if (!baseline[suite].jobIds.length) {
throw new Error(`No ${suite} timing evidence`)
}
baseline[suite].timings = Object.fromEntries(
Object.entries(baseline[suite].timings).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
)
}
baseline.unit.overheadMs = Math.ceil(
baseline.unit.overheadMs / Object.keys(baseline.unit.timings).length
)
return baseline
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [directory, unitRun, e2eRun, output] = process.argv.slice(2)
if (!directory || !unitRun || !e2eRun || !output) {
throw new Error('Usage: ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN E2E_RUN OUTPUT')
}
writeFileSync(
output,
`${JSON.stringify(importTimingLogs(directory, unitRun, e2eRun), null, 2)}\n`
)
}
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
# Timing-based CI shards
The eight unit shards and fourteen general E2E shards use longest-processing-time
assignment of whole files to the currently lightest shard. Ties use file path and
then shard index, independent of filesystem enumeration and locale. Unknown,
zero, or invalid durations use the baseline's positive median (1 second when no
positive evidence exists). Deleted files never enter discovery. Unit weights add
526ms per file for measured transform/setup/import/environment overhead.
Unit assignment runs inside Vitest's sequencer after discovery and CLI exclusions;
Vitest's default sort, workers and isolation remain intact. It is enabled only by
`ORCA_BALANCE_UNIT_SHARDS=1`; ordinary local runs and explicit file filters retain
their existing behavior. E2E uses Playwright's native `--list` and `--test-list`,
retaining project filters, skipped tests and complete serial groups within files.
The workflow verifies selected test IDs against full discovery before executing.
Dedicated SSH, native IME, WSL and first-paint lanes are unchanged.
## Evidence and limits
`ci-shard-timings.json` records run IDs and every contributing job ID:
- Unit run **34675583768**, Node 24, all eight successful shards: 8,484 completed
file durations. The summed transform/setup/import/environment durations divided
by measured file count give a rounded-up **526ms** per-file overhead allowance.
The original shard weighted loads were **764–849 worker-seconds**, versus
**792–792** after balancing the identical measured files. File counts change
from **1,056–1,065** to **1,060–1,061**.
- General E2E run **34652504501**, all fourteen shard logs: 291 files with completed
headless test durations, including failures. Headful benchmark reruns are not
counted. Original completed test loads were **540–1,727 seconds**, versus
**1,083–1,093** after whole-file balancing on the same measured files. The longest
measured file is **528 seconds**, below the balanced shard load.
- Current checkout discovery at validation contained **8,553 unit files** after the
workflow's exact exclusions and **733 headless E2E tests in 340 files**. New and
unmeasured files remain selected. Projected current loads were about **797
worker-seconds** per unit shard (1,068–1,070 files) and **1,190–1,200 seconds** per
E2E shard (22–25 files).
These are scheduling projections, not measured post-change wall-clock gains.
Unit durations overlap across workers and the overhead allowance is an average,
not a per-file import profile. E2E evidence includes failed shards and can omit
unfinished tests; unknowns receive a deterministic estimate. Historical timings
age as specs change. Full CI runs on the existing runner classes are required to
measure elapsed-time and occupancy improvements, including discovery overhead.
No retries, assertions, coverage exclusions, runner classes or shard counts changed.
## Reproduction and refresh
Every shard uploads an artifact named with its shard, Node version where relevant,
and run attempt. `assignment.json` contains the checked-out source SHA, run ID,
attempt, baseline SHA-256, algorithm, fallback, all shard files and chosen shard.
E2E also retains both discovery reports and `selected.txt`. Artifacts live for
14 days. A rerun of the same source uses the same checked-in baseline rather than
mutable timing caches; a GitHub job rerun therefore keeps its assignment.
For E2E reproduction, check out the recorded source and pass the saved list to the
existing command: `pnpm run test:e2e --test-list=/path/to/selected.txt` with the same
CI environment/build inputs. For unit reproduction, use the unchanged workflow
command and exclusions with `ORCA_BALANCE_UNIT_SHARDS=1` and the recorded
`--shard=INDEX/8`. Direct test-file reruns remain supported.
To refresh the baseline, download `log-JOB_ID.txt` files into one directory from
exactly one eight-shard unit run and one fourteen-shard general E2E run. Use the
job IDs from the Actions jobs API and fetch each with
`gh api repos/stablyai/orca/actions/jobs/JOB_ID/logs`. Do not include dedicated
lanes or multiple attempts. Then run:
```sh
node config/scripts/ci-shard-timing-import.mjs LOG_DIRECTORY UNIT_RUN_ID E2E_RUN_ID config/scripts/ci-shard-timings.json
```
The initial source logs are in `/tmp/orca-ci-shard-logs`; two were reused from
`/tmp/orca-ci-audit`, and the remaining twenty were fetched read-only. Reimporting
those logs reproduced the checked-in JSON byte-for-byte. Review file-count and
load projections before adopting a new baseline; no network access is needed to
plan or run shards.
## Validation
- 74 focused tests passed across the two new test files and existing PR
parallelism, E2E gate and release E2E dispatch contracts.
- The pinned Playwright CLI selected the real 733-test suite across all fourteen
saved test lists with exact-once identity coverage and no missing tests.
- A temporary native Playwright fixture checks fourteen shards, serial groups,
skipped cases, headful filtering and mismatch rejection without launching UI.
- Real Vitest discovery with all workflow exclusions yielded 8,553 files; the
sequencer's eight assignments covered each exactly once. An actual opt-in
Vitest shard executed successfully and persisted its manifest.
- Focused TypeScript checking of `config/vitest.config.ts` and imported modules,
oxlint, formatting and baseline reimport checks passed.
All local tests used `ORCA_BACKGROUND_LAUNCH=1` in background tool sessions. No app
windows or full E2E test bodies were launched.
+19
View File
@@ -0,0 +1,19 @@
import { relative } from 'node:path'
import { BaseSequencer } from 'vitest/node'
import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs'
export default class TimingSequencer extends BaseSequencer {
async shard(specs) {
const { index, count } = this.ctx.config.shard
const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/')
const baseline = readTimingBaseline('unit')
const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs)
writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', {
...assignment,
baselineSha256: baseline.baselineSha256,
selectedShard: index
})
const selected = new Set(assignment.shards[index - 1].files)
return specs.filter((spec) => selected.has(key(spec)))
}
}
@@ -116,9 +116,9 @@ describe('Electron Vite output contract', () => {
expect(external('node:fs', undefined, false)).toBe(true)
expect(external('@xterm/headless', undefined, false)).toBe(false)
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
expect(external('psl', undefined, false)).toBe(false)
expect(external('tldts', undefined, false)).toBe(false)
expect(external('zod', undefined, false)).toBe(false)
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('tldts')
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
})
@@ -197,9 +197,10 @@ ${uncataloged.map((name) => ` '${name}'`).join(',\n')}
export type RpcMethodName = keyof typeof RPC_PARAMS_BY_METHOD
// Why: z.output is the post-parse shape the handler receives. z.input is not a
// send-side type here — requiredString is z.unknown().transform(...), so its input
// admits any value and loses optional/default semantics.
// Why: z.output is the post-parse shape the handler receives, which is not what a
// client may send — a .default() field reads as required. z.input is not the answer
// either: requiredString is z.unknown().transform(...), so its input admits any value.
// Senders use RpcSendParams from ./rpc-send-params, which is derived from this map.
export type RpcParams<Method extends RpcMethodName> =
(typeof RPC_PARAMS_BY_METHOD)[Method] extends z.ZodType
? z.output<(typeof RPC_PARAMS_BY_METHOD)[Method]>
@@ -0,0 +1,140 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { spawnSync } = vi.hoisted(() => ({ spawnSync: vi.fn() }))
vi.mock('node:child_process', () => ({ spawnSync }))
let directory
let artifact
let originalArgv
let originalExitCode
const commands = () => spawnSync.mock.calls.map(([, args]) => args)
const signalRuns = () => commands().filter((args) => ['INT', 'TERM'].includes(args.at(-1)))
const succeeded = { status: 0, stdout: '', stderr: '' }
async function run(...options) {
process.argv = ['node', 'runner', '--appimage', artifact, ...options]
await import('./run-headless-serve-shutdown-docker.mjs')
}
beforeEach(() => {
vi.resetModules()
spawnSync.mockReset().mockReturnValue(succeeded)
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
directory = mkdtempSync(join(tmpdir(), 'orca-shutdown-matrix-'))
artifact = join(directory, 'original.AppImage')
writeFileSync(artifact, 'original package bytes')
originalArgv = process.argv
originalExitCode = process.exitCode
})
afterEach(() => {
process.argv = originalArgv
process.exitCode = originalExitCode
rmSync(directory, { recursive: true, force: true })
vi.restoreAllMocks()
})
describe('packaged shutdown matrix', () => {
it('shares extraction but isolates every entrypoint and signal', async () => {
await run('--all-entrypoints')
expect(commands().filter((args) => args[0] === 'build')).toHaveLength(1)
const startup = commands().filter((args) =>
args.includes('/usr/local/bin/run-appimage-desktop-startup-case')
)
const extraction = commands().filter((args) =>
args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract'))
)
expect(startup).toHaveLength(1)
expect(extraction).toHaveLength(1)
expect(commands().indexOf(startup[0])).toBeLessThan(commands().indexOf(extraction[0]))
expect(signalRuns()).toHaveLength(6)
const names = new Set()
for (const [index, args] of signalRuns().entries()) {
const entrypoint = ['app', 'launcher', 'appimage'][Math.floor(index / 2)]
expect(args).toContain(`ORCA_TEST_ENTRYPOINT=${entrypoint}`)
expect(args).toContain(
`ORCA_SIGNAL_TARGET=${entrypoint === 'appimage' ? 'serving-electron' : 'app'}`
)
expect(args).toContain(
`ORCA_INT_DELIVERY=${entrypoint === 'appimage' ? 'pid' : 'foreground-process-group'}`
)
expect(args.at(-1)).toBe(index % 2 === 0 ? 'INT' : 'TERM')
expect(args).toContain(`${artifact}:/input/orca.AppImage:ro`)
expect(args.some((arg) => arg.endsWith(':/artifacts:ro'))).toBe(true)
expect(args).toContain('--rm')
names.add(args[args.indexOf('--name') + 1])
}
expect(names.size).toBe(6)
const evidence = console.log.mock.calls
.map(([line]) => line)
.filter((line) => line.startsWith('{'))
.map(JSON.parse)
expect(evidence).toHaveLength(3)
expect(
evidence.every(
(entry) =>
entry.sha256 === createHash('sha256').update('original package bytes').digest('hex')
)
).toBe(true)
expect(
commands()
.slice(-2)
.map((args) => args.slice(0, 2))
).toEqual([
['volume', 'rm'],
['image', 'rm']
])
})
it('attributes failures and still attempts later cases before cleanup', async () => {
spawnSync.mockImplementation((_, args) =>
args.at(-1) === 'INT' ? { ...succeeded, status: 7 } : succeeded
)
await expect(run('--all-entrypoints')).rejects.toThrow(
'app:INT:7, launcher:INT:7, appimage:INT:7'
)
expect(signalRuns()).toHaveLength(6)
expect(commands().at(-2).slice(0, 2)).toEqual(['volume', 'rm'])
})
it('cleans setup resources without running cases after failed extraction', async () => {
spawnSync.mockImplementation((_, args) =>
args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract'))
? { ...succeeded, status: 9 }
: succeeded
)
await expect(run('--all-entrypoints')).rejects.toThrow('docker run failed')
expect(signalRuns()).toHaveLength(0)
expect(
commands()
.slice(-2)
.map((args) => args.slice(0, 2))
).toEqual([
['volume', 'rm'],
['image', 'rm']
])
})
it('preserves individual launcher overlay invocations', async () => {
await run('--entrypoint', 'launcher', '--launcher-exec-overlay')
expect(signalRuns()).toHaveLength(2)
expect(signalRuns().every((args) => args.includes('ORCA_TEST_ENTRYPOINT=launcher'))).toBe(true)
expect(
commands().some((args) =>
args.some((arg) => arg.includes("sed -i 's/^ELECTRON_RUN_AS_NODE=1"))
)
).toBe(true)
})
it('rejects ambiguous matrix overrides before invoking Docker', async () => {
await expect(run('--all-entrypoints', '--entrypoint', 'launcher')).rejects.toThrow(
'cannot be combined'
)
expect(spawnSync).not.toHaveBeenCalled()
})
})
@@ -56,12 +56,6 @@ describe('headless serve shutdown PR gate', () => {
const packageStep = steps.find((step) => step.name === 'Package unpacked app')
const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads')
const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown')
const launcherShutdownStep = steps.find(
(step) => step.name === 'Verify extracted launcher serve signal shutdown'
)
const appImageShutdownStep = steps.find(
(step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown'
)
expect(workflow.jobs.package['timeout-minutes']).toBe(90)
expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never')
@@ -69,19 +63,13 @@ describe('headless serve shutdown PR gate', () => {
expect(markerStep.run).toContain('rpm2cpio')
expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep))
expect(shutdownStep.run).toBe(
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage'
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --all-entrypoints'
)
expect(launcherShutdownStep.run).toContain(
'node config/scripts/run-headless-serve-shutdown-docker.mjs'
)
expect(launcherShutdownStep.run).toContain('--entrypoint launcher')
expect(appImageShutdownStep.run).toContain('--entrypoint appimage')
expect(appImageShutdownStep.run).toContain('--signal-target serving-electron')
expect(appImageShutdownStep.run).toContain('--int-delivery pid')
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep))
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep))
expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep))
expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep))
expect(
steps.filter((step) => step.run?.includes('run-headless-serve-shutdown-docker.mjs'))
).toHaveLength(1)
})
it('keeps readiness polling finite and leak-free', () => {
@@ -0,0 +1,136 @@
#!/usr/bin/env node
import assert from 'node:assert/strict'
import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, basename } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
const root = fileURLToPath(new URL('../..', import.meta.url))
const fixture = await mkdtemp(join(tmpdir(), 'orca-hermes-correlation-'))
const baselineDirectory = process.argv[2]
const key = (seconds) =>
new Date(Date.UTC(2026, 0, 1) + seconds * 1000)
.toISOString()
.replace(/[-:]/g, '')
.replace('T', '_')
.slice(0, 15)
try {
for (const host of ['native', 'relay']) {
const entry =
host === 'native'
? 'src/main/automations/hermes-cron-run-content.ts'
: 'src/relay/hermes-run-correlation.ts'
const readers = []
for (const mode of baselineDirectory ? ['baseline', 'current'] : ['current']) {
const bundle = join(fixture, `${host}-${mode}.cjs`)
await build({
entryPoints: [join(root, entry)],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: bundle,
plugins:
mode === 'baseline'
? [
{
name: 'baseline-correlation',
setup(builder) {
builder.onLoad(
{ filter: /hermes-(cron-run-content|run-correlation)\.ts$/ },
async (args) => ({
contents: await readFile(
join(baselineDirectory, basename(args.path)),
'utf8'
),
loader: 'ts'
})
)
}
}
]
: []
})
readers.push({ mode, ...createRequire(import.meta.url)(bundle) })
}
if (readers.length === 2) {
let seed = 92817
const random = (max) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
return seed % max
}
const pool = [
null,
'',
'invalid',
'20260101_000000',
'20260101_000200',
'20260102_000000',
'20260103_000000',
'20260101_240000'
]
for (let trial = 0; trial < 200; trial++) {
const sessions = Array.from({ length: random(70) }, (_, i) => ({
kind: 'session',
id: `session-${i}`,
job_id: 'job',
run_at: null,
run_key: pool[random(pool.length)],
output_content: `session ${i}`
}))
const outputs = Array.from({ length: random(70) }, (_, i) => ({
kind: 'output',
id: `output-${i}`,
job_id: 'job',
run_at: null,
run_key: pool[random(pool.length)],
output_path: 'unused',
output_content: `output ${i}`
}))
for (const method of [
'mergeHermesOutputAndSessionRunRefs',
'mergeHermesOutputAndSessionRuns'
]) {
assert.deepEqual(
readers[1][method](outputs, sessions),
readers[0][method](outputs, sessions)
)
}
}
console.log(JSON.stringify({ host, randomizedParityCases: 400 }))
}
for (const runs of [100, 1000, 5000]) {
const sessions = Array.from({ length: runs }, (_, i) => ({
kind: 'session',
id: `session-${i}`,
job_id: 'job',
run_at: null,
run_key: key(i * 3600)
})).toReversed()
const outputs = Array.from({ length: runs }, (_, i) => ({
kind: 'output',
id: `output-${i}`,
job_id: 'job',
run_at: null,
run_key: key(i * 3600 + 120),
output_path: 'unused'
}))
let expected
for (const reader of [...readers, ...readers.toReversed()]) {
const start = performance.now()
const result = reader.mergeHermesOutputAndSessionRunRefs(outputs, sessions)
const durationMs = performance.now() - start
assert.equal(result.length, runs)
result.forEach((row, i) => assert.equal(row.session.id, `session-${i}`))
if (expected) {
assert.deepEqual(result, expected)
}
expected = result
console.log(JSON.stringify({ host, mode: reader.mode, runs, durationMs }))
}
}
}
} finally {
await rm(fixture, { recursive: true, force: true })
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { build } from 'esbuild'
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
const baseline = process.argv[2]
if (!baseline) {
throw new Error(
'Usage: node config/scripts/mobile-history-scope-paths-benchmark.mjs <baseline-ref>'
)
}
const file = 'mobile/src/agent-history/agent-history-scope-paths.ts'
async function load(contents) {
const result = await build({
stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) },
bundle: true,
platform: 'node',
format: 'esm',
write: false,
logLevel: 'silent',
tsconfigRaw: {}
})
return (
await import(
`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`
)
).deriveMobileAiVaultScopePaths
}
const arms = {
before: await load(execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8' })),
after: await load(readFileSync(file, 'utf8'))
}
const iterations = 200
const results = []
for (const [count, unique] of [
[1, 1],
[16, 16],
[64, 64],
[1000, 32]
]) {
for (const root of ['/home/ada/café/project', 'C:\\Users\\ada\\café\\project']) {
const rows = Array.from({ length: count }, (_, index) => ({
worktreeId: `w-${index}`,
repoId: 'repo',
path: `${root}/workspace-${index % unique}`
}))
const expected = arms.before('project', rows[0], rows)
assert.deepEqual(arms.after('project', rows[0], rows), expected)
const samples = { before: [], after: [] }
function run(arm) {
let length = 0
const start = performance.now()
for (let i = 0; i < iterations; i++) {
length += arms[arm]('project', rows[0], rows).length
}
const elapsed = performance.now() - start
assert.equal(length, iterations * expected.length)
return elapsed / iterations
}
for (const arm of ['before', 'after']) {
run(arm)
}
for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) {
for (const arm of pair) {
samples[arm].push(run(arm))
}
}
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b)
return (sorted[4] + sorted[5]) / 2
}
results.push({
count,
unique,
root,
beforeMs: median(samples.before),
afterMs: median(samples.after),
samples
})
}
}
console.log(
JSON.stringify(
{ baseline, node: process.version, platform: process.platform, iterations, results },
null,
2
)
)
@@ -11,6 +11,22 @@ const signalTarget = valueAfter('--signal-target') ?? 'app'
const entrypoint = valueAfter('--entrypoint') ?? 'app'
const intDelivery = valueAfter('--int-delivery') ?? 'foreground-process-group'
const launcherExecOverlay = args.includes('--launcher-exec-overlay')
const allEntrypoints = args.includes('--all-entrypoints')
if (
allEntrypoints &&
['--entrypoint', '--signal-target', '--int-delivery', '--launcher-exec-overlay'].some((flag) =>
args.includes(flag)
)
) {
fail('--all-entrypoints cannot be combined with individual case options')
}
const cases = allEntrypoints
? [
{ entrypoint: 'app', signalTarget: 'app', intDelivery: 'foreground-process-group' },
{ entrypoint: 'launcher', signalTarget: 'app', intDelivery: 'foreground-process-group' },
{ entrypoint: 'appimage', signalTarget: 'serving-electron', intDelivery: 'pid' }
]
: [{ entrypoint, signalTarget, intDelivery }]
if (!appImageArg) {
fail('Usage: run-headless-serve-shutdown-docker.mjs --appimage /path/to/orca.AppImage')
}
@@ -98,50 +114,52 @@ try {
].join(' && ')
])
console.log(
JSON.stringify({
type: 'appimage_under_test',
appImage,
sha256,
platform,
signalTarget,
entrypoint,
intDelivery,
launcherExecOverlay
})
)
const failedSignals = []
for (const signal of ['INT', 'TERM']) {
const result = docker(
[
'run',
'--rm',
'--init',
'--platform',
for (const { entrypoint, signalTarget, intDelivery } of cases) {
console.log(
JSON.stringify({
type: 'appimage_under_test',
appImage,
sha256,
platform,
'--shm-size',
'256m',
'--name',
`orca-headless-serve-shutdown-${signal.toLowerCase()}-${suffix}`,
'-e',
`ORCA_SIGNAL_TARGET=${signalTarget}`,
'-e',
`ORCA_TEST_ENTRYPOINT=${entrypoint}`,
'-e',
`ORCA_INT_DELIVERY=${intDelivery}`,
'-v',
`${appImage}:/input/orca.AppImage:ro`,
'-v',
`${artifactVolume}:/artifacts:ro`,
image,
signal
],
{ allowFailure: true }
signalTarget,
entrypoint,
intDelivery,
launcherExecOverlay
})
)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status !== 0) {
failedSignals.push(`${signal}:${result.status}`)
for (const signal of ['INT', 'TERM']) {
const result = docker(
[
'run',
'--rm',
'--init',
'--platform',
platform,
'--shm-size',
'256m',
'--name',
`orca-headless-serve-shutdown-${entrypoint}-${signal.toLowerCase()}-${suffix}`,
'-e',
`ORCA_SIGNAL_TARGET=${signalTarget}`,
'-e',
`ORCA_TEST_ENTRYPOINT=${entrypoint}`,
'-e',
`ORCA_INT_DELIVERY=${intDelivery}`,
'-v',
`${appImage}:/input/orca.AppImage:ro`,
'-v',
`${artifactVolume}:/artifacts:ro`,
image,
signal
],
{ allowFailure: true }
)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status !== 0) {
failedSignals.push(`${entrypoint}:${signal}:${result.status}`)
}
}
}
if (failedSignals.length > 0) {
@@ -0,0 +1,192 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from '../../src/main/ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers'
import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine'
import type {
SessionSearchRequest,
SessionSearchScope
} from '../../src/main/ai-vault-search/session-search-engine-types'
import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer'
import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
import type SyncDatabase from '../../src/main/sqlite/sync-database'
import {
writeSyntheticTranscriptCorpus,
type SyntheticCorpus,
type SyntheticCorpusOptions
} from '../../src/main/ai-vault-search/session-search-synthetic-corpus'
import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures'
// What a query costs, and what the session candidate limit buys. Everything
// runs through the real store and the real engine over a synthetic corpus;
// never point this at a real transcript tree.
const WARMUP = 5
const SAMPLES = 25
// One query per rung the ladder can take, plus the two shapes that skip it.
const QUERIES: { name: string; request: SessionSearchRequest }[] = [
{ name: 'phrase', request: { query: '"terminal reattach"' } },
{ name: 'identifier', request: { query: 'resolveTerminalPath' } },
{ name: 'path', request: { query: 'src/main/ai-vault/session-transcript-reader.ts' } },
{ name: 'prose', request: { query: 'why is the daemon snapshot stale' } },
{ name: 'typo', request: { query: 'reattahc worktre' } },
{ name: 'common-term', request: { query: 'index' } },
{ name: 'operator-only', request: { query: 'repo:app-3' } },
{ name: 'scoped', request: { query: 'worktree', filters: { scopePaths: ['/repo/app-3'] } } }
]
type Timing = { p50: number; p95: number }
function percentile(sorted: readonly number[], fraction: number): number {
const at = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))
return Math.round((sorted[at] ?? 0) * 100) / 100
}
function timing(samples: number[]): Timing {
const sorted = [...samples].sort((left, right) => left - right)
return { p50: percentile(sorted, 0.5), p95: percentile(sorted, 0.95) }
}
function time(engine: SessionSearchEngine, request: SessionSearchRequest): number {
const started = performance.now()
engine.search(request)
return performance.now() - started
}
async function indexCorpus(
options: SyntheticCorpusOptions
): Promise<{ corpus: SyntheticCorpus; db: SyncDatabase; release: () => void }> {
resetSessionParseCacheForTests()
const corpus = await writeSyntheticTranscriptCorpus(options)
const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => {
throw error
})
const unregister = registerSessionSearchIndexConsumer(store)
const stats = createSessionParseStats()
for (const path of corpus.files) {
await parseAgentSessionFileCached(
await sessionCandidate('claude', path),
process.platform,
stats
)
}
return {
corpus,
// The handle a composed reader gets. Every read here is one synchronous
// statement, which is the contract that comes with it.
db: store.connection,
release: () => {
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
}
}
}
/** Per-query and overall latency for one scope. */
function scopeReport(db: SyncDatabase, scope: SessionSearchScope): Record<string, unknown> {
const engine = new SessionSearchEngine(db)
const everything: number[] = []
const perQuery: Record<string, Timing & { hits: number; route: string }> = {}
for (const { name, request } of QUERIES) {
const scoped = { ...request, scope }
for (let run = 0; run < WARMUP; run++) {
engine.search(scoped)
}
const samples = Array.from({ length: SAMPLES }, () => time(engine, scoped))
everything.push(...samples)
const result = engine.search(scoped)
perQuery[name] = { ...timing(samples), hits: result.hits.length, route: result.planner.route }
}
return { ...timing(everything), perQuery }
}
/**
* The candidate limit only costs anything once there are more matching sessions
* than the limit, so this runs over many short sessions rather than the wide
* corpus above. Limits are interleaved sample by sample: run back to back, the
* first configuration pays for every page the OS cache had not seen yet and the
* ordering alone moves p95 by more than the limit does.
*/
function candidateSweep(db: SyncDatabase, limits: readonly number[]): Record<string, unknown> {
const request: SessionSearchRequest = { query: 'index', limit: 20 }
const engines = new Map(
limits.map((limit) => [limit, new SessionSearchEngine(db, { sessionCandidateLimit: limit })])
)
const samples = new Map(limits.map((limit) => [limit, [] as number[]]))
for (let run = 0; run < WARMUP; run++) {
for (const engine of engines.values()) {
engine.search(request)
}
}
for (let run = 0; run < SAMPLES; run++) {
for (const limit of limits) {
samples.get(limit)!.push(time(engines.get(limit)!, request))
}
}
const report: Record<string, unknown> = {}
for (const limit of limits) {
const result = engines.get(limit)!.search(request)
report[String(limit)] = {
...timing(samples.get(limit)!),
truncated: result.truncated.candidates,
// Pages a caller could walk before the limit stops handing out sessions.
reachablePages: Math.ceil(limit / (request.limit ?? 20))
}
}
return report
}
const wide = await indexCorpus({ sessions: Number(process.env.SESSIONS ?? 40) })
let report: string
try {
const scope = {
all: scopeReport(wide.db, 'all'),
conversation: scopeReport(wide.db, 'conversation')
}
wide.release()
await rm(wide.corpus.root, { recursive: true, force: true })
// Many short sessions: what makes the candidate limit binding is the session
// count, not the byte count.
const many = await indexCorpus({ sessions: 2500, turnsPerSession: 1, seed: 7 })
try {
report = JSON.stringify(
{
scopeCorpus: {
sessions: wide.corpus.files.length,
transcriptMb: Math.round((wide.corpus.transcriptBytes / 1024 / 1024) * 100) / 100,
messages: wide.corpus.messageCount
},
scope,
candidateCorpus: {
sessions: many.corpus.files.length,
transcriptMb: Math.round((many.corpus.transcriptBytes / 1024 / 1024) * 100) / 100
},
candidateSweep: candidateSweep(many.db, [200, 600, 1200, 2400])
},
null,
2
)
} finally {
many.release()
await rm(many.corpus.root, { recursive: true, force: true })
}
} catch (error) {
await rm(wide.corpus.root, { recursive: true, force: true })
throw error
}
// Why a file as well as stdout: a runner that intercepts console output
// (vitest does) would otherwise swallow the whole report.
const out = process.env.BENCH_OUT
if (out) {
await writeFile(out, `${report}\n`)
}
console.log(report)
@@ -0,0 +1,205 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from '../../src/main/ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers'
import { SessionSearchEngine } from '../../src/main/ai-vault-search/session-search-engine'
import type {
SessionSearchRequest,
SessionSearchScope
} from '../../src/main/ai-vault-search/session-search-engine-types'
import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer'
import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures'
import type SyncDatabase from '../../src/main/sqlite/sync-database'
import { writeToolHeavyCorpus, type ToolHeavyCorpus } from './session-search-tool-heavy-corpus'
// What each scope costs on an index the size of a real transcript tree.
//
// The 10.5 MB corpus in `session-search-query-benchmark.ts` sizes the route
// ladder; this one sizes the corpus. `conversation` is a column filter over the
// one FTS table rather than a second table of its own, and the whole cost of
// that decision is how much of `messages_fts` a conversation query has to read
// past — which is set by how much of a transcript is tool output.
//
// Synthetic, always: this must never be pointed at a real transcript.
const WARMUP = 5
/** Conversation-shaped queries; every term is one the prose actually uses. */
const QUERIES = [
'terminal reattach',
'stale snapshot',
'daemon cursor',
'worktree index',
'publish transaction',
'relay daemon',
'session cursor',
'because stale',
'terminal worktree',
'index snapshot',
'reattach cursor',
'transaction relay',
'snapshot session',
'daemon publish',
'worktree terminal',
'cursor index',
'stale relay',
'session transaction',
'publish snapshot',
'reattach daemon'
]
async function indexCorpus(
corpus: ToolHeavyCorpus
): Promise<{ db: SyncDatabase; release: () => void }> {
resetSessionParseCacheForTests()
const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'), (error) => {
throw error
})
const unregister = registerSessionSearchIndexConsumer(store)
const stats = createSessionParseStats()
for (const path of corpus.files) {
await parseAgentSessionFileCached(
await sessionCandidate('claude', path),
process.platform,
stats
)
}
return {
// The store's own handle, which is what a composed reader gets: every
// retrieval is one synchronous statement, so nothing pins a WAL snapshot.
db: store.connection,
release: () => {
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
}
}
}
type Timing = { p50: number; p95: number }
function timing(samples: readonly number[]): Timing {
const sorted = [...samples].sort((left, right) => left - right)
const at = (fraction: number): number => {
const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))
return Math.round((sorted[index] ?? 0) * 100) / 100
}
return { p50: at(0.5), p95: at(0.95) }
}
/**
* The query sets, one per rung of the ladder the engine may take.
*
* Which rung each one reaches is not forced, it is observed: samples are
* bucketed by the route the engine reports, so the table says what was measured
* rather than what was intended, and a query that lands on a different rung
* than expected shows up as a bucket rather than as a wrong number.
*/
function queries(): string[] {
const run = (index: number, length: number): string =>
Array.from({ length }, (_unused, step) => QUERIES[(index + step) % QUERIES.length]).join(' ')
return [
// Two terms, unquoted: not literal, so straight to OR.
...QUERIES,
// Two terms, quoted: literal, and on this corpus any two of fourteen words
// sit next to each other somewhere, so the phrase rung answers.
...QUERIES.map((query) => `"${query}"`),
// Eight terms, quoted: an ordered run that long does not occur in 105 MB of
// draws from fourteen words, so the phrase rung misses and AND answers.
...QUERIES.map((_query, index) => `"${run(index, 4)}"`)
]
}
type Bucket = { samples: number[]; hits: number }
/**
* Both scopes over the same queries, interleaved scope by scope: run back to
* back, the first one pays for every page the OS cache had not seen and the
* ordering moves p95 more than the scope does.
*/
function scopeReport(db: SyncDatabase): Record<string, unknown> {
const engine = new SessionSearchEngine(db)
const scopes: SessionSearchScope[] = ['all', 'conversation']
const requests: SessionSearchRequest[] = queries().map((query) => ({ query }))
const buckets = new Map<string, Bucket>()
for (let run = 0; run < WARMUP; run++) {
for (const scope of scopes) {
for (const request of requests) {
engine.search({ ...request, scope })
}
}
}
for (const request of requests) {
for (const scope of scopes) {
const started = performance.now()
const result = engine.search({ ...request, scope })
const elapsed = performance.now() - started
const key = `${result.planner.route}/${scope}`
const bucket = buckets.get(key) ?? { samples: [], hits: 0 }
bucket.samples.push(elapsed)
bucket.hits += result.hits.length
buckets.set(key, bucket)
}
}
const report: Record<string, unknown> = {}
for (const [key, bucket] of [...buckets].sort(([left], [right]) => left.localeCompare(right))) {
report[key] = { ...timing(bucket.samples), samples: bucket.samples.length, hits: bucket.hits }
}
return report
}
/** Bytes the FTS table occupies, which is the cost the deleted second table saved. */
function indexBytes(db: SyncDatabase): Record<string, number> | { unavailable: string } {
try {
const sum = (where: string, ...values: string[]): number =>
Number(
(
db
.prepare(`SELECT COALESCE(SUM(pgsize),0) AS bytes FROM dbstat ${where}`)
.get(...values) as { bytes: number }
).bytes
)
return { total: sum(''), messagesFts: sum('WHERE name LIKE ?', 'messages_fts%') }
} catch {
// dbstat is a compile-time option; the latency numbers stand without it.
return { unavailable: 'no dbstat' }
}
}
const corpus = await writeToolHeavyCorpus({
targetBytes: Number(process.env.CORPUS_MB ?? 100) * 1024 * 1024,
toolShare: Number(process.env.TOOL_SHARE ?? 0.9)
})
let report: string
const indexed = await indexCorpus(corpus)
try {
report = JSON.stringify(
{
corpus: {
sessions: corpus.files.length,
transcriptMb: Math.round((corpus.transcriptBytes / 1024 / 1024) * 100) / 100,
toolShareOfMessageText:
Math.round((corpus.toolBytes / (corpus.toolBytes + corpus.proseBytes)) * 1000) / 1000
},
indexBytes: indexBytes(indexed.db),
route: scopeReport(indexed.db)
},
null,
2
)
} finally {
indexed.release()
await rm(corpus.root, { recursive: true, force: true })
}
const out = process.env.BENCH_OUT
if (out) {
await writeFile(out, `${report}\n`)
}
console.log(report)
@@ -0,0 +1,152 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// The corpus the scope benchmark runs over. Written here rather than by
// `session-search-synthetic-corpus.ts` because what it costs to answer a
// conversation query out of the one FTS table turns on the property that
// generator fixes: how much of a transcript is tool output.
//
// Synthetic, always. This must never be pointed at a real transcript.
const PROSE = [
'terminal',
'reattach',
'worktree',
'the',
'index',
'cursor',
'publish',
'transaction',
'relay',
'daemon',
'snapshot',
'because',
'stale',
'session'
]
// Tool output is paths, hashes and log lines — and the same words the
// conversation uses, because a `rg` over this repository prints them. That
// overlap is what the benchmark turns on: it is what makes a conversation
// term's posting list carry rows the column filter then has to discard. A tool
// vocabulary disjoint from the prose would leave nothing to discard and measure
// the wrong thing.
const TOOL_ONLY = [
'src/main/ai-vault/session-transcript-reader.ts',
'node_modules/.pnpm/typescript@5.9.2',
'0x00007ff8',
'ENOENT',
'drwxr-xr-x',
'2026-09-10T00:00:00.000Z',
'sha256:9f2c1a',
'chunk-VHQ4NWQK.js',
'warning:',
'resolveTerminalPath',
'byteOffset',
'MAX_RETRIES'
]
// Half the tool tokens are conversation words. Deliberately pessimistic: the
// more of a query term lives in `tool_text`, the more the column filter costs,
// so a number measured here holds on a real transcript tree.
const TOOL = [...PROSE, ...TOOL_ONLY]
function mulberry32(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = Math.imul(state ^ (state >>> 15), 1 | state)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function words(random: () => number, vocabulary: readonly string[], count: number): string {
const out: string[] = []
for (let index = 0; index < count; index++) {
out.push(vocabulary[Math.floor(random() * vocabulary.length)]!)
}
return out.join(' ')
}
export type ToolHeavyCorpus = {
root: string
files: string[]
transcriptBytes: number
toolBytes: number
proseBytes: number
}
/**
* Claude JSONL transcripts whose tool output is `toolShare` of the message text.
* One turn is a user question, an assistant answer, a tool call and its output;
* only the last one grows with the share.
*/
export async function writeToolHeavyCorpus(args: {
targetBytes: number
toolShare: number
seed?: number
}): Promise<ToolHeavyCorpus> {
const random = mulberry32(args.seed ?? 11)
const root = await mkdtemp(join(tmpdir(), 'orca-search-convfts-'))
const files: string[] = []
const proseWordsPerTurn = 160
// Tool and prose words are not the same length, so the share is over bytes.
const proseBytesPerTurn = proseWordsPerTurn * 6
const toolWordCount = Math.max(
1,
Math.round((proseBytesPerTurn * args.toolShare) / (1 - args.toolShare) / 22)
)
let transcriptBytes = 0
let toolBytes = 0
let proseBytes = 0
for (let session = 0; transcriptBytes < args.targetBytes; session++) {
const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}`
const lines: string[] = []
for (let turn = 0; turn < 40; turn++) {
const at = new Date(1740000000000 + turn * 60_000).toISOString()
const question = words(random, PROSE, 40)
const answer = words(random, PROSE, proseWordsPerTurn - 40)
const output = words(random, TOOL, toolWordCount)
proseBytes += Buffer.byteLength(question) + Buffer.byteLength(answer)
toolBytes += Buffer.byteLength(output)
lines.push(
JSON.stringify({
type: 'user',
sessionId,
timestamp: at,
cwd: `/repo/app-${session % 7}`,
gitBranch: 'main',
message: { role: 'user', content: question }
}),
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: at,
message: {
role: 'assistant',
model: 'claude-fable-5',
content: [
{ type: 'text', text: answer },
{ type: 'tool_use', name: 'Bash', input: { command: 'rg needle' } }
]
}
}),
JSON.stringify({
type: 'user',
sessionId,
timestamp: at,
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: output }]
}
})
)
}
const path = join(root, `${sessionId}.jsonl`)
const body = `${lines.join('\n')}\n`
await writeFile(path, body)
transcriptBytes += Buffer.byteLength(body)
files.push(path)
}
return { root, files, transcriptBytes, toolBytes, proseBytes }
}
@@ -1,7 +1,10 @@
import { readFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { runProcessSync } from '../../src/shared/child-process/run-process'
const projectDir = resolve(import.meta.dirname, '../..')
@@ -15,13 +18,74 @@ const REF_MIRRORS = [
]
describe('ref-mirroring vet steps', () => {
it('keeps the full-history adhoc checkout on the same case-safe backend', () => {
it.each(['daily', 'hourly', 'adhoc'])('%s builds only need the current commit', (channel) => {
const job = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[
`build-${channel}-mac`
]
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
expect(checkout.with['fetch-depth']).toBe(1)
expect(job.steps.some((step) => step.run?.includes('gh release list'))).toBe(true)
expect(
job.steps.some((step) => step.run?.includes('ORCA_PUBLISHED_VERSIONS="$published"'))
).toBe(true)
})
it('retains release-cut history for version reservation and retry ancestry', () => {
const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find(
(step) => step.uses === 'actions/checkout@v6'
)
expect(checkout.with['fetch-depth']).toBe(0)
})
it('resolves identical dev identities in full and depth-one checkouts without local tags', () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-checkout-identity-'))
const source = join(directory, 'source')
const shallow = join(directory, 'shallow')
const run = (program, args, cwd) => {
const result = runProcessSync({ program, args, cwd })
expect(result.code, result.stderr).toBe(0)
return result.stdout.trim()
}
const git = (args, cwd = directory) => run('git', args, cwd)
try {
git(['init', source])
git(['config', 'user.name', 'CI test'], source)
git(['config', 'user.email', 'ci@example.invalid'], source)
writeFileSync(join(source, 'package.json'), JSON.stringify({ version: '1.4.165-rc.0' }))
git(['add', 'package.json'], source)
git(['-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'], source)
git(['tag', 'v1.4.167'], source)
git(['-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'head'], source)
git(['clone', '--depth=1', '--no-tags', pathToFileURL(source).href, shallow])
expect(git(['rev-list', '--count', 'HEAD'], shallow)).toBe('1')
expect(git(['tag', '--list'], shallow)).toBe('')
const script = `
const result = [];
for (const [channel, exported] of [['daily', 'Daily'], ['hourly', 'Hourly'], ['adhoc', 'Adhoc']]) {
const module = await import(${JSON.stringify(pathToFileURL(join(projectDir, 'config/scripts/')).href)} + channel + '-build-version.mjs');
const date = new Date('2026-09-12T00:00:00Z');
result.push(channel === 'adhoc'
? module.getAdhocBuildIdentity(date, 'branch', ['v1.4.167'])
: module['get' + exported + 'BuildIdentity'](date, { publishedVersions: ['v1.4.167'], releaseNames: [] }));
}
process.stdout.write(JSON.stringify(result));
`
const identities = (cwd) => run(process.execPath, ['--input-type=module', '-e', script], cwd)
expect(identities(shallow)).toBe(identities(source))
expect(
JSON.parse(identities(shallow)).every((identity) => identity.version.startsWith('1.4.168-'))
).toBe(true)
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
it('checks out only the vetted commit without remirroring refs', () => {
const steps = readWorkflow('.github/workflows/adhoc-mac-build.yml').jobs['build-adhoc-mac']
.steps
const checkout = steps.find((step) => step.name === 'Checkout the requested ref')
expect(checkout.env.GIT_DEFAULT_REF_FORMAT).toBe('reftable')
expect(checkout.with.ref).toBe('${{ steps.vetted.outputs.sha }}')
expect(checkout.with['fetch-depth']).toBe(0)
expect(checkout.with['fetch-depth']).toBe(1)
expect(checkout.with['persist-credentials']).toBe(false)
})
@@ -95,7 +95,7 @@ describe('release ref trust with case-twin names', () => {
expect(result.stdout).toContain('Refusing to build PR ref')
})
it('preserves both case variants in the subsequent full-history checkout', async () => {
it('checks out the vetted SHA shallowly without mirroring case-twin refs again', async () => {
const checkout = join(directory, 'checkout')
const env = { ...identity, ...macCheckout.env }
await git(['init', checkout], env)
@@ -105,21 +105,15 @@ describe('release ref trust with case-twin names', () => {
checkout,
'fetch',
'--no-tags',
`--depth=${macCheckout.with['fetch-depth']}`,
repository,
'+refs/heads/*:refs/remotes/origin/*',
'+refs/tags/*:refs/tags/*'
upper
],
env
)
await git(['-C', checkout, 'checkout', '--detach', upper], env)
for (const [ref, sha] of [
['refs/remotes/origin/Fix', upper],
['refs/remotes/origin/fix', lower],
['refs/tags/Release', upper],
['refs/tags/release', lower]
]) {
expect(await git(['-C', checkout, 'rev-parse', `${ref}^{commit}`], env)).toBe(sha)
}
expect(await git(['-C', checkout, 'rev-parse', 'HEAD'], env)).toBe(upper)
expect(await git(['-C', checkout, 'rev-list', '--count', 'HEAD'], env)).toBe('1')
expect(await git(['-C', checkout, 'for-each-ref', '--format=%(refname)'], env)).toBe('')
})
})
+4
View File
@@ -1,5 +1,6 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vitest/config'
import TimingSequencer from './scripts/ci-unit-sequencer.mjs'
const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {}
@@ -15,6 +16,9 @@ export default defineConfig({
},
test: {
environment: 'node',
...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1'
? { sequence: { sequencer: TimingSequencer } }
: {}),
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 48m">
<title>downloads: 48m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 49m">
<title>downloads: 49m</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">48m</text>
<text x="90" y="14">48m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">49m</text>
<text x="90" y="14">49m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -0,0 +1,219 @@
# Agent session search: query tuning
What a search costs, and what the knobs in `src/main/ai-vault-search/session-search-engine.ts`
buy. Every number here comes from `config/scripts/session-search-query-benchmark.ts`
over the synthetic corpus in `session-search-synthetic-corpus.ts`, except the
`conversation_fts` shoot-out, which writes its own corpus because the answer
turns on how much of a transcript is tool output. Nothing in this file was
measured against a real transcript, and neither benchmark must ever be pointed
at one.
## Running it
The benchmark is a top-level-await module that imports the main-process tree by
extensionless path, so it needs a bundler-backed runner rather than bare `node`:
```sh
cat > src/main/ai-vault-search/zz-bench.test.ts <<'EOF'
import { it } from 'vitest'
it('runs', { timeout: 1_800_000 }, async () => {
await import('../../../config/scripts/session-search-query-benchmark')
})
EOF
BENCH_OUT=/tmp/ss-query-bench.json pnpm test src/main/ai-vault-search/zz-bench.test.ts
rm src/main/ai-vault-search/zz-bench.test.ts
```
The `conversation_fts` shoot-out below runs the same way, importing
`config/scripts/session-search-conversation-fts-benchmark` instead, with
`CORPUS_MB` and `TOOL_SHARE` to size and shape its corpus. `config/scripts` is
not inside any typecheck project, so while that throwaway test exists `tsc`
reports TS6307 for each script it pulls in; delete it and the run is clean
again.
`BENCH_OUT` exists because vitest intercepts `console.log`; the report is written
to that path as well as printed.
## Scope: what the second FTS table buys a reader
Corpus: 40 synthetic Claude transcripts, 10.5 MB, 9,600 messages, indexed through
the real store. Eight queries, one per rung of the route ladder plus the two
shapes that skip it; 5 warm-up runs and 25 samples each. Apple silicon, warm page
cache, machine otherwise idle. Milliseconds, and p95 over 25 samples moves
several milliseconds run to run if anything else is competing for the disk.
| Scope | p50 | p95 |
| -------------- | ---- | ---- |
| `all` | 7.22 | 8.94 |
| `conversation` | 5.33 | 7.86 |
Per query, `all` then `conversation` (p50 / p95):
| Query | `all` | `conversation` |
| ------------------------------------------------ | ------------ | -------------- |
| `"terminal reattach"` (phrase) | 5.24 / 8.42 | 2.97 / 3.24 |
| `resolveTerminalPath` (identifier) | 7.55 / 8.94 | 6.47 / 6.72 |
| `src/main/…/session-transcript-reader.ts` (path) | 8.69 / 10.12 | 7.78 / 8.04 |
| `why is the daemon snapshot stale` (prose) | 7.84 / 8.57 | 5.90 / 7.01 |
| `reattahc worktre` (typo repair) | 7.30 / 7.39 | 5.53 / 5.89 |
| `index` (common term) | 5.45 / 5.66 | 3.81 / 4.02 |
| `repo:app-3` (operator only) | 0.12 / 0.16 | 0.10 / 0.10 |
| `worktree` scoped to one cwd | 1.47 / 1.63 | 1.25 / 1.49 |
Reading it:
- `conversation` is about 1.4x faster at p50 and 1.1x at p95, and it is a column
filter over the same table rather than a table of its own. Narrowing to the
two prose columns is what buys the gap: fewer postings to score. It is also
the scope where a match is something a person wrote rather than something a
tool printed.
- A `scopePaths` query is the cheapest real search on the page. It is the one
narrowing SQL can express exactly, so it seeks `sessions_cwd_key` and hands
ranking a small candidate set.
- The operator-only figure is a floor, not a typical cost. `repo:` and `path:`
are applied in JS over retrieved rows (see `session-search-row-filter` for why
they cannot be pushed into SQL), so their cost tracks how many sessions the
walk has to read before it fills a candidate set. This corpus has 40 sessions,
which is one page of that walk; an index where few sessions match the operator
will read up to the ceiling in `session-search-retrieval` instead.
## What the conversation scope costs at real corpus size
`conversation` was a second FTS table holding a copy of the two prose columns.
It is a column filter now — `{user_text assistant_text}: (…)` with bm25 weights
that zero the other two — and PR 2 deleted the table on the strength of the
shoot-out this section used to hold: the filter came in at 1.16-1.36x the p95 of
the dedicated table, under the 2x bar, while the table cost a tenth of the index
to maintain. What follows is what the shipped schema actually does, measured
again on the same corpus after the table went and tool rows were capped.
Corpus: Claude transcripts from `config/scripts/session-search-tool-heavy-corpus.ts`,
105 MB, indexed through the real store, at two points in the 80-97% band a real
transcript tree sits in. Half the tokens in tool output are words the
conversation also uses, so a conversation term really does have postings the
filter must discard. Twenty queries per rung, both scopes interleaved query by
query, warm cache; `config/scripts/session-search-scope-benchmark.ts`, run twice.
| Tool share | Rung | `all` p50 / p95 | `conversation` p50 / p95 |
| ---------- | ------ | --------------- | ------------------------ |
| 86% | phrase | 16.69 / 17.48 | 13.08 / 13.52 |
| 86% | or | 31.91 / 35.74 | 22.25 / 23.87 |
| 86% | and | 70.04 / 74.00 | 53.47 / 59.39 |
| 93% | phrase | 9.14 / 13.36 | 7.23 / 8.51 |
| 93% | or | 16.46 / 18.70 | 12.34 / 14.88 |
| 93% | and | 39.65 / 43.44 | 31.05 / 32.92 |
Three things to read out of it.
**The filter is a win, not a cost.** Every rung is faster narrow than wide, by
1.2x to 1.4x at p50. The shoot-out compared the filter against a table built for
exactly this query; against the wide table it replaces, it does what the second
table did, which is read fewer postings.
**The `and` rung is where the corpus size shows.** Those queries are eight terms,
chosen so no ordered run that long occurs and the phrase rung has to miss; a
real two-term AND sits nearer the phrase row. It is also the noisiest: the
second run's p95 reached 140 ms on one bucket, which is what twenty samples of a
70 ms query buys. Read the p50 column.
**The index is far smaller than the shoot-out's was.** 57 MB at 93% tool output
and 103 MB at 86%, against roughly 150 MB for `messages_fts` alone before PR 2
capped an indexed tool row at 3,072 characters. Most of a tool-heavy transcript
is now not in the index at all, which moves every number above and is the larger
effect of the two.
What is **not** measured here is relevance, and the column filter does carry one
ranking difference the deleted table did not. FTS5's bm25 normalises by the
whole row's length and has no per-column length, so two rows with identical
prose score differently when one also holds tool output. The rowid set is
unchanged, which is what the deletion was decided on; the order within it can
move. `session-search-engine.test.ts` pins the direction.
## `sessionCandidateLimit`
The reviewer's F13: this is a tunable default, not a constant. It bounds how many
sessions the SQL hands ranking, so it bounds both retrieval cost and how deep a
caller can page before the answer simply stops.
The limit only costs anything once more sessions match than the limit allows, so
this is measured over a second corpus: 2,500 one-turn transcripts, 10.9 MB, every
one of them matching the query. Limits are interleaved sample by sample, because
run back to back the first configuration pays for every page the OS cache had not
seen and the ordering alone moves p95 further than the limit does.
| Limit | p50 | p95 | Pages of 20 a caller can reach |
| ----- | ----- | ----- | ------------------------------ |
| 200 | 6.85 | 7.21 | 10 |
| 600 | 7.93 | 8.36 | 30 |
| 1200 | 9.55 | 10.53 | 60 |
| 2400 | 12.32 | 13.45 | 120 |
600 is the default: it costs about 16% over 200 at p50 and buys three times the
reachable depth, and the curve only turns steep past 1200. A host with a much
larger index can raise it; the result's `truncated.candidates` says when the limit
was the thing that cut the answer, so a caller never has to guess.
What is **not** measured here is relevance. These numbers say what a limit costs,
not what it retrieves. The MRR figures quoted in the BM25 weights
(`session-search-retrieval.ts`) and in the identifier shadow column
(`session-search-identifier-split.ts`) come from the original retrieval shoot-out
on real transcripts and are not reproducible from this repository. Any change to
the limit justified on relevance grounds needs an eval set, not this benchmark.
## What typo repair costs
The repair is the one rung whose cost tracks the size of the vocabulary rather
than the size of a result. It only runs for a term the scope has no posting for,
so an ordinary query never pays it; a query of nonsense pays it once per term.
Measured over a synthetic vocabulary of 1.6 M distinct terms, every term in two
rows so none is filtered out:
| Query | p50 |
| -------------------------------------- | ------ |
| one known term (no repair) | 11 ms |
| one unknown term | 10 ms |
| 39 unknown 12-character terms (480 ch) | 387 ms |
| 12 unknown 40-character terms | 99 ms |
Two things follow. The cost is linear in unknown terms and in vocabulary size,
and `search` is synchronous, so a 512-character query of nonsense holds the
thread for a third of a second on an index that large. And the scoped-count fix
made this cheaper rather than dearer — it was 737 ms before — because ordering
the vocabulary scan by term drops the sort that ordering by `doc` required, and
the counts it added are at most eight bounded probes per prefix. A cap on
unknown terms per query is recorded as a follow-up in the split plan.
## Page warmup, dropped
PR 2 deferred `warm()` — a sliced read of `messages` that pulls its pages into
the OS cache before the first query — to whoever knew which pages a read
touches. It is not re-added here, for two reasons. The measurement that
justified it (first query 1.3 s to 0.45 s) was on a 4 GB index, and neither
corpus in this file is within an order of magnitude of that, so PR 4 cannot
show a win: removing the call moved the 10.5 MB corpus's p50 by less than the
run-to-run spread. And it is a cancellable background pass, which needs an owner
with a lifecycle; a query library that holds no timers has nothing to hang the
`stopped()` on, and a fire-and-forget async read from a synchronous `search` is
a rejection nothing can supervise. It belongs with the indexer in PR 3b, which
already owns starting and stopping work.
## Not settled here
Which process may open, unlink and rebuild the index is PR 3b's decision. A
second handle that finds an older schema version replaces the file while a live
store keeps answering from the unlinked inode, and this PR is what first makes
that reachable, because it is the first thing that reads. What PR 4 does is
refuse to make it worse. The engine restores its derived vocabulary and generation
triggers before a search. A missing `messages_fts` fails clearly; the connection
owner must rebuild the source index. There is no degraded-search capability state
or query logging. Logging can be added by a caller when an evaluation consumer exists.
Each search checks the generation before retrieval and after its final content
read. A concurrent commit rejects the page with `stale-generation`, including a
first page without a cursor. The caller can retry from page one. No long-lived
read transaction is needed, and a mixed page is never returned as a valid snapshot.
Repository/path operators are applied before a phrase or AND route is accepted.
Candidate truncation remains explicit, including when an earlier route reached
its cap but had no eligible sessions.
@@ -583,8 +583,8 @@ breakaway hands the whole tree its escape. The per-PTY job therefore omits
`BREAKAWAY_OK` whenever `msys-2.0.dll` or `cygwin1.dll` sits on the shell's DLL
search path — beside the executable, or under `usr/bin` for Git's `bin`
launcher. Native shells keep explicit breakaway. Denying it costs Cygwin
nothing, because it *pre-checks* the limit rather than retrying, so no spawn
fails; but a *native* program that passes `CREATE_BREAKAWAY_FROM_JOB` itself
nothing, because it _pre-checks_ the limit rather than retrying, so no spawn
fails; but a _native_ program that passes `CREATE_BREAKAWAY_FROM_JOB` itself
inside such a pane now gets `ERROR_ACCESS_DENIED`. `nohup` and `disown` are
unaffected — they are Cygwin signal/session concepts, unrelated to job
membership. The daemon's host job is unchanged.
+2 -2
View File
@@ -10,7 +10,7 @@ import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set([
'@xterm/headless',
'@xterm/addon-serialize',
'psl',
'tldts',
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
// not race the later resources/node_modules copy.
'zod'
@@ -291,7 +291,7 @@ export const electronViteConfig: UserConfig = {
preload: {
build: {
externalizeDeps: {
exclude: ['@electron-toolkit/preload', 'zod']
exclude: ['zod']
}
}
},
+32
View File
@@ -0,0 +1,32 @@
// Why this file exists: a bare "expo-notifications" plugin entry writes
// `aps-environment: development` into the iOS entitlements, while push-token.ts
// reports `production` for every non-__DEV__ build. A TestFlight or App Store build
// would then register a production APNs token against a sandbox entitlement, and the
// gateway's pushes would be accepted by Apple and delivered nowhere. Deriving the
// mode from an env var the release workflow sets makes the two agree by construction
// instead of relying on the export step to rewrite the entitlement.
//
// app.json stays the source for everything else: Expo reads it first and hands it to
// this function, so the fastlane version/buildNumber rewrite still flows through.
const APS_ENVIRONMENT =
process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development'
module.exports = ({ config }) => ({
...config,
ios: {
...config.ios,
entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT }
},
plugins: (config.plugins ?? []).map((plugin) =>
plugin === 'expo-notifications'
? [
'expo-notifications',
{
enableBackgroundRemoteNotifications: true,
mode: APS_ENVIRONMENT,
icon: './assets/notification-icon.png'
}
]
: plugin
)
})
+3 -1
View File
@@ -75,10 +75,12 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 16
"versionCode": 16,
"googleServicesFile": "./google-services.json"
},
"plugins": [
"expo-router",
"expo-notifications",
"./plugins/android-respect-rotation-lock.js",
[
"expo-splash-screen",
+47 -15
View File
@@ -1,6 +1,10 @@
import { startAndroidForegroundPushPresentation } from '../src/notifications/android-foreground-push'
import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal'
import { readNativeNotificationData } from '../src/notifications/native-notification-data'
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
import { useCallback, useEffect, useRef } from 'react'
import { View, StyleSheet } from 'react-native'
import { Stack, useRouter } from 'expo-router'
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import * as Notifications from 'expo-notifications'
@@ -10,6 +14,13 @@ import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
import {
isRemotePushTrigger,
pushNotificationRouteData,
foregroundNotificationBehavior
} from '../src/notifications/push-receive'
import { startPushTokenSync } from '../src/notifications/push-registration'
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
@@ -19,22 +30,29 @@ import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing
// between the native splash and the first React paint.
SplashScreen.preventAutoHideAsync()
// Why: without this, expo-notifications silently drops notifications when
// the app is in the foreground. Setting all three to true makes iOS/Android
// display the banner, play the sound, and show the badge even while the
// app is active. This runs once at module load time before any notification
// is scheduled.
// Why at boot and not only on subscribe: the gateway's FCM payload targets the
// 'orca-desktop' channel, and a background push can land before any socket has
// connected. Android drops a notification whose channel does not exist yet.
void ensureDesktopNotificationChannel().catch(() => {})
void registerPushDismissalTask().catch(() => {})
// Register before scheduling so foreground delivery uses the same suppression policy.
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false
})
handleNotification: foregroundNotificationBehavior
})
export default function RootLayout() {
const router = useRouter()
const pathname = usePathname()
const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
useEffect(() => {
setNotificationViewingWorkspace(
pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
? { hostId, worktreeId }
: null
)
return () => setNotificationViewingWorkspace(null)
}, [pathname, hostId, worktreeId])
const openNotificationRoute = useOpenNotificationRoute()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
@@ -44,6 +62,11 @@ export default function RootLayout() {
void recoverMobileRelayPairing()
}, [])
// Why: a rolled APNs/FCM token stops delivering silently, so every paired host
// has to be re-registered with the new one as soon as the provider hands it over.
useEffect(() => startPushTokenSync(), [])
useEffect(() => startAndroidForegroundPushPresentation(), [])
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
@@ -94,9 +117,18 @@ export default function RootLayout() {
}
}
async function getNavigationTarget(data: unknown) {
async function getNavigationTarget(notification: Notifications.Notification) {
const hosts = await loadHostCatalog().catch(() => null)
return getNotificationNavigationTarget(data, {
const data = readNativeNotificationData(notification.request)
// A gateway push names its host by key fingerprint, not by this device's hostId.
// With no catalog to resolve against, such a push stays unrouted instead of
// falling back to whatever hostId its raw data carries.
const routeData = pushNotificationRouteData(
data,
hosts ?? [],
isRemotePushTrigger(notification.request.trigger)
)
return getNotificationNavigationTarget(routeData, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
credentialStatusByHostId: hosts
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
@@ -124,7 +156,7 @@ export default function RootLayout() {
}
}
const target = await getNavigationTarget(response.notification.request.content.data)
const target = await getNavigationTarget(response.notification)
clearLastNotificationResponse()
if (disposed) {
return
+2 -2
View File
@@ -22,7 +22,7 @@ import {
saveDefaultSessionView,
type MobileSessionView
} from '../src/storage/session-view-preferences'
import { savePushNotificationsEnabled } from '../src/storage/preferences'
import { setRemotePushEnabled } from '../src/notifications/push-registration'
const SLIDE_DURATION_MS = 280
@@ -127,7 +127,7 @@ function MobileOnboardingFlow({
setError(null)
try {
const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
await savePushNotificationsEnabled(enabled)
await setRemotePushEnabled(enabled)
advanceOrContinue()
} catch {
setError('Notification settings could not be updated. Try again.')
+11 -1
View File
@@ -1,3 +1,5 @@
import { NotificationDisplayTest } from '../src/settings/notification-display-test'
import { NativeNotificationDeliverySettings } from '../src/settings/native-notification-delivery-settings'
import { useRouter } from 'expo-router'
import NotificationsScreen from '../src/settings/notification-settings-screen'
import { nativeNotificationSettingsOperations } from '../src/settings/native-notification-settings-operations'
@@ -7,6 +9,14 @@ export default function NativeNotificationsRoute() {
<NotificationsScreen
operations={nativeNotificationSettingsOperations}
onBack={() => router.back()}
/>
description="Get agent alerts even when the app is closed. Delivered through Orca’s push service and Apple or Google."
>
{(enabled) => (
<>
<NativeNotificationDeliverySettings enabled={enabled} />
<NotificationDisplayTest onTroubleshoot={() => router.push('/troubleshoot')} />
</>
)}
</NotificationsScreen>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 639 B

+39
View File
@@ -0,0 +1,39 @@
{
"project_info": {
"project_number": "120364513935",
"project_id": "onorca-cloud",
"storage_bucket": "onorca-cloud.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:120364513935:android:1d951dc430aeb9bc664efa",
"android_client_info": {
"package_name": "com.stably.orca.mobile"
}
},
"oauth_client": [
{
"client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBmT_w0OUQSiVfxblx-F0qlRvGkBBkTNQU"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "120364513935-evfa8502bp5r9hn7afhd9i03oibs8223.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}
+3
View File
@@ -0,0 +1,3 @@
// Headless notification launches do not mount the router layout.
import './src/notifications/push-background-dismissal'
import 'expo-router/entry'
@@ -0,0 +1,7 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["OrcaNotificationDismissalModule"],
"appDelegateSubscribers": ["OrcaNotificationDismissalSubscriber"]
}
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'OrcaNotificationDismissal'
s.version = '0.0.1'
s.summary = 'Native notification dismissal and sequence fencing'
s.description = s.summary
s.license = { :type => 'MIT' }
s.author = 'Orca'
s.homepage = 'https://onorca.dev'
s.source = { :git => 'https://github.com/stablyai/orca.git' }
s.platforms = { :ios => '15.1' }
s.swift_version = '5.9'
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,14 @@
import ExpoModulesCore
public class OrcaNotificationDismissalModule: Module {
public func definition() -> ModuleDefinition {
Name("OrcaNotificationDismissal")
AsyncFunction("remember") { (payload: [String: Any]) in
if let identity = PushDismissalIdentity(payload) { PushDismissalLedger.shared.remember(identity) }
}
AsyncFunction("wasDismissed") { (payload: [String: Any]) -> Bool in
guard let identity = PushDismissalIdentity(payload) else { return false }
return PushDismissalLedger.shared.contains(identity)
}
}
}
@@ -0,0 +1,26 @@
import ExpoModulesCore
import UserNotifications
public class OrcaNotificationDismissalSubscriber: ExpoAppDelegateSubscriber {
public func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
guard let payload = userInfo["orca"] as? [String: Any],
payload["kind"] as? String == "dismiss", let fence = PushDismissalIdentity(payload)
else { completionHandler(.noData); return }
PushDismissalLedger.shared.remember(fence)
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { notifications in
let ids = notifications.compactMap { notification -> String? in
guard let data = notification.request.content.userInfo["orca"] as? [String: Any],
data["hostFingerprint"] as? String == fence.hostFingerprint,
PushDismissalLedger.shared.containsNotification(data) else { return nil }
return notification.request.identifier
}
center.removeDeliveredNotifications(withIdentifiers: ids)
completionHandler(ids.isEmpty ? .noData : .newData)
}
}
}
@@ -0,0 +1,67 @@
import Foundation
import CoreFoundation
struct PushDismissalIdentity: Codable {
let hostFingerprint: String
let notificationId: String
let notificationEpoch: String
let notificationSeq: Int64
init?(_ value: [String: Any]) {
guard let host = value["hostFingerprint"] as? String, !host.isEmpty, host.count <= 512,
let id = value["notificationId"] as? String, !id.isEmpty, id.count <= 2048,
let epoch = value["notificationEpoch"] as? String, !epoch.isEmpty, epoch.count <= 128,
let seq = value["notificationSeq"] as? NSNumber,
CFGetTypeID(seq) != CFBooleanGetTypeID(), seq.doubleValue.isFinite,
seq.doubleValue >= 0, seq.doubleValue <= 9_007_199_254_740_991,
seq.doubleValue.rounded(.down) == seq.doubleValue else { return nil }
hostFingerprint = host; notificationId = id; notificationEpoch = epoch
notificationSeq = seq.int64Value
}
func matches(_ other: PushDismissalIdentity) -> Bool {
hostFingerprint == other.hostFingerprint && notificationId == other.notificationId &&
notificationEpoch == other.notificationEpoch
}
}
final class PushDismissalLedger {
static let shared = PushDismissalLedger()
private struct Entry: Codable { let identity: PushDismissalIdentity; let expiresAt: TimeInterval }
private let defaults: UserDefaults
private let lock = NSLock()
private let storageKey = "orca.pushDismissals.v1"
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
private func read(now: TimeInterval) -> [Entry] {
guard let data = defaults.data(forKey: storageKey),
let entries = try? JSONDecoder().decode([Entry].self, from: data) else { return [] }
return entries.filter { $0.expiresAt > now }
}
func remember(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) {
lock.lock(); defer { lock.unlock() }
let entries = read(now: now)
let previous = entries.first { $0.identity.matches(identity) }
let newest = (previous?.identity.notificationSeq ?? -1) > identity.notificationSeq
? previous!.identity : identity
// Keep every live fence: count-based eviction lets delayed alerts reappear.
let next = entries.filter { !$0.identity.matches(identity) } +
[Entry(identity: newest, expiresAt: now + 86400)]
if let data = try? JSONEncoder().encode(next) {
defaults.set(data, forKey: storageKey)
}
}
func contains(_ identity: PushDismissalIdentity, now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
lock.lock(); defer { lock.unlock() }
return read(now: now).contains {
$0.identity.matches(identity) && $0.identity.notificationSeq >= identity.notificationSeq
}
}
func containsNotification(_ payload: [String: Any], now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
guard let identity = PushDismissalIdentity(payload) else { return false }
return contains(identity, now: now)
}
}
@@ -0,0 +1,5 @@
{
"name": "orca-notification-dismissal",
"version": "0.0.1",
"private": true
}
@@ -0,0 +1,44 @@
import Foundation
@main struct PushDismissalLedgerChecks {
static func main() {
let suite = "orca.qa.dismissal." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
func payload(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> [String: Any] {
["hostFingerprint": host, "notificationId": id, "notificationEpoch": epoch, "notificationSeq": seq]
}
func identity(_ seq: Int, _ host: String = "qa-host", _ epoch: String = "qa-epoch", _ id: String = "qa-alert") -> PushDismissalIdentity {
PushDismissalIdentity(payload(seq, host, epoch, id))!
}
let ledger = PushDismissalLedger(defaults: defaults)
ledger.remember(identity(2), now: 100)
ledger.remember(identity(1), now: 101)
let restored = PushDismissalLedger(defaults: defaults)
precondition(restored.contains(identity(1), now: 102))
precondition(restored.contains(identity(2), now: 102))
precondition(!restored.contains(identity(3), now: 102))
precondition(!restored.contains(identity(1, "other"), now: 102))
precondition(!restored.contains(identity(1, "qa-host", "other"), now: 102))
precondition(!restored.contains(identity(1, "qa-host", "qa-epoch", "other"), now: 102))
precondition(!restored.contains(identity(1), now: 86501))
precondition(PushDismissalIdentity(["hostFingerprint":"h", "notificationId":"n", "notificationEpoch":"e", "notificationSeq":true]) == nil)
precondition(restored.containsNotification(payload(1), now: 102))
precondition(!restored.containsNotification(payload(3), now: 102))
precondition(!restored.containsNotification(payload(1, "other"), now: 102))
precondition(!restored.containsNotification(payload(1, "qa-host", "other"), now: 102))
precondition(!restored.containsNotification(payload(1, "qa-host", "qa-epoch", "other"), now: 102))
precondition(!restored.containsNotification(["hostFingerprint": "qa-host"], now: 102))
for hosts in [1, 3] {
for index in 0..<520 {
ledger.remember(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 200)
}
let reopened = PushDismissalLedger(defaults: defaults)
for index in [0, 1, 519] {
precondition(reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201))
precondition(!reopened.contains(identity(3, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 201))
precondition(!reopened.contains(identity(2, "host-\(index % hosts)", "epoch-\(hosts)", "note-\(index)"), now: 86600))
}
}
print("Native persisted fence: restart, ordering, identity isolation, expiry and invalid sequence checks passed")
}
}
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "orca-mobile",
"version": "0.0.1",
"private": true,
"main": "expo-router/entry",
"main": "index.js",
"scripts": {
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
@@ -46,6 +46,7 @@
"expo-secure-store": "^55.0.18",
"expo-splash-screen": "^55.0.25",
"expo-status-bar": "^55.0.6",
"expo-task-manager": "~55.0.20",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "11.17.2",
@@ -0,0 +1,36 @@
diff --git a/build/getDevicePushTokenAsync.js b/build/getDevicePushTokenAsync.js
index f0875c5eaa84d45d646edd1ad5f21a962f68ab02..d93813b2636f1ac5e09081c3207407410a404698 100644
--- a/build/getDevicePushTokenAsync.js
+++ b/build/getDevicePushTokenAsync.js
@@ -20,8 +20,11 @@ export async function getDevicePushTokenAsync() {
else {
// Create a new Promise and clear it afterwards
nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync();
- devicePushToken = await nativeTokenPromise;
- nativeTokenPromise = null;
+ try {
+ devicePushToken = await nativeTokenPromise;
+ } finally {
+ nativeTokenPromise = null;
+ }
}
// @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it
return { type: Platform.OS, data: devicePushToken };
diff --git a/src/getDevicePushTokenAsync.ts b/src/getDevicePushTokenAsync.ts
index ab518dff463bc1a92052329ce5ac7c9055cbcf62..ad164f162998b5c1e218f089be82ab474727d68e 100644
--- a/src/getDevicePushTokenAsync.ts
+++ b/src/getDevicePushTokenAsync.ts
@@ -24,8 +24,11 @@ export async function getDevicePushTokenAsync(): Promise<DevicePushToken> {
} else {
// Create a new Promise and clear it afterwards
nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync();
- devicePushToken = await nativeTokenPromise;
- nativeTokenPromise = null;
+ try {
+ devicePushToken = await nativeTokenPromise;
+ } finally {
+ nativeTokenPromise = null;
+ }
}
// @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it
+1223 -1225
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,5 +7,6 @@ overrides:
xcode>uuid: 11.1.1
patchedDependencies:
expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch
react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch
react-native@0.83.10: patches/react-native@0.83.10.patch
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import * as runtimePaths from '../../../src/shared/cross-platform-path'
import type { Worktree } from '../worktree/workspace-list-types'
import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths'
@@ -57,4 +58,51 @@ describe('deriveMobileAiVaultScopePaths', () => {
'/Users/ada/repo/app'
])
})
it('retains first spelling and host-specific path equivalence', () => {
const paths = [
' /repo/café/ ',
'/repo/cafe\u0301',
'/repo//café',
'/repo/Café',
'C:\\Repo\\App',
'c:/repo/app/',
'\\\\server\\share\\App',
'//SERVER/share/app/',
'\\\\wsl.localhost\\Ubuntu\\home\\App',
'//wsl$/ubuntu/home/App/',
'//wsl$/ubuntu/home/app',
'//wsl$/Debian/home/App',
'/repo/back\\slash',
'/repo/back/slash',
'relative/path',
' '
]
const rows = paths.map((path, index) => worktree({ path, worktreeId: `w-${index}` }))
expect(deriveMobileAiVaultScopePaths('project', rows[0], rows)).toEqual([
'/repo/café/',
'/repo/Café',
'C:\\Repo\\App',
'\\\\server\\share\\App',
'\\\\wsl.localhost\\Ubuntu\\home\\App',
'//wsl$/ubuntu/home/app',
'//wsl$/Debian/home/App',
'/repo/back\\slash',
'/repo/back/slash'
])
})
it('normalizes each candidate once even with many duplicate siblings below the cap', () => {
const rows = Array.from({ length: 1000 }, (_, index) =>
worktree({ path: `/repo/app-${index % 32}`, worktreeId: `w-${index}` })
)
const normalize = vi.spyOn(runtimePaths, 'normalizeRuntimePathForComparison')
try {
const result = deriveMobileAiVaultScopePaths('project', rows[0], rows)
expect(result).toEqual(Array.from({ length: 32 }, (_, index) => `/repo/app-${index}`))
expect(normalize).toHaveBeenCalledTimes(rows.length + 1)
} finally {
normalize.mockRestore()
}
})
})
@@ -25,7 +25,8 @@ export function deriveMobileAiVaultScopePaths(
}
const paths: string[] = []
addScopePath(paths, activeWorktree.path)
const comparisonPaths = new Set<string>()
addScopePath(paths, comparisonPaths, activeWorktree.path)
// Workspace scope = the active worktree only. Project scope additionally
// covers same-repo sibling worktrees so the project view stays complete.
@@ -37,7 +38,7 @@ export function deriveMobileAiVaultScopePaths(
break
}
if (worktree.repoId === activeWorktree.repoId) {
addScopePath(paths, worktree.path)
addScopePath(paths, comparisonPaths, worktree.path)
}
}
}
@@ -45,16 +46,19 @@ export function deriveMobileAiVaultScopePaths(
return paths
}
function addScopePath(paths: string[], pathValue: string | undefined): void {
function addScopePath(
paths: string[],
comparisonPaths: Set<string>,
pathValue: string | undefined
): void {
const trimmedPath = pathValue?.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
if (
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
) {
if (comparisonPaths.has(comparisonPath)) {
return
}
comparisonPaths.add(comparisonPath)
paths.push(trimmedPath)
}
@@ -54,6 +54,21 @@ describe('MobileMarkdown file links', () => {
return renderer!
}
it('preserves a long unmatched bracket run as literal rendered text', () => {
const text = '['.repeat(60_000)
const tree = render(text)
expect(flattenText(tree.root)).toBe(text)
expect(pressables(tree)).toEqual([])
})
it('preserves nested labels and empty image labels', () => {
const tree = render('[[nested](https://example.com) ![](https://example.com/image)')
pressByText(tree, '[nested')
expect(openURL).toHaveBeenLastCalledWith('https://example.com')
pressByText(tree, 'image')
expect(openURL).toHaveBeenLastCalledWith('https://example.com/image')
})
it('opens a tapped POSIX absolute path in prose', () => {
pressByText(render('Edit /Users/me/wt/src/app.tsx now'), '/Users/me/wt/src/app.tsx')
expect(onOpenFile).toHaveBeenCalledWith('/Users/me/wt/src/app.tsx')
+8 -4
View File
@@ -1,3 +1,4 @@
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
import { MobileSelectableText } from './MobileSelectableText'
import {
Fragment,
@@ -104,12 +105,15 @@ function renderTextRun(
function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] {
const parts: ReactNode[] = []
const pattern =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
const pattern = createMarkdownInlineMatcher(
text,
/(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g,
true
)
let pendingStart = 0
let match: RegExpExecArray | null
let match: MarkdownInlineMatch | null
while ((match = pattern.exec(text))) {
while ((match = pattern.exec())) {
const token = match[0]
// Intraword `_` runs (snake_case, dunder tails) are literal text per
// CommonMark; leaving them unflushed keeps surrounding file paths whole
@@ -0,0 +1,103 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { createMarkdownInlineMatcher } from './markdown-inline-matcher'
import { isIntrawordUnderscoreToken } from './markdown-inline-token-rules'
import { parseInline } from './pr-sidebar/markdown-blocks'
const ORIGINAL_CHAT =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
const CHAT_OTHER =
/(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g
const ORIGINAL_REVIEW =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g
const REVIEW_OTHER = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g
function tokens(text: string, chat: boolean, original: boolean) {
const pattern = new RegExp(chat ? ORIGINAL_CHAT : ORIGINAL_REVIEW)
const matcher = original
? {
get lastIndex() {
return pattern.lastIndex
},
set lastIndex(value) {
pattern.lastIndex = value
},
exec: () => pattern.exec(text)
}
: createMarkdownInlineMatcher(text, new RegExp(chat ? CHAT_OTHER : REVIEW_OTHER), chat)
const result: Array<{ text: string; index: number; end: number }> = []
let match
while ((match = matcher.exec())) {
if (chat && isIntrawordUnderscoreToken(text, match.index, match[0])) {
matcher.lastIndex = match.index + 1
continue
}
result.push({ text: match[0], index: match.index, end: matcher.lastIndex })
}
return result
}
describe('mobile inline link scanning', () => {
it.each([false, true])('preserves original token order (chat=%s)', (chat) => {
const fragments = [
'[',
']',
'(',
')',
'!',
'a',
' ',
'*',
'**',
'_',
'__',
'`',
'~',
'\n',
'[a](b)',
'![](x)',
'https://x.y',
'[bad]',
'foo_bar',
'[[nested](url)'
]
let seed = 12345
const random = () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
return seed
}
for (let i = 0; i < 5000; i++) {
const text = Array.from(
{ length: 1 + (random() % 40) },
() => fragments[random() % fragments.length]
).join('')
expect(tokens(text, chat, false), text).toEqual(tokens(text, chat, true))
}
})
it.each([
{ name: 'unmatched labels', text: '['.repeat(60_000) },
{ name: 'unmatched destinations', text: '[a]('.repeat(12_000) }
])('keeps $name literal within the parser deadline', ({ text }) => {
const result = runInNewContext('parse(text)', { parse: parseInline, text }, { timeout: 250 })
expect(result).toEqual([{ kind: 'text', text }])
})
it('does not repeatedly search the suffix for absent non-link tokens', () => {
const text = '[a](b)'.repeat(10_000)
const pattern = new RegExp(REVIEW_OTHER)
const originalExec = pattern.exec.bind(pattern)
let calls = 0
pattern.exec = (value) => {
calls++
return originalExec(value)
}
const matcher = createMarkdownInlineMatcher(text, pattern)
let count = 0
while (matcher.exec()) {
count++
}
expect(count).toBe(10_000)
expect(calls).toBe(1)
})
})
@@ -0,0 +1,72 @@
export type MarkdownInlineMatch = { 0: string; index: number; end: number }
/** Merge a global non-link regex with links; search starts must advance between calls. */
export function createMarkdownInlineMatcher(
text: string,
nonLinkPattern: RegExp,
images = false
): { lastIndex: number; exec: () => MarkdownInlineMatch | null } {
let nextOther: MarkdownInlineMatch | null | undefined
let nextLink: MarkdownInlineMatch | null | undefined
let labelEnd = -1
let destinationEnd = -1
let noMoreLabels = false
let noMoreDestinations = false
function findLink(from: number): MarkdownInlineMatch | null {
if (noMoreLabels || noMoreDestinations) {
return null
}
let open = text.indexOf('[', from)
while (open !== -1) {
if (labelEnd < open + 1) {
labelEnd = text.indexOf(']', open + 1)
}
if (labelEnd === -1) {
noMoreLabels = true
return null
}
const image = images && open > from && text[open - 1] === '!'
if ((image || labelEnd > open + 1) && text[labelEnd + 1] === '(') {
if (destinationEnd < labelEnd + 2) {
destinationEnd = text.indexOf(')', labelEnd + 2)
}
if (destinationEnd === -1) {
noMoreDestinations = true
return null
}
if (destinationEnd > labelEnd + 2) {
const index = image ? open - 1 : open
return { 0: text.slice(index, destinationEnd + 1), index, end: destinationEnd + 1 }
}
}
// Every opener before this closing bracket shares the same invalid suffix.
open = text.indexOf('[', labelEnd + 1)
}
return null
}
const matcher = {
lastIndex: 0,
exec(): MarkdownInlineMatch | null {
const from = matcher.lastIndex
if (nextOther === undefined || (nextOther !== null && nextOther.index < from)) {
nonLinkPattern.lastIndex = from
const match = nonLinkPattern.exec(text)
nextOther = match
? { 0: match[0], index: match.index, end: nonLinkPattern.lastIndex }
: null
}
if (nextLink === undefined || (nextLink !== null && nextLink.index < from)) {
nextLink = findLink(from)
}
const match =
nextLink && (!nextOther || nextLink.index < nextOther.index) ? nextLink : nextOther
if (match) {
matcher.lastIndex = match.end
}
return match
}
}
return matcher
}
@@ -0,0 +1,46 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
function parseWithDeadline(input: string) {
// A synchronous parser loop must fail without hanging the test worker.
return runInNewContext('parse(input)', { parse: parseMobileMarkdown, input }, { timeout: 250 })
}
describe('mobile Markdown parser progress', () => {
it.each(['```c++', '```c#', '``` ts', '```ts title="file.ts"', '````', '```!'])(
'retains unsupported fence %s as paragraph text',
(fence) => {
expect(parseWithDeadline(fence)).toEqual([{ type: 'paragraph', text: fence }])
expect(parseWithDeadline(`before\n${fence}\nafter`)).toEqual([
{ type: 'paragraph', text: `before\n${fence}\nafter` }
])
}
)
it.each(['# ', '## ', '###### ', '#\t'])('consumes incomplete heading %s', (heading) => {
expect(parseWithDeadline(`${heading}\nnext`)).toEqual([
{ type: 'paragraph', text: `${heading}\nnext` }
])
})
it('handles unsupported fences through the production preview normalization', () => {
const input = normalizeMobileMarkdownPreviewHtml('```c++\nint x = 1;')
expect(parseWithDeadline(input)).toEqual([{ type: 'paragraph', text: input }])
})
it('recognizes a supported fence after unsupported fence text', () => {
expect(parseWithDeadline('```c++\n```ts\nconst x = 1\n```')).toEqual([
{ type: 'paragraph', text: '```c++' },
{ type: 'code', text: 'const x = 1', language: 'ts', closed: true }
])
})
it('preserves supported fences and their streaming state', () => {
expect(parseWithDeadline('before\n```ts\nconst x = 1')).toEqual([
{ type: 'paragraph', text: 'before' },
{ type: 'code', text: 'const x = 1', language: 'ts', closed: false }
])
})
})
@@ -8,6 +8,9 @@ export type MobileMarkdownBlock =
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'rule' }
const HEADING = /^(#{1,6})\s+(.+)$/
const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/
function splitTableRow(line: string): string[] {
return line
.trim()
@@ -34,7 +37,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
continue
}
const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/)
const fence = line.match(CODE_FENCE)
if (fence) {
index += 1
const code: string[] = []
@@ -80,7 +83,7 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
continue
}
const heading = line.match(/^(#{1,6})\s+(.+)$/)
const heading = line.match(HEADING)
if (heading) {
blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() })
index += 1
@@ -121,8 +124,8 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
while (
index < lines.length &&
lines[index]?.trim() &&
!(lines[index] ?? '').startsWith('```') &&
!/^(#{1,6})\s+/.test(lines[index] ?? '') &&
!CODE_FENCE.test(lines[index] ?? '') &&
!HEADING.test(lines[index] ?? '') &&
!/^>\s?/.test(lines[index] ?? '') &&
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') &&
!/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '')
@@ -1,3 +1,5 @@
import { createMarkdownInlineMatcher } from '../markdown-inline-matcher'
// Tiny, dependency-free markdown model for PR comment bodies. We render GitHub
// markdown without a third-party RN markdown library (the previous dependency hung
// the JS thread when a comment list mounted). Scope is deliberately small — the
@@ -30,8 +32,6 @@ const HEADING = /^(#{1,6})\s+(.*)$/
const FENCE = /^```/
// Captures the fence info string (language) on the opening fence, e.g. ```mermaid.
const FENCE_OPEN = /^```\s*([^\s`]*)/
// A GFM table delimiter row: cells of dashes with optional leading/trailing colons.
const TABLE_DELIM = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
const QUOTE = /^>\s?(.*)$/
const HR = /^(?:---+|\*\*\*+|___+)\s*$/
const UNORDERED = /^\s*[-*+]\s+(.*)$/
@@ -114,7 +114,7 @@ function parseLines(content: string): MarkdownBlock[] {
// GFM pipe table: a header row immediately followed by a delimiter row.
// Requires the delimiter row so plain prose with a stray `|` isn't captured.
if (line.includes('|') && i + 1 < lines.length && TABLE_DELIM.test(lines[i + 1])) {
if (line.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])) {
flushParagraph()
const headers = splitTableRow(line)
const align = parseAlignRow(lines[i + 1])
@@ -217,6 +217,10 @@ function splitTableRow(line: string): string[] {
return cells
}
function isTableDelimiter(line: string): boolean {
return splitTableRow(line).every((cell) => /^:?-+:?$/.test(cell))
}
// Reads alignment from a delimiter row's colons: `:--` left, `:-:` center, `--:` right.
function parseAlignRow(line: string): CellAlign[] {
return splitTableRow(line).map((spec) => {
@@ -234,23 +238,25 @@ function parseAlignRow(line: string): CellAlign[] {
// Inline emphasis/code/link tokenizer. Walks the string once, longest-match first,
// emitting plain-text runs between matches. Unbalanced markers stay literal text.
const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/
const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g
export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = []
// Strip residual inline HTML tags (<b>, <kbd>, <sub>, …) so they don't render
// literally; emphasis/code/links below are markdown, not HTML, so this is safe.
let rest = stripHtmlTags(text)
const plain = stripHtmlTags(text)
const matcher = createMarkdownInlineMatcher(plain, INLINE)
let cursor = 0
let guard = 0
while (rest.length > 0 && guard < 5000) {
while (cursor < plain.length && guard < 5000) {
guard += 1
const m = INLINE.exec(rest)
const m = matcher.exec()
if (!m || m.index === undefined) {
tokens.push({ kind: 'text', text: rest })
tokens.push({ kind: 'text', text: plain.slice(cursor) })
break
}
if (m.index > 0) {
tokens.push({ kind: 'text', text: rest.slice(0, m.index) })
if (m.index > cursor) {
tokens.push({ kind: 'text', text: plain.slice(cursor, m.index) })
}
const token = m[0]
if (token.startsWith('`')) {
@@ -267,7 +273,7 @@ export function parseInline(text: string): InlineToken[] {
} else {
tokens.push({ kind: 'italic', text: token.slice(1, -1) })
}
rest = rest.slice(m.index + token.length)
cursor = matcher.lastIndex
}
return tokens
}
@@ -0,0 +1,36 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { parseMarkdownBlocks } from './markdown-blocks'
const ORIGINAL_DELIMITER = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
function isParsedTable(delimiter: string): boolean {
return parseMarkdownBlocks(`a|b\n${delimiter}`)[0]?.kind === 'table'
}
describe('review Markdown table delimiter cost', () => {
it.each([
{ name: 'leading whitespace', delimiter: ' '.repeat(60_000) + 'x' },
{ name: 'trailing cell whitespace', delimiter: '-|-' + ' '.repeat(60_000) + 'x' }
])('rejects $name without backtracking', ({ delimiter }) => {
// Interrupt synchronous regex regressions instead of hanging the test worker.
const blocks = runInNewContext(
'parse(input)',
{ parse: parseMarkdownBlocks, input: `a|b\n${delimiter}` },
{ timeout: 250 }
)
expect(blocks).toEqual([{ kind: 'paragraph', text: `a|b\n${delimiter}` }])
})
it('preserves the original delimiter grammar over generated rows', () => {
const parts = ['', '-', '--', ':', ':-:', ':--', '--:', '|', ' ', '\t', '\r', '\\|', 'x']
for (const left of parts) {
for (const middle of parts) {
for (const right of parts) {
const row = left + middle + right
expect(isParsedTable(row), JSON.stringify(row)).toBe(ORIGINAL_DELIMITER.test(row))
}
}
}
})
})
@@ -1,4 +1,4 @@
import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native'
import { WifiOff, Shield, Monitor, Clock, Globe, Bell } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
export type TroubleshootSection = {
@@ -9,6 +9,16 @@ export type TroubleshootSection = {
}
export const troubleshootCommonIssues: TroubleshootSection[] = [
{
id: 'notifications',
icon: <Bell size={16} color={colors.textSecondary} />,
title: 'Push Notifications',
steps: [
'Check that system settings allow Orca notifications and that Focus or Do Not Disturb is off.',
'Try cellular or another Wi-Fi network. If alerts arrive after switching, your network may be delaying delivery.'
]
},
{
id: 'wifi',
icon: <WifiOff size={16} color={colors.textSecondary} />,
@@ -0,0 +1,37 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { expect, it, vi } from 'vitest'
import { NotificationDeliverySection } from './NotificationDeliverySection'
import { DEFAULT_NOTIFICATION_DELIVERY } from './notification-delivery-preferences'
vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
vi.mock('react-native', () => ({
StyleSheet: { create: (value: unknown) => value },
View: 'View',
Text: 'Text',
Switch: 'Switch'
}))
it('shows only phone-specific controls while desktop owns category eligibility', () => {
const onChange = vi.fn()
let renderer: ReturnType<typeof create>
act(() => {
renderer = create(
createElement(NotificationDeliverySection, { value: DEFAULT_NOTIFICATION_DELIVERY, onChange })
)
})
const switches = () => renderer.root.findAllByType('Switch' as never)
expect(switches().map((node) => node.props.accessibilityLabel)).toEqual([
'Only when away from desktop',
'Notification sound',
'Suppress while focused'
])
expect(JSON.stringify(renderer.toJSON())).toContain(
'Alert types follow each paired desktop’s notification settings.'
)
act(() => switches()[0].props.onValueChange(false))
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ onlyWhenDesktopAway: false, sound: true, suppressWhileViewing: true })
)
act(() => renderer.unmount())
})
@@ -0,0 +1,72 @@
import { StyleSheet, Switch, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { NotificationDeliveryPreferences } from './notification-delivery-preferences'
type Props = {
value: NotificationDeliveryPreferences
disabled?: boolean
onChange: (value: NotificationDeliveryPreferences) => void
}
export function NotificationDeliverySection({ value, disabled, onChange }: Props) {
const row = (key: keyof NotificationDeliveryPreferences, label: string, hint?: string) => {
return (
<View key={key} style={[styles.row, disabled && styles.disabled]}>
<View style={styles.labelGroup}>
<Text style={styles.label}>{label}</Text>
{hint && <Text style={styles.hint}>{hint}</Text>}
</View>
<Switch
accessibilityLabel={label}
testID={`notification-${key}`}
value={value[key]}
disabled={disabled}
onValueChange={(enabled) => onChange({ ...value, [key]: enabled })}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
return (
<>
<View style={styles.section}>
{row(
'onlyWhenDesktopAway',
'Only when away from desktop',
'After 3 minutes without keyboard or mouse activity, or when locked.'
)}
{row('sound', 'Notification sound')}
{row(
'suppressWhileViewing',
'Suppress while focused',
'Skip alerts for the workspace open on this phone.'
)}
</View>
<Text style={styles.footer}>
Alert types follow each paired desktop’s notification settings. Notifications pause after 7
days without using this app; open it and reconnect to resume.
</Text>
</>
)
}
const styles = StyleSheet.create({
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden',
marginTop: spacing.md
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, padding: spacing.md },
labelGroup: { flex: 1, gap: spacing.xs },
label: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary },
hint: { fontSize: typography.metaSize, color: colors.textMuted },
disabled: { opacity: 0.5 },
footer: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: spacing.md,
paddingHorizontal: spacing.sm
}
})
@@ -0,0 +1,112 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { Notification } from 'expo-notifications'
import { startAndroidForegroundPushPresentation } from './android-foreground-push'
const mocks = vi.hoisted(() => ({
platform: { OS: 'android' },
receive: (_notification: Notification) => {},
remove: vi.fn(),
eligible: vi.fn().mockResolvedValue(true),
schedule: vi.fn().mockResolvedValue('message-1')
}))
vi.mock('./push-receive', () => ({ canPresentForegroundPush: mocks.eligible }))
vi.mock('react-native', () => ({ Platform: mocks.platform }))
vi.mock('expo-notifications', () => ({
addNotificationReceivedListener: (listener: typeof mocks.receive) => {
mocks.receive = listener
return { remove: mocks.remove }
},
scheduleNotificationAsync: mocks.schedule
}))
function notification(trigger: unknown = { type: 'push', remoteMessage: { notification: null } }) {
return {
request: {
identifier: 'message-1',
trigger,
content: {
title: 'Test notification',
body: '',
sound: 'default',
data: {
hostFingerprint: 'host',
notificationId: 'event',
notificationEpoch: 'epoch',
notificationSeq: '3',
paneKey: 'pane',
channelId: 'orca-desktop'
}
}
}
} as unknown as Notification
}
beforeEach(() => {
vi.clearAllMocks()
mocks.eligible.mockReset().mockResolvedValue(true)
mocks.platform.OS = 'android'
})
it('presents a title-only data push with its original identity, routing and channel', async () => {
const stop = startAndroidForegroundPushPresentation()
const incoming = notification()
mocks.receive(incoming)
await vi.waitFor(() => expect(mocks.schedule).toHaveBeenCalledOnce())
expect(mocks.schedule).toHaveBeenCalledWith({
identifier: incoming.request.identifier,
content: incoming.request.content,
trigger: { channelId: 'orca-desktop' }
})
stop()
expect(mocks.remove).toHaveBeenCalledOnce()
})
it('does not reschedule its own local notification or normal provider notifications', () => {
startAndroidForegroundPushPresentation()
mocks.receive(notification(null))
mocks.receive(notification({ type: 'channel', channelId: 'orca-desktop' }))
mocks.receive(notification({ type: 'push', remoteMessage: { notification: { title: 'Test' } } }))
expect(mocks.schedule).not.toHaveBeenCalled()
})
it('leaves silent dismissals and unrelated messages alone', () => {
startAndroidForegroundPushPresentation()
const incoming = notification()
incoming.request.content.data.kind = 'dismiss'
mocks.receive(incoming)
incoming.request.content.data = {}
mocks.receive(incoming)
expect(mocks.schedule).not.toHaveBeenCalled()
})
it('leaves iOS delivery unchanged', () => {
mocks.platform.OS = 'ios'
startAndroidForegroundPushPresentation()()
expect(mocks.remove).not.toHaveBeenCalled()
})
it('waits for eligibility before scheduling, even if native presentation will bypass JS', async () => {
let resolve!: (eligible: boolean) => void
mocks.eligible.mockReturnValue(
new Promise<boolean>((done) => {
resolve = done
})
)
startAndroidForegroundPushPresentation()
mocks.receive(notification())
expect(mocks.schedule).not.toHaveBeenCalled()
// Model a dismissal arriving while the eligibility reads are in flight.
resolve(false)
await Promise.resolve()
expect(mocks.schedule).not.toHaveBeenCalled()
})
it('does not schedule when eligibility cannot be read', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mocks.eligible.mockRejectedValueOnce(new Error('storage unavailable'))
startAndroidForegroundPushPresentation()
mocks.receive(notification())
await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce())
expect(mocks.schedule).not.toHaveBeenCalled()
warn.mockRestore()
})
@@ -0,0 +1,49 @@
import { Platform } from 'react-native'
import * as Notifications from 'expo-notifications'
import { canPresentForegroundPush } from './push-receive'
import { readOrcaPushPayload } from './push-payload'
export function startAndroidForegroundPushPresentation(): () => void {
if (Platform.OS !== 'android') {
return () => {}
}
const subscription = Notifications.addNotificationReceivedListener((notification) => {
const { trigger, content, identifier } = notification.request
// Expo emits foreground data pushes but only auto-presents them in the background.
if (
!trigger ||
!('type' in trigger) ||
trigger.type !== 'push' ||
trigger.remoteMessage?.notification !== null
) {
return
}
const payload = readOrcaPushPayload(content.data)
if (!payload || payload.kind === 'dismiss' || (!content.title && !content.body)) {
return
}
void present().catch((error: unknown) => {
console.warn('[push] Foreground notification presentation failed', error)
})
async function present(): Promise<void> {
if (!payload || !(await canPresentForegroundPush(payload))) {
return
}
await Notifications.scheduleNotificationAsync({
identifier,
content: {
title: content.title,
body: content.body,
data: content.data,
sound: content.sound === 'default' ? 'default' : false
},
trigger:
typeof content.data?.channelId === 'string' ? { channelId: content.data.channelId } : null
})
}
})
return () => subscription.remove()
}
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
DESKTOP_NOTIFICATION_CHANNEL_ID,
ensureDesktopNotificationChannel
} from './desktop-notification-channel'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'background' },
Platform: { OS: 'android' }
}))
beforeEach(() => {
vi.clearAllMocks()
Object.assign(Platform, { OS: 'android' })
vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null as never)
})
describe('ensureDesktopNotificationChannel', () => {
it('creates the channel the gateway payload names', async () => {
await ensureDesktopNotificationChannel()
expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
'orca-desktop',
expect.objectContaining({ importance: 'high' })
)
expect(DESKTOP_NOTIFICATION_CHANNEL_ID).toBe('orca-desktop')
})
it('does nothing on iOS, which has no notification channels', () => {
Object.assign(Platform, { OS: 'ios' })
ensureDesktopNotificationChannel()
expect(Notifications.setNotificationChannelAsync).not.toHaveBeenCalled()
})
it('reports channel failure so registration can retry', async () => {
vi.mocked(Notifications.setNotificationChannelAsync).mockRejectedValue(new Error('no channels'))
await expect(ensureDesktopNotificationChannel()).rejects.toThrow('no channels')
})
})
describe('app boot', () => {
it('creates the channel at startup, not only once a socket subscribes', () => {
// A background push can be the first thing to target 'orca-desktop', and Android
// drops a notification whose channel does not exist. Asserted against the source
// because vitest only collects src/, so app/_layout.tsx has no runtime coverage.
const layout = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8')
expect(layout).toContain("from '../src/notifications/desktop-notification-channel'")
expect(layout).toMatch(/^void ensureDesktopNotificationChannel\(\)\.catch\(/m)
})
})
@@ -0,0 +1,27 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
// Why an id both sides share: the gateway's FCM payload names this channel, so a
// background push can be the first thing that ever targets it. Android drops a
// notification whose channel does not exist, and the channel used to be created
// only inside subscribeToDesktopNotifications — i.e. only once a socket connected.
export const DESKTOP_NOTIFICATION_CHANNEL_ID = 'orca-desktop'
/** Idempotent on Android (the OS updates the existing channel); a no-op elsewhere. */
export async function ensureDesktopNotificationChannel(): Promise<void> {
if (Platform.OS !== 'android') {
return
}
await Notifications.setNotificationChannelAsync(`${DESKTOP_NOTIFICATION_CHANNEL_ID}-silent`, {
name: 'Orca silent notifications',
importance: Notifications.AndroidImportance.HIGH,
sound: null,
enableVibrate: false
})
await Notifications.setNotificationChannelAsync(DESKTOP_NOTIFICATION_CHANNEL_ID, {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
@@ -0,0 +1,6 @@
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
@@ -0,0 +1,40 @@
import { beforeEach, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => vi.fn())
vi.mock('expo-modules-core', () => ({ Platform: { OS: 'ios' }, UnavailabilityError: Error }))
vi.mock('expo-notifications/build/PushTokenManager', () => ({
default: { getDevicePushTokenAsync: native }
}))
vi.mock('expo-notifications/build/warnOfExpoGoPushUsage', () => ({
warnOfExpoGoPushUsage: () => {}
}))
beforeEach(() => {
vi.resetModules()
native.mockReset()
})
it('releases a failed Expo native-token request so the next attempt can succeed', async () => {
const { getDevicePushTokenAsync } =
await import('expo-notifications/build/getDevicePushTokenAsync')
native.mockRejectedValueOnce(new Error('APNs unavailable')).mockResolvedValueOnce('device-token')
await expect(getDevicePushTokenAsync()).rejects.toThrow('APNs unavailable')
await expect(getDevicePushTokenAsync()).resolves.toEqual({ type: 'ios', data: 'device-token' })
expect(native).toHaveBeenCalledTimes(2)
})
it('still shares one pending native request between concurrent callers', async () => {
const { getDevicePushTokenAsync } =
await import('expo-notifications/build/getDevicePushTokenAsync')
let resolve!: (token: string) => void
native.mockReturnValue(
new Promise<string>((done) => {
resolve = done
})
)
const first = getDevicePushTokenAsync()
const second = getDevicePushTokenAsync()
expect(native).toHaveBeenCalledOnce()
resolve('device-token')
expect(await first).toEqual(await second)
})
@@ -1,191 +0,0 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import { ensureNotificationPermissions } from './notification-permissions'
export type NotificationEvent = {
type: 'notification'
source: DesktopNotificationSource
title: string
body: string
worktreeId?: string
notificationId?: string
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
notificationSeq?: number
// Counter lifetime the seq belongs to (#8591); absent on older runtimes.
notificationEpoch?: string
}
export type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
notificationEpoch?: string
}
type ScheduledNotificationState = {
identifier?: string
pending?: Promise<string | null>
dismissAfterSchedule?: boolean
}
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export function configureNotificationChannel(): void {
if (Platform.OS === 'android') {
void Notifications.setNotificationChannelAsync('orca-desktop', {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250],
lightColor: '#6366f1'
})
}
}
export async function showLocalNotification(
event: NotificationEvent,
hostId: string
): Promise<void> {
const storedKey = event.notificationId
? getStoredNotificationKey(hostId, event.notificationId)
: null
if (!storedKey) {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return
}
await Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
return
}
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (state?.pending) {
return
}
if (!state) {
state = {}
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
}
const notificationState = state
const pending = (async () => {
const enabled = await loadPushNotificationsEnabled()
if (!enabled) {
return null
}
const granted = await ensureNotificationPermissions()
if (!granted) {
return null
}
if (notificationState.identifier) {
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
notificationState.identifier = undefined
}
return Notifications.scheduleNotificationAsync({
content: {
title: event.title,
body: event.body,
data: buildLocalNotificationData(event, hostId),
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
},
trigger: null
})
})()
notificationState.pending = pending
try {
const scheduledIdentifier = await pending
if (!scheduledIdentifier) {
if (!notificationState.identifier) {
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
}
return
}
if (notificationState.dismissAfterSchedule) {
notificationState.dismissAfterSchedule = false
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
notificationState.dismissAfterSchedule = false
}
}
}
export async function dismissLocalNotification(
event: DismissNotificationEvent,
hostId: string
): Promise<void> {
if (!event.notificationId) {
return
}
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
if (!state) {
return
}
if (state.pending) {
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
state.dismissAfterSchedule = true
return
}
if (!state.identifier) {
return
}
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
}
File diff suppressed because it is too large Load Diff
+12 -252
View File
@@ -1,211 +1,22 @@
import { requestNotificationCatchup } from './push-dismissal-reconciliation'
import { dismissHostPushNotification } from './push-socket-dismissal'
import type { DismissNotificationEvent } from './desktop-notification-events'
import type { RpcClient } from '../transport/rpc-client'
// Re-exported so the existing importers (and their vi.mock paths) keep working.
export {
ensureNotificationPermissions,
getNotificationPermissionState,
type NotificationPermissionState
} from './notification-permissions'
export { setScheduledNotificationsMaxForTests } from './local-notification-scheduling'
import {
configureNotificationChannel,
dismissLocalNotification,
showLocalNotification,
type DismissNotificationEvent,
type NotificationEvent
} from './local-notification-scheduling'
import {
adoptNotificationEpoch,
catchUpWatermarkSeq,
enqueueHostDelivery,
getHostNotificationSession,
quarantineCatchUpWatermark,
releaseQueuedShowNotificationId,
resolveCatchUpQuarantine,
saveWatermark,
seedWatermarkFromStorage,
seenKeyForEvent,
shouldQueueShowForNotificationId
} from './notification-reconnect-catchup'
type SubscribeResult = {
type: 'ready'
subscriptionId: string
// Desktop counter lifetime (#8591); absent from runtimes that predate it.
epoch?: string
}
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Why (#8591): survives the unsubscribe/resubscribe the app performs on every
// socket drop, so a reconnect still knows its watermark and that it reconnected.
const session = getHostNotificationSession(hostId)
/**
* Queue one delivery on the host chain, dropping a show whose notificationId
* already has one queued.
*
* Why the claim is taken HERE and not inside deliverLive (#8591): the point of
* the dedup is to notice a second event arriving while the first is still
* outstanding. Inside the queued task the first has already finished, so the
* overlap is no longer observable — it has to be checked before enqueueing.
*/
function queueDelivery(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
if (
type === 'notification' &&
!shouldQueueShowForNotificationId(session, event.notificationId)
) {
return Promise.resolve()
}
return enqueueHostDelivery(session, async () => {
try {
await deliverLive(type, event)
} finally {
if (type === 'notification') {
releaseQueuedShowNotificationId(session, event.notificationId)
}
}
// Why swallowed: the caller is an un-awaited handler, so a rejected show would
// surface as an unhandled rejection (a RN redbox) instead of being retried by
// the next catch-up — which is now possible, since `seen` is marked after the show.
}).catch(() => {})
}
async function deliverLive(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
const epochAtDelivery = session.lastDeliveredEpoch
if (type === 'notification') {
await showLocalNotification(event as NotificationEvent, hostId)
} else {
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why after the await, exactly like the watermark below: `seen` asserts this event
// reached the user (#8129). Marked before, a rejected show leaves the key behind and
// every later replay is dropped as a duplicate — loss the quarantine cannot recover,
// since the first event to drain a batch lifts it past the one never shown.
const key = seenKeyForEvent(event)
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
session.seen.add(key)
}
// Why after the await (#8591): the watermark is a promise that everything up
// to this seq has been shown. Advancing it before the local notification lands
// means a process death in between silently drops it — the next launch asks the
// desktop for seq greater than one the user never saw.
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
session.lastDeliveredSeq = event.notificationSeq
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
// the live seq would let the next catch-up ask from above the gap and the desktop
// would cut it. resolveCatchUpQuarantine writes the held-back value on success.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
}
// Claimed inline rather than via queueDelivery: the batch is already one queue
// entry, and re-enqueueing per item is what let a live event cut in.
async function deliverMissedEvent(
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
// No pre-marking here either: deliverLive marks the key once the show lands.
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
return
}
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
return
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
return
}
if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
// Captured before the request: everything at or below it is known delivered, so
// it is the floor the watermark falls back to if this catch-up never completes.
const askFrom = catchUpWatermarkSeq(session)
const missed = await client
.sendRequest('notifications.getMissedSince', {
lastSeenSeq: askFrom,
// Why: sending the epoch lets the desktop reject a watermark from a counter
// it no longer has and return the whole retained buffer instead of nothing.
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
})
.then((response) => {
if (!response.ok) {
return null
}
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
adoptNotificationEpoch(session, hostId, result?.epoch)
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => null)
if (missed == null) {
// Why quarantine rather than retry: the range this catch-up abandoned stays
// unrecovered until SOME later one succeeds, and a live seq persisting past it
// meanwhile would make the desktop cut it forever.
quarantineCatchUpWatermark(session, hostId, askFrom)
return
}
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
// the event loop between replays, so a live seq 11 slots into the chain between
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
// chain for that would stall live delivery on a slow link.
await enqueueHostDelivery(session, async () => {
// Advances only past events this batch settled, so a teardown or a failing show
// quarantines the true contiguous point instead of the range it never reached.
let contiguousSeq = askFrom
let drained = false
try {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
const event = raw as NotificationEvent | DismissNotificationEvent
await deliverMissedEvent(event)
contiguousSeq = event.notificationSeq ?? contiguousSeq
}
drained = true
} finally {
if (drained) {
resolveCatchUpQuarantine(session, hostId)
} else {
quarantineCatchUpWatermark(session, hostId, contiguousSeq)
}
}
// Why swallowed here: the `finally` above already recorded the contiguous point,
// and the only caller is an un-awaited 'ready' continuation — letting a failed
// show escape turns every one into an unhandled rejection (a RN redbox).
}).catch(() => {})
}
seedWatermarkFromStorage(session, hostId)
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
@@ -213,79 +24,28 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
}
}
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
const event = data as
| NotificationEvent
| DismissNotificationEvent
| SubscribeResult
| { type: 'end' }
const params = { includeDesktopSuppressed: true }
const unsubscribeStream = client.subscribe('notifications.subscribe', params, (data: unknown) => {
const event = data as DismissNotificationEvent | SubscribeResult | { type: string }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
const isReconnect = session.connectedBefore
session.connectedBefore = true
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
return
}
const readyEpoch = (event as SubscribeResult).epoch
// Why (#8591) the await: on a cold app open the persisted read is still in
// flight, so deciding here would see watermarkLoaded false and skip catch-up —
// which is precisely the post-upgrade / post-process-death case that loses
// every notification between the stored watermark and the next live seq.
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why before fetchMissed: adopting the epoch here is what voids a watermark
// left over from a previous desktop lifetime, so the catch-up request carries
// a watermark that means something against the counter now answering it.
adoptNotificationEpoch(session, hostId, readyEpoch)
// A reconnect always catches up. A cold open catches up only when this device
// has delivered for this host before — a first-ever pairing must not be handed
// the desktop's whole retained buffer.
if (isReconnect || session.hadStoredWatermark) {
await fetchMissed()
}
})()
// A max watermark asks only which delivered pushes are stale; socket history
// never becomes a second OS-notification delivery route.
void requestNotificationCatchup(client, hostId, () => disposed).catch(() => {})
return
}
if (event.type === 'end') {
if (disposed) {
unsubscribeStream()
}
return
if (!disposed && event.type === 'dismiss') {
void dismissHostPushNotification(event as DismissNotificationEvent, hostId).catch(() => {})
}
if (disposed) {
return
}
if (event.type !== 'notification' && event.type !== 'dismiss') {
return
}
// Why the await (#8591): deliverLive advances the watermark. A live event landing
// while the persisted read is still in flight would push it past the buffered seqs
// the catch-up is about to ask for, and getMissedSince would cut them. Ordering is
// preserved — every handler waits on the same promise, and the 'ready' continuation
// registered on it first, so catch-up still builds its request before any live seq.
const liveEvent = event
void (async () => {
await session.watermarkSeeded
if (disposed) {
return
}
// Why the queue (#8591): a live event must not overtake an in-flight
// catch-up replay, or it persists a watermark past seqs still unshown.
await queueDelivery(
liveEvent.type === 'notification' ? 'notification' : 'dismiss',
liveEvent as NotificationEvent | DismissNotificationEvent
)
})()
})
return () => {
disposed = true
// Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending.
unsubscribeStream()
if (subscriptionId) {
unsubscribeServer(subscriptionId)
@@ -0,0 +1,39 @@
import { afterEach, expect, it, vi } from 'vitest'
import { AppState } from 'react-native'
import { startMobilePushLeaseRenewal } from './mobile-push-lease-renewal'
let onChange: (state: string) => void
const remove = vi.fn()
vi.mock('react-native', () => ({
AppState: {
currentState: 'active',
addEventListener: (_: string, callback: typeof onChange) => {
onChange = callback
return { remove }
}
}
}))
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('renews only while mobile is foregrounded, resumes on return, and tears down', async () => {
vi.useFakeTimers()
AppState.currentState = 'active'
const renew = vi.fn(async () => {})
const stop = startMobilePushLeaseRenewal(renew)
await vi.advanceTimersByTimeAsync(15 * 60_000)
expect(renew).toHaveBeenCalledTimes(1)
AppState.currentState = 'background'
onChange('background')
await vi.advanceTimersByTimeAsync(8 * 24 * 60 * 60_000)
expect(renew).toHaveBeenCalledTimes(1)
AppState.currentState = 'active'
onChange('active')
expect(renew).toHaveBeenCalledTimes(2)
stop()
await vi.advanceTimersByTimeAsync(15 * 60_000)
expect(renew).toHaveBeenCalledTimes(2)
expect(remove).toHaveBeenCalledOnce()
})
@@ -0,0 +1,19 @@
import { AppState } from 'react-native'
export function startMobilePushLeaseRenewal(renew: () => Promise<void>): () => void {
const refresh = () => {
if (AppState.currentState === 'active') {
void renew().catch(() => {})
}
}
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refresh()
}
})
const timer = setInterval(refresh, 15 * 60_000)
return () => {
subscription.remove()
clearInterval(timer)
}
}
@@ -0,0 +1,22 @@
import { expect, it } from 'vitest'
import { readNativeNotificationData } from './native-notification-data'
import { readOrcaPushPayload } from './push-payload'
it('reads actual Expo APNs payloads when content.data is null', () => {
const orca = {
hostFingerprint: 'qa-host',
notificationId: 'done',
notificationSeq: 4,
notificationEpoch: 'epoch'
}
const data = readNativeNotificationData({
content: { data: null },
trigger: { type: 'push', payload: { aps: {}, orca } }
})
expect(readOrcaPushPayload(data)).toMatchObject(orca)
})
it('keeps Android push and local notification data', () => {
const data = { hostId: 'host', notificationId: 'done' }
expect(readNativeNotificationData({ content: { data }, trigger: { type: 'push' } })).toBe(data)
expect(readNativeNotificationData({ content: { data }, trigger: null })).toBe(data)
})
@@ -0,0 +1,13 @@
export function readNativeNotificationData(request: {
content: { data?: unknown }
trigger?: unknown
}): unknown {
const trigger = request.trigger
if (trigger && typeof trigger === 'object' && 'type' in trigger && trigger.type === 'push') {
// Expo iOS keeps raw APNs custom fields here when content.data is null.
if ('payload' in trigger && trigger.payload && typeof trigger.payload === 'object') {
return trigger.payload
}
}
return request.content.data
}
@@ -0,0 +1,4 @@
import { requireNativeModule } from 'expo-modules-core'
import type { NativeDismissal } from './native-push-dismissal'
export const nativePushDismissal = requireNativeModule<NativeDismissal>('OrcaNotificationDismissal')
@@ -0,0 +1,23 @@
import { beforeEach, expect, it, vi } from 'vitest'
const requireNativeModule = vi.hoisted(() => vi.fn())
vi.mock('expo-modules-core', () => ({ requireNativeModule }))
beforeEach(() => {
vi.resetModules()
requireNativeModule.mockReset()
})
it('requires the iOS ledger and surfaces a missing native module as a build defect', async () => {
requireNativeModule.mockImplementation(() => {
throw new Error('Cannot find native module OrcaNotificationDismissal')
})
await expect(import('./native-push-dismissal.ios')).rejects.toThrow(
'Cannot find native module OrcaNotificationDismissal'
)
})
it('does not load an iOS module on the default Android/web path', async () => {
expect((await import('./native-push-dismissal')).nativePushDismissal).toBeNull()
expect(requireNativeModule).not.toHaveBeenCalled()
})
@@ -0,0 +1,8 @@
import type { OrcaPushPayload } from './push-payload'
export type NativeDismissal = {
remember(payload: OrcaPushPayload): Promise<void>
wasDismissed(payload: OrcaPushPayload): Promise<boolean>
}
// Android and web use JavaScript storage; iOS requires the native ledger.
export const nativePushDismissal: NativeDismissal | null = null
@@ -1,316 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
type MissedOutcome =
| { kind: 'reject' }
| { kind: 'notOk' }
| { kind: 'ok'; notifications: unknown[] }
// Rejects only once `settle()` is called, so a live event can land mid-request.
| { kind: 'heldReject' }
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const askedFrom: number[] = []
let outcome: MissedOutcome = { kind: 'ok', notifications: [] }
let releaseHeld: (() => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method !== 'notifications.getMissedSince') {
return { ok: true, result: undefined } as never
}
askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq)
if (outcome.kind === 'heldReject') {
await new Promise<void>((resolve) => {
releaseHeld = resolve
})
throw new Error('socket closed')
}
if (outcome.kind === 'reject') {
throw new Error('socket closed')
}
if (outcome.kind === 'notOk') {
return { ok: false, error: { message: 'timeout' } } as never
}
return { ok: true, result: { notifications: outcome.notifications } } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
askedFrom,
setOutcome(next: MissedOutcome) {
outcome = next
},
settleHeld() {
releaseHeld?.()
}
}
}
function notification(seq: number) {
return {
type: 'notification',
title: `m${seq}`,
body: 'b',
notificationId: `agent:${seq}`,
notificationSeq: seq
}
}
describe('#8591 catch-up failure quarantines the watermark', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => {
// The phone was offline while seqs 6-7 dispatched. The catch-up that would have
// replayed them dies (socket close / timeout / ok:false), and live traffic keeps
// flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by
// `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the
// window stays open until some catch-up succeeds, not for one round trip.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'reject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
// Second catch-up also fails; the gap is still open.
host.setOutcome({ kind: 'notOk' })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(5)
// Third succeeds and replays the abandoned range.
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5])
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
// Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents.
// m11/m12 are the live events that kept flowing while the gap stayed open.
expect(titles).toEqual(['m11', 'm12', 'm6', 'm7'])
// Only now may the watermark move past the recovered range.
expect(persistedSeq()).toBe(12)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5, 12])
})
it('rolls back a watermark a live event stored while the catch-up was in flight', async () => {
// getMissedSince waits up to 30s, so live traffic routinely persists during it.
// Clamping only writes made AFTER the failure leaves that higher seq on disk, and
// the next launch reads it back and resumes past the range this catch-up abandoned.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'heldReject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(11)
host.settleHeld()
await flushAsync()
expect(persistedSeq()).toBe(5)
})
it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => {
// The batch can start before a teardown and still be draining after it, so the
// events past the interruption were never shown. A live seq arriving on the next
// connection must not persist over them.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({
kind: 'ok',
notifications: [notification(6), notification(7), notification(8)]
})
let unsubscribe: (() => void) | null = null
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
if ((request as { content: { title: string } }).content.title === 'm6') {
unsubscribe?.()
}
return 'sched-1'
})
unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6'])
// A fresh subscription on the same module-scope session takes a live seq 20 before
// its own catch-up, then resumes from 6 rather than from 20.
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
const host2 = makeHostClient()
host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] })
subscribeToDesktopNotifications(host2.client, 'host-1')
host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(6)
host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' })
await flushAsync()
expect(host2.askedFrom).toEqual([6])
expect(
vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
).toEqual(['m6', 'm20', 'm7', 'm8'])
expect(persistedSeq()).toBe(20)
})
it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => {
// The quarantine only holds the RANGE. If the failing event is also marked seen,
// the next catch-up re-fetches it and the dedup guard drops it — the banner is
// never shown, and the first later event to drain the batch lifts the quarantine
// past it. Silent loss with the watermark looking healthy.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
const title = (request as { content: { title: string } }).content.title
if (title === 'm6' && failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6', 'm7'])
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(7)
})
it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => {
// The same hole without any catch-up failing: the live path marks seen before the
// show, so a rejected show leaves the key behind while the watermark stays put.
// The next catch-up dutifully re-fetches the seq and the guard eats it.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
if (failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.setOutcome({ kind: 'ok', notifications: [notification(6)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6'])
expect(persistedSeq()).toBe(6)
})
})
@@ -0,0 +1,310 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import AsyncStorage from '@react-native-async-storage/async-storage'
import NotificationsScreen from '../../app/notifications'
import MobileOnboardingScreen from '../../app/mobile-onboarding'
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
import {
attachPushRegistration,
NOTIFICATIONS_REMOTE_PUSH_CAPABILITY,
resetPushRegistrationForTests,
setRemotePushEnabled,
startPushTokenSync
} from './push-registration'
import { getDevicePushToken } from './push-token'
const mocks = vi.hoisted(() => ({ storage: new Map<string, string>(), replace: vi.fn() }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => mocks.storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
mocks.storage.set(key, value)
})
}
}))
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: vi.fn() }) },
AccessibilityInfo: {
addEventListener: () => ({ remove: vi.fn() }),
isReduceMotionEnabled: async () => false
},
Animated: { Value: class {}, View: 'View', multiply: () => 0 },
BackHandler: { addEventListener: () => ({ remove: vi.fn() }) },
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View',
Switch: 'Switch',
ScrollView: 'ScrollView',
Pressable: 'Pressable',
Alert: { alert: vi.fn() },
Linking: { openSettings: vi.fn() },
useWindowDimensions: () => ({ width: 390, height: 844 })
}))
vi.mock('expo-router', () => ({
useFocusEffect: vi.fn(),
useLocalSearchParams: () => ({ hostId: 'host', steps: 'notifications' }),
useRouter: () => ({ replace: mocks.replace })
}))
vi.mock('react-native-safe-area-context', () => ({
SafeAreaView: 'View',
useSafeAreaInsets: () => ({ top: 0, bottom: 0 })
}))
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon' }))
vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'Logo' }))
vi.mock('../onboarding/MobileOnboardingPage', () => ({ MobileOnboardingPage: 'Page' }))
vi.mock('../transport/use-all-host-clients', () => ({ useAllHostClients: () => [] }))
vi.mock('../transport/host-store', () => ({ loadHostCatalog: async () => [] }))
vi.mock('./NotificationDeliverySection', () => ({ NotificationDeliverySection: 'Delivery' }))
vi.mock('./use-remote-push-capable-hosts', () => ({ useRemotePushCapableHosts: () => [] }))
vi.mock('./notification-permissions', () => ({
ensureNotificationPermissions: async () => true,
getNotificationPermissionState: async () => ({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
}))
vi.mock('./mobile-notifications', () => ({
ensureNotificationPermissions: async () => true,
getNotificationPermissionState: async () => ({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
}))
vi.mock('./desktop-notification-channel', () => ({
ensureDesktopNotificationChannel: async () => {}
}))
vi.mock('./push-token', () => ({
getDevicePushToken: vi.fn(),
addPushTokenListener: () => () => {}
}))
const token = {
platform: 'ios' as const,
token: 'a'.repeat(64),
apnsEnvironment: 'sandbox' as const
}
let renderer: ReactTestRenderer | undefined
let stopSync: () => void
const records = () => JSON.parse(mocks.storage.get('orca:remotePushHostRegistrations') ?? '{}')
const drain = () => vi.advanceTimersByTimeAsync(0)
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
function connection() {
return {
sendRequest: vi.fn(async (method: string): Promise<unknown> => ({
ok: true,
result:
method === 'status.get'
? { capabilities: [NOTIFICATIONS_REMOTE_PUSH_CAPABILITY] }
: { registered: true, unregistered: true }
}))
}
}
async function connectedHost() {
const client = connection()
attachPushRegistration('host', client as never)
await drain()
client.sendRequest.mockClear()
return client
}
async function choose(entry: string) {
await act(async () => {
renderer = create(
createElement(entry === 'settings' ? NotificationsScreen : MobileOnboardingScreen)
)
})
await act(async () => {
if (entry === 'settings') {
renderer!.root.findByType('Switch').props.onValueChange(true)
} else {
renderer!.root.findByType('Page').props.onNotificationChoice('enable')
}
})
}
function expectChoiceComplete(entry: string) {
expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('true')
if (entry === 'settings') {
expect(renderer!.root.findByType('Switch').props).toMatchObject({
value: true,
disabled: false
})
}
if (entry === 'onboarding') {
expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('/h/host')
}
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mocks.storage.clear()
resetPushRegistrationForTests()
vi.mocked(getDevicePushToken).mockResolvedValue(token)
stopSync = startPushTokenSync()
})
afterEach(async () => {
await act(async () => renderer?.unmount())
renderer = undefined
stopSync()
resetPushRegistrationForTests()
vi.useRealTimers()
})
it.each(['true', 'false'])(
'requires consent before registering a legacy %s user',
async (legacy) => {
mocks.storage.set('orca:pushNotificationsEnabled', legacy)
const client = await connectedHost()
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
await drain()
expect(getDevicePushToken).not.toHaveBeenCalled()
expect(client.sendRequest).not.toHaveBeenCalled()
await choose('onboarding')
await drain()
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.registerPush'
])
}
)
it('remembers Not now without registering and does not ask again', async () => {
mocks.storage.set('orca:pushNotificationsEnabled', 'true')
const client = await connectedHost()
await act(async () => {
renderer = create(createElement(MobileOnboardingScreen))
})
await act(async () => {
renderer!.root.findByType('Page').props.onNotificationChoice('skip')
})
await drain()
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false')
expect(getDevicePushToken).not.toHaveBeenCalled()
expect(
client.sendRequest.mock.calls.some(([method]) => method === 'notifications.registerPush')
).toBe(false)
})
it.each(['settings', 'onboarding'])(
'%s schedules exactly one registration with token sync running',
async (entry) => {
const client = await connectedHost()
await choose(entry)
await drain()
expectChoiceComplete(entry)
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.registerPush'
])
expect(records().registeredHostIds).toEqual(['host'])
}
)
it.each(['settings', 'onboarding'])(
'%s finishes local consent while native token acquisition is pending',
async (entry) => {
const client = await connectedHost()
const pending = deferred<typeof token>()
vi.mocked(getDevicePushToken).mockReturnValue(pending.promise)
await choose(entry)
await drain()
expectChoiceComplete(entry)
expect(getDevicePushToken).toHaveBeenCalledOnce()
expect(client.sendRequest).not.toHaveBeenCalled()
pending.resolve(token)
await drain()
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.registerPush'
])
}
)
it.each(['settings', 'onboarding'])(
'%s finishes local consent while registration RPC is pending',
async (entry) => {
const client = await connectedHost()
const pending = deferred<unknown>()
client.sendRequest.mockImplementationOnce(() => pending.promise)
await choose(entry)
await drain()
expectChoiceComplete(entry)
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(records().registeredHostIds).toEqual([])
pending.resolve({ ok: true, result: { registered: true } })
await drain()
expect(records().registeredHostIds).toEqual(['host'])
expect(client.sendRequest).toHaveBeenCalledOnce()
}
)
it('waits for durable local records and schedules one unregister without waiting for its RPC', async () => {
const client = await connectedHost()
await setRemotePushEnabled(true)
await drain()
client.sendRequest.mockClear()
const write = deferred<void>()
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (key, value) => {
mocks.storage.set(key, value)
})
.mockImplementationOnce(async (key, value) => {
await write.promise
mocks.storage.set(key, value)
})
const rpc = deferred<unknown>()
client.sendRequest.mockImplementationOnce(() => rpc.promise)
const completed = vi.fn()
const disable = setRemotePushEnabled(false).then(completed)
await drain()
expect(completed).not.toHaveBeenCalled()
expect(client.sendRequest).not.toHaveBeenCalled()
write.resolve()
await disable
await drain()
expect(completed).toHaveBeenCalledOnce()
expect(records().pendingUnregisterHostIds).toEqual(['host'])
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.unregisterPush'
])
rpc.resolve({ ok: true })
await drain()
expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('exposes a failed consent write without scheduling or changing durable consent', async () => {
const client = await connectedHost()
vi.mocked(AsyncStorage.setItem).mockRejectedValueOnce(new Error('consent write failed'))
await expect(setRemotePushEnabled(true)).rejects.toThrow('consent write failed')
await drain()
expect(mocks.storage.has('orca:pushServiceNotificationsEnabled')).toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('exposes a failed records write and still schedules exactly one cleanup', async () => {
const client = await connectedHost()
await setRemotePushEnabled(true)
await drain()
client.sendRequest.mockClear()
vi.mocked(AsyncStorage.setItem)
.mockImplementationOnce(async (key, value) => {
mocks.storage.set(key, value)
})
.mockRejectedValueOnce(new Error('records write failed'))
await expect(setRemotePushEnabled(false)).rejects.toThrow('records write failed')
await drain()
expect(mocks.storage.get('orca:pushServiceNotificationsEnabled')).toBe('false')
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'notifications.unregisterPush'
])
expect(records()).toEqual({ registeredHostIds: [], pendingUnregisterHostIds: [] })
})
@@ -1,248 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
let getItemImpl: (key: string) => Promise<string | null> = async (key) => storage.get(key) ?? null
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn((key: string) => getItemImpl(key)),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
describe('#8591 per-host delivery ordering', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
getItemImpl = async (key) => storage.get(key) ?? null
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('never persists a watermark past a notification the catch-up has not shown', async () => {
// The watermark is a promise that everything up to that seq reached the user.
// If a live seq 11 is processed while catch-up is still showing seq 6, it
// persists 11 — and a process death before 7 is shown loses 7 forever, because
// the next launch asks the desktop for seq > 11. That is the original #8591
// loss re-entered through concurrency rather than through a restarted counter.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return 'sched-1'
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'm6',
body: 'b',
notificationId: 'a:6',
notificationSeq: 6
},
{
type: 'notification',
title: 'm7',
body: 'b',
notificationId: 'a:7',
notificationSeq: 7
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Live seq 11 arrives while the replay is wedged on seq 6.
onData?.({
type: 'notification',
title: 'live-11',
body: 'b',
notificationId: 'a:11',
notificationSeq: 11
})
await flushAsync()
expect(persistedSeq()).toBeLessThan(6)
releaseFirstShow()
await flushAsync()
// Once the chain drains, everything is shown and the watermark catches up.
expect(persistedSeq()).toBe(11)
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm7', 'live-11'])
})
it('shows one banner when a replay and a live event carry the same notification id', async () => {
// Serializing deliveries removed the overlap the old dedup relied on: the
// replay's show now COMPLETES before the live duplicate starts, so nothing is
// pending for it to observe and the user gets the same notification twice.
let releaseFirstShow!: () => void
const firstShowBlocked = new Promise<void>((resolve) => {
releaseFirstShow = resolve
})
let shown = 0
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
shown += 1
if (shown === 1) {
await firstShowBlocked
}
return `sched-${shown}`
})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 6
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
} as unknown as RpcClient
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
// Same id arrives live while the replay's show is still blocked. A different
// seq, so the seen-set does not catch it — only the queued-show claim does.
onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 7
})
await flushAsync()
releaseFirstShow()
await flushAsync()
expect(vi.mocked(Notifications.scheduleNotificationAsync)).toHaveBeenCalledTimes(1)
})
it('still delivers when the persisted watermark read never resolves', async () => {
// Every delivery awaits the seed, so a wedged AsyncStorage read would disable
// this host's notifications for the whole app lifetime — silently.
getItemImpl = () => new Promise<string | null>(() => {})
let onData: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async () => ({ ok: true, result: undefined }) as never)
} as unknown as RpcClient
vi.useFakeTimers()
try {
subscribeToDesktopNotifications(client, 'host-1')
onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
onData?.({
type: 'notification',
title: 'live-1',
body: 'b',
notificationId: 'a:1',
notificationSeq: 1
})
await vi.advanceTimersByTimeAsync(3100)
} finally {
vi.useRealTimers()
}
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toContain('live-1')
})
})
@@ -0,0 +1,86 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { AppState } from 'react-native'
import {
DEFAULT_NOTIFICATION_DELIVERY,
loadNotificationDeliveryPreferences,
notificationPreferencesFilter,
saveNotificationDeliveryPreferences
} from './notification-delivery-preferences'
import {
setNotificationViewingWorkspace,
shouldSuppressNotificationWhileViewing
} from './notification-viewing-policy'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('react-native', () => ({ AppState: { currentState: 'background' } }))
beforeEach(() => {
storage.clear()
setNotificationViewingWorkspace(null)
AppState.currentState = 'background'
})
it('persists only phone-specific delivery preferences', async () => {
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
const value = {
...DEFAULT_NOTIFICATION_DELIVERY,
onlyWhenDesktopAway: false,
sound: false
}
await saveNotificationDeliveryPreferences(value)
expect(await loadNotificationDeliveryPreferences()).toEqual(value)
expect(notificationPreferencesFilter(value)).toEqual({
onlyWhenDesktopAway: false,
sound: false
})
})
it('ignores unrelated stored preferences', async () => {
storage.set(
'orca:notificationDeliveryPreferences',
JSON.stringify({
onlyWhenDesktopAway: false,
sound: false,
suppressWhileViewing: false,
unrelatedSetting: false
})
)
expect(await loadNotificationDeliveryPreferences()).toEqual({
onlyWhenDesktopAway: false,
sound: false,
suppressWhileViewing: false
})
expect(notificationPreferencesFilter(await loadNotificationDeliveryPreferences())).toEqual({
onlyWhenDesktopAway: false,
sound: false
})
})
it('suppresses only the workspace being viewed on this phone, and never while backgrounded', async () => {
const event = { source: 'terminal-bell', worktreeId: 'folder-id' }
setNotificationViewingWorkspace({ hostId: 'ssh-host', worktreeId: 'folder-id' })
AppState.currentState = 'active'
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(true)
expect(await shouldSuppressNotificationWhileViewing(event, 'another-host', true)).toBe(false)
expect(
await shouldSuppressNotificationWhileViewing(
{ ...event, worktreeId: 'other' },
'ssh-host',
true
)
).toBe(false)
AppState.currentState = 'background'
expect(await shouldSuppressNotificationWhileViewing(event, 'ssh-host', true)).toBe(false)
})
it('recovers defaults from malformed stored preferences', async () => {
storage.set('orca:notificationDeliveryPreferences', '{broken')
expect(await loadNotificationDeliveryPreferences()).toEqual(DEFAULT_NOTIFICATION_DELIVERY)
})
@@ -0,0 +1,49 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { MobilePushFilter } from '../../../src/shared/mobile-push-contract'
const KEY = 'orca:notificationDeliveryPreferences'
export type NotificationDeliveryPreferences = {
onlyWhenDesktopAway: boolean
sound: boolean
suppressWhileViewing: boolean
}
export const DEFAULT_NOTIFICATION_DELIVERY: NotificationDeliveryPreferences = {
onlyWhenDesktopAway: true,
sound: true,
suppressWhileViewing: true
}
export async function loadNotificationDeliveryPreferences(): Promise<NotificationDeliveryPreferences> {
try {
const raw = await AsyncStorage.getItem(KEY)
if (!raw) {
return { ...DEFAULT_NOTIFICATION_DELIVERY }
}
const stored = JSON.parse(raw) as Record<string, unknown>
const result = { ...DEFAULT_NOTIFICATION_DELIVERY }
for (const key of Object.keys(result) as (keyof NotificationDeliveryPreferences)[]) {
if (typeof stored?.[key] === 'boolean') {
result[key] = stored[key]
}
}
return result
} catch {
return { ...DEFAULT_NOTIFICATION_DELIVERY }
}
}
export async function saveNotificationDeliveryPreferences(
value: NotificationDeliveryPreferences
): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(value))
}
export function notificationPreferencesFilter(
value: NotificationDeliveryPreferences
): MobilePushFilter {
return {
onlyWhenDesktopAway: value.onlyWhenDesktopAway,
sound: value.sound
}
}
@@ -1,92 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
import { describe, expect, it, vi } from 'vitest'
import { readPushNotificationsPreference } from '../storage/preferences'
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
vi.mock('../storage/preferences', () => ({
readPushNotificationsPreference: vi.fn(),
savePushNotificationsEnabled: vi.fn()
}))
vi.mock('./mobile-notifications', () => ({
getNotificationPermissionState: vi.fn()
readPushNotificationsPreference: vi.fn()
}))
describe('notification opt-in gate', () => {
beforeEach(() => {
vi.mocked(readPushNotificationsPreference).mockReset()
vi.mocked(savePushNotificationsEnabled).mockReset()
vi.mocked(getNotificationPermissionState).mockReset()
})
it('presents only when the local preference and system decision are both unset', async () => {
it('asks for push-service consent when no choice is saved, regardless of OS permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'undetermined',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it.each([true, false])('preserves an existing %s mobile preference', async (value) => {
it.each([true, false])('does not ask again after choosing %s', async (value) => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(getNotificationPermissionState).not.toHaveBeenCalled()
})
it('adopts existing system authorization without prompting', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
})
it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it('skips the gate when iOS has already denied permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'denied',
canAskAgain: false,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false)
})
it('does not block startup when storage or permission checks fail', async () => {
it('does not prompt when the saved choice cannot be read', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable'))
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
})
})
@@ -1,36 +1,6 @@
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
import { readPushNotificationsPreference } from '../storage/preferences'
export async function shouldPresentNotificationOptIn(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
if (!preference.loaded || preference.value !== null) {
return false
}
try {
const permission = await getNotificationPermissionState()
if (permission.granted) {
if (!permission.authorizationReflectsUserChoice) {
return true
}
// Why: an already-authorized device should inherit the useful default
// without seeing an onboarding decision it has effectively made.
await savePushNotificationsEnabled(true)
return false
}
if (permission.status === 'denied' || !permission.canAskAgain) {
// Why: iOS cannot show its authorization prompt again, so a blocking
// onboarding screen would be a dead end; Settings remains the recovery.
await savePushNotificationsEnabled(false)
return false
}
return permission.status === 'undetermined'
} catch {
// Why: permission or persistence failures must not trap startup behind a
// decision screen whose result cannot be applied reliably.
return false
}
return preference.loaded && preference.value === null
}
@@ -1,412 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
// The highest desktop notification seq this device has delivered is persisted
// per-host so it survives app restarts. On reconnect we send it to
// notifications.getMissedSince as the catch-up watermark — the desktop then
// returns only notifications dispatched after it, so we never re-push a
// notification we already delivered. The in-memory seen-set is a second guard
// against double-delivery for events that arrive on both the live stream and a
// replay (e.g. a brief liveness spell before a reap).
// Why (#8591): a seq is meaningless without the counter it indexes — after a
// desktop restart that counter is gone. The epoch names the counter's lifetime so
// a reconnect can tell "nothing missed" from "different counter".
//
// Why ONE key holding both, rather than a key each: they are only meaningful as a
// pair. Written separately, a process death between the two writes leaves an epoch
// from one counter beside a seq from another — a pair that looks internally valid
// on the next launch and is therefore trusted, silently cutting real notifications.
// A single JSON value cannot tear that way.
const WATERMARK_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsWatermark:'
// Pre-#8591 installs wrote the seq alone. Read once to migrate; never written.
const LEGACY_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
function watermarkStorageKey(hostId: string): string {
return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
}
// A null epoch means "the counter this seq came from is unknown" — a legacy
// watermark, or nothing stored. It can never be assumed to be the live counter.
export type PersistedWatermark = { seq: number; epoch: string | null }
// `stored` is the record's existence, independent of its seq: it answers "has this
// device ever been subscribed to this host", which is what a cold open needs to tell
// a returning device from a first pairing. A seq of 0 is a real answer, not an absence.
export type LoadedWatermark = PersistedWatermark & { stored: boolean }
function coerceSeq(value: unknown): number {
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
export async function loadWatermark(hostId: string): Promise<LoadedWatermark> {
try {
const raw = await AsyncStorage.getItem(watermarkStorageKey(hostId))
if (raw != null) {
const parsed = JSON.parse(raw) as { seq?: unknown; epoch?: unknown }
const epoch =
typeof parsed.epoch === 'string' && parsed.epoch.length > 0 ? parsed.epoch : null
return { seq: coerceSeq(parsed.seq), epoch, stored: true }
}
} catch {
// Unreadable or malformed: fall through to the legacy key rather than throw.
}
try {
const legacy = await AsyncStorage.getItem(
LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
)
return { seq: coerceSeq(legacy), epoch: null, stored: legacy != null }
} catch {
return { seq: 0, epoch: null, stored: false }
}
}
export async function clearWatermark(hostId: string): Promise<void> {
// Why both keys: loadWatermark falls back to the legacy one, so removing only the
// current key would let a re-paired host resurrect a pre-#8591 seq from a counter
// lifetime that is long gone — the exact stale cut this fix removes.
await Promise.all([
AsyncStorage.removeItem(watermarkStorageKey(hostId)).catch(() => {}),
AsyncStorage.removeItem(LEGACY_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)).catch(
() => {}
)
])
}
export async function saveWatermark(hostId: string, watermark: PersistedWatermark): Promise<void> {
try {
await AsyncStorage.setItem(watermarkStorageKey(hostId), JSON.stringify(watermark))
} catch {
// Why: persisting the watermark is best-effort. If it fails (or lags), the
// stored value stays BELOW what we delivered, so a later cold start can
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
// delivered notification. That's the accepted at-least-once trade-off;
// within a live session the in-memory watermark is authoritative, so only
// post-restart reconnects are affected.
}
}
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
// on the current connection. The desktop already dedupes by seq on replay, but
// a socket that flickers background→foreground→background can deliver an event
// on the live stream and again in a replay; the seen-set guarantees each
// notificationId maps to at most one local push for the connection lifetime.
// Bounded so a long-lived session can't grow without limit — a 2x superset of
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
const RECENTLY_SEEN_CAP = 512
export function createSeenNotificationGuard(): {
has: (id: string) => boolean
add: (id: string) => void
clear: () => void
} {
const seen = new Set<string>()
return {
has(id: string): boolean {
return seen.has(id)
},
add(id: string): void {
seen.add(id)
if (seen.size > RECENTLY_SEEN_CAP) {
// Why: insertion order; the oldest entries are first. Drop one to stay
// bounded without disturbing the more-recently-relevant keys.
const first = seen.values().next().value
if (first !== undefined) {
seen.delete(first)
}
}
},
clear(): void {
seen.clear()
}
}
}
// Why (#8591): app/index.tsx tears the notification subscription down on every
// non-'connected' state and builds a fresh one on reconnect, so everything held
// in the subscription closure — the ready counter, the delivered watermark, the
// seen-set — is destroyed exactly when a reconnect needs it. Keeping it per host
// at module scope is what makes the catch-up recognise a reconnect (instead of
// mistaking it for a cold open) and keeps dedup effective across the teardown.
export type HostNotificationSession = {
// Highest desktop seq delivered for this host in this app process. Outranks
// the persisted value, which lags because saveLastSeenSeq is fire-and-forget.
lastDeliveredSeq: number
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
// mismatch on reconnect means the desktop restarted and the watermark is void.
lastDeliveredEpoch: string | null
// Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is
// outstanding; null when none has failed. See quarantineCatchUpWatermark.
catchUpQuarantineSeq: number | null
seen: ReturnType<typeof createSeenNotificationGuard>
// False only until the host's first subscription reaches 'ready' — a true cold open.
connectedBefore: boolean
// Why (#8591): distinguishes "this device has delivered for this host before"
// from a first-ever pairing. Only the former may catch up on a cold open — a
// brand-new pairing fetching from seq 0 would push the desktop's whole buffer
// at someone who was never subscribed for any of it.
hadStoredWatermark: boolean
// Resolves once the persisted read has landed, so the first 'ready' can wait for
// it instead of deciding catch-up against an unread watermark.
watermarkSeeded: Promise<void> | null
// Tail of the per-host delivery chain; see enqueueHostDelivery.
deliveryTail: Promise<void>
// notificationIds with a show queued or in flight on that chain; see
// shouldQueueShowForNotificationId.
queuedShowIds: Set<string>
}
const sessionsByHost = new Map<string, HostNotificationSession>()
export function getHostNotificationSession(hostId: string): HostNotificationSession {
let session = sessionsByHost.get(hostId)
if (!session) {
session = {
lastDeliveredSeq: 0,
lastDeliveredEpoch: null,
catchUpQuarantineSeq: null,
seen: createSeenNotificationGuard(),
connectedBefore: false,
hadStoredWatermark: false,
watermarkSeeded: null,
deliveryTail: Promise.resolve(),
queuedShowIds: new Set<string>()
}
sessionsByHost.set(hostId, session)
}
return session
}
/**
* Run `task` after every delivery already queued for this host, and return a
* promise for its completion.
*
* Why (#8591): the watermark is persisted by whichever delivery advances it, so
* replay and live delivery running concurrently can persist out of order. A live
* seq 11 handled while catch-up is still showing seq 6 writes watermark 11, and a
* process death before 7..10 are shown loses them permanently — the next launch
* asks the desktop for seq > 11. Serializing per host makes the watermark's
* monotonic advance mean "everything up to here was actually delivered".
*
* A rejected task does not break the chain: the tail swallows the failure so a
* single bad notification cannot wedge the host's queue forever.
*/
export function enqueueHostDelivery(
session: HostNotificationSession,
task: () => Promise<void>
): Promise<void> {
const run = session.deliveryTail.then(task)
session.deliveryTail = run.catch(() => {})
return run
}
/**
* Claim a notificationId for a queued show, returning false if one is already
* queued or in flight for it.
*
* Why this exists (#8591): showLocalNotification deduped two same-id events by
* observing that the first was still pending when the second arrived. Serializing
* deliveries removed that overlap — the first now COMPLETES before the second
* starts, so the second reads no pending state and schedules a second banner for
* the same notification. The dedup has to happen where concurrency is still
* visible, which after serialization is enqueue time rather than delivery time.
*
* Only shows are tracked. A dismiss for the same id must still run: it is the
* mechanism that retires the notification the show created.
*/
export function shouldQueueShowForNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): boolean {
if (notificationId == null) {
return true
}
if (session.queuedShowIds.has(notificationId)) {
return false
}
session.queuedShowIds.add(notificationId)
return true
}
/** Release the claim taken by shouldQueueShowForNotificationId once the show settles. */
export function releaseQueuedShowNotificationId(
session: HostNotificationSession,
notificationId: string | undefined
): void {
if (notificationId != null) {
session.queuedShowIds.delete(notificationId)
}
}
/** Test-only: drop per-host session state so each test starts from a cold open. */
export function resetHostNotificationSessionsForTests(): void {
sessionsByHost.clear()
}
/**
* Freeze the catch-up watermark at the last seq known delivered contiguously,
* after a catch-up that did not complete.
*
* Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned
* catch-up otherwise lets the NEXT one ask from above the range it gave up on —
* the desktop cuts by seq, so those notifications are never replayed and are
* gone. Lowest wins: an earlier failure's gap is still open.
*/
export function quarantineCatchUpWatermark(
session: HostNotificationSession,
hostId: string,
contiguousSeq: number
): void {
session.catchUpQuarantineSeq =
session.catchUpQuarantineSeq == null
? contiguousSeq
: Math.min(session.catchUpQuarantineSeq, contiguousSeq)
// Why re-persist: a live event delivered while the catch-up was still in flight
// already stored a seq above the gap. Clamping only later writes would leave that
// value on disk, so a restart still resumes past the abandoned range.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
/** Lift the quarantine once a catch-up completes, persisting what it held back. */
export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void {
if (session.catchUpQuarantineSeq == null) {
return
}
session.catchUpQuarantineSeq = null
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
epoch: session.lastDeliveredEpoch
})
}
/**
* The seq a catch-up may ask from and the highest seq safe to persist — the live
* watermark, clamped to any open gap.
*/
export function catchUpWatermarkSeq(session: HostNotificationSession): number {
return session.catchUpQuarantineSeq == null
? session.lastDeliveredSeq
: Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq)
}
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
// from a previous lifetime indexes a counter that no longer exists. Comparing it
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
// catch-up dies silently until the new process out-dispatches the old watermark.
// Adopting the new epoch means dropping the watermark with it.
export function adoptNotificationEpoch(
session: HostNotificationSession,
hostId: string,
epoch: string | undefined
): void {
if (!epoch || epoch === session.lastDeliveredEpoch) {
return
}
// Why reset on a FIRST observation too (lastDeliveredEpoch === null): a seq seeded
// from a legacy store carries no epoch, so it cannot be shown to belong to this
// counter. Keeping it would let a pre-upgrade 57 cut the new counter's 1..57 —
// the exact #8591 failure, reached through the upgrade path instead of a restart.
session.lastDeliveredSeq = 0
// Why clear `seen`: its keys are seq-derived, and terminal-bell notifications have
// no notificationId at all (they key on `seq:N` alone). Across a restart the new
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
// the new counter's first bell. The dedup window belongs to one counter lifetime.
session.seen.clear()
// The quarantined gap indexed the dead counter; the watermark it guarded is gone too.
session.catchUpQuarantineSeq = null
session.lastDeliveredEpoch = epoch
void saveWatermark(hostId, { seq: 0, epoch })
}
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
// Only the first subscription for a host needs it; later ones inherit the live value.
/**
* Ms the persisted read may block catch-up and live delivery before they proceed
* without it. AsyncStorage normally answers in single-digit ms; a read that has
* not landed by now is assumed wedged.
*
* Why a bound at all (#8591): every delivery awaits this promise, so a read that
* never settles silently disables notifications for the host for the whole app
* lifetime — no error, no banner, nothing to see. Proceeding unseeded is strictly
* better: the watermark stays 0, so catch-up over-fetches and the seen-set
* de-duplicates, which costs a redundant request instead of every notification.
*/
const WATERMARK_SEED_TIMEOUT_MS = 3000
function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms)
void promise.then(
() => {
clearTimeout(timer)
resolve()
},
() => {
clearTimeout(timer)
resolve()
}
)
})
}
export function seedWatermarkFromStorage(session: HostNotificationSession, hostId: string): void {
if (session.watermarkSeeded) {
return
}
const seeded = loadWatermark(hostId).then(({ seq, epoch, stored }) => {
// Why the record's existence and not `seq > 0`: adoptNotificationEpoch persists
// `{seq: 0, epoch}` when it voids a watermark, so a device that HAS delivered for
// this host reloads as seq 0. Keying on the seq would read that as a first pairing
// and skip catch-up for the whole window the epoch change was meant to recover.
if (stored) {
session.hadStoredWatermark = true
}
// Why the epoch comparison: this read can land AFTER 'ready' already adopted a
// live epoch. If the stored watermark belongs to a different (older) counter,
// applying it here would silently reinstate exactly the stale cut this fixes.
// A null stored epoch is a legacy watermark of unknown provenance — it may only
// seed while no live epoch is known, and adopting one later resets it.
if (session.lastDeliveredEpoch === null || session.lastDeliveredEpoch === epoch) {
session.lastDeliveredSeq = Math.max(session.lastDeliveredSeq, seq)
if (session.lastDeliveredEpoch === null && epoch !== null) {
session.lastDeliveredEpoch = epoch
}
}
})
// The late seed still applies when it eventually lands; the timeout only stops it
// from holding delivery hostage. `seeded` never rejects into the awaiters.
session.watermarkSeeded = withTimeout(seeded, WATERMARK_SEED_TIMEOUT_MS)
}
// Why (#8591): sessions live at module scope so they survive the subscription
// teardown a reconnect performs. Nothing else drops them, so a host that is removed
// and re-paired would retain its session and up to 512 seen keys until app restart.
export function forgetHostNotificationSession(hostId: string): void {
sessionsByHost.delete(hostId)
}
// Why: key for the replay dedup guard. Uses notificationId when present, but
// disambiguates by seq so a legitimate live re-delivery of the same id at a
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
// live is suppressed. Replay events always carry a seq (the desktop assigns
// one), so the guard is effective on the reconnect path.
export function seenKeyForEvent(event: {
notificationId?: string
notificationSeq?: number
}): string | null {
const id = event.notificationId
if (id != null && event.notificationSeq != null) {
return `id:${id}#${event.notificationSeq}`
}
if (id != null) {
return `id:${id}`
}
if (event.notificationSeq != null) {
return `seq:${event.notificationSeq}`
}
return null
}

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