mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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.
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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
@@ -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
|
||||
Generated
+1223
-1225
File diff suppressed because it is too large
Load Diff
@@ -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) ')
|
||||
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')
|
||||
|
||||
@@ -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)',
|
||||
'',
|
||||
'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
@@ -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
Reference in New Issue
Block a user