mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-codex-account-switch
# Conflicts: # src/renderer/src/i18n/locales/en.json # src/renderer/src/i18n/locales/es.json # src/renderer/src/i18n/locales/ja.json # src/renderer/src/i18n/locales/ko.json # src/renderer/src/i18n/locales/zh.json
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the head-of-chain reads in getBranchCompare (src/main/git/status.ts).
|
||||
//
|
||||
// Four spawns ran strictly in series before any compare work started: branch
|
||||
// --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>. compareRef is
|
||||
// display-only metadata and HEAD's oid does not depend on the base ref, so the first three
|
||||
// can overlap. The probe oid also replaces the fourth spawn when it proves refs/heads/*;
|
||||
// remote-tracking refs require a raw rev-parse because they may store annotated tags.
|
||||
//
|
||||
// This spawns the real git binary against this repo, so it measures actual process-launch
|
||||
// cost rather than a model of it. Over SSH these are host-local spawns inside the relay,
|
||||
// so the saving applies to remote spawn time, not to network round trips.
|
||||
//
|
||||
// Both arms are compared for identical resolved values before timing.
|
||||
//
|
||||
// Run with: node config/scripts/branch-compare-head-benchmark.mjs
|
||||
import { execFile } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readBranchCompareHead } from '../../src/shared/git-branch-compare-head.ts'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_ITERATIONS ?? '8')
|
||||
const WARMUP = Number(process.env.ORCA_BRANCH_COMPARE_BENCH_WARMUP ?? '2')
|
||||
const ROUNDS = 6
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_BRANCH_COMPARE_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_BRANCH_COMPARE_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) =>
|
||||
error ? reject(error) : resolve(stdout.trim())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function probeOid(qualifiedRef) {
|
||||
try {
|
||||
const out = await git(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`])
|
||||
return out.length > 0 ? out : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fix: serial chain, and the probe's oid discarded then re-resolved.
|
||||
async function readSerial(baseRef) {
|
||||
const compareRef = (await git(['branch', '--show-current']).catch(() => '')) || 'HEAD'
|
||||
let resolvedBaseRef = baseRef
|
||||
if (!baseRef.startsWith('refs/')) {
|
||||
const candidates = baseRef.includes('/')
|
||||
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
|
||||
: [`refs/heads/${baseRef}`]
|
||||
for (const candidate of candidates) {
|
||||
if ((await probeOid(candidate)) !== null) {
|
||||
resolvedBaseRef = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const headOid = await git(['rev-parse', '--verify', '--end-of-options', 'HEAD'])
|
||||
const baseOid = await git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
|
||||
return { compareRef, resolvedBaseRef, headOid, baseOid }
|
||||
}
|
||||
|
||||
// Production head reader: overlaps independent reads and reuses only safe probe oids.
|
||||
async function readConcurrent(baseRef) {
|
||||
const reusableProbedOidByRef = new Map()
|
||||
const resolveBaseRef = async () => {
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return baseRef
|
||||
}
|
||||
const candidates = baseRef.includes('/')
|
||||
? [`refs/remotes/${baseRef}`, `refs/heads/${baseRef}`]
|
||||
: [`refs/heads/${baseRef}`]
|
||||
for (const candidate of candidates) {
|
||||
const oid = await probeOid(candidate)
|
||||
if (oid !== null) {
|
||||
if (candidate.startsWith('refs/heads/')) {
|
||||
reusableProbedOidByRef.set(candidate, oid)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return baseRef
|
||||
}
|
||||
const result = await readBranchCompareHead({
|
||||
readCompareRef: () =>
|
||||
git(['branch', '--show-current'])
|
||||
.then((out) => out || 'HEAD')
|
||||
.catch(() => 'HEAD'),
|
||||
resolveBaseRef,
|
||||
readHeadOid: () => git(['rev-parse', '--verify', '--end-of-options', 'HEAD']),
|
||||
readBaseOid: (resolvedBaseRef) => {
|
||||
const reusableOid = reusableProbedOidByRef.get(resolvedBaseRef)
|
||||
return reusableOid === undefined
|
||||
? git(['rev-parse', '--verify', '--end-of-options', resolvedBaseRef])
|
||||
: Promise.resolve(reusableOid)
|
||||
}
|
||||
})
|
||||
if (!result.headOidResult.ok) {
|
||||
throw result.headOidResult.error
|
||||
}
|
||||
if (!result.baseOidResult.ok) {
|
||||
throw result.baseOidResult.error
|
||||
}
|
||||
return {
|
||||
compareRef: result.compareRef,
|
||||
resolvedBaseRef: result.resolvedBaseRef,
|
||||
headOid: result.headOidResult.oid,
|
||||
baseOid: result.baseOidResult.oid
|
||||
}
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
async function timeArm(read, baseRef) {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
await read(baseRef)
|
||||
}
|
||||
return (performance.now() - start) / ITERATIONS
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
async function measure(baseRef) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
await readSerial(baseRef)
|
||||
await readConcurrent(baseRef)
|
||||
}
|
||||
const serialSamples = []
|
||||
const concurrentSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
serialSamples.push(await timeArm(readSerial, baseRef))
|
||||
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
|
||||
} else {
|
||||
concurrentSamples.push(await timeArm(readConcurrent, baseRef))
|
||||
serialSamples.push(await timeArm(readSerial, baseRef))
|
||||
}
|
||||
}
|
||||
return { serialMs: median(serialSamples), concurrentMs: median(concurrentSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('getBranchCompare head-of-chain reads, per call. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
|
||||
console.log(
|
||||
`${pad('base ref', 30)} ${pad('serial', 11)} ${pad('concurrent', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// A short remote label is the common case (Orca's base picker emits `origin/main`); the
|
||||
// already-qualified ref skips the probe entirely, so only the concurrency half applies.
|
||||
const upstream = await git(['rev-parse', '--abbrev-ref', 'HEAD@{upstream}']).catch(() => null)
|
||||
const baseRefs = ['origin/main', 'refs/remotes/origin/main', 'main']
|
||||
if (upstream && !baseRefs.includes(upstream)) {
|
||||
baseRefs.push(upstream)
|
||||
}
|
||||
|
||||
for (const baseRef of baseRefs) {
|
||||
const serial = await readSerial(baseRef)
|
||||
const concurrent = await readConcurrent(baseRef)
|
||||
if (JSON.stringify(serial) !== JSON.stringify(concurrent)) {
|
||||
throw new Error(
|
||||
`resolved values differ for ${baseRef}:\n serial ${JSON.stringify(serial)}\n concurrent ${JSON.stringify(concurrent)}`
|
||||
)
|
||||
}
|
||||
if (!serial.headOid) {
|
||||
throw new Error(`fixture resolved no HEAD oid for ${baseRef}`)
|
||||
}
|
||||
const { serialMs, concurrentMs } = await measure(baseRef)
|
||||
console.log(
|
||||
`${pad(baseRef, 30)} ${pad(`${serialMs.toFixed(1)} ms`, 11)} ${pad(`${concurrentMs.toFixed(1)} ms`, 11)} ${pad(`${(serialMs / concurrentMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nThe already-qualified refs/... row skips the probe by design, so it only shows the\nconcurrency half. This times the native/WSL head-of-chain reads, not the whole compare;\nthe relay path has separate production-concurrency coverage.'
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: CLI process startup with the RuntimeClient module graph deferred.
|
||||
//
|
||||
// src/cli/index.ts used to value-import RuntimeClient at module scope, and five
|
||||
// modules that load on every invocation (args, flags, dispatch, format,
|
||||
// selectors) pulled RuntimeClientError from the ./runtime-client barrel. Either
|
||||
// edge alone drags in the whole client graph: zod (via shared/pairing ->
|
||||
// shared/mobile-relay-pairing-offer), ws + tweetnacl (via websocket-transport),
|
||||
// plus the environment store and secure-file stack.
|
||||
//
|
||||
// The fix repoints those five at ./runtime/types (zero children) and loads the
|
||||
// client through `await import()` after flag validation, so --help, `help
|
||||
// <cmd>` and every command/flag error return without ever touching it.
|
||||
//
|
||||
// Both arms are REAL tsc emits of real source: the baseline arm restores the
|
||||
// seven touched files from a git rev and compiles that. Each sample is a FRESH
|
||||
// process (module-graph cost is a once-per-process cost; timing it in-process
|
||||
// would measure a warm require cache).
|
||||
//
|
||||
// Arms alternate lead across an even number of rounds and report per-arm
|
||||
// medians. Byte-for-byte output equality is checked BEFORE timing.
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
const ROUNDS = Number(process.env.ORCA_CLI_DEFER_BENCH_ROUNDS ?? '30')
|
||||
const WARMUP = Number(process.env.ORCA_CLI_DEFER_BENCH_WARMUP ?? '3')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_CLI_DEFER_BENCH_ROUNDS', ROUNDS],
|
||||
['ORCA_CLI_DEFER_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
if (ROUNDS % 2 !== 0) {
|
||||
// Why: arms alternate which one leads; an odd count biases one arm.
|
||||
throw new Error(`ORCA_CLI_DEFER_BENCH_ROUNDS must be even, received ${ROUNDS}`)
|
||||
}
|
||||
|
||||
const TOUCHED = [
|
||||
'src/cli/args.ts',
|
||||
'src/cli/dispatch.ts',
|
||||
'src/cli/flags.ts',
|
||||
'src/cli/format.ts',
|
||||
'src/cli/index.ts',
|
||||
'src/cli/runtime/client.ts',
|
||||
'src/cli/selectors.ts'
|
||||
]
|
||||
|
||||
// Why: if the deferral is reverted or reshaped, both arms would compile to the
|
||||
// same thing and this would quietly report 1.00x forever. Re-read the real call
|
||||
// forms out of the source. Matching the CALL form (not a bare identifier) so a
|
||||
// comment that merely names the function cannot satisfy the check.
|
||||
function assertMarkersFresh() {
|
||||
const checks = [
|
||||
['src/cli/index.ts', "await import('./runtime-client.js')"],
|
||||
['src/cli/index.ts', 'await loadRuntimeClientClass()'],
|
||||
['src/cli/index.ts', "import type { RuntimeClient } from './runtime-client'"],
|
||||
['src/cli/runtime/client.ts', "await import('./websocket-transport.js')"],
|
||||
['src/cli/runtime/client.ts', 'await loadSendWebSocketRequest()'],
|
||||
['src/cli/args.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/flags.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/dispatch.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/selectors.ts', "import { RuntimeClientError } from './runtime/types'"],
|
||||
['src/cli/format.ts', "} from './runtime/types'"]
|
||||
]
|
||||
for (const [file, marker] of checks) {
|
||||
if (!readFileSync(join(REPO, file), 'utf8').includes(marker)) {
|
||||
throw new Error(
|
||||
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-benchmark.mjs is stale`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildArm(label, baselineRev) {
|
||||
// Why: a build under /tmp cannot resolve the repo's node_modules, so the
|
||||
// output has to live inside the repo.
|
||||
const outDir = join(REPO, `.bench-out-${label}`)
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
const restore = []
|
||||
try {
|
||||
if (baselineRev) {
|
||||
for (const file of TOUCHED) {
|
||||
const path = join(REPO, file)
|
||||
restore.push([path, readFileSync(path)])
|
||||
writeFileSync(
|
||||
path,
|
||||
execFileSync('git', ['show', `${baselineRev}:${file}`], {
|
||||
cwd: REPO,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'tsc',
|
||||
'-p',
|
||||
'config/tsconfig.cli.json',
|
||||
'--outDir',
|
||||
outDir,
|
||||
'--composite',
|
||||
'false',
|
||||
'--incremental',
|
||||
'false'
|
||||
],
|
||||
{ cwd: REPO, stdio: 'inherit' }
|
||||
)
|
||||
} finally {
|
||||
for (const [path, contents] of restore) {
|
||||
writeFileSync(path, contents)
|
||||
}
|
||||
}
|
||||
return join(outDir, 'cli/index.js')
|
||||
}
|
||||
|
||||
function run(entry, argv, env) {
|
||||
const result = spawnSync(process.execPath, [entry, ...argv], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'buffer'
|
||||
})
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout.toString('utf8'),
|
||||
stderr: result.stderr.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
// Counts the eager CommonJS module graph of a built entry point by hooking
|
||||
// Module._load in a child process. This is the quantity the change moves.
|
||||
function countEagerModules(entry) {
|
||||
const probe = `
|
||||
const Module = require('module')
|
||||
const original = Module._load
|
||||
const seen = new Set()
|
||||
Module._load = function (request, parent, isMain) {
|
||||
try { seen.add(Module._resolveFilename(request, parent, isMain)) } catch { seen.add(request) }
|
||||
return original.apply(this, arguments)
|
||||
}
|
||||
require(${JSON.stringify(entry)})
|
||||
const all = [...seen]
|
||||
process.stdout.write(JSON.stringify({
|
||||
total: all.length,
|
||||
nodeModules: all.filter((p) => p.includes('node_modules')).length
|
||||
}))
|
||||
`
|
||||
const result = spawnSync(process.execPath, ['-e', probe], { cwd: REPO, encoding: 'utf8' })
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`module probe failed: ${result.stderr}`)
|
||||
}
|
||||
return JSON.parse(result.stdout)
|
||||
}
|
||||
|
||||
const median = (values) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
const baselineIndex = process.argv.indexOf('--baseline')
|
||||
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
|
||||
|
||||
assertMarkersFresh()
|
||||
|
||||
const userDataPath = mkdtempSync(join(REPO, '.bench-userdata-'))
|
||||
|
||||
try {
|
||||
console.log(`Building eager baseline (${baselineRev}) …`)
|
||||
const eagerEntry = buildArm('eager', baselineRev)
|
||||
console.log('Building deferred (working tree) …')
|
||||
const deferredEntry = buildArm('deferred', null)
|
||||
|
||||
const eagerGraph = countEagerModules(eagerEntry)
|
||||
const deferredGraph = countEagerModules(deferredEntry)
|
||||
console.log(
|
||||
`\nEager modules at process load: ${eagerGraph.total} -> ${deferredGraph.total} ` +
|
||||
`(node_modules ${eagerGraph.nodeModules} -> ${deferredGraph.nodeModules})`
|
||||
)
|
||||
if (deferredGraph.total >= eagerGraph.total) {
|
||||
throw new Error(
|
||||
'deferred arm loads no fewer modules — the fixture does not exercise the change'
|
||||
)
|
||||
}
|
||||
|
||||
// Each case is (label, argv, env). The runtime-dependent ones point at an
|
||||
// empty user-data dir so both arms get the same deterministic answer.
|
||||
const isolated = { ORCA_USER_DATA_PATH: userDataPath }
|
||||
const cases = [
|
||||
['orca --help', ['--help'], {}],
|
||||
['orca help worktree', ['help', 'worktree'], {}],
|
||||
['orca (no args)', [], {}],
|
||||
['unknown command', ['no-such-command'], {}],
|
||||
['unknown flag', ['worktree', 'list', '--nope'], {}],
|
||||
['orca agent-context --json', ['agent-context', '--json'], {}],
|
||||
['orca status --json', ['status', '--json'], isolated],
|
||||
['orca worktree list --json', ['worktree', 'list', '--json'], isolated]
|
||||
]
|
||||
|
||||
// Why: a semantically broken arm that prints nothing would look fastest.
|
||||
// Compare bytes and exit codes BEFORE timing anything.
|
||||
for (const [label, argv, env] of cases) {
|
||||
const before = run(eagerEntry, argv, env)
|
||||
const after = run(deferredEntry, argv, env)
|
||||
if (
|
||||
before.status !== after.status ||
|
||||
before.stdout !== after.stdout ||
|
||||
before.stderr !== after.stderr
|
||||
) {
|
||||
throw new Error(`arms disagree for "${label}" — refusing to report a timing`)
|
||||
}
|
||||
if (before.stdout.length + before.stderr.length === 0) {
|
||||
throw new Error(`"${label}" produced no output on either arm; it proves nothing`)
|
||||
}
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('\nFresh process per sample, wall clock. Lower is better.')
|
||||
console.log(`rounds=${ROUNDS} warmup=${WARMUP} (per-arm median, arms alternate lead)`)
|
||||
console.log(`${pad('case', 26)} ${pad('eager', 10)} ${pad('deferred', 10)} ${pad('speedup', 9)}`)
|
||||
|
||||
// Accumulated so V8 cannot treat the spawn loop as dead code.
|
||||
let consumed = 0
|
||||
|
||||
for (const [label, argv, env] of cases) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
consumed += run(eagerEntry, argv, env).stdout.length
|
||||
consumed += run(deferredEntry, argv, env).stdout.length
|
||||
}
|
||||
const samples = { eager: [], deferred: [] }
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
// Alternate which arm leads so a drifting machine load cannot be
|
||||
// attributed to one arm.
|
||||
const order =
|
||||
round % 2 === 0
|
||||
? [
|
||||
['eager', eagerEntry],
|
||||
['deferred', deferredEntry]
|
||||
]
|
||||
: [
|
||||
['deferred', deferredEntry],
|
||||
['eager', eagerEntry]
|
||||
]
|
||||
for (const [arm, entry] of order) {
|
||||
const started = performance.now()
|
||||
const result = run(entry, argv, env)
|
||||
samples[arm].push(performance.now() - started)
|
||||
consumed += result.stdout.length
|
||||
}
|
||||
}
|
||||
const eagerMs = median(samples.eager)
|
||||
const deferredMs = median(samples.deferred)
|
||||
console.log(
|
||||
`${pad(label, 26)} ${pad(`${eagerMs.toFixed(1)} ms`, 10)} ${pad(`${deferredMs.toFixed(1)} ms`, 10)} ${pad(`${(eagerMs / deferredMs).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (consumed === 0) {
|
||||
throw new Error('no output consumed — the timing loop was optimised away')
|
||||
}
|
||||
console.log(
|
||||
'\nThe help and error rows are the ones the change targets: they return\n' +
|
||||
'before any client construction, so they drop the whole graph. `status` and\n' +
|
||||
'`worktree list` still construct a client, so they only save the eager parse\n' +
|
||||
'of the parts the local path never uses (ws/tweetnacl via websocket-transport).'
|
||||
)
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
for (const label of ['eager', 'deferred']) {
|
||||
rmSync(join(REPO, `.bench-out-${label}`), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env node
|
||||
// Equivalence check for deferring the RuntimeClient module graph in the CLI.
|
||||
//
|
||||
// Builds the CLI twice with the REAL tsc emit — once from the working tree and
|
||||
// once with the seven touched files restored from git HEAD~ (the pre-deferral
|
||||
// implementation) — then compares stdout, stderr and exit code BYTE FOR BYTE
|
||||
// across a matrix of invocations.
|
||||
//
|
||||
// Why a script and not a vitest case: this compiles two full CLI trees. It is
|
||||
// the artifact that proves the refactor is behaviour-preserving; the fast
|
||||
// invariants (class identity, no eager barrel import) live in
|
||||
// src/cli/runtime-client-deferral.test.ts and run in the normal suite.
|
||||
//
|
||||
// Usage: node config/scripts/cli-runtime-client-deferral-equivalence.mjs [--baseline <rev>]
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const REPO = fileURLToPath(new URL('../..', import.meta.url))
|
||||
|
||||
// The files this change touches. Restoring exactly these from the baseline rev
|
||||
// reconstructs the old implementation without disturbing anything else.
|
||||
const TOUCHED = [
|
||||
'src/cli/args.ts',
|
||||
'src/cli/dispatch.ts',
|
||||
'src/cli/flags.ts',
|
||||
'src/cli/format.ts',
|
||||
'src/cli/index.ts',
|
||||
'src/cli/runtime/client.ts',
|
||||
'src/cli/selectors.ts'
|
||||
]
|
||||
|
||||
// Why: if the deferral is ever reverted or reshaped, the "old" arm would
|
||||
// silently become identical to the new one and every case would pass
|
||||
// vacuously. Re-read the real call form out of the source and fail loudly.
|
||||
function assertMarkersFresh() {
|
||||
const index = readFileSync(join(REPO, 'src/cli/index.ts'), 'utf8')
|
||||
const client = readFileSync(join(REPO, 'src/cli/runtime/client.ts'), 'utf8')
|
||||
const checks = [
|
||||
['src/cli/index.ts', index, 'await loadRuntimeClientClass()'],
|
||||
['src/cli/index.ts', index, "await import('./runtime-client.js')"],
|
||||
['src/cli/index.ts', index, "import type { RuntimeClient } from './runtime-client'"],
|
||||
['src/cli/runtime/client.ts', client, 'await loadSendWebSocketRequest()'],
|
||||
['src/cli/runtime/client.ts', client, "await import('./websocket-transport.js')"]
|
||||
]
|
||||
for (const [file, source, marker] of checks) {
|
||||
// Match the call form, not a bare word: a comment naming the function must
|
||||
// not satisfy the check.
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(
|
||||
`${file} no longer contains \`${marker}\` — cli-runtime-client-deferral-equivalence.mjs is stale`
|
||||
)
|
||||
}
|
||||
}
|
||||
const repointed = ['src/cli/args.ts', 'src/cli/flags.ts', 'src/cli/dispatch.ts']
|
||||
for (const file of repointed) {
|
||||
const source = readFileSync(join(REPO, file), 'utf8')
|
||||
if (!source.includes("import { RuntimeClientError } from './runtime/types'")) {
|
||||
throw new Error(`${file} no longer repoints RuntimeClientError at ./runtime/types — stale`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(label, baselineRev) {
|
||||
// Why: builds under /tmp cannot resolve the repo's node_modules, so the
|
||||
// output dir has to live inside the repo.
|
||||
const outDir = join(REPO, `.equiv-out-${label}`)
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
const restored = []
|
||||
try {
|
||||
if (baselineRev) {
|
||||
for (const file of TOUCHED) {
|
||||
const path = join(REPO, file)
|
||||
restored.push([path, readFileSync(path)])
|
||||
const old = execFileSync('git', ['show', `${baselineRev}:${file}`], {
|
||||
cwd: REPO,
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
writeFileSync(path, old)
|
||||
}
|
||||
}
|
||||
execFileSync(
|
||||
'npx',
|
||||
[
|
||||
'tsc',
|
||||
'-p',
|
||||
'config/tsconfig.cli.json',
|
||||
'--outDir',
|
||||
outDir,
|
||||
'--composite',
|
||||
'false',
|
||||
'--incremental',
|
||||
'false'
|
||||
],
|
||||
{ cwd: REPO, stdio: 'inherit' }
|
||||
)
|
||||
} finally {
|
||||
for (const [path, contents] of restored) {
|
||||
writeFileSync(path, contents)
|
||||
}
|
||||
}
|
||||
return join(outDir, 'cli/index.js')
|
||||
}
|
||||
|
||||
// Why: every case is a one-shot CLI invocation that must exit on its own. An
|
||||
// unbounded spawnSync turns "this argv reached a blocking command" into an
|
||||
// indefinite stall — a guard that can hang instead of failing is not a guard.
|
||||
const RUN_TIMEOUT_MS = 30_000
|
||||
|
||||
function run(entry, argv, env) {
|
||||
const result = spawnSync(process.execPath, [entry, ...argv], {
|
||||
cwd: REPO,
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'buffer',
|
||||
timeout: RUN_TIMEOUT_MS,
|
||||
killSignal: 'SIGKILL'
|
||||
})
|
||||
if (result.error) {
|
||||
if (result.error.code === 'ETIMEDOUT') {
|
||||
throw new Error(
|
||||
`orca ${argv.join(' ')} did not exit within ${RUN_TIMEOUT_MS} ms — it reached a blocking command`
|
||||
)
|
||||
}
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout.toString('utf8'),
|
||||
stderr: result.stderr.toString('utf8')
|
||||
}
|
||||
}
|
||||
|
||||
// Every case must produce identical bytes on both arms. The runtime-dependent
|
||||
// ones (status/worktree list) are pointed at an empty user-data dir so the
|
||||
// answer is deterministic "not running" rather than whatever the dev machine
|
||||
// happens to be doing.
|
||||
function buildCases(isolatedUserData) {
|
||||
const isolated = { ORCA_USER_DATA_PATH: isolatedUserData }
|
||||
const cases = [
|
||||
// Paths that must never load the runtime client at all.
|
||||
[[], {}],
|
||||
[['--help'], {}],
|
||||
[['help'], {}],
|
||||
[['help', 'worktree'], {}],
|
||||
[['help', 'browser'], {}],
|
||||
[['worktree', '--help'], {}],
|
||||
[['help', 'no-such-command'], {}],
|
||||
[['no-such-command'], {}],
|
||||
[['no-such-command'], { ORCA_PAIRING_CODE: 'garbage' }],
|
||||
[['wrktree', 'list'], {}],
|
||||
[['agent-context'], {}],
|
||||
[['agent-context', '--json'], {}],
|
||||
// Flag validation must still fire before any runtime lookup.
|
||||
[['worktree', 'list', '--nonexistent-flag'], {}],
|
||||
[['worktree', 'list', '--nonexistent-flag', '--json'], {}],
|
||||
[['browser', 'snapshot', '--nonexistent-flag'], {}],
|
||||
// resolveRemotePairing throws from the RuntimeClient CONSTRUCTOR.
|
||||
[['status', '--pairing-code', 'x', '--environment', 'y'], isolated],
|
||||
[['status', '--pairing-code', 'x', '--environment', 'y', '--json'], isolated],
|
||||
[['status', '--pairing-code', 'not-a-pairing-code'], isolated],
|
||||
[['status', '--pairing-code', 'not-a-pairing-code', '--json'], isolated],
|
||||
[['status', '--pairing-code', 'orca://pair?code=zzzz'], isolated],
|
||||
[['status', '--environment', 'no-such-environment'], isolated],
|
||||
[['status', '--environment', 'no-such-environment', '--json'], isolated],
|
||||
[['worktree', 'list', '--environment', 'no-such-environment', '--json'], isolated],
|
||||
// The env-var fallback must stay live for non-suppressed commands...
|
||||
[['status', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
[['status', '--json'], { ...isolated, ORCA_REMOTE_PAIRING: 'not-a-pairing-code' }],
|
||||
[['status', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
|
||||
// ...and must stay suppressed for the local-only command groups.
|
||||
//
|
||||
// NOTE: the only commands that both live in a suppressed group AND touch
|
||||
// ctx.client are `agent hooks on|off`, which rewrite the user's real agent
|
||||
// hook configuration in ~/.claude and friends — far outside
|
||||
// ORCA_USER_DATA_PATH. They are deliberately NOT invoked here. The
|
||||
// null-vs-undefined suppression they would exercise is covered
|
||||
// side-effect-free by the constructor-argument assertions in
|
||||
// src/cli/runtime-client-deferral.test.ts instead.
|
||||
[['environment', 'list', '--json'], { ...isolated, ORCA_ENVIRONMENT: 'no-such-environment' }],
|
||||
[['environment', 'list', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
[['agent-context', '--json'], { ...isolated, ORCA_PAIRING_CODE: 'not-a-pairing-code' }],
|
||||
// Runtime-unavailable reporting (RuntimeClientError formatting).
|
||||
[['status'], isolated],
|
||||
[['status', '--json'], isolated],
|
||||
[['worktree', 'list', '--json'], isolated],
|
||||
[['terminal', 'list', '--json'], isolated]
|
||||
]
|
||||
// Fuzz: random argv drawn from real command tokens, flags and hostile
|
||||
// strings. Seeded so a failure is reproducible. Every token here must be
|
||||
// safe to actually execute — see UNSAFE_TOKENS, which is cross-checked
|
||||
// against this list before a single case runs.
|
||||
const tokens = [
|
||||
'worktree',
|
||||
'list',
|
||||
'status',
|
||||
'browser',
|
||||
'snapshot',
|
||||
'terminal',
|
||||
'environment',
|
||||
'agent',
|
||||
'agent-context',
|
||||
'vm',
|
||||
'--json',
|
||||
'--help',
|
||||
'--pairing-code',
|
||||
'--environment',
|
||||
'--worktree',
|
||||
'orca://pair?code=!!!',
|
||||
'',
|
||||
'-',
|
||||
'--',
|
||||
'--=',
|
||||
'a'.repeat(300),
|
||||
'näme-ünicode',
|
||||
'💥',
|
||||
'../..',
|
||||
'x\ty'
|
||||
]
|
||||
let seed = 0x9e3779b9
|
||||
const next = () => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
return (seed >>> 0) / 0x100000000
|
||||
}
|
||||
// Why: check the draw POOL, not just the 400 cases it happens to produce.
|
||||
// `serve` sat in this array for the whole review because the per-case scan
|
||||
// never named it, and the cases that drew it only survived by accident.
|
||||
assertTokensSafe(tokens, 'fuzz token pool')
|
||||
assertFuzzPoolDeclaredReadOnly(tokens)
|
||||
for (let index = 0; index < 400; index += 1) {
|
||||
const length = 1 + Math.floor(next() * 4)
|
||||
const argv = []
|
||||
for (let part = 0; part < length; part += 1) {
|
||||
argv.push(tokens[Math.floor(next() * tokens.length)])
|
||||
}
|
||||
cases.push([argv, isolated])
|
||||
}
|
||||
for (const [argv] of cases) {
|
||||
assertTokensSafe(argv, `argv ${JSON.stringify(argv)}`)
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
// Why: this script shells out to the REAL CLI with the developer's own HOME and
|
||||
// PATH, so an argv that reaches the wrong verb does real damage. Two classes:
|
||||
//
|
||||
// 1. FOREGROUND — `orca serve` runs Orca until Ctrl+C and `orca open` /
|
||||
// `claude-teams` spawn processes that outlive the case. A blocking case
|
||||
// does not fail the run, it stalls it, which is worse than a mismatch.
|
||||
// 2. MUTATING — writes outside ORCA_USER_DATA_PATH (`agent hooks off` parks
|
||||
// the real ~/.claude hooks) or drives real browser/desktop input.
|
||||
//
|
||||
// Group tokens whose subcommands split read/write (`capture`, `intercept`,
|
||||
// `label`, `relation`) are denied wholesale: the fuzzer cannot tell them apart.
|
||||
const FOREGROUND_TOKENS = ['serve', 'open', 'claude-teams', 'exec', 'eval', 'launch', 'attach']
|
||||
const MUTATING_TOKENS = [
|
||||
// persistent config and registry state
|
||||
'on',
|
||||
'off',
|
||||
'hooks',
|
||||
'create',
|
||||
'remove',
|
||||
'rm',
|
||||
'delete',
|
||||
'add',
|
||||
'edit',
|
||||
'set',
|
||||
'set-value',
|
||||
'set-base-ref',
|
||||
'setup-clone',
|
||||
'setup-create',
|
||||
'setup-update',
|
||||
'setup-delete',
|
||||
'setup-existing-folder',
|
||||
'install',
|
||||
'uninstall',
|
||||
'reinstall',
|
||||
'update',
|
||||
'clone',
|
||||
'apply',
|
||||
'write',
|
||||
'reset',
|
||||
'clear',
|
||||
'enable',
|
||||
'disable',
|
||||
'use-default',
|
||||
'permissions',
|
||||
// process and pane lifecycle
|
||||
'run',
|
||||
'run-stop',
|
||||
'start',
|
||||
'stop',
|
||||
'kill',
|
||||
'shutdown',
|
||||
'close',
|
||||
'split',
|
||||
'switch',
|
||||
'rename',
|
||||
'focus',
|
||||
// orchestration writes
|
||||
'send',
|
||||
'reply',
|
||||
'dispatch',
|
||||
'task-create',
|
||||
'task-update',
|
||||
'gate-create',
|
||||
'gate-resolve',
|
||||
// issue-tracker writes
|
||||
'save-issue',
|
||||
'comment',
|
||||
'label',
|
||||
'relation',
|
||||
'assignee',
|
||||
'priority',
|
||||
'estimate',
|
||||
'due-date',
|
||||
// browser / computer / emulator input and navigation
|
||||
'goto',
|
||||
'back',
|
||||
'forward',
|
||||
'reload',
|
||||
'click',
|
||||
'dblclick',
|
||||
'hover',
|
||||
'fill',
|
||||
'type',
|
||||
'type-text',
|
||||
'select',
|
||||
'select-all',
|
||||
'uncheck',
|
||||
'keypress',
|
||||
'press-key',
|
||||
'inserttext',
|
||||
'hotkey',
|
||||
'paste-text',
|
||||
'perform-secondary-action',
|
||||
'drag',
|
||||
'scroll',
|
||||
'scrollintoview',
|
||||
'wheel',
|
||||
'move',
|
||||
'up',
|
||||
'down',
|
||||
'tap',
|
||||
'gesture',
|
||||
'button',
|
||||
'rotate',
|
||||
'upload',
|
||||
'download',
|
||||
'dismiss',
|
||||
'accept',
|
||||
'highlight',
|
||||
'viewport',
|
||||
'geolocation',
|
||||
'headers',
|
||||
'credentials',
|
||||
'offline',
|
||||
'media',
|
||||
'device',
|
||||
'capture',
|
||||
'intercept'
|
||||
]
|
||||
|
||||
const UNSAFE_TOKENS = new Map([
|
||||
...FOREGROUND_TOKENS.map((token) => [token, 'runs in the foreground or spawns a process']),
|
||||
...MUTATING_TOKENS.map((token) => [token, 'can write outside ORCA_USER_DATA_PATH'])
|
||||
])
|
||||
|
||||
// Why: the deny list only catches verbs someone already thought of — `serve`
|
||||
// sat in the fuzz pool for the whole review because nobody added it. The pool
|
||||
// is therefore ALSO checked against this allowlist, so a token added to the
|
||||
// pool fails closed until it is consciously declared read-only here.
|
||||
const READ_ONLY_FUZZ_TOKENS = new Set([
|
||||
// command tokens: every path they can form is a list/show or a parse error
|
||||
'agent',
|
||||
'agent-context',
|
||||
'browser',
|
||||
'environment',
|
||||
'list',
|
||||
'snapshot',
|
||||
'status',
|
||||
'terminal',
|
||||
'vm',
|
||||
'worktree',
|
||||
// global flags and hostile strings, which reach no handler at all
|
||||
'--json',
|
||||
'--help',
|
||||
'--pairing-code',
|
||||
'--environment',
|
||||
'--worktree',
|
||||
'orca://pair?code=!!!',
|
||||
'',
|
||||
'-',
|
||||
'--',
|
||||
'--=',
|
||||
'a'.repeat(300),
|
||||
'näme-ünicode',
|
||||
'💥',
|
||||
'../..',
|
||||
'x\ty'
|
||||
])
|
||||
|
||||
function assertTokensSafe(tokens, context) {
|
||||
for (const token of tokens) {
|
||||
const reason = UNSAFE_TOKENS.get(token)
|
||||
if (reason) {
|
||||
throw new Error(`Refusing to run ${context}: "${token}" ${reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a token on neither list (or, worse, on both) means the two lists have
|
||||
// drifted apart. Fail before any case runs rather than sampling and hoping.
|
||||
function assertFuzzPoolDeclaredReadOnly(tokens) {
|
||||
for (const token of tokens) {
|
||||
if (!READ_ONLY_FUZZ_TOKENS.has(token)) {
|
||||
throw new Error(
|
||||
`Fuzz token ${JSON.stringify(token)} is not declared in READ_ONLY_FUZZ_TOKENS — declare it read-only or drop it`
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const token of READ_ONLY_FUZZ_TOKENS) {
|
||||
if (UNSAFE_TOKENS.has(token)) {
|
||||
throw new Error(
|
||||
`Token ${JSON.stringify(token)} is declared both read-only and unsafe — the two lists disagree`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baselineIndex = process.argv.indexOf('--baseline')
|
||||
const baselineRev = baselineIndex === -1 ? 'HEAD' : process.argv[baselineIndex + 1]
|
||||
|
||||
assertMarkersFresh()
|
||||
|
||||
const isolatedUserData = mkdtempSync(join(REPO, '.equiv-userdata-'))
|
||||
mkdirSync(join(isolatedUserData, 'empty'), { recursive: true })
|
||||
|
||||
let oldEntry
|
||||
let newEntry
|
||||
try {
|
||||
// Why: build the case list (and run its safety guards) BEFORE the two tsc
|
||||
// compiles, so an unsafe token fails in a second instead of two minutes in.
|
||||
const cases = buildCases(isolatedUserData)
|
||||
|
||||
console.log(`Building baseline (${baselineRev}) …`)
|
||||
oldEntry = buildTree('old', baselineRev)
|
||||
console.log('Building working tree …')
|
||||
newEntry = buildTree('new', null)
|
||||
|
||||
console.log(`Comparing ${cases.length} invocations byte for byte …`)
|
||||
let mismatches = 0
|
||||
for (const [argv, env] of cases) {
|
||||
const before = run(oldEntry, argv, env)
|
||||
const after = run(newEntry, argv, env)
|
||||
if (
|
||||
before.status !== after.status ||
|
||||
before.stdout !== after.stdout ||
|
||||
before.stderr !== after.stderr
|
||||
) {
|
||||
mismatches += 1
|
||||
console.error(`\nMISMATCH argv=${JSON.stringify(argv)} env=${JSON.stringify(env)}`)
|
||||
console.error(` exit before=${before.status} after=${after.status}`)
|
||||
if (before.stdout !== after.stdout) {
|
||||
console.error(` stdout before=${JSON.stringify(before.stdout.slice(0, 400))}`)
|
||||
console.error(` after =${JSON.stringify(after.stdout.slice(0, 400))}`)
|
||||
}
|
||||
if (before.stderr !== after.stderr) {
|
||||
console.error(` stderr before=${JSON.stringify(before.stderr.slice(0, 400))}`)
|
||||
console.error(` after =${JSON.stringify(after.stderr.slice(0, 400))}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mismatches > 0) {
|
||||
throw new Error(`${mismatches} of ${cases.length} invocations differ`)
|
||||
}
|
||||
console.log(`\nAll ${cases.length} invocations byte-identical (stdout, stderr, exit code).`)
|
||||
} finally {
|
||||
rmSync(isolatedUserData, { recursive: true, force: true })
|
||||
for (const label of ['old', 'new']) {
|
||||
rmSync(resolve(REPO, `.equiv-out-${label}`), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: the per-id worktree/tab lookups in session hydration and terminal reconnect.
|
||||
//
|
||||
// Four sites in store/slices/terminals.ts re-flattened worktreesByRepo (or tabsByWorktree)
|
||||
// and linearly searched it once per loop iteration -- O(rows x ids) for O(rows + ids)
|
||||
// distinct work. The fix builds one first-wins index per loop.
|
||||
//
|
||||
// This runs on the renderer's synchronous cold-start path and gates workspaceSessionReady,
|
||||
// which blocks terminal pane mounting, so the cost is paid before the first frame.
|
||||
//
|
||||
// Both arms produce the resolved rows and are compared for equality before timing, so an
|
||||
// index that resolved differently could not be reported as a win.
|
||||
//
|
||||
// Run with: node config/scripts/hydrate-worktree-lookup-benchmark.mjs
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const ITERATIONS = Number(process.env.ORCA_HYDRATE_BENCH_ITERATIONS ?? '60')
|
||||
const WARMUP = Number(process.env.ORCA_HYDRATE_BENCH_WARMUP ?? '10')
|
||||
const ROUNDS = 6
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_HYDRATE_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_HYDRATE_BENCH_WARMUP', WARMUP]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-fix: re-flatten and linear-search per id.
|
||||
function resolveByFlatten(worktreesByRepo, ids) {
|
||||
const resolved = []
|
||||
for (const id of ids) {
|
||||
const worktree = Object.values(worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === id)
|
||||
resolved.push(worktree ? worktree.repoId : null)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// Post-fix: mirrors buildWorktreeByIdIndex in store/slices/worktree-by-id-index.ts.
|
||||
function resolveByIndex(worktreesByRepo, ids) {
|
||||
const index = new Map()
|
||||
for (const worktrees of Object.values(worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
if (!index.has(worktree.id)) {
|
||||
index.set(worktree.id, worktree)
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolved = []
|
||||
for (const id of ids) {
|
||||
const worktree = index.get(id)
|
||||
resolved.push(worktree ? worktree.repoId : null)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function makeStore(repoCount, worktreesPerRepo) {
|
||||
const worktreesByRepo = {}
|
||||
for (let repo = 0; repo < repoCount; repo += 1) {
|
||||
const repoId = `repo-${repo}`
|
||||
worktreesByRepo[repoId] = Array.from({ length: worktreesPerRepo }, (_value, index) => ({
|
||||
id: `${repoId}/wt-${index}`,
|
||||
repoId,
|
||||
path: `/Users/dev/worktrees/${repoId}/wt-${index}`,
|
||||
branch: `feature/branch-${index}`
|
||||
}))
|
||||
}
|
||||
// Why a deliberate duplicate: `.find()` is first-wins, so an index that overwrote on
|
||||
// collision would resolve a different repo. Without a collision in the fixture that
|
||||
// difference is unobservable and the equality check below would pass a broken index.
|
||||
if (repoCount > 1) {
|
||||
const [firstRepo, secondRepo] = Object.keys(worktreesByRepo)
|
||||
worktreesByRepo[secondRepo] = [
|
||||
{ ...worktreesByRepo[firstRepo][0], repoId: secondRepo },
|
||||
...worktreesByRepo[secondRepo]
|
||||
]
|
||||
}
|
||||
return worktreesByRepo
|
||||
}
|
||||
|
||||
// Why a miss fraction: SSH worktrees are absent from worktreesByRepo at cold start, so
|
||||
// the real workload includes ids that scan the whole list without matching -- the worst
|
||||
// case for the linear arm, and the one the code comments call out explicitly.
|
||||
function makeIds(worktreesByRepo, count) {
|
||||
const all = Object.values(worktreesByRepo).flat()
|
||||
const ids = Array.from({ length: count }, (_value, index) =>
|
||||
index % 7 === 0 ? `absent/wt-${index}` : all[(index * 31) % all.length].id
|
||||
)
|
||||
// Always look up the duplicated id, so first-wins is exercised, not just present.
|
||||
ids[1] = all[0].id
|
||||
return ids
|
||||
}
|
||||
|
||||
function timeArm(resolve, worktreesByRepo, ids) {
|
||||
let sink = 0
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
// Consume the result so V8 cannot drop the call as dead.
|
||||
sink += resolve(worktreesByRepo, ids).length
|
||||
}
|
||||
const elapsed = (performance.now() - start) / ITERATIONS
|
||||
if (sink === -1) {
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
return elapsed
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const mid = sorted.length / 2
|
||||
return (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
// Arms alternate which one leads so within-round drift cannot favour either.
|
||||
function measure(worktreesByRepo, ids) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
resolveByFlatten(worktreesByRepo, ids)
|
||||
resolveByIndex(worktreesByRepo, ids)
|
||||
}
|
||||
const flattenSamples = []
|
||||
const indexSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
if (round % 2 === 0) {
|
||||
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
|
||||
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
|
||||
} else {
|
||||
indexSamples.push(timeArm(resolveByIndex, worktreesByRepo, ids))
|
||||
flattenSamples.push(timeArm(resolveByFlatten, worktreesByRepo, ids))
|
||||
}
|
||||
}
|
||||
return { flattenMs: median(flattenSamples), indexMs: median(indexSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('Session-hydration worktree lookup, per cold start. Lower is better.')
|
||||
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (per-arm medians)`)
|
||||
console.log(
|
||||
`${pad('repos', 6)} ${pad('worktrees', 10)} ${pad('ids', 5)} ${pad('flatten', 11)} ${pad('indexed', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
// Row shapes are synthetic, sized against a real orca-data.json on a heavy machine
|
||||
// (10 repos / 423 worktrees / 188 pending reconnect). They are not that dataset: the
|
||||
// generator spreads worktrees evenly and injects one duplicate id, so treat the counts
|
||||
// as "about this scale", not a replay.
|
||||
for (const [repoCount, worktreesPerRepo, idCount] of [
|
||||
[1, 5, 5],
|
||||
[3, 20, 20],
|
||||
[10, 42, 188],
|
||||
[10, 100, 400]
|
||||
]) {
|
||||
const worktreesByRepo = makeStore(repoCount, worktreesPerRepo)
|
||||
const ids = makeIds(worktreesByRepo, idCount)
|
||||
const flattenResult = resolveByFlatten(worktreesByRepo, ids)
|
||||
const indexResult = resolveByIndex(worktreesByRepo, ids)
|
||||
if (JSON.stringify(flattenResult) !== JSON.stringify(indexResult)) {
|
||||
throw new Error(`resolution differs at ${repoCount} repos x ${worktreesPerRepo} worktrees`)
|
||||
}
|
||||
if (!flattenResult.some((value) => value !== null)) {
|
||||
throw new Error(`fixture resolved nothing at ${repoCount} repos`)
|
||||
}
|
||||
if (!flattenResult.some((value) => value === null)) {
|
||||
throw new Error(`fixture had no absent ids at ${repoCount} repos`)
|
||||
}
|
||||
// Count the generated rows rather than multiplying: makeStore injects a duplicate
|
||||
// id for multi-repo cases, so the product would misreport the fixture by one.
|
||||
const worktreeCount = Object.values(worktreesByRepo).reduce((sum, rows) => sum + rows.length, 0)
|
||||
const { flattenMs, indexMs } = measure(worktreesByRepo, ids)
|
||||
console.log(
|
||||
`${pad(repoCount, 6)} ${pad(worktreeCount, 10)} ${pad(idCount, 5)} ${pad(`${flattenMs.toFixed(4)} ms`, 11)} ${pad(`${indexMs.toFixed(4)} ms`, 11)} ${pad(`${(flattenMs / indexMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'\nFixtures are synthetic at real-world scale, not a replay of a real session.\nThis times one of the four lookup sites. A one-repo session sees almost nothing;\nthe win scales with worktrees x pending ids, and lands on the cold-start path that\ngates terminal pane mounting.'
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Measures the complete ProseMirror doc traversal used by the two doc-link plugins.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
import {
|
||||
canHoldDocLink,
|
||||
DOC_LINK_PATTERN,
|
||||
isDocLinkLiteralCodeTextNode
|
||||
} from '../../src/renderer/src/components/editor/rich-markdown-doc-link-scan.ts'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const ITERATIONS = Number(process.env.ORCA_DOC_LINK_BENCH_ITERATIONS ?? '41')
|
||||
const WARMUP_ITERATIONS = Math.min(9, ITERATIONS)
|
||||
|
||||
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
|
||||
throw new Error(`ORCA_DOC_LINK_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
|
||||
}
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: { content: 'text*' },
|
||||
text: { group: 'inline' }
|
||||
}
|
||||
})
|
||||
|
||||
function walkUngated(doc) {
|
||||
let matches = 0
|
||||
let visited = 0
|
||||
doc.descendants((node, _pos, parent) => {
|
||||
visited += 1
|
||||
if (node.type.name !== 'text' || !node.text || isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
return
|
||||
}
|
||||
for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
matches += 1
|
||||
}
|
||||
})
|
||||
return { matches, visited }
|
||||
}
|
||||
|
||||
function walkGated(doc) {
|
||||
let matches = 0
|
||||
let visited = 0
|
||||
doc.descendants((node, _pos, parent) => {
|
||||
visited += 1
|
||||
if (!canHoldDocLink(node, parent)) {
|
||||
return
|
||||
}
|
||||
for (const _match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
matches += 1
|
||||
}
|
||||
})
|
||||
return { matches, visited }
|
||||
}
|
||||
|
||||
function countMatches(source) {
|
||||
let matches = 0
|
||||
for (const _match of source.matchAll(DOC_LINK_PATTERN)) {
|
||||
matches += 1
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function loadDocs() {
|
||||
const files = execFileSync('git', ['ls-files', '*.md', 'docs/*.md'], {
|
||||
cwd: REPO_ROOT,
|
||||
maxBuffer: 256 * 1024 * 1024
|
||||
})
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
const docs = []
|
||||
for (const file of files) {
|
||||
try {
|
||||
const source = readFileSync(join(REPO_ROOT, file), 'utf8')
|
||||
const lines = source.split('\n').filter(Boolean)
|
||||
if (lines.length > 0) {
|
||||
docs.push({ file, lines, size: source.length, matches: countMatches(source) })
|
||||
}
|
||||
} catch {
|
||||
// Indexed paths can disappear while the benchmark is running.
|
||||
}
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
function createFixture(doc, nonce) {
|
||||
const paragraphs = doc.lines.map((line, index) => {
|
||||
const text = index === 0 ? `${line} bench-${nonce}` : line
|
||||
return schema.node('paragraph', null, text ? schema.text(text) : undefined)
|
||||
})
|
||||
return schema.node('doc', null, paragraphs)
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
function measureCorpus(docs) {
|
||||
const samples = { ungated: [], gated: [] }
|
||||
const totals = {
|
||||
ungated: { matches: 0, visited: 0 },
|
||||
gated: { matches: 0, visited: 0 }
|
||||
}
|
||||
const seenFixtures = new WeakSet()
|
||||
let expectedMatches = 0
|
||||
let expectedVisited = 0
|
||||
const measuredOrder = []
|
||||
|
||||
for (let round = -WARMUP_ITERATIONS; round < ITERATIONS; round += 1) {
|
||||
const doc = docs[(round + WARMUP_ITERATIONS) % docs.length]
|
||||
const measured = round >= 0
|
||||
const order =
|
||||
(round + WARMUP_ITERATIONS) % 2 === 0 ? ['ungated', 'gated'] : ['gated', 'ungated']
|
||||
const fixtures = {
|
||||
ungated: createFixture(doc, String(round)),
|
||||
gated: createFixture(doc, String(round))
|
||||
}
|
||||
|
||||
for (const arm of order) {
|
||||
const fixture = fixtures[arm]
|
||||
if (seenFixtures.has(fixture)) {
|
||||
throw new Error('timed fixture was reused')
|
||||
}
|
||||
seenFixtures.add(fixture)
|
||||
const start = performance.now()
|
||||
const result = arm === 'ungated' ? walkUngated(fixture) : walkGated(fixture)
|
||||
const elapsed = performance.now() - start
|
||||
if (measured) {
|
||||
samples[arm].push(elapsed)
|
||||
totals[arm].matches += result.matches
|
||||
totals[arm].visited += result.visited
|
||||
measuredOrder.push(arm)
|
||||
}
|
||||
}
|
||||
if (measured) {
|
||||
expectedMatches += doc.matches
|
||||
expectedVisited += doc.lines.length * 2
|
||||
}
|
||||
}
|
||||
|
||||
if (totals.ungated.matches !== expectedMatches || totals.gated.matches !== expectedMatches) {
|
||||
throw new Error(
|
||||
`gate changed matches: expected ${expectedMatches}, ungated ${totals.ungated.matches}, gated ${totals.gated.matches}`
|
||||
)
|
||||
}
|
||||
if (totals.ungated.visited !== expectedVisited || totals.gated.visited !== expectedVisited) {
|
||||
throw new Error(
|
||||
`full traversal result was not consumed: expected ${expectedVisited}, ungated ${totals.ungated.visited}, gated ${totals.gated.visited}`
|
||||
)
|
||||
}
|
||||
for (let index = 0; index < measuredOrder.length; index += 2) {
|
||||
const pair = measuredOrder.slice(index, index + 2).join(',')
|
||||
const previousPair = index === 0 ? null : measuredOrder.slice(index - 2, index).join(',')
|
||||
if (!['ungated,gated', 'gated,ungated'].includes(pair) || pair === previousPair) {
|
||||
throw new Error('benchmark arms were not interleaved in alternating order')
|
||||
}
|
||||
}
|
||||
|
||||
return { ungated: median(samples.ungated), gated: median(samples.gated) }
|
||||
}
|
||||
|
||||
const docs = loadDocs()
|
||||
if (docs.length === 0) {
|
||||
throw new Error('no markdown files found in the index')
|
||||
}
|
||||
const large = docs.filter((doc) => doc.size > 3000)
|
||||
const biggest = docs.reduce((a, b) => (b.size > a.size ? b : a))
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
|
||||
console.log('Doc-link ProseMirror traversal, per editor transaction. Lower is better.')
|
||||
console.log(
|
||||
`docs=${docs.length} (>3KB: ${large.length}) iterations=${ITERATIONS} (interleaved median)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('corpus', 26)} ${pad('ungated', 11)} ${pad('gated', 11)} ${pad('delta', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
for (const [label, set] of [
|
||||
['all repo markdown', docs],
|
||||
['docs over 3 KB', large],
|
||||
[`biggest (${biggest.file.split('/').pop()})`, [biggest]]
|
||||
]) {
|
||||
if (set.length === 0) {
|
||||
console.log(`${pad(label, 26)} ${pad('no docs in this corpus — skipped', 44)}`)
|
||||
continue
|
||||
}
|
||||
const { ungated, gated } = measureCorpus(set)
|
||||
const delta = ungated - gated
|
||||
console.log(
|
||||
`${pad(label, 26)} ${pad(`${(ungated * 1000).toFixed(1)} us`, 11)} ${pad(`${(gated * 1000).toFixed(1)} us`, 11)} ${pad(`${(delta * 1000).toFixed(1)} us`, 11)} ${pad(`${(ungated / gated).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'\nAuto-conversion pays this once per keystroke; preview decorations pay it once\nper keystroke and once per caret move.'
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
|
||||
export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
|
||||
|
||||
describe('telemetry bundle constant patterns', () => {
|
||||
it.each(['const', 'let', 'var'])('accepts %s declarations', (declaration) => {
|
||||
expect(`${declaration} BUILD_IDENTITY = "rc"`).toMatch(BUILD_IDENTITY_RE)
|
||||
expect(`${declaration} WRITE_KEY = "phc_example-key_123"`).toMatch(WRITE_KEY_RE)
|
||||
})
|
||||
|
||||
it('rejects assignments and invalid values', () => {
|
||||
expect('BUILD_IDENTITY = "rc"').not.toMatch(BUILD_IDENTITY_RE)
|
||||
expect('const BUILD_IDENTITY = "dev"').not.toMatch(BUILD_IDENTITY_RE)
|
||||
expect('const WRITE_KEY = null').not.toMatch(WRITE_KEY_RE)
|
||||
expect('const WRITE_KEY = "example-key"').not.toMatch(WRITE_KEY_RE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: iterateTerminalOutputFrameChunks, which every byte of remote terminal
|
||||
// output passes through on its way to a mobile/remote-desktop multiplex stream.
|
||||
//
|
||||
// The pre-fix loop was `for (const part of data)`: V8 materializes a fresh 1-2 code
|
||||
// unit string per code point, then measureClipboardTextByteLength re-walked that
|
||||
// string through codePointAt to get its UTF-8 width, and the chunk was rebuilt with
|
||||
// `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the
|
||||
// same per-code-point walk over the whole payload a second time.
|
||||
//
|
||||
// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point
|
||||
// string, chunk text taken as data.slice(chunkStart, end), and a gate of
|
||||
// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a
|
||||
// string's UTF-8 length is never below its UTF-16 length.
|
||||
//
|
||||
// BOTH arms run the complete production path, encodeTerminalStreamText included, and
|
||||
// their emitted frames (base64 + seq + opcode) are compared before any timing, so an
|
||||
// arm that split differently or renumbered a seq cannot be reported as a win.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// terminal-stream-protocol.ts declares a TS enum, which Node's default strip-only
|
||||
// loader rejects; re-exec once with type transformation rather than re-modelling
|
||||
// the opcodes here (a hand copy could drift from the wire contract).
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
// The app's TS sources import siblings without an extension; Node's ESM resolver needs it.
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40')
|
||||
const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8')
|
||||
const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6')
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS],
|
||||
['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP],
|
||||
['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS]
|
||||
]) {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
if (ROUNDS % 2 !== 0) {
|
||||
throw new Error(`ORCA_FRAME_CHUNK_BENCH_ROUNDS must be even so each arm leads equally`)
|
||||
}
|
||||
|
||||
const CHUNK_SOURCE = readFileSync(
|
||||
new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const CLIPBOARD_SOURCE = readFileSync(
|
||||
new URL('../../src/shared/clipboard-text.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Why re-read the sources: this benchmark's claim is that the OLD arm paid a
|
||||
// measureClipboardTextByteLength call per code point and the NEW one pays a single
|
||||
// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming
|
||||
// the function would satisfy a substring check.
|
||||
for (const [source, label, marker] of [
|
||||
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"],
|
||||
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'],
|
||||
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'],
|
||||
[CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('],
|
||||
[CLIPBOARD_SOURCE, 'clipboard-text.ts', 'text.codePointAt(index)']
|
||||
]) {
|
||||
if (!source.includes(marker)) {
|
||||
throw new Error(`${label} no longer contains \`${marker}\`; this benchmark is stale`)
|
||||
}
|
||||
}
|
||||
if (CHUNK_SOURCE.includes('for (const part of data)')) {
|
||||
throw new Error(
|
||||
'terminal-output-frame-chunks.ts still iterates code points as strings; this benchmark is stale'
|
||||
)
|
||||
}
|
||||
|
||||
const { TERMINAL_STREAM_CHUNK_BYTES } = await import(
|
||||
new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href
|
||||
)
|
||||
const { measureClipboardTextByteLength } = await import(
|
||||
new URL('../../src/shared/clipboard-text.ts', import.meta.url).href
|
||||
)
|
||||
const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import(
|
||||
new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href
|
||||
)
|
||||
const { iterateTerminalOutputFrameChunks } = await import(
|
||||
new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href
|
||||
)
|
||||
|
||||
// Pre-fix arm: the exact code that shipped, including the second full walk in the gate.
|
||||
function* iterateBefore(data, meta) {
|
||||
const rawLength = meta?.rawLength ?? data.length
|
||||
if (meta?.transformed || rawLength !== data.length) {
|
||||
yield {
|
||||
opcode: TerminalStreamOpcode.OutputSpan,
|
||||
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
|
||||
seq: meta?.seq
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
!measureClipboardTextByteLength(data, { stopAfterBytes: TERMINAL_STREAM_CHUNK_BYTES })
|
||||
.exceededLimit
|
||||
) {
|
||||
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
|
||||
return
|
||||
}
|
||||
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
|
||||
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
|
||||
const startSeq = canPreserveChunkSeq ? meta.seq - rawLength : undefined
|
||||
let chunk = ''
|
||||
let chunkBytes = 0
|
||||
let chunkStartOffset = 0
|
||||
let offset = 0
|
||||
let delayedChunk = null
|
||||
|
||||
const takeChunk = () => {
|
||||
if (!chunk) {
|
||||
return null
|
||||
}
|
||||
const chunkSeq = canPreserveChunkSeq ? startSeq + chunkStartOffset + chunk.length : undefined
|
||||
const current = { text: chunk, seq: chunkSeq }
|
||||
chunk = ''
|
||||
chunkBytes = 0
|
||||
chunkStartOffset = offset
|
||||
return current
|
||||
}
|
||||
|
||||
for (const part of data) {
|
||||
const partBytes = measureClipboardTextByteLength(part).byteLength
|
||||
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
|
||||
const nextChunk = takeChunk()
|
||||
if (nextChunk) {
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = nextChunk
|
||||
} else {
|
||||
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk += part
|
||||
chunkBytes += partBytes
|
||||
offset += part.length
|
||||
}
|
||||
const finalChunk = takeChunk()
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (finalChunk) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = finalChunk
|
||||
}
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (finalChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
|
||||
}
|
||||
}
|
||||
|
||||
// The multiplex stream consumes every frame's bytes/seq/opcode; charge both arms for it.
|
||||
let frameChecksum = 0
|
||||
function drain(iterate, data, meta) {
|
||||
let frames = 0
|
||||
let bytes = 0
|
||||
let seqSum = 0
|
||||
for (const frame of iterate(data, meta)) {
|
||||
frames += 1
|
||||
bytes += frame.bytes.byteLength
|
||||
seqSum += frame.seq ?? 0
|
||||
}
|
||||
frameChecksum = Math.imul(frameChecksum ^ (frames + bytes + seqSum), 16777619) >>> 0
|
||||
return frames
|
||||
}
|
||||
|
||||
function describeFrames(iterate, data, meta) {
|
||||
const shapes = []
|
||||
for (const frame of iterate(data, meta)) {
|
||||
shapes.push(
|
||||
`${Buffer.from(frame.bytes).toString('base64')}|${frame.seq ?? 'u'}|${frame.opcode ?? 'u'}`
|
||||
)
|
||||
}
|
||||
return shapes.join('\n')
|
||||
}
|
||||
|
||||
const SURROGATE_PAIR = '\u{1f600}'
|
||||
const LONE_HIGH = '\ud83d'
|
||||
|
||||
function repeatTo(unit, codeUnits) {
|
||||
let out = ''
|
||||
while (out.length < codeUnits) {
|
||||
out += unit
|
||||
}
|
||||
return out.slice(0, out.length - (out.length % unit.length))
|
||||
}
|
||||
|
||||
// A realistic agent-TUI line: SGR runs, a wide glyph, a currency sign, an emoji.
|
||||
const TUI_LINE =
|
||||
'\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed, 0 failed\r\n'
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
label: 'ascii 4KiB (typical batch)',
|
||||
data: 'x'.repeat(4 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: `ascii ${TERMINAL_STREAM_CHUNK_BYTES}B (at cap)`,
|
||||
data: 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'ascii 64KiB (batch cap, 2 chunks)',
|
||||
data: 'x'.repeat(64 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'mixed TUI 64KiB (2 chunks)',
|
||||
data: repeatTo(TUI_LINE, 64 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
// seq without an explicit rawLength: the batcher's ordinary shape.
|
||||
label: 'mixed TUI 64KiB (implicit raw)',
|
||||
data: repeatTo(TUI_LINE, 64 * 1024),
|
||||
meta: () => ({ seq: 5_000_000 })
|
||||
},
|
||||
{
|
||||
label: 'emoji 64KiB (4-byte, 2 chunks)',
|
||||
data: repeatTo(SURROGATE_PAIR, 32 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'lone surrogates 64KiB',
|
||||
data: repeatTo(LONE_HIGH, 64 * 1024),
|
||||
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
|
||||
},
|
||||
{
|
||||
label: 'ascii 512KiB (snapshot chunking)',
|
||||
data: 'x'.repeat(512 * 1024),
|
||||
meta: () => undefined
|
||||
}
|
||||
]
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
// Why interleaved with an alternating lead: running one arm's whole batch first lets
|
||||
// CPU frequency drift correlate with whichever arm is being measured. Elsewhere in this
|
||||
// effort that alone reported 23.3x for a real 6.7x.
|
||||
function measureInterleaved(data, meta) {
|
||||
for (let index = 0; index < WARMUP; index += 1) {
|
||||
drain(iterateBefore, data, meta)
|
||||
drain(iterateTerminalOutputFrameChunks, data, meta)
|
||||
}
|
||||
const beforeSamples = []
|
||||
const afterSamples = []
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
const runBefore = () => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
drain(iterateBefore, data, meta)
|
||||
}
|
||||
beforeSamples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
const runAfter = () => {
|
||||
const start = performance.now()
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
drain(iterateTerminalOutputFrameChunks, data, meta)
|
||||
}
|
||||
afterSamples.push((performance.now() - start) / ITERATIONS)
|
||||
}
|
||||
if (round % 2 === 0) {
|
||||
runBefore()
|
||||
runAfter()
|
||||
} else {
|
||||
runAfter()
|
||||
runBefore()
|
||||
}
|
||||
}
|
||||
return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) }
|
||||
}
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} warmup=${WARMUP} rounds=${ROUNDS} (alternating lead, per-arm median)`
|
||||
)
|
||||
console.log(
|
||||
`${pad('fixture', 34)} ${pad('frames', 7)} ${pad('per-part', 11)} ${pad('scanned', 11)} ${pad('speedup', 9)}`
|
||||
)
|
||||
|
||||
let comparedFixtures = 0
|
||||
for (const fixture of fixtures) {
|
||||
const meta = fixture.meta(fixture.data)
|
||||
const before = describeFrames(iterateBefore, fixture.data, meta)
|
||||
const after = describeFrames(iterateTerminalOutputFrameChunks, fixture.data, meta)
|
||||
if (before !== after) {
|
||||
throw new Error(`frames differ for ${fixture.label}`)
|
||||
}
|
||||
const frames = before.split('\n').length
|
||||
// Guard against a fixture that never reaches the chunking loop; then both arms would
|
||||
// just be measuring the gate and the comparison above would prove nothing about it.
|
||||
if (fixture.data.length > TERMINAL_STREAM_CHUNK_BYTES && frames < 2) {
|
||||
throw new Error(`${fixture.label} never split; fixture does not exercise the chunk loop`)
|
||||
}
|
||||
comparedFixtures += 1
|
||||
const { beforeMs, afterMs } = measureInterleaved(fixture.data, meta)
|
||||
console.log(
|
||||
`${pad(fixture.label, 34)} ${pad(frames, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}`
|
||||
)
|
||||
console.log(
|
||||
'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.'
|
||||
)
|
||||
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmarks the production terminal byte-measurement exports at their real budgets: the output
|
||||
// batcher push and the snapshot budget scan. Every scenario asserts whether production invoked
|
||||
// Buffer.byteLength, so implementation drift cannot preserve stale speedup claims.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import fs from 'node:fs'
|
||||
import nodeModule from 'node:module'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
if (!process.execArgv.includes('--experimental-transform-types')) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['--experimental-transform-types', '--no-warnings', import.meta.filename],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
// The app's TS sources import siblings without an extension; Node's ESM resolver needs it.
|
||||
nodeModule.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
|
||||
const candidate = new URL(`${specifier}.ts`, context.parentURL)
|
||||
if (fs.existsSync(fileURLToPath(candidate))) {
|
||||
return { url: candidate.href, shortCircuit: true }
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
}
|
||||
})
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '../..')
|
||||
const ITERATIONS = Number(process.env.ORCA_BYTE_LENGTH_BENCH_ITERATIONS ?? '61')
|
||||
let resultChecksum = 0
|
||||
let validatedPairs = 0
|
||||
|
||||
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
|
||||
throw new Error(`ORCA_BYTE_LENGTH_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
|
||||
}
|
||||
|
||||
function readSource(relative) {
|
||||
return fs.readFileSync(path.join(ROOT, relative), 'utf8')
|
||||
}
|
||||
|
||||
const TERMINAL_SOURCE = readSource('src/main/runtime/rpc/methods/terminal.ts')
|
||||
|
||||
function requireCallForm(source, needle, label) {
|
||||
if (!source.includes(needle)) {
|
||||
throw new Error(`${label} is stale: expected call form \`${needle}\` was not found`)
|
||||
}
|
||||
}
|
||||
|
||||
const { TERMINAL_OUTPUT_BATCH_MAX_BYTES, TERMINAL_STREAM_CHUNK_BYTES } = await import(
|
||||
new URL('../../src/shared/terminal-multiplex-flow-control.ts', import.meta.url).href
|
||||
)
|
||||
const { measureClipboardTextByteLength } = await import(
|
||||
new URL('../../src/shared/clipboard-text.ts', import.meta.url).href
|
||||
)
|
||||
const {
|
||||
MIN_NATIVE_BYTE_LENGTH_CODE_UNITS,
|
||||
measureTerminalStreamByteLength,
|
||||
terminalStreamByteLengthExceeds
|
||||
} = await import(
|
||||
new URL('../../src/main/runtime/rpc/terminal-stream-byte-length.ts', import.meta.url).href
|
||||
)
|
||||
|
||||
const REQUESTED_SNAPSHOT_BYTE_BUDGET = (() => {
|
||||
const match = /const REQUESTED_SNAPSHOT_BYTE_BUDGET = ([^\n]+)/.exec(TERMINAL_SOURCE)
|
||||
if (!match) {
|
||||
throw new Error('terminal.ts is stale: REQUESTED_SNAPSHOT_BYTE_BUDGET is gone')
|
||||
}
|
||||
return Number(new Function(`return (${match[1].trim()})`)())
|
||||
})()
|
||||
|
||||
requireCallForm(TERMINAL_SOURCE, 'measureTerminalStreamByteLength(data, {', 'terminal.ts')
|
||||
requireCallForm(TERMINAL_SOURCE, 'stopAfterBytes: remainingBudget', 'terminal.ts')
|
||||
requireCallForm(
|
||||
TERMINAL_SOURCE,
|
||||
'terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET)',
|
||||
'terminal.ts'
|
||||
)
|
||||
|
||||
const nativeByteLength = Buffer.byteLength
|
||||
function runWithNativeCallCount(fn) {
|
||||
let calls = 0
|
||||
Buffer.byteLength = (...args) => {
|
||||
calls += 1
|
||||
return Reflect.apply(nativeByteLength, Buffer, args)
|
||||
}
|
||||
try {
|
||||
return { output: fn(), calls }
|
||||
} finally {
|
||||
Buffer.byteLength = nativeByteLength
|
||||
}
|
||||
}
|
||||
|
||||
// ---- OLD ARM: the production implementation terminal.ts called before this change.
|
||||
const legacyMeasure = measureClipboardTextByteLength
|
||||
const legacyExceeds = (data, maxBytes) =>
|
||||
measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
|
||||
|
||||
// ---- Fixtures. Deterministic, seeded, and varied per sample so V8 cannot hoist.
|
||||
function mulberry32(seed) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// Realistic agent-TUI output: mostly ASCII with SGR runs, box drawing, and emoji status
|
||||
// glyphs, plus a per-sample marker so no two measured strings are identical.
|
||||
function makeTerminalText(codeUnits, sampleId) {
|
||||
const random = mulberry32(sampleId * 2654435761)
|
||||
const lines = [
|
||||
'[35m✻ Thinking…[0m\r\n',
|
||||
' ⏺ Running tests… 42 passed, 0 failed\r\n',
|
||||
'│ src/main/runtime/rpc/methods/terminal.ts │\r\n',
|
||||
' ✅ build succeeded in 12.4s — café naïve\r\n',
|
||||
'[32m+ added line[0m\r\n'
|
||||
]
|
||||
let text = `sample:${sampleId}\r\n`
|
||||
while (text.length < codeUnits) {
|
||||
text += lines[Math.floor(random() * lines.length)]
|
||||
}
|
||||
return text.slice(0, codeUnits)
|
||||
}
|
||||
|
||||
// Keystroke echo and tiny interactive writes: the shapes a PTY emits between key presses.
|
||||
function makeInteractiveText(codeUnits, sampleId) {
|
||||
const random = mulberry32(sampleId * 40503)
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789 ./-_'
|
||||
let text = ''
|
||||
while (text.length < codeUnits) {
|
||||
text += alphabet[Math.floor(random() * alphabet.length)]
|
||||
}
|
||||
return text.slice(0, codeUnits)
|
||||
}
|
||||
|
||||
// Trim to just under a BYTE budget so the legacy arm runs its full scan without tripping the limit.
|
||||
function makeTerminalTextUnderBytes(byteBudget, sampleId) {
|
||||
let text = makeTerminalText(byteBudget, sampleId)
|
||||
while (Buffer.byteLength(text, 'utf8') > byteBudget) {
|
||||
text = text.slice(0, Math.floor(text.length * (byteBudget / Buffer.byteLength(text, 'utf8'))))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// The TRUE adversary for the exceeds gate: stay at the code-unit cap so `length > maxBytes`
|
||||
// cannot short-circuit, but pack 3-byte BMP scalars so the legacy scan bails out after only a
|
||||
// THIRD of the string while Buffer.byteLength still walks all of it.
|
||||
function makeEarlyTripText(byteBudget, sampleId) {
|
||||
const tripUnits = Math.ceil((byteBudget + 1) / 3)
|
||||
const marker = String.fromCharCode(0x4e00 + (sampleId % 4096))
|
||||
const prefix = `${marker}${'走'.repeat(tripUnits - 1)}`
|
||||
return `${prefix}${'a'.repeat(byteBudget - tripUnits)}`
|
||||
}
|
||||
|
||||
function median(samples) {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
||||
}
|
||||
|
||||
function consume(value) {
|
||||
resultChecksum = Math.imul(resultChecksum ^ (value | 0), 16777619) >>> 0
|
||||
}
|
||||
|
||||
// Small inputs are far below timer resolution, so batch them: build `repeats` distinct
|
||||
// samples, time the whole loop, and report per-call cost. Consuming the running total
|
||||
// inside the timed region keeps V8 from hoisting the calls out.
|
||||
function runScenario(scenario) {
|
||||
const repeats = scenario.repeats ?? 1
|
||||
const samples = { legacy: [], next: [] }
|
||||
const runArm = (fn, inputs) => {
|
||||
const start = performance.now()
|
||||
let total = 0
|
||||
for (const input of inputs) {
|
||||
total += scenario.checksum(fn(input))
|
||||
}
|
||||
const elapsed = performance.now() - start
|
||||
return { elapsed, total }
|
||||
}
|
||||
for (let index = 0; index < ITERATIONS; index += 1) {
|
||||
// Alternate which arm leads on every iteration so cache/JIT warmup is shared evenly.
|
||||
for (const legacyFirst of index % 2 === 0 ? [true, false] : [false, true]) {
|
||||
const batch = index * 2 + (legacyFirst ? 0 : 1)
|
||||
const inputs = []
|
||||
for (let repeat = 0; repeat < repeats; repeat += 1) {
|
||||
inputs.push(scenario.make(batch * repeats + repeat))
|
||||
}
|
||||
const legacyOutputs = inputs.map(scenario.legacy)
|
||||
const observed = runWithNativeCallCount(() => inputs.map(scenario.next))
|
||||
for (let inputIndex = 0; inputIndex < inputs.length; inputIndex += 1) {
|
||||
const input = inputs[inputIndex]
|
||||
const legacyOutput = legacyOutputs[inputIndex]
|
||||
const nextOutput = observed.output[inputIndex]
|
||||
if (!scenario.equal(legacyOutput, nextOutput)) {
|
||||
throw new Error(
|
||||
`${scenario.label}: arms disagree on ${JSON.stringify(input.slice(0, 40))}`
|
||||
)
|
||||
}
|
||||
scenario.assertResult(legacyOutput, input)
|
||||
validatedPairs += 1
|
||||
}
|
||||
scenario.assertNativeCalls(inputs.length, observed.calls)
|
||||
let legacyResult
|
||||
let nextResult
|
||||
if (legacyFirst) {
|
||||
legacyResult = runArm(scenario.legacy, inputs)
|
||||
nextResult = runArm(scenario.next, inputs)
|
||||
} else {
|
||||
nextResult = runArm(scenario.next, inputs)
|
||||
legacyResult = runArm(scenario.legacy, inputs)
|
||||
}
|
||||
consume(legacyResult.total)
|
||||
consume(nextResult.total)
|
||||
samples.legacy.push(legacyResult.elapsed / repeats)
|
||||
samples.next.push(nextResult.elapsed / repeats)
|
||||
}
|
||||
}
|
||||
return { legacy: median(samples.legacy), next: median(samples.next) }
|
||||
}
|
||||
|
||||
const measurementEqual = (a, b) =>
|
||||
a.byteLength === b.byteLength && a.exceededLimit === b.exceededLimit
|
||||
const measurementChecksum = (m) => m.byteLength + (m.exceededLimit ? 1 : 0)
|
||||
const booleanChecksum = (value) => (value ? 1 : 0)
|
||||
|
||||
// Every scenario states which production branch it expects. Native fixtures require exactly one
|
||||
// Buffer.byteLength call per input; fallback fixtures require none.
|
||||
function requireBranch(expected) {
|
||||
return (inputCount, calls) => {
|
||||
const expectedCalls = expected === 'nativeFastPath' ? inputCount : 0
|
||||
if (calls !== expectedCalls) {
|
||||
throw new Error(
|
||||
`expected the production ${expected} branch (${expectedCalls} Buffer.byteLength calls), got ${calls}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchScenario = (label, make, options = {}) => ({
|
||||
label,
|
||||
make,
|
||||
repeats: options.repeats,
|
||||
legacy: (input) => legacyMeasure(input, { stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES }),
|
||||
next: (input) =>
|
||||
measureTerminalStreamByteLength(input, {
|
||||
stopAfterBytes: TERMINAL_OUTPUT_BATCH_MAX_BYTES
|
||||
}),
|
||||
equal: measurementEqual,
|
||||
checksum: measurementChecksum,
|
||||
assertResult: (out, input) => options.assert?.(out, input),
|
||||
assertNativeCalls: requireBranch(options.branch)
|
||||
})
|
||||
|
||||
const gateScenario = (label, budget, make, options = {}) => ({
|
||||
label,
|
||||
make,
|
||||
repeats: options.repeats,
|
||||
legacy: (input) => legacyExceeds(input, budget),
|
||||
next: (input) => terminalStreamByteLengthExceeds(input, budget),
|
||||
equal: (a, b) => a === b,
|
||||
checksum: booleanChecksum,
|
||||
assertResult: (out, input) => options.assert?.(out, input),
|
||||
assertNativeCalls: requireBranch(options.branch)
|
||||
})
|
||||
|
||||
const scenarios = [
|
||||
batchScenario('batcher push 8KiB', (sampleId) => makeTerminalText(8 * 1024, sampleId), {
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out) => {
|
||||
if (out.exceededLimit) {
|
||||
throw new Error('batcher push 8KiB should stay under the batch budget')
|
||||
}
|
||||
}
|
||||
}),
|
||||
batchScenario(
|
||||
'batcher push over budget',
|
||||
// Oversized on purpose: this is the case where the arms MUST both return the partial count.
|
||||
(sampleId) => makeTerminalText(3 * TERMINAL_OUTPUT_BATCH_MAX_BYTES, sampleId),
|
||||
{
|
||||
branch: 'scanFallback',
|
||||
assert: (out) => {
|
||||
if (!out.exceededLimit) {
|
||||
throw new Error('over-budget fixture never exceeded the limit')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
// TRUE WORST CASE for the batcher push. The guard only takes the native count when
|
||||
// length*3 <= stopAfterBytes, which PROVES the limit cannot trip, so the fast path can never
|
||||
// pay for both a Buffer.byteLength and a scan. What is left is the shape where the native
|
||||
// call replaces the fewest scan iterations: a chunk sitting just above the code-unit floor.
|
||||
batchScenario(
|
||||
`batcher push ${MIN_NATIVE_BYTE_LENGTH_CODE_UNITS}B (floor)`,
|
||||
(sampleId) => makeInteractiveText(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS, sampleId),
|
||||
{ branch: 'nativeFastPath', repeats: 4096 }
|
||||
),
|
||||
// Below the floor the new arm deliberately keeps the scan, so it is the legacy code exactly.
|
||||
batchScenario('batcher push 4B keystroke', (sampleId) => makeInteractiveText(4, sampleId), {
|
||||
branch: 'scanFallback',
|
||||
repeats: 4096
|
||||
}),
|
||||
gateScenario(
|
||||
`snapshot scan ${(REQUESTED_SNAPSHOT_BYTE_BUDGET / (1024 * 1024)).toFixed(0)}MiB`,
|
||||
REQUESTED_SNAPSHOT_BYTE_BUDGET,
|
||||
(sampleId) => makeTerminalTextUnderBytes(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out) => {
|
||||
if (out) {
|
||||
throw new Error('snapshot fixture should sit under the budget so the full scan runs')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
// TRUE WORST CASE for the boolean gate: at the code-unit cap so `length > maxBytes` cannot
|
||||
// short-circuit, but 3-byte scalars let the legacy scan bail out a THIRD of the way in while
|
||||
// Buffer.byteLength still walks the whole string. This is where the new arm can actually lose.
|
||||
gateScenario(
|
||||
'snapshot gate early-trip',
|
||||
REQUESTED_SNAPSHOT_BYTE_BUDGET,
|
||||
(sampleId) => makeEarlyTripText(REQUESTED_SNAPSHOT_BYTE_BUDGET, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out, input) => {
|
||||
if (!out) {
|
||||
throw new Error('early-trip gate fixture must exceed the budget')
|
||||
}
|
||||
if (input.length > REQUESTED_SNAPSHOT_BYTE_BUDGET) {
|
||||
throw new Error('early-trip fixture must not hit the code-unit short circuit')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
gateScenario(
|
||||
'chunk gate early-trip',
|
||||
TERMINAL_STREAM_CHUNK_BYTES,
|
||||
(sampleId) => makeEarlyTripText(TERMINAL_STREAM_CHUNK_BYTES, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out, input) => {
|
||||
if (!out) {
|
||||
throw new Error('early-trip chunk fixture must exceed the budget')
|
||||
}
|
||||
if (input.length > TERMINAL_STREAM_CHUNK_BYTES) {
|
||||
throw new Error('early-trip fixture must not hit the code-unit short circuit')
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
gateScenario(
|
||||
`chunk gate ${TERMINAL_STREAM_CHUNK_BYTES / 1024}KiB`,
|
||||
TERMINAL_STREAM_CHUNK_BYTES,
|
||||
(sampleId) => makeTerminalTextUnderBytes(TERMINAL_STREAM_CHUNK_BYTES, sampleId),
|
||||
{
|
||||
branch: 'nativeFastPath',
|
||||
assert: (out) => {
|
||||
if (out) {
|
||||
throw new Error('chunk gate fixture should sit under the chunk budget')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
const pad = (value, width) => String(value).padStart(width)
|
||||
const formatTime = (ms) =>
|
||||
ms >= 0.001 ? `${(ms * 1000).toFixed(1)} us` : `${(ms * 1e6).toFixed(1)} ns`
|
||||
console.log('Production terminal byte-measurement paths. Lower is better.')
|
||||
console.log(
|
||||
`iterations=${ITERATIONS} (${ITERATIONS * 2} counterbalanced batches/scenario, per-arm medians)`
|
||||
)
|
||||
console.log(`${pad('scenario', 30)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)}`)
|
||||
for (const scenario of scenarios) {
|
||||
const { legacy, next } = runScenario(scenario)
|
||||
console.log(
|
||||
`${pad(scenario.label, 30)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Small-chunk sweep across real interactive PTY write sizes. The floor makes everything below
|
||||
// MIN_NATIVE_BYTE_LENGTH_CODE_UNITS byte-identical to the legacy scan, so those rows must land
|
||||
// at ~1.00x; anything materially below that is a regression the change would be shipping.
|
||||
{
|
||||
console.log(
|
||||
`\nbatcher push small-chunk sweep (stopAfterBytes=${TERMINAL_OUTPUT_BATCH_MAX_BYTES}):`
|
||||
)
|
||||
console.log(
|
||||
`${pad('bytes', 10)} ${pad('legacy', 12)} ${pad('new', 12)} ${pad('speedup', 9)} branch`
|
||||
)
|
||||
for (const codeUnits of [4, 8, 16, 64, 256, 1024, 4096]) {
|
||||
const expectedBranch =
|
||||
codeUnits >= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS ? 'nativeFastPath' : 'scanFallback'
|
||||
const { legacy, next } = runScenario(
|
||||
batchScenario(`sweep ${codeUnits}`, (sampleId) => makeInteractiveText(codeUnits, sampleId), {
|
||||
branch: expectedBranch,
|
||||
repeats: Math.max(64, Math.min(4096, Math.ceil(2 ** 18 / codeUnits)))
|
||||
})
|
||||
)
|
||||
const branch = expectedBranch === 'nativeFastPath' ? 'native' : 'scan (unchanged)'
|
||||
console.log(
|
||||
`${pad(`${codeUnits} B`, 10)} ${pad(formatTime(legacy), 12)} ${pad(formatTime(next), 12)} ${pad(`${(legacy / next).toFixed(2)}x`, 9)} ${branch}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
|
||||
@@ -38,6 +38,7 @@ import { join, resolve } from 'node:path'
|
||||
// `node_modules`). If electron-builder ever drops it, promote this to a
|
||||
// direct devDependency in package.json.
|
||||
import { extractFile, listPackage } from '@electron/asar'
|
||||
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
|
||||
|
||||
// Why resolve from import.meta.url instead of cwd: a release runner (or a
|
||||
// developer debugging locally) may invoke this script from a non-root cwd.
|
||||
@@ -118,20 +119,8 @@ for (const m of asarMatches) {
|
||||
// Why these regexes: electron-vite's `define` block substitutes the bare
|
||||
// identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their
|
||||
// JSON-stringified values at build time. `src/main/telemetry/client.ts`
|
||||
// then assigns those into module-local consts named `BUILD_IDENTITY` and
|
||||
// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for
|
||||
// Electron main builds), so Rollup emits the substituted constants verbatim
|
||||
// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a
|
||||
// regression — e.g. the env var unset and the substitution falling back to
|
||||
// literal `null` — fails the grep instead of slipping through as a falsy-
|
||||
// but-stringy value. NOTE: if `build.minify` is ever enabled on the main
|
||||
// bundle, esbuild/terser will rename top-level consts and this regex must
|
||||
// be revisited (or replaced with a value-based assertion).
|
||||
//
|
||||
// WRITE_KEY char class includes `_` and `-` because PostHog project API
|
||||
// keys use URL-safe base64 alphabet beyond `phc_`.
|
||||
const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
|
||||
const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
|
||||
// then assigns those into module-local declarations named `BUILD_IDENTITY`
|
||||
// and `WRITE_KEY`. Rollup may preserve `const` or lower it to `var`.
|
||||
|
||||
function verifyAsar(asarPath) {
|
||||
console.log(`Verifying ${asarPath}`)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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(() => {})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: { HIGH: 'high' },
|
||||
@@ -39,6 +40,10 @@ vi.mock('../storage/preferences', () => ({
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(Platform, { OS: 'ios', Version: 18 })
|
||||
// Why (#8591): the reconnect watermark/seen-set now live per host at module
|
||||
// scope so they survive the app's unsubscribe-on-disconnect. Reset between
|
||||
// tests so each case starts from a genuine cold open.
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
|
||||
describe('getNotificationPermissionState', () => {
|
||||
@@ -68,10 +73,14 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
// Why the macrotask and not N microtask ticks (#8591): deliveries now run through
|
||||
// the per-host serialization queue, so a delivery is several more `await` hops deep
|
||||
// than it used to be and a fixed tick count silently under-drains. Yielding to the
|
||||
// macrotask queue drains whatever depth the chain happens to have.
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 0)
|
||||
})
|
||||
}
|
||||
|
||||
function makeDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
@@ -489,6 +498,120 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
|
||||
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('voids a persisted watermark whose epoch predates a desktop restart', async () => {
|
||||
// #8591: the desktop's seq counter restarts at 0 each launch while this watermark
|
||||
// is persisted. Reconnecting to a restarted desktop with seq 57 would make
|
||||
// `57 >= 2` true and silently kill catch-up. The epoch on 'ready' is what tells
|
||||
// the client the counter changed, so the stale watermark must be dropped.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
|
||||
: null
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// Cold open under the OLD desktop process, so the watermark loads as 57.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-before-restart' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// Desktop restarts: new epoch, counter back near 0.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCalls = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
// The cold open catches up from its stored watermark against the SAME counter —
|
||||
// 57 is meaningful there, so it is the correct cut (#8591 second pass).
|
||||
expect(missedCalls[0]?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-before-restart' })
|
||||
// After the restart the watermark is reset to 0 and tagged with the live epoch —
|
||||
// not the stale 57, which would make `57 >= 2` true and kill catch-up silently.
|
||||
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' })
|
||||
})
|
||||
|
||||
it('refuses to seed a stored watermark that lost the race to a newer live epoch', async () => {
|
||||
// The seed read is deliberately not awaited (so subscribe doesn't block on
|
||||
// AsyncStorage), which means it can land AFTER 'ready' already adopted the live
|
||||
// epoch. If it seeds unconditionally it reinstates the exact stale cut #8591 is
|
||||
// about — the reset having already happened doesn't help, because the seed runs
|
||||
// last and wins. Only a stored epoch matching the live one may seed.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
|
||||
// Hold the storage read open so 'ready' is guaranteed to be processed first.
|
||||
let releaseStorage: () => void = () => {}
|
||||
const storageGate = new Promise<void>((resolve) => {
|
||||
releaseStorage = resolve
|
||||
})
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) => {
|
||||
await storageGate
|
||||
return key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-before-restart' })
|
||||
: null
|
||||
})
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// Live epoch adopted while the stored one is still in flight.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-after-restart' })
|
||||
await flushAsync()
|
||||
|
||||
releaseStorage()
|
||||
await flushAsync()
|
||||
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-after-restart' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-after-restart' })
|
||||
})
|
||||
|
||||
it('keeps the persisted watermark when the desktop epoch is unchanged', async () => {
|
||||
// The reset must be narrow: a plain socket reap with the same desktop process
|
||||
// still has to send the real watermark, or every reconnect re-pushes the buffer.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-stable' })
|
||||
: null
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-stable' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-stable' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-stable' })
|
||||
})
|
||||
|
||||
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
@@ -577,8 +700,8 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
|
||||
await flushAsync()
|
||||
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsLastSeq:host-1',
|
||||
'5'
|
||||
'orca:mobileNotificationsWatermark:host-1',
|
||||
JSON.stringify({ seq: 5, epoch: null })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -627,8 +750,8 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
|
||||
|
||||
// Watermark advanced to the replayed seq and was persisted.
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsLastSeq:host-1',
|
||||
'8'
|
||||
'orca:mobileNotificationsWatermark:host-1',
|
||||
JSON.stringify({ seq: 8, epoch: null })
|
||||
)
|
||||
|
||||
// Second reconnect resumes from the advanced watermark, not 0.
|
||||
@@ -639,4 +762,208 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
|
||||
})
|
||||
|
||||
it('replays a terminal bell at a seq the previous desktop counter already used', async () => {
|
||||
// Round-1 review finding: seen-keys are seq-derived, and terminal bells carry no
|
||||
// notificationId (they key on `seq:N` alone). Epoch A delivers a bell at seq 1;
|
||||
// after a restart, epoch B's first bell is ALSO seq 1. The catch-up path is the
|
||||
// one that consults the seen-set, so without clearing it on epoch change the
|
||||
// replayed post-restart bell is mistaken for a duplicate and silently skipped —
|
||||
// #8591's silent loss again, now one notification at a time.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Catch-up returns epoch B's first bell — same seq 1 the old counter used.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
epoch: 'epoch-B',
|
||||
notifications: [{ type: 'notification', title: 'bell', body: 'B', notificationSeq: 1 }]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
|
||||
await flushAsync()
|
||||
// A live bell under epoch A — no notificationId, so its seen-key is `seq:1`.
|
||||
sub.onData?.({ type: 'notification', title: 'bell', body: 'A', notificationSeq: 1 })
|
||||
await flushAsync()
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
|
||||
|
||||
// Desktop restarts; reconnect triggers catch-up against the fresh counter.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-B' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// The post-restart bell must reach the user, not be swallowed as a stale `seq:1`.
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('does not trust a legacy epoch-less watermark against a live counter', async () => {
|
||||
// Round-1 review finding: pre-upgrade installs stored a bare seq with no epoch.
|
||||
// Seeding it and then treating the first observed epoch as "nothing changed"
|
||||
// leaves 57 cutting a counter it was never measured against — #8591 reached
|
||||
// through the upgrade path. An unprovenanced seq may not survive epoch adoption.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
// Only the LEGACY key exists — exactly what an upgrading install has on disk.
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsLastSeq:') ? '57' : null
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// Seed lands FIRST (no epoch known yet), so 57 is provisionally adopted...
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
// ...then the live epoch arrives for the first time.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
// Must not be 57: that seq was never shown to belong to this counter.
|
||||
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 0, epoch: 'epoch-live' })
|
||||
})
|
||||
|
||||
it('catches up on the FIRST connection after an upgrade, without a second ready', async () => {
|
||||
// Round-2 review finding: catch-up hung off `connectedBefore`, which is false on
|
||||
// the first 'ready' of a process. So a cold app open — post-upgrade, or after the
|
||||
// OS evicted the app — adopted the epoch but never replayed. Everything between
|
||||
// the stored watermark and the next live seq was then lost permanently, because
|
||||
// the first live event advances the watermark past the gap.
|
||||
//
|
||||
// The earlier migration test masked this by emitting a SECOND 'ready'. This one
|
||||
// emits exactly one, which is what a real cold open does.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key: string) =>
|
||||
key.startsWith('orca:mobileNotificationsWatermark:')
|
||||
? JSON.stringify({ seq: 57, epoch: 'epoch-live' })
|
||||
: null
|
||||
)
|
||||
|
||||
const sub = makeClient()
|
||||
vi.mocked(sub.client.sendRequest).mockImplementation(async (method: string) =>
|
||||
method === 'notifications.getMissedSince'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
epoch: 'epoch-live',
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
notificationId: 'missed-58',
|
||||
notificationSeq: 58,
|
||||
notificationEpoch: 'epoch-live',
|
||||
title: 'while the app was closed',
|
||||
body: 'b'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
: { ok: true, result: {} }
|
||||
)
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
// The single 'ready' must replay from the stored watermark, not skip it.
|
||||
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 57, epoch: 'epoch-live' })
|
||||
// And the missed notification must actually reach the user.
|
||||
expect(vi.mocked(Notifications.scheduleNotificationAsync).mock.calls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('does not replay the desktop buffer at a first-ever pairing', async () => {
|
||||
// The other side of the finding above: with nothing stored, this device has never
|
||||
// delivered for this host. Catching up would push the whole retained buffer at a
|
||||
// user who was never subscribed for any of it.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-live' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('persists seq and epoch as one value so a crash cannot split the pair', async () => {
|
||||
// Round-1 review finding: written as two keys, a process death between the writes
|
||||
// leaves epoch-B beside seq-57-from-A. That pair looks internally valid on the
|
||||
// next launch and is therefore trusted — silently cutting B's first 57 events.
|
||||
// One key means the pair is always written whole or not at all.
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-A' })
|
||||
await flushAsync()
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
title: 't',
|
||||
body: 'b',
|
||||
notificationId: 'agent:x',
|
||||
notificationSeq: 9
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
// Every watermark write is a single key carrying both halves together.
|
||||
const watermarkWrites = AsyncStorageMock.setItem.mock.calls.filter((c: unknown[]) =>
|
||||
String(c[0]).startsWith('orca:mobileNotifications')
|
||||
)
|
||||
expect(watermarkWrites.length).toBeGreaterThan(0)
|
||||
for (const [key, value] of watermarkWrites) {
|
||||
expect(key).toBe('orca:mobileNotificationsWatermark:host-1')
|
||||
expect(JSON.parse(String(value))).toHaveProperty('epoch')
|
||||
expect(JSON.parse(String(value))).toHaveProperty('seq')
|
||||
}
|
||||
expect(JSON.parse(String(watermarkWrites.at(-1)?.[1]))).toEqual({
|
||||
seq: 9,
|
||||
epoch: 'epoch-A'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,228 +1,34 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
|
||||
// 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 {
|
||||
createSeenNotificationGuard,
|
||||
loadLastSeenSeq,
|
||||
saveLastSeenSeq,
|
||||
seenKeyForEvent
|
||||
configureNotificationChannel,
|
||||
dismissLocalNotification,
|
||||
showLocalNotification,
|
||||
type DismissNotificationEvent,
|
||||
type NotificationEvent
|
||||
} from './local-notification-scheduling'
|
||||
import {
|
||||
adoptNotificationEpoch,
|
||||
enqueueHostDelivery,
|
||||
getHostNotificationSession,
|
||||
releaseQueuedShowNotificationId,
|
||||
saveWatermark,
|
||||
seedWatermarkFromStorage,
|
||||
seenKeyForEvent,
|
||||
shouldQueueShowForNotificationId
|
||||
} from './notification-reconnect-catchup'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type DismissNotificationEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
type SubscribeResult = {
|
||||
type: 'ready'
|
||||
subscriptionId: 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 type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
canAskAgain: boolean
|
||||
authorizationReflectsUserChoice: boolean
|
||||
}
|
||||
|
||||
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
|
||||
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
|
||||
return {
|
||||
granted: status === 'granted',
|
||||
status,
|
||||
canAskAgain,
|
||||
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
|
||||
authorizationReflectsUserChoice:
|
||||
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
|
||||
export async function ensureNotificationPermissions(): Promise<boolean> {
|
||||
const existing = await getNotificationPermissionState()
|
||||
if (existing.granted) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
return status === 'granted'
|
||||
}
|
||||
|
||||
function configureNotificationChannel(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Notifications.setNotificationChannelAsync('orca-desktop', {
|
||||
name: 'Desktop Notifications',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250],
|
||||
lightColor: '#6366f1'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {})
|
||||
// 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.
|
||||
@@ -231,68 +37,129 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
// Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
|
||||
let lastDeliveredSeq = 0
|
||||
// Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
|
||||
const seenReplay = createSeenNotificationGuard()
|
||||
// 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)
|
||||
|
||||
function deliverLive(
|
||||
/**
|
||||
* 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 (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
|
||||
lastDeliveredSeq = event.notificationSeq
|
||||
void saveLastSeenSeq(hostId, lastDeliveredSeq)
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function deliverLive(
|
||||
type: 'notification' | 'dismiss',
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
|
||||
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
session.seen.add(key)
|
||||
}
|
||||
if (type === 'notification') {
|
||||
return showLocalNotification(event as NotificationEvent, hostId)
|
||||
await showLocalNotification(event as NotificationEvent, hostId)
|
||||
} else {
|
||||
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
}
|
||||
// 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
|
||||
// Persisted as a pair: a seq is only trustworthy alongside the epoch it indexes.
|
||||
void saveWatermark(hostId, {
|
||||
seq: session.lastDeliveredSeq,
|
||||
epoch: session.lastDeliveredEpoch
|
||||
})
|
||||
}
|
||||
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
}
|
||||
|
||||
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
|
||||
// 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
|
||||
}
|
||||
const missed = await client
|
||||
.sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq })
|
||||
.sendRequest('notifications.getMissedSince', {
|
||||
lastSeenSeq: session.lastDeliveredSeq,
|
||||
// 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 []
|
||||
}
|
||||
const result = response.result as { notifications?: unknown[] } | undefined
|
||||
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
|
||||
adoptNotificationEpoch(session, hostId, result?.epoch)
|
||||
return Array.isArray(result?.notifications) ? result.notifications : []
|
||||
})
|
||||
.catch(() => [])
|
||||
for (const raw of missed) {
|
||||
const event = raw as NotificationEvent | DismissNotificationEvent
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && seenReplay.has(key)) {
|
||||
continue
|
||||
// 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 () => {
|
||||
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
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && session.seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (key) {
|
||||
session.seen.add(key)
|
||||
}
|
||||
// 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.
|
||||
if (event.type === 'notification') {
|
||||
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await deliverLive('notification', event)
|
||||
} finally {
|
||||
releaseQueuedShowNotificationId(session, event.notificationId)
|
||||
}
|
||||
} else if (event.type === 'dismiss') {
|
||||
await deliverLive('dismiss', event)
|
||||
}
|
||||
}
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
await deliverLive('notification', event)
|
||||
} else if (event.type === 'dismiss') {
|
||||
await deliverLive('dismiss', event)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read.
|
||||
let watermarkLoaded = false
|
||||
void loadLastSeenSeq(hostId).then((seq) => {
|
||||
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
|
||||
watermarkLoaded = true
|
||||
})
|
||||
seedWatermarkFromStorage(session, hostId)
|
||||
|
||||
function unsubscribeServer(id: string) {
|
||||
if (client.getState() === 'connected') {
|
||||
@@ -300,7 +167,6 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
}
|
||||
}
|
||||
|
||||
let reconnectReadyCount = 0
|
||||
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
|
||||
const event = data as
|
||||
| NotificationEvent
|
||||
@@ -309,16 +175,34 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
| { type: 'end' }
|
||||
if (event.type === 'ready') {
|
||||
subscriptionId = (event as SubscribeResult).subscriptionId
|
||||
reconnectReadyCount += 1
|
||||
const isReconnect = session.connectedBefore
|
||||
session.connectedBefore = true
|
||||
if (disposed) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
unsubscribeStream()
|
||||
return
|
||||
}
|
||||
// Why: only reconnects fetch missed; watermarkLoaded guards against fetching from a stale 0 (which re-pushes everything).
|
||||
if (reconnectReadyCount > 1 && watermarkLoaded) {
|
||||
void fetchMissed()
|
||||
}
|
||||
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()
|
||||
}
|
||||
})()
|
||||
return
|
||||
}
|
||||
if (event.type === 'end') {
|
||||
@@ -330,11 +214,27 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
void deliverLive('notification', event as NotificationEvent)
|
||||
} else if (event.type === 'dismiss') {
|
||||
void deliverLive('dismiss', event as DismissNotificationEvent)
|
||||
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 () => {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
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,36 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
// Why: OS notification-permission state, separate from the delivery pipeline in
|
||||
// mobile-notifications.ts. Nothing here touches sockets, watermarks, or the
|
||||
// scheduled-push registry — it only reflects what the OS currently allows.
|
||||
|
||||
export type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
canAskAgain: boolean
|
||||
authorizationReflectsUserChoice: boolean
|
||||
}
|
||||
|
||||
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
|
||||
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
|
||||
return {
|
||||
granted: status === 'granted',
|
||||
status,
|
||||
canAskAgain,
|
||||
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
|
||||
authorizationReflectsUserChoice:
|
||||
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
|
||||
export async function ensureNotificationPermissions(): Promise<boolean> {
|
||||
const existing = await getNotificationPermissionState()
|
||||
if (existing.granted) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
return status === 'granted'
|
||||
}
|
||||
@@ -9,28 +9,73 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
// 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).
|
||||
const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
|
||||
// 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 lastSeqStorageKey(hostId: string): string {
|
||||
return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
|
||||
function watermarkStorageKey(hostId: string): string {
|
||||
return WATERMARK_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
|
||||
}
|
||||
|
||||
export async function loadLastSeenSeq(hostId: string): Promise<number> {
|
||||
// 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(lastSeqStorageKey(hostId))
|
||||
const parsed = raw == null ? 0 : Number(raw)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
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 {
|
||||
return 0
|
||||
// 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 saveLastSeenSeq(hostId: string, seq: number): Promise<void> {
|
||||
if (!Number.isFinite(seq) || seq <= 0) {
|
||||
return
|
||||
}
|
||||
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(lastSeqStorageKey(hostId), String(seq))
|
||||
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
|
||||
@@ -53,6 +98,7 @@ const RECENTLY_SEEN_CAP = 512
|
||||
export function createSeenNotificationGuard(): {
|
||||
has: (id: string) => boolean
|
||||
add: (id: string) => void
|
||||
clear: () => void
|
||||
} {
|
||||
const seen = new Set<string>()
|
||||
return {
|
||||
@@ -69,10 +115,224 @@ export function createSeenNotificationGuard(): {
|
||||
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
|
||||
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,
|
||||
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()
|
||||
}
|
||||
|
||||
// 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()
|
||||
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
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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 AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
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 }
|
||||
}))
|
||||
|
||||
// In-memory AsyncStorage so the persisted watermark survives across the
|
||||
// subscribe/unsubscribe cycles this test exercises (the real device behaviour).
|
||||
const storage = new Map<string, string>()
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async (k: string) => storage.get(k) ?? null),
|
||||
setItem: vi.fn(async (k: string, v: string) => {
|
||||
storage.set(k, v)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
// Models mobile/app/index.tsx:497-537: a per-host client whose notification
|
||||
// subscription is torn down on any non-'connected' state and re-created from
|
||||
// scratch on the next 'connected'.
|
||||
function makeHostClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const getMissedCalls: { lastSeenSeq: number }[] = []
|
||||
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') {
|
||||
getMissedCalls.push(params as { lastSeenSeq: number })
|
||||
return { ok: true, result: { notifications: missedQueue } } as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
}
|
||||
let missedQueue: unknown[] = []
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
getMissedCalls,
|
||||
setMissed(events: unknown[]) {
|
||||
missedQueue = events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('#8591 reconnect catch-up under the real app teardown lifecycle', () => {
|
||||
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)
|
||||
vi.mocked(AsyncStorage.getItem).mockClear()
|
||||
})
|
||||
|
||||
it('fetches missed notifications after a disconnect tears the subscription down', async () => {
|
||||
const host = makeHostClient()
|
||||
|
||||
// ── Connected: cold open, one live notification delivered (desktop seq 7).
|
||||
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
host.onData?.({
|
||||
type: 'notification',
|
||||
title: 'live',
|
||||
body: 'b',
|
||||
notificationId: 'agent:live',
|
||||
notificationSeq: 7
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
// ── Socket drops. app/index.tsx wireUp() calls unsubNotif() on the
|
||||
// non-'connected' state, destroying the subscribeToDesktopNotifications
|
||||
// closure (and with it reconnectReadyCount / lastDeliveredSeq).
|
||||
unsub()
|
||||
await flushAsync()
|
||||
|
||||
// ── While disconnected the desktop dispatched seq 8 and 9.
|
||||
host.setMissed([
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'missed-8',
|
||||
body: 'b',
|
||||
notificationId: 'agent:m8',
|
||||
notificationSeq: 8
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'missed-9',
|
||||
body: 'b',
|
||||
notificationId: 'agent:m9',
|
||||
notificationSeq: 9
|
||||
}
|
||||
])
|
||||
|
||||
// ── Reconnected: app re-subscribes with a FRESH closure.
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
|
||||
await flushAsync()
|
||||
|
||||
// The user must be told about seq 8 and 9. Nothing else can deliver them:
|
||||
// the desktop only fans out live, so this catch-up is the only path.
|
||||
expect(host.getMissedCalls).toHaveLength(1)
|
||||
expect(host.getMissedCalls[0]).toEqual({ lastSeenSeq: 7 })
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
|
||||
expect(titles).toContain('missed-8')
|
||||
expect(titles).toContain('missed-9')
|
||||
})
|
||||
|
||||
it('does not re-push a live notification the catch-up replays after a teardown', async () => {
|
||||
// Why: the seen-set lives on the host session precisely so it survives the teardown.
|
||||
// getMissedSince cuts by seq > lastSeenSeq, but a notification delivered live in the
|
||||
// brief window before the drop is still inside the desktop's retained buffer, so the
|
||||
// reconnect fetch returns it again. Only the session-scoped seen-set stops a duplicate
|
||||
// banner for something the user was already shown.
|
||||
const host = makeHostClient()
|
||||
|
||||
const unsub = subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
host.onData?.({
|
||||
type: 'notification',
|
||||
title: 'live-7',
|
||||
body: 'b',
|
||||
notificationId: 'agent:seven',
|
||||
notificationSeq: 7
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
unsub()
|
||||
await flushAsync()
|
||||
|
||||
// The desktop replays seq 7 alongside the genuinely-missed seq 8.
|
||||
host.setMissed([
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'live-7',
|
||||
body: 'b',
|
||||
notificationId: 'agent:seven',
|
||||
notificationSeq: 7
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'missed-8',
|
||||
body: 'b',
|
||||
notificationId: 'agent:m8',
|
||||
notificationSeq: 8
|
||||
}
|
||||
])
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-2' })
|
||||
await flushAsync()
|
||||
|
||||
const titles = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map((c) => (c[0] as { content: { title: string } }).content.title)
|
||||
expect(titles.filter((title) => title === 'live-7')).toHaveLength(1)
|
||||
expect(titles).toContain('missed-8')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import {
|
||||
adoptNotificationEpoch,
|
||||
clearWatermark,
|
||||
getHostNotificationSession,
|
||||
resetHostNotificationSessionsForTests,
|
||||
seedWatermarkFromStorage
|
||||
} from './notification-reconnect-catchup'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
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 }
|
||||
}))
|
||||
|
||||
// A storage whose reads can be held open, so a live event can be injected into the
|
||||
// exact window a real cold open has: subscription up, persisted watermark not yet read.
|
||||
const storage = new Map<string, string>()
|
||||
let heldReads: (() => void)[] = []
|
||||
let holdReads = false
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn((key: string) => {
|
||||
const read = (): string | null => storage.get(key) ?? null
|
||||
if (!holdReads) {
|
||||
return Promise.resolve(read())
|
||||
}
|
||||
return new Promise<string | null>((resolve) => {
|
||||
heldReads.push(() => resolve(read()))
|
||||
})
|
||||
}),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function releaseReads(): void {
|
||||
const pending = heldReads
|
||||
heldReads = []
|
||||
for (const resolve of pending) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function makeHostClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const getMissedCalls: { lastSeenSeq: number; epoch?: string }[] = []
|
||||
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') {
|
||||
getMissedCalls.push(params as { lastSeenSeq: number; epoch?: string })
|
||||
return { ok: true, result: { notifications: [] } } as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
}
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
getMissedCalls
|
||||
}
|
||||
}
|
||||
|
||||
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
|
||||
const LEGACY_KEY = 'orca:mobileNotificationsLastSeq:host-1'
|
||||
|
||||
describe('#8591 watermark seeding races a cold open', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storage.clear()
|
||||
heldReads = []
|
||||
holdReads = false
|
||||
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('asks for catch-up from the persisted seq even if a live event lands first', async () => {
|
||||
// The window is real: app/index.tsx subscribes immediately, and the desktop's
|
||||
// 'ready' plus its first live fan-out can both beat an AsyncStorage read. If the
|
||||
// live seq is allowed to advance the watermark first, getMissedSince is asked to
|
||||
// start from it and the desktop cuts everything the device actually missed.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-a' }))
|
||||
holdReads = true
|
||||
const host = makeHostClient()
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
|
||||
host.onData?.({
|
||||
type: 'notification',
|
||||
title: 'live-12',
|
||||
body: 'b',
|
||||
notificationId: 'agent:live',
|
||||
notificationSeq: 12,
|
||||
notificationEpoch: 'epoch-a'
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
// Nothing may be decided while the read is outstanding.
|
||||
expect(host.getMissedCalls).toHaveLength(0)
|
||||
|
||||
releaseReads()
|
||||
await flushAsync()
|
||||
|
||||
expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 5, epoch: 'epoch-a' }])
|
||||
})
|
||||
|
||||
it('treats a zeroed-but-present watermark as a returning device, not a first pairing', async () => {
|
||||
// adoptNotificationEpoch persists {seq: 0, epoch} when it voids a watermark from a
|
||||
// dead counter. That record still proves this device has been subscribed here, so a
|
||||
// cold open after it must catch up — reading it as "never paired" drops the window.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 0, epoch: 'epoch-a' }))
|
||||
const host = makeHostClient()
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host.getMissedCalls).toEqual([{ lastSeenSeq: 0, epoch: 'epoch-a' }])
|
||||
})
|
||||
|
||||
it('does not catch up on a first-ever pairing', async () => {
|
||||
const host = makeHostClient()
|
||||
|
||||
subscribeToDesktopNotifications(host.client, 'host-1')
|
||||
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-a' })
|
||||
await flushAsync()
|
||||
|
||||
expect(host.getMissedCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('a seed landing after a live epoch is adopted cannot reinstate the dead watermark', async () => {
|
||||
// Ordering invariant on the exported pair, not a path subscribeToDesktopNotifications
|
||||
// can currently take — 'ready' awaits watermarkSeeded before adopting, so the seed
|
||||
// always resolves first today. Pinned anyway because the guard is load-bearing the
|
||||
// moment any caller adopts an epoch before seeding: applying a seq 40 from a counter
|
||||
// that no longer exists would let getMissedSince cut the new counter's 1..40, which
|
||||
// is the original #8591 loss re-entered through the seeding path.
|
||||
const session = getHostNotificationSession('host-1')
|
||||
adoptNotificationEpoch(session, 'host-1', 'epoch-new')
|
||||
await flushAsync()
|
||||
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 40, epoch: 'epoch-old' }))
|
||||
seedWatermarkFromStorage(session, 'host-1')
|
||||
await session.watermarkSeeded
|
||||
await flushAsync()
|
||||
|
||||
expect(session.lastDeliveredEpoch).toBe('epoch-new')
|
||||
expect(session.lastDeliveredSeq).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the legacy seq key too, so an unpaired host cannot resurrect it', async () => {
|
||||
// loadWatermark falls back to the legacy key, so leaving it behind lets a re-paired
|
||||
// host read a pre-#8591 seq belonging to a counter lifetime that no longer exists.
|
||||
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 9, epoch: 'epoch-a' }))
|
||||
storage.set(LEGACY_KEY, '57')
|
||||
|
||||
await clearWatermark('host-1')
|
||||
|
||||
expect(vi.mocked(AsyncStorage.removeItem).mock.calls.map((call) => call[0])).toEqual(
|
||||
expect.arrayContaining([WATERMARK_KEY, LEGACY_KEY])
|
||||
)
|
||||
expect(storage.has(WATERMARK_KEY)).toBe(false)
|
||||
expect(storage.has(LEGACY_KEY)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const removeHostMock = vi.hoisted(() => vi.fn())
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
// Why removeItem is here: clearWatermark() swallows its own failures, so a mock
|
||||
// missing this method turns the persisted-watermark cleanup into a caught
|
||||
// TypeError — the assertion below would pass even if the call were deleted.
|
||||
removeItem: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
||||
|
||||
vi.mock('./host-store', () => ({
|
||||
removeHost: (hostId: string) => removeHostMock(hostId)
|
||||
}))
|
||||
|
||||
import { removeHostAndCloseClient } from './host-removal-lifecycle'
|
||||
import {
|
||||
getHostNotificationSession,
|
||||
resetHostNotificationSessionsForTests
|
||||
} from '../notifications/notification-reconnect-catchup'
|
||||
|
||||
describe('host removal lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
removeHostMock.mockReset()
|
||||
asyncStorage.removeItem.mockClear()
|
||||
resetHostNotificationSessionsForTests()
|
||||
})
|
||||
|
||||
it('closes the client only after metadata removal commits', async () => {
|
||||
@@ -39,4 +55,37 @@ describe('host removal lifecycle', () => {
|
||||
)
|
||||
expect(closeHostClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retires the notification session so a removed host leaves nothing behind', async () => {
|
||||
// Round-1 review finding: the session lives at module scope (it must survive the
|
||||
// subscription teardown a reconnect performs), so removal is the only thing that
|
||||
// can retire it. Left behind, each remove/re-pair cycle strands a session plus up
|
||||
// to 512 seen keys, and a re-paired host inherits a watermark it never earned.
|
||||
removeHostMock.mockResolvedValue(undefined)
|
||||
const session = getHostNotificationSession('host-1')
|
||||
session.lastDeliveredSeq = 42
|
||||
session.lastDeliveredEpoch = 'epoch-A'
|
||||
|
||||
await removeHostAndCloseClient('host-1', vi.fn())
|
||||
|
||||
// A fresh session for the same id — not the retained one.
|
||||
const afterRemoval = getHostNotificationSession('host-1')
|
||||
expect(afterRemoval).not.toBe(session)
|
||||
expect(afterRemoval.lastDeliveredSeq).toBe(0)
|
||||
expect(afterRemoval.lastDeliveredEpoch).toBeNull()
|
||||
})
|
||||
|
||||
it('erases the persisted watermark, not just the in-memory session', async () => {
|
||||
// Why separately from the test above: the session is process-local, the
|
||||
// watermark is not. Retiring only the session lets a re-pair of the same host
|
||||
// read the old seq off disk and resume against a counter it never saw — the
|
||||
// catch-up would then start above the real cut and drop everything below it.
|
||||
removeHostMock.mockResolvedValue(undefined)
|
||||
|
||||
await removeHostAndCloseClient('host-1', vi.fn())
|
||||
// clearWatermark is fire-and-forget; let its microtask land.
|
||||
await Promise.resolve()
|
||||
|
||||
expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
clearWatermark,
|
||||
forgetHostNotificationSession
|
||||
} from '../notifications/notification-reconnect-catchup'
|
||||
import { removeHost } from './host-store'
|
||||
|
||||
export async function removeHostAndCloseClient(
|
||||
@@ -8,4 +12,9 @@ export async function removeHostAndCloseClient(
|
||||
// storage failure; closing immediately after success prevents socket leaks.
|
||||
await removeHost(hostId)
|
||||
closeHostClient(hostId)
|
||||
// Why: the notification session outlives the socket by design (it must survive
|
||||
// reconnects), so removal is the only thing that can retire it. Left behind, a
|
||||
// re-pair of the same host would inherit a watermark for a counter it never saw.
|
||||
forgetHostNotificationSession(hostId)
|
||||
void clearWatermark(hostId)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.160-rc.0",
|
||||
"version": "1.4.160-rc.2",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
import { unknownCommandData, unknownFlagData } from './command-suggestion'
|
||||
|
||||
export type ParsedArgs = {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest'
|
||||
|
||||
export type HandlerContext = {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
import { REPEATED_FLAG_SEPARATOR } from './args'
|
||||
|
||||
export function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import type { CliStatusResult } from '../shared/runtime-types'
|
||||
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
|
||||
import { prepareComputerCliJsonResult } from './computer-format'
|
||||
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
|
||||
|
||||
export {
|
||||
formatBrowserProfileList,
|
||||
|
||||
@@ -2,36 +2,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
vi.mock('../runtime-client', () => {
|
||||
vi.mock('../runtime-client', async () => {
|
||||
class RuntimeClient {
|
||||
call = callMock
|
||||
getCliStatus = vi.fn()
|
||||
openOrca = vi.fn()
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError
|
||||
}
|
||||
// Why: re-export the REAL error classes rather than redefining them. format.ts
|
||||
// narrows with `instanceof` against ./runtime/types, so a look-alike class
|
||||
// here would make every CLI error fall through to the generic `runtime_error`
|
||||
// shape — mirroring the barrel keeps the mock faithful to production.
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
|
||||
return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError }
|
||||
})
|
||||
|
||||
import { main } from '../index'
|
||||
|
||||
@@ -2,36 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
vi.mock('../runtime-client', () => {
|
||||
vi.mock('../runtime-client', async () => {
|
||||
class RuntimeClient {
|
||||
call = callMock
|
||||
getCliStatus = vi.fn()
|
||||
openOrca = vi.fn()
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError
|
||||
}
|
||||
// Why: re-export the REAL error classes; format.ts narrows with `instanceof`
|
||||
// against ./runtime/types, so a look-alike would collapse every CLI error
|
||||
// code into the generic `runtime_error` shape.
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
|
||||
return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError }
|
||||
})
|
||||
|
||||
import { main } from '../index'
|
||||
|
||||
@@ -6,7 +6,7 @@ const { callMock, remoteMock } = vi.hoisted(() => ({
|
||||
remoteMock: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('../runtime-client', () => {
|
||||
vi.mock('../runtime-client', async () => {
|
||||
class RuntimeClient {
|
||||
readonly isRemote: boolean
|
||||
call = callMock
|
||||
@@ -18,29 +18,11 @@ vi.mock('../runtime-client', () => {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError
|
||||
}
|
||||
// Why: re-export the REAL error classes; format.ts narrows with `instanceof`
|
||||
// against ./runtime/types, so a look-alike would collapse every CLI error
|
||||
// code into the generic `runtime_error` shape.
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
|
||||
return { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError }
|
||||
})
|
||||
|
||||
import { main } from '../index'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
vi.mock('../runtime-client', () => {
|
||||
vi.mock('../runtime-client', async () => {
|
||||
class RuntimeClient {
|
||||
readonly isRemote: boolean
|
||||
call = callMock
|
||||
@@ -19,23 +19,10 @@ vi.mock('../runtime-client', () => {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
// Why: re-export the REAL error classes; format.ts narrows with `instanceof`
|
||||
// against ./runtime/types, so a look-alike would collapse every CLI error
|
||||
// code into the generic `runtime_error` shape.
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
|
||||
+7
-21
@@ -22,7 +22,13 @@ const {
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', () => {
|
||||
vi.mock('./runtime-client', async () => {
|
||||
// Why: re-export the REAL error classes rather than redefining them. format.ts
|
||||
// narrows with `instanceof` against ./runtime/types, so a look-alike class
|
||||
// here would make every CLI error fall through to the generic `runtime_error`
|
||||
// shape — mirroring the barrel keeps the mock faithful to production.
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
|
||||
|
||||
class RuntimeClient {
|
||||
readonly isRemote: boolean
|
||||
call = callMock
|
||||
@@ -52,26 +58,6 @@ vi.mock('./runtime-client', () => {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
readonly data?: unknown
|
||||
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
|
||||
+14
-4
@@ -11,7 +11,7 @@ import {
|
||||
import { dispatch } from './dispatch'
|
||||
import { reportCliError } from './format'
|
||||
import { printHelp } from './help'
|
||||
import { RuntimeClient } from './runtime-client'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
|
||||
export { COMMAND_SPECS } from './specs'
|
||||
@@ -29,6 +29,15 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the RuntimeClient graph is 153 of the CLI's 199 eager modules (zod via
|
||||
// shared/pairing, ws + tweetnacl via websocket-transport). Loading it here
|
||||
// rather than at module scope means --help, `help <cmd>`, and command/flag
|
||||
// errors — which all return before this call — never pay for it. Awaited
|
||||
// before dispatch so `ctx.client` stays a synchronous getter.
|
||||
async function loadRuntimeClientClass(): Promise<typeof RuntimeClient> {
|
||||
return (await import('./runtime-client.js')).RuntimeClient
|
||||
}
|
||||
|
||||
// Why: the SSH relay bridge executes this CLI on the Orca host while the
|
||||
// caller's shell cwd lives on the remote machine (which cannot be chdir'd
|
||||
// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like
|
||||
@@ -74,6 +83,7 @@ export async function main(
|
||||
// lookup so users do not get misleading "Orca is not running" failures for
|
||||
// simple command typos or unsupported flags.
|
||||
validateCommandAndFlags(COMMAND_SPECS, parsed)
|
||||
const RuntimeClientClass = await loadRuntimeClientClass()
|
||||
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
|
||||
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
|
||||
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
|
||||
@@ -86,7 +96,7 @@ export async function main(
|
||||
flags: parsed.flags,
|
||||
// Why: local-only handlers must not resolve runtime metadata just to dispatch.
|
||||
get client() {
|
||||
client ??= new RuntimeClient(
|
||||
client ??= new RuntimeClientClass(
|
||||
undefined,
|
||||
undefined,
|
||||
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
|
||||
@@ -111,7 +121,7 @@ async function runClaudeTeams(argv: string[], cwd: string): Promise<void> {
|
||||
try {
|
||||
// Why: everything after `orca claude-teams` belongs to Claude Code, not
|
||||
// Orca's own flag parser, so new Claude flags work without Orca changes.
|
||||
const client = new RuntimeClient(undefined, undefined, null, null)
|
||||
const client = new (await loadRuntimeClientClass())(undefined, undefined, null, null)
|
||||
await dispatch(['claude-teams'], {
|
||||
flags: new Map(),
|
||||
client,
|
||||
@@ -127,7 +137,7 @@ async function runClaudeTeams(argv: string[], cwd: string): Promise<void> {
|
||||
|
||||
async function runAgentTeamsTmuxShim(argv: string[]): Promise<void> {
|
||||
try {
|
||||
const client = new RuntimeClient(undefined, 10_000)
|
||||
const client = new (await loadRuntimeClientClass())(undefined, 10_000)
|
||||
const response = await client.call<{
|
||||
tmux: { stdout: string; stderr: string; exitCode: number }
|
||||
}>(
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { constructorArgsMock, callMock, getCliStatusMock } = vi.hoisted(() => ({
|
||||
constructorArgsMock: vi.fn(),
|
||||
callMock: vi.fn(),
|
||||
getCliStatusMock: vi.fn()
|
||||
}))
|
||||
|
||||
// Why: `main` reaches RuntimeClient through `await import('./runtime-client.js')`
|
||||
// now. Mocking the same specifier the eager import used proves the dynamic
|
||||
// import still resolves to the module the 10 existing vi.mock suites target.
|
||||
vi.mock('./runtime-client', () => {
|
||||
class RuntimeClient {
|
||||
call = callMock
|
||||
getCliStatus = getCliStatusMock
|
||||
openOrca = vi.fn()
|
||||
|
||||
constructor(...args: unknown[]) {
|
||||
constructorArgsMock(...args)
|
||||
}
|
||||
}
|
||||
return { RuntimeClient }
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import * as dispatchModule from './dispatch'
|
||||
|
||||
const CLI_DIR = __dirname
|
||||
|
||||
describe('RuntimeClient module-graph deferral', () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
constructorArgsMock.mockClear()
|
||||
callMock.mockReset()
|
||||
getCliStatusMock.mockReset()
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore()
|
||||
errorSpy.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
// Why: the whole point of the change. These five modules load on EVERY
|
||||
// invocation, so a value-import of the barrel from any of them drags the
|
||||
// RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path.
|
||||
it.each(['args.ts', 'flags.ts', 'dispatch.ts', 'format.ts', 'selectors.ts'])(
|
||||
'%s imports error classes from ./runtime/types, not the barrel',
|
||||
(file) => {
|
||||
const source = readFileSync(join(CLI_DIR, file), 'utf8')
|
||||
const valueImports = source
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'"))
|
||||
for (const line of valueImports) {
|
||||
expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /)
|
||||
}
|
||||
expect(source).toContain("} from './runtime/types'")
|
||||
}
|
||||
)
|
||||
|
||||
it('index.ts has no eager value-import of the runtime client', () => {
|
||||
const source = readFileSync(join(CLI_DIR, 'index.ts'), 'utf8')
|
||||
expect(source).toContain("import type { RuntimeClient } from './runtime-client'")
|
||||
expect(source).not.toMatch(/^import \{[^}]*RuntimeClient[^}]*\} from '\.\/runtime-client'/m)
|
||||
expect(source).toContain("await import('./runtime-client.js')")
|
||||
})
|
||||
|
||||
it('constructs no client for --help', async () => {
|
||||
await main(['--help'], '/tmp/repo')
|
||||
expect(constructorArgsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('constructs no client for an unknown flag', async () => {
|
||||
await main(['worktree', 'list', '--nope'], '/tmp/repo')
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(constructorArgsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: `agent hooks on|off` are the only commands that both sit in a
|
||||
// suppressed group and touch ctx.client, and they rewrite the real ~/.claude
|
||||
// hook config — so the byte-for-byte equivalence script cannot invoke them.
|
||||
// Assert the constructor arguments directly instead: `null` (not `undefined`)
|
||||
// is what stops the ORCA_* env fallback re-activating for local-only groups.
|
||||
//
|
||||
// `constructs` is declared per case and asserted BEFORE the args, because
|
||||
// only `agent hooks off` reads ctx.client. Looping over `mock.calls` alone
|
||||
// would pass vacuously for the other four, and would keep passing if the one
|
||||
// case that carries the null-vs-undefined coverage stopped constructing at
|
||||
// all. The zero rows are not filler: they assert the deferral itself — a
|
||||
// local-only group must reach its handler without building a client.
|
||||
const SUPPRESSED_GROUPS: [name: string, argv: string[], constructs: number][] = [
|
||||
['agent', ['agent', 'hooks', 'off'], 1],
|
||||
['environment', ['environment', 'list'], 0],
|
||||
['serve', ['serve'], 0],
|
||||
['vm', ['vm', 'recipe', 'doctor'], 0],
|
||||
['agent-context', ['agent-context'], 0]
|
||||
]
|
||||
|
||||
it.each(SUPPRESSED_GROUPS)(
|
||||
'constructs exactly %s expected clients, with null remote selection',
|
||||
async (_name, argv, constructs) => {
|
||||
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
|
||||
vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment')
|
||||
getCliStatusMock.mockResolvedValue({ result: { runtime: { reachable: false } } })
|
||||
|
||||
await main(argv, '/tmp/repo')
|
||||
|
||||
const calls = constructorArgsMock.mock.calls
|
||||
expect(calls.length, `${argv.join(' ')} client constructions`).toBe(constructs)
|
||||
for (const call of calls) {
|
||||
expect(call[2], `${argv.join(' ')} pairing code`).toBeNull()
|
||||
expect(call[3], `${argv.join(' ')} environment`).toBeNull()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Why: four of the five groups above never read ctx.client, so they can only
|
||||
// assert that no client is built — not that suppression forwards `null`.
|
||||
// Stub dispatch and read the getter directly so every group asserts the
|
||||
// constructor arguments unconditionally, exactly once.
|
||||
it.each(SUPPRESSED_GROUPS.map(([name, argv]) => [name, argv] as const))(
|
||||
'forwards null remote selection to the client %s would build',
|
||||
async (_name, argv) => {
|
||||
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
|
||||
vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment')
|
||||
const dispatchSpy = vi.spyOn(dispatchModule, 'dispatch').mockResolvedValue(undefined)
|
||||
try {
|
||||
await main(argv, '/tmp/repo')
|
||||
|
||||
const ctx = dispatchSpy.mock.calls.at(-1)?.[1]
|
||||
expect(dispatchSpy, `${argv.join(' ')} reached dispatch`).toHaveBeenCalledTimes(1)
|
||||
void ctx?.client
|
||||
expect(constructorArgsMock, `${argv.join(' ')} constructions`).toHaveBeenCalledTimes(1)
|
||||
const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0]
|
||||
expect(pairingCode, `${argv.join(' ')} pairing code`).toBeNull()
|
||||
expect(environment, `${argv.join(' ')} environment`).toBeNull()
|
||||
} finally {
|
||||
dispatchSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the mirror — the same stubbed-dispatch probe must show `undefined`
|
||||
// (not `null`) for a non-suppressed group, or the assertion above would pass
|
||||
// for a build that suppressed EVERY command's env fallback.
|
||||
it('forwards undefined remote selection for a non-suppressed group', async () => {
|
||||
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
|
||||
const dispatchSpy = vi.spyOn(dispatchModule, 'dispatch').mockResolvedValue(undefined)
|
||||
try {
|
||||
await main(['worktree', 'list'], '/tmp/repo')
|
||||
|
||||
void dispatchSpy.mock.calls.at(-1)?.[1]?.client
|
||||
expect(constructorArgsMock).toHaveBeenCalledTimes(1)
|
||||
const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0]
|
||||
expect(pairingCode).toBeUndefined()
|
||||
expect(environment).toBeUndefined()
|
||||
} finally {
|
||||
dispatchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the mirror of the above — for every other command the env fallback
|
||||
// MUST stay live, which the RuntimeClient default parameters implement. That
|
||||
// only works if `undefined` is forwarded.
|
||||
it('forwards undefined for non-suppressed commands so the env fallback applies', async () => {
|
||||
callMock.mockResolvedValue({ result: { worktrees: [] } })
|
||||
|
||||
await main(['worktree', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
expect(constructorArgsMock).toHaveBeenCalled()
|
||||
const [, , pairingCode, environment] = constructorArgsMock.mock.calls[0]
|
||||
expect(pairingCode).toBeUndefined()
|
||||
expect(environment).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards explicit --pairing-code and --environment values verbatim', async () => {
|
||||
callMock.mockResolvedValue({ result: { worktrees: [] } })
|
||||
|
||||
await main(['worktree', 'list', '--pairing-code', 'code-1', '--json'], '/tmp/repo')
|
||||
expect(constructorArgsMock.mock.calls[0][2]).toBe('code-1')
|
||||
expect(constructorArgsMock.mock.calls[0][3]).toBeUndefined()
|
||||
|
||||
constructorArgsMock.mockClear()
|
||||
await main(['worktree', 'list', '--environment', 'env-1', '--json'], '/tmp/repo')
|
||||
expect(constructorArgsMock.mock.calls[0][2]).toBeUndefined()
|
||||
expect(constructorArgsMock.mock.calls[0][3]).toBe('env-1')
|
||||
})
|
||||
|
||||
// Why: the getter is memoised; a dynamic import inside it would have made it
|
||||
// async and changed every handler signature.
|
||||
it('reuses one client instance across repeated ctx.client reads', async () => {
|
||||
callMock.mockResolvedValue({ result: { worktrees: [] } })
|
||||
|
||||
await main(['worktree', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
expect(constructorArgsMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus, resolveDesktopWindowStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { sendWebSocketRequest } from './websocket-transport'
|
||||
import type { sendWebSocketRequest } from './websocket-transport'
|
||||
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
|
||||
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
|
||||
import {
|
||||
@@ -29,6 +29,13 @@ import {
|
||||
// keepalive window. See design doc §3.1.
|
||||
const LONG_POLL_CLIENT_GRACE_MS = 10_000
|
||||
|
||||
// Why: ws + tweetnacl + the remote-runtime frame stack only matter once a
|
||||
// request actually goes over a pairing offer, which local CLI calls never do.
|
||||
// Both call sites already await this, so deferring the load changes no ordering.
|
||||
async function loadSendWebSocketRequest(): Promise<typeof sendWebSocketRequest> {
|
||||
return (await import('./websocket-transport.js')).sendWebSocketRequest
|
||||
}
|
||||
|
||||
export class RuntimeClient {
|
||||
private readonly userDataPath: string
|
||||
private readonly requestTimeoutMs: number
|
||||
@@ -80,6 +87,7 @@ export class RuntimeClient {
|
||||
if (method !== 'status.get') {
|
||||
await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
|
||||
}
|
||||
const sendWebSocketRequest = await loadSendWebSocketRequest()
|
||||
let response
|
||||
try {
|
||||
response = await sendWebSocketRequest<TResult>(
|
||||
@@ -179,6 +187,7 @@ export class RuntimeClient {
|
||||
if (!this.remotePairing || this.remoteCompatChecked) {
|
||||
return
|
||||
}
|
||||
const sendWebSocketRequest = await loadSendWebSocketRequest()
|
||||
const response = await sendWebSocketRequest<RuntimeStatus>(
|
||||
this.remotePairing,
|
||||
'status.get',
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '../shared/runtime-types'
|
||||
import { isPathInsideOrEqual } from '../shared/cross-platform-path'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
|
||||
|
||||
export type BrowserCliTarget = {
|
||||
|
||||
@@ -39,6 +39,29 @@ describe('BackgroundTransientFactRelay', () => {
|
||||
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }])
|
||||
})
|
||||
|
||||
it('preserves a provisional 2031 subscribe when scan authority moves to the daemon', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
relay.onSessionData('s1', '\x1b[?2031h\x1b[?')
|
||||
relay.setSessionBackground('s1', true)
|
||||
relay.seedSessionScanState('s1', '\x1b[?')
|
||||
relay.onSessionData('s1', '25h')
|
||||
|
||||
expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: '2031-subscribe' } }])
|
||||
})
|
||||
|
||||
it('exposes a provisional 2031 subscribe when scan authority returns to main', () => {
|
||||
const { relay } = createRelay()
|
||||
relay.setSessionBackground('s1', true)
|
||||
relay.seedSessionScanState('s1', '')
|
||||
relay.onSessionData('s1', '\x1b[?2031h\x1b[?')
|
||||
relay.setSessionBackground('s1', false)
|
||||
|
||||
expect(relay.getMode2031ReplyScanState('s1')).toEqual({
|
||||
tail: '\x1b[?',
|
||||
pendingSubscribe: true
|
||||
})
|
||||
})
|
||||
|
||||
it('stops emitting after un-background and reports the toggle as a state change', () => {
|
||||
const { relay, emitted } = createRelay()
|
||||
expect(relay.setSessionBackground('s1', true)).toBe(true)
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
createTerminalTitleTracker,
|
||||
type TerminalTitleTracker
|
||||
} from '../../shared/terminal-output-side-effects'
|
||||
import {
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
scanMode2031ReplyDecision,
|
||||
type Mode2031ReplyScanState
|
||||
} from '../../shared/terminal-color-scheme-protocol'
|
||||
import type { DaemonTransientFact } from './types'
|
||||
|
||||
// Kill switch for the whole background keep-tail mechanism (thinning +
|
||||
@@ -27,6 +32,8 @@ export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND
|
||||
|
||||
export class BackgroundTransientFactRelay {
|
||||
private trackersBySessionId = new Map<string, TerminalTitleTracker>()
|
||||
// Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff.
|
||||
private mode2031ReplyScanStateBySessionId = new Map<string, Mode2031ReplyScanState>()
|
||||
private emitFact: (sessionId: string, fact: DaemonTransientFact) => void
|
||||
|
||||
constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) {
|
||||
@@ -59,7 +66,8 @@ export class BackgroundTransientFactRelay {
|
||||
// PR-link dedup memory, so a link re-printed across toggles can
|
||||
// re-fire — consumers treat pr-link as a latest-association update.
|
||||
onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }),
|
||||
onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' })
|
||||
onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }),
|
||||
onMode2031Unsubscribe: () => this.emitFact(sessionId, { kind: '2031-unsubscribe' })
|
||||
})
|
||||
)
|
||||
} else {
|
||||
@@ -73,30 +81,63 @@ export class BackgroundTransientFactRelay {
|
||||
* background toggle neither mints a phantom bell nor loses its fact. A
|
||||
* partial tail contains no complete sequence, so this can never fire. */
|
||||
seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void {
|
||||
if (partialEscapeTailAnsi.length > 0) {
|
||||
this.trackersBySessionId
|
||||
.get(sessionId)
|
||||
?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' })
|
||||
let mode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId)
|
||||
if (!mode2031State && partialEscapeTailAnsi.length > 0) {
|
||||
mode2031State = scanMode2031ReplyDecision(
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
partialEscapeTailAnsi
|
||||
).state
|
||||
if (mode2031State.tail.length > 0) {
|
||||
this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031State)
|
||||
}
|
||||
}
|
||||
mode2031State ??= INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
const scanSeedAnsi = mode2031State.tail || partialEscapeTailAnsi
|
||||
if (scanSeedAnsi.length > 0) {
|
||||
this.trackersBySessionId.get(sessionId)?.handleChunk(scanSeedAnsi, {
|
||||
titleScanData: '',
|
||||
mode2031PendingSubscribe: mode2031State.pendingSubscribe
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery —
|
||||
* facts must be captured even when the chunk is later keep-tail dropped. */
|
||||
onSessionData(sessionId: string, data: string): void {
|
||||
const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId)
|
||||
if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) {
|
||||
const mode2031Result = scanMode2031ReplyDecision(
|
||||
previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
data
|
||||
)
|
||||
if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) {
|
||||
this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state)
|
||||
} else {
|
||||
this.mode2031ReplyScanStateBySessionId.delete(sessionId)
|
||||
}
|
||||
}
|
||||
// titleScanData:'' skips title extraction (titles stay main-authoritative)
|
||||
// and keeps the stale-working-title timer permanently unarmed — only the
|
||||
// four transient scanners consume the chunk.
|
||||
this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' })
|
||||
}
|
||||
|
||||
getMode2031ReplyScanState(sessionId: string): Mode2031ReplyScanState {
|
||||
return (
|
||||
this.mode2031ReplyScanStateBySessionId.get(sessionId) ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
)
|
||||
}
|
||||
|
||||
onSessionExit(sessionId: string): void {
|
||||
this.disposeTracker(sessionId)
|
||||
this.mode2031ReplyScanStateBySessionId.delete(sessionId)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const sessionId of Array.from(this.trackersBySessionId.keys())) {
|
||||
this.disposeTracker(sessionId)
|
||||
}
|
||||
this.mode2031ReplyScanStateBySessionId.clear()
|
||||
}
|
||||
|
||||
private disposeTracker(sessionId: string): void {
|
||||
|
||||
@@ -3,12 +3,14 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
|
||||
|
||||
describe('foreground-confirmation daemon protocol', () => {
|
||||
it('rejects daemons from before the fresh-confirmation RPC', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(28)
|
||||
expect(PROTOCOL_VERSION).toBe(29)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(25)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(27)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(28)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,19 +4,33 @@ import {
|
||||
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
|
||||
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
|
||||
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
|
||||
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
|
||||
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
|
||||
PROTOCOL_VERSION
|
||||
PROTOCOL_VERSION,
|
||||
supportsMode2031UnsubscribeFact
|
||||
} from './daemon-protocol-version'
|
||||
|
||||
describe('daemon protocol version', () => {
|
||||
it('ships preflight-cache replacement after completion inspection', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(28)
|
||||
it('ships the 2031-unsubscribe fact after preflight-cache replacement', () => {
|
||||
expect(PROTOCOL_VERSION).toBe(29)
|
||||
expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29)
|
||||
expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27)
|
||||
expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11)
|
||||
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
|
||||
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
|
||||
Array.from({ length: 27 }, (_, index) => index + 1)
|
||||
Array.from({ length: 28 }, (_, index) => index + 1)
|
||||
)
|
||||
})
|
||||
|
||||
it('withholds 2031-unsubscribe support from every preserved older daemon', () => {
|
||||
// Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app
|
||||
// update is the live hazard — it emits '2031-subscribe' with no way to retract it.
|
||||
// The boundary must sit at 29, not merely "recent enough".
|
||||
expect(supportsMode2031UnsubscribeFact(PROTOCOL_VERSION)).toBe(true)
|
||||
expect(supportsMode2031UnsubscribeFact(28)).toBe(false)
|
||||
for (const version of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
|
||||
expect(supportsMode2031UnsubscribeFact(version)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Why: daemons survive app updates, so wire behavior must be version-gated.
|
||||
// v28 replaces v26/v27 daemons that can retain permanent macOS preflight rejections (#9756).
|
||||
export const PROTOCOL_VERSION = 28
|
||||
// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993).
|
||||
export const PROTOCOL_VERSION = 29
|
||||
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
|
||||
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
|
||||
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
|
||||
@@ -8,10 +8,26 @@ export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26
|
||||
export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26
|
||||
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
|
||||
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
|
||||
// Why (#9993): a gate-managed pane's bytes never reach the renderer, so main's
|
||||
// transient facts are the only thing that can retire a 2031 subscription for it.
|
||||
// Daemons before this version emit '2031-subscribe' but have no unsubscribe fact
|
||||
// at all, so a TUI exiting while hidden would leave the subscription registered
|
||||
// forever and the next theme flip would inject CSI 997 into whatever replaced it.
|
||||
// Scan authority moves to the daemon only while a session is backgrounded, so the
|
||||
// gate lives on backgrounding itself (setPtyBackgrounded): a pre-v29 daemon is
|
||||
// never asked to thin, and main's scanner — which emits BOTH facts — stays
|
||||
// authoritative over the whole stream. Filtering the subscribe fact alone would
|
||||
// not help, because the visible-era subscription is registered by main.
|
||||
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
|
||||
28
|
||||
] as const
|
||||
|
||||
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
|
||||
return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
export function supportsMode2031UnsubscribeFact(protocolVersion: number): boolean {
|
||||
return protocolVersion >= MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION
|
||||
}
|
||||
|
||||
@@ -765,6 +765,177 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mode 2031 fact compatibility (#9993)', () => {
|
||||
let onEventSpy: ReturnType<typeof vi.spyOn>
|
||||
// Why these tests exist: daemons survive app updates, so a NEW desktop can be
|
||||
// driving a PRESERVED older daemon. Those daemons emit '2031-subscribe' but have
|
||||
// no unsubscribe fact at all. For a gate-managed pane the renderer never sees the
|
||||
// bytes, so main's facts are the only thing that can retire the subscription —
|
||||
// trusting a subscribe that can never be retracted leaves it live forever, and the
|
||||
// next theme flip injects CSI 997 into whatever shell replaced the exited TUI.
|
||||
function captureForwardedFacts(target: DaemonPtyAdapter): {
|
||||
kinds: () => string[]
|
||||
emit: (fact: { kind: string }) => void
|
||||
} {
|
||||
const forwarded: string[] = []
|
||||
target.onBackgroundStreamEvent((payload) => {
|
||||
if (payload.kind === 'transientFact') {
|
||||
forwarded.push((payload.fact as { kind: string }).kind)
|
||||
}
|
||||
})
|
||||
const listeners: ((event: unknown) => void)[] = []
|
||||
onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => {
|
||||
listeners.push(listener)
|
||||
return () => {}
|
||||
})
|
||||
return {
|
||||
kinds: () => forwarded,
|
||||
emit: (fact) => {
|
||||
expect(listeners.length).toBeGreaterThan(0)
|
||||
for (const listener of listeners) {
|
||||
// `type: 'event'` is the envelope the routing switch requires.
|
||||
listener({
|
||||
type: 'event',
|
||||
event: 'transientFact',
|
||||
sessionId: 'session-1',
|
||||
payload: fact
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('drops a pre-v29 daemon 2031-subscribe it could never retract', () => {
|
||||
// v28 is the version shipping today, so this is the live upgrade hazard: a v28
|
||||
// daemon preserved across an app update, still holding real sessions.
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
|
||||
try {
|
||||
const captured = captureForwardedFacts(legacy)
|
||||
// Force a fresh wire-up so the spy above is the listener the adapter installs.
|
||||
legacy['removeEventListener'] = null
|
||||
legacy['setupEventRouting']()
|
||||
captured.emit({ kind: '2031-subscribe' })
|
||||
captured.emit({ kind: 'bell' })
|
||||
|
||||
// The unretractable subscribe is withheld; unrelated facts still flow, so the
|
||||
// gate is narrow rather than "ignore this daemon's facts".
|
||||
expect(captured.kinds()).toEqual(['bell'])
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
onEventSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('never delegates scan authority to a v28 daemon, so a hidden withdrawal is still seen', () => {
|
||||
// The scenario the fact filter alone does NOT cover, and the reason the gate sits
|
||||
// on backgrounding: while the pane is VISIBLE, main's own scanner registers the
|
||||
// 2031 subscribe (bytes transit main either way). Only backgrounding hands scan
|
||||
// authority to the daemon. If a v28 daemon were allowed to take it, the TUI could
|
||||
// exit while hidden with no party able to emit the withdrawal — #9993 via upgrade.
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
|
||||
try {
|
||||
legacy.setPtyBackgrounded('v28-session', true)
|
||||
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
|
||||
sessionId: 'v28-session',
|
||||
background: false
|
||||
})
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('delegates scan authority to a v29 daemon, which can retract', () => {
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
|
||||
try {
|
||||
current.setPtyBackgrounded('v29-session', true)
|
||||
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
|
||||
sessionId: 'v29-session',
|
||||
background: true
|
||||
})
|
||||
} finally {
|
||||
current.dispose()
|
||||
notifySpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards a provisional subscribe with the foreground handoff marker', () => {
|
||||
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
|
||||
const forwarded: unknown[] = []
|
||||
current.onBackgroundStreamEvent((payload) => forwarded.push(payload))
|
||||
const listeners: ((event: unknown) => void)[] = []
|
||||
onEventSpy = vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => {
|
||||
listeners.push(listener)
|
||||
return () => {}
|
||||
})
|
||||
try {
|
||||
current['removeEventListener'] = null
|
||||
current['setupEventRouting']()
|
||||
for (const listener of listeners) {
|
||||
listener({
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId: 'session-1',
|
||||
payload: {
|
||||
background: false,
|
||||
scanSeedAnsi: '\x1b[?',
|
||||
mode2031PendingSubscribe: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expect(forwarded).toEqual([
|
||||
{
|
||||
id: 'session-1',
|
||||
kind: 'backgroundMarker',
|
||||
background: false,
|
||||
scanSeedAnsi: '\x1b[?',
|
||||
mode2031PendingSubscribe: true
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
current.dispose()
|
||||
onEventSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards a v28 unsubscribe, which can only retire state main registered', () => {
|
||||
// Asymmetric on purpose: an unretractable subscribe is the hazard, a withdrawal
|
||||
// never is. A stale relay tracker on a preserved daemon must still be able to
|
||||
// clear a subscription rather than be silenced into stranding it.
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
|
||||
try {
|
||||
const captured = captureForwardedFacts(legacy)
|
||||
legacy['removeEventListener'] = null
|
||||
legacy['setupEventRouting']()
|
||||
captured.emit({ kind: '2031-unsubscribe' })
|
||||
|
||||
expect(captured.kinds()).toEqual(['2031-unsubscribe'])
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
onEventSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards 2031 facts from a v29 daemon that can retract them', () => {
|
||||
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
|
||||
try {
|
||||
const captured = captureForwardedFacts(current)
|
||||
current['removeEventListener'] = null
|
||||
current['setupEventRouting']()
|
||||
captured.emit({ kind: '2031-subscribe' })
|
||||
captured.emit({ kind: '2031-unsubscribe' })
|
||||
|
||||
expect(captured.kinds()).toEqual(['2031-subscribe', '2031-unsubscribe'])
|
||||
} finally {
|
||||
current.dispose()
|
||||
onEventSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('background stream thinning compatibility', () => {
|
||||
it('reports authoritative snapshot support only for protocol v20 and newer', () => {
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 })
|
||||
@@ -834,6 +1005,111 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ protocolVersion: 28, clearsHint: true },
|
||||
{ protocolVersion: 29, clearsHint: false }
|
||||
])(
|
||||
'uses protocol v$protocolVersion background authority before spawn',
|
||||
async ({ protocolVersion, clearsHint }) => {
|
||||
const ensureConnectedSpy = vi
|
||||
.spyOn(DaemonClient.prototype, 'ensureConnected')
|
||||
.mockResolvedValue()
|
||||
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
|
||||
isNew: true,
|
||||
pid: null,
|
||||
shellState: 'unsupported',
|
||||
snapshot: null
|
||||
} as never)
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const target = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion })
|
||||
const sessionId = `spawn-v${protocolVersion}-session`
|
||||
try {
|
||||
await target.spawn({ sessionId, cols: 80, rows: 24 })
|
||||
|
||||
if (clearsHint) {
|
||||
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
|
||||
sessionId,
|
||||
background: false
|
||||
})
|
||||
expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestSpy.mock.invocationCallOrder[0]
|
||||
)
|
||||
} else {
|
||||
expect(notifySpy).not.toHaveBeenCalledWith(
|
||||
'setSessionBackground',
|
||||
expect.objectContaining({ sessionId })
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
target.dispose()
|
||||
notifySpy.mockRestore()
|
||||
requestSpy.mockRestore()
|
||||
ensureConnectedSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('clears a preserved v28 background hint before attaching, so scan authority comes home', async () => {
|
||||
// The gate on setPtyBackgrounded only binds THIS process. Daemons outlive the desktop:
|
||||
// a v28 that a previous desktop backgrounded is still scanning when a new desktop
|
||||
// attaches, and this process never called setPtyBackgrounded for it — so without the
|
||||
// pre-attach clear the daemon keeps authority it can never retract (#9993).
|
||||
const ensureConnectedSpy = vi
|
||||
.spyOn(DaemonClient.prototype, 'ensureConnected')
|
||||
.mockResolvedValue()
|
||||
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
|
||||
isNew: false,
|
||||
pid: 4242,
|
||||
shellState: 'unsupported',
|
||||
snapshot: null
|
||||
} as never)
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 28 })
|
||||
try {
|
||||
await legacy.attach('preserved-v28-session')
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', {
|
||||
sessionId: 'preserved-v28-session',
|
||||
background: false
|
||||
})
|
||||
expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestSpy.mock.invocationCallOrder[0]
|
||||
)
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
notifySpy.mockRestore()
|
||||
requestSpy.mockRestore()
|
||||
ensureConnectedSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a v29 background hint alone on attach, because it can retract on its own', async () => {
|
||||
const ensureConnectedSpy = vi
|
||||
.spyOn(DaemonClient.prototype, 'ensureConnected')
|
||||
.mockResolvedValue()
|
||||
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
|
||||
isNew: false,
|
||||
pid: 4242,
|
||||
shellState: 'unsupported',
|
||||
snapshot: null
|
||||
} as never)
|
||||
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
|
||||
const current = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 29 })
|
||||
try {
|
||||
await current.attach('preserved-v29-session')
|
||||
|
||||
expect(notifySpy).not.toHaveBeenCalledWith(
|
||||
'setSessionBackground',
|
||||
expect.objectContaining({ sessionId: 'preserved-v29-session' })
|
||||
)
|
||||
} finally {
|
||||
current.dispose()
|
||||
notifySpy.mockRestore()
|
||||
requestSpy.mockRestore()
|
||||
ensureConnectedSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('still returns the v19 attach snapshot for a desktop renderer replay', async () => {
|
||||
const ensureConnectedSpy = vi
|
||||
.spyOn(DaemonClient.prototype, 'ensureConnected')
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
|
||||
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
supportsMode2031UnsubscribeFact,
|
||||
supportsPtyStartupIngress,
|
||||
type CreateOrAttachResult,
|
||||
type DaemonEvent,
|
||||
@@ -200,6 +201,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return this.supportsAuthoritativeBufferSnapshots
|
||||
}
|
||||
|
||||
// Why one predicate (#9993): the attach-time clear and setPtyBackgrounded must agree on
|
||||
// which daemons may hold a background hint. Daemons outlive the desktop that set it, so
|
||||
// if these two drift a preserved daemon keeps a hint this process would never grant.
|
||||
private get canDelegateBackgroundToDaemon(): boolean {
|
||||
return (
|
||||
this.supportsAuthoritativeBufferSnapshots &&
|
||||
supportsMode2031UnsubscribeFact(this.protocolVersion)
|
||||
)
|
||||
}
|
||||
|
||||
constructor(opts: DaemonPtyAdapterOptions) {
|
||||
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
||||
this.socketPath = opts.socketPath
|
||||
@@ -360,8 +371,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
await this.ensureConnected()
|
||||
// Why before createOrAttach: a preserved v19 daemon may still think this session is backgrounded; clear it before attached bytes get thinned without a recoverable seq.
|
||||
if (!this.supportsAuthoritativeBufferSnapshots) {
|
||||
// Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from
|
||||
// a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop
|
||||
// handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached.
|
||||
if (!this.canDelegateBackgroundToDaemon) {
|
||||
this.setPtyBackgrounded(sessionId, false)
|
||||
}
|
||||
|
||||
@@ -717,7 +730,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
|
||||
async attach(id: string): Promise<void> {
|
||||
await this.ensureConnected()
|
||||
if (!this.supportsAuthoritativeBufferSnapshots) {
|
||||
if (!this.canDelegateBackgroundToDaemon) {
|
||||
this.setPtyBackgrounded(id, false)
|
||||
}
|
||||
|
||||
@@ -785,7 +798,12 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return
|
||||
}
|
||||
// Why: preserved v19 daemons can thin but can't return the absolute snapshot sequence to recover a gap; clear their stale hint too.
|
||||
const safeBackground = this.supportsAuthoritativeBufferSnapshots && background
|
||||
// Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan
|
||||
// authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never
|
||||
// retract it, so a TUI exiting while hidden would strand the subscription and the
|
||||
// next theme flip would inject CSI 997 into its replacement shell. Declining to
|
||||
// background keeps main's scanner — which emits both facts — authoritative.
|
||||
const safeBackground = this.canDelegateBackgroundToDaemon && background
|
||||
if (safeBackground) {
|
||||
this.backgroundedSessionIds.add(id)
|
||||
} else {
|
||||
@@ -1913,6 +1931,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
background: event.payload.background,
|
||||
...(event.payload.scanSeedAnsi !== undefined
|
||||
? { scanSeedAnsi: event.payload.scanSeedAnsi }
|
||||
: {}),
|
||||
...(event.payload.mode2031PendingSubscribe
|
||||
? { mode2031PendingSubscribe: true as const }
|
||||
: {})
|
||||
})
|
||||
} else if (event.event === 'dataGap') {
|
||||
@@ -1925,6 +1946,18 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
: { sequenceChars: event.payload.sequenceChars })
|
||||
})
|
||||
} else if (event.event === 'transientFact') {
|
||||
// Why (#9993): belt-and-braces behind the setPtyBackgrounded gate. A pre-v29
|
||||
// daemon is never asked to background, so it should emit no transient facts at
|
||||
// all — but one preserved across a reconnect could still have a stale relay
|
||||
// tracker. An unretractable subscribe is the harmful direction, so drop it.
|
||||
// An unsubscribe is always forwarded: retiring a subscription main registered
|
||||
// can only ever help, never strand one.
|
||||
if (
|
||||
event.payload.kind === '2031-subscribe' &&
|
||||
!supportsMode2031UnsubscribeFact(this.protocolVersion)
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.emitBackgroundStreamEvent({
|
||||
id: event.sessionId,
|
||||
kind: 'transientFact',
|
||||
|
||||
@@ -868,14 +868,20 @@ export class DaemonServer {
|
||||
return {}
|
||||
}
|
||||
// Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker.
|
||||
const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId)
|
||||
const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId)
|
||||
const scanSeedAnsi = background
|
||||
? ''
|
||||
: mode2031State.pendingSubscribe
|
||||
? mode2031State.tail
|
||||
: this.host.getPartialEscapeTailAnsi(sessionId)
|
||||
this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, {
|
||||
type: 'event',
|
||||
event: 'sessionBackgroundMarker',
|
||||
sessionId,
|
||||
payload: {
|
||||
background,
|
||||
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {})
|
||||
...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}),
|
||||
...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {})
|
||||
}
|
||||
})
|
||||
return {}
|
||||
|
||||
@@ -175,6 +175,37 @@ describe('daemon stream droppability lifecycle', () => {
|
||||
expect(lifecycle).toEqual(['refresh:false', 'marker:false'])
|
||||
})
|
||||
|
||||
it('returns a provisional 2031 subscribe with the foreground handoff marker', async () => {
|
||||
const harness = createServerHarness()
|
||||
server = harness.server
|
||||
const { daemon } = harness
|
||||
addClient(daemon)
|
||||
daemon.streamClientIdBySessionId.set('session-toggle', 'client-1')
|
||||
daemon.transientFactRelay.setSessionBackground('session-toggle', true)
|
||||
daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?')
|
||||
vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?')
|
||||
const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent')
|
||||
|
||||
await daemon.routeRequest('client-1', {
|
||||
id: 'foreground',
|
||||
type: 'setSessionBackground',
|
||||
payload: { sessionId: 'session-toggle', background: false }
|
||||
})
|
||||
|
||||
expect(enqueue).toHaveBeenCalledWith(
|
||||
'client-1',
|
||||
'session-toggle',
|
||||
expect.objectContaining({
|
||||
event: 'sessionBackgroundMarker',
|
||||
payload: {
|
||||
background: false,
|
||||
scanSeedAnsi: '\x1b[?',
|
||||
mode2031PendingSubscribe: true
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('routes an attached session before refresh and emits its background marker after', async () => {
|
||||
const harness = createServerHarness()
|
||||
server = harness.server
|
||||
|
||||
@@ -41,12 +41,17 @@ export type TerminalErrorEvent = {
|
||||
* exactly this position so no fact double-fires or goes missing.
|
||||
* scanSeedAnsi (un-background only) carries the emulator's dangling
|
||||
* incomplete escape so main can prime its fresh scanner carry — a sequence
|
||||
* split across the handoff must not mint a phantom bell or lose its fact. */
|
||||
* split across the handoff must not mint a phantom bell or lose its fact.
|
||||
* mode2031PendingSubscribe preserves a subscribe deferred behind that tail. */
|
||||
export type SessionBackgroundMarkerEvent = {
|
||||
type: 'event'
|
||||
event: 'sessionBackgroundMarker'
|
||||
sessionId: string
|
||||
payload: { background: boolean; scanSeedAnsi?: string }
|
||||
payload: {
|
||||
background: boolean
|
||||
scanSeedAnsi?: string
|
||||
mode2031PendingSubscribe?: true
|
||||
}
|
||||
}
|
||||
|
||||
/** A backgrounded session's oldest undelivered output was dropped at the
|
||||
@@ -69,6 +74,7 @@ export type DaemonTransientFact =
|
||||
| { kind: 'command-finished'; exitCode: number | null }
|
||||
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
|
||||
| { kind: '2031-subscribe' }
|
||||
| { kind: '2031-unsubscribe' }
|
||||
|
||||
export type TransientFactEvent = {
|
||||
type: 'event'
|
||||
|
||||
@@ -278,14 +278,16 @@ describe('incremental terminal history restore', () => {
|
||||
const firstReplay = reader.detectColdRestore(SESSION_ID)
|
||||
const secondReplay = reader.detectColdRestore(secondSessionId)
|
||||
try {
|
||||
// Drain by yield because awaiting the session that loses the replay-slot race deadlocks.
|
||||
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
|
||||
|
||||
pendingYields.shift()!()
|
||||
expect((await firstReplay)?.scrollbackAnsi).toContain('😀second')
|
||||
|
||||
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
|
||||
|
||||
pendingYields.shift()!()
|
||||
expect((await secondReplay)?.scrollbackAnsi).toContain('😀second')
|
||||
|
||||
for (const restore of await Promise.all([firstReplay, secondReplay])) {
|
||||
expect(restore?.scrollbackAnsi).toContain('😀second')
|
||||
}
|
||||
expect(pendingYields).toHaveLength(0)
|
||||
} finally {
|
||||
for (const resume of pendingYields.splice(0)) {
|
||||
|
||||
@@ -32,6 +32,8 @@ export {
|
||||
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
|
||||
PROTOCOL_VERSION,
|
||||
PTY_STARTUP_INGRESS_PROTOCOL_VERSION,
|
||||
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION,
|
||||
supportsMode2031UnsubscribeFact,
|
||||
supportsPtyStartupIngress
|
||||
} from './daemon-protocol-version'
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getBranchCompare } from './status'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(repo: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('getBranchCompare real refs', () => {
|
||||
it('preserves the raw oid of a remote-tracking ref that stores an annotated tag', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-compare-ref-'))
|
||||
tempRoots.push(root)
|
||||
const source = path.join(root, 'source')
|
||||
const client = path.join(root, 'client')
|
||||
|
||||
execFileSync('git', ['init', '-q', source])
|
||||
git(source, ['config', 'user.email', 'test@example.com'])
|
||||
git(source, ['config', 'user.name', 'Test User'])
|
||||
git(source, ['config', 'commit.gpgSign', 'false'])
|
||||
git(source, ['config', 'tag.gpgSign', 'false'])
|
||||
git(source, ['commit', '--allow-empty', '-m', 'initial'])
|
||||
git(source, ['tag', '-a', 'annotated', '-m', 'annotated base'])
|
||||
execFileSync('git', ['clone', '-q', source, client])
|
||||
git(client, ['fetch', source, 'refs/tags/annotated:refs/remotes/origin/tagbase'])
|
||||
|
||||
expect(git(client, ['branch', '-r', '--format=%(refname:short)']).split(/\r?\n/)).toContain(
|
||||
'origin/tagbase'
|
||||
)
|
||||
const rawOid = git(client, ['rev-parse', '--verify', 'refs/remotes/origin/tagbase'])
|
||||
const peeledOid = git(client, [
|
||||
'rev-parse',
|
||||
'--verify',
|
||||
'--quiet',
|
||||
'refs/remotes/origin/tagbase^{commit}'
|
||||
])
|
||||
expect(rawOid).not.toBe(peeledOid)
|
||||
|
||||
const result = await getBranchCompare(client, 'origin/tagbase')
|
||||
|
||||
expect(result.summary).toMatchObject({ baseOid: rawOid, status: 'ready' })
|
||||
})
|
||||
})
|
||||
+176
-51
@@ -1929,21 +1929,72 @@ describe('getBranchCompare', () => {
|
||||
readFileMock.mockReset()
|
||||
})
|
||||
|
||||
// Why dispatch on args instead of mockResolvedValueOnce chains: getBranchCompare now
|
||||
// issues its head-of-chain reads concurrently, so a positional mock would encode call
|
||||
// order rather than behaviour and break on any safe reordering.
|
||||
type BranchCompareGitResponses = {
|
||||
branch?: string | Error
|
||||
probe?: Record<string, string | Error>
|
||||
headOid?: string | Error
|
||||
baseOid?: string | Error
|
||||
mergeBase?: string | Error
|
||||
nameStatus?: string | Error
|
||||
numstat?: string | Error
|
||||
revList?: string | Error
|
||||
}
|
||||
|
||||
function mockBranchCompareGit(responses: BranchCompareGitResponses): void {
|
||||
const reply = (
|
||||
value: string | Error | undefined,
|
||||
label: string
|
||||
): Promise<{ stdout: string }> => {
|
||||
if (value === undefined) {
|
||||
throw new Error(`unexpected git call: ${label}`)
|
||||
}
|
||||
return value instanceof Error ? Promise.reject(value) : Promise.resolve({ stdout: value })
|
||||
}
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'branch') {
|
||||
return reply(responses.branch, 'branch --show-current')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('--quiet')) {
|
||||
const probed = args.find((arg) => arg.endsWith('^{commit}')) ?? ''
|
||||
return reply(responses.probe?.[probed], `probe ${probed}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
|
||||
return reply(responses.headOid, 'rev-parse HEAD')
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
return reply(responses.baseOid, `rev-parse ${args.at(-1)}`)
|
||||
}
|
||||
if (args[0] === 'merge-base') {
|
||||
return reply(responses.mergeBase, 'merge-base')
|
||||
}
|
||||
if (args.includes('--name-status')) {
|
||||
return reply(responses.nameStatus, 'diff --name-status')
|
||||
}
|
||||
if (args.includes('--numstat')) {
|
||||
return reply(responses.numstat, 'diff --numstat')
|
||||
}
|
||||
if (args[0] === 'rev-list') {
|
||||
return reply(responses.revList, 'rev-list')
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
it('returns a pinned branch compare snapshot and parsed branch entries', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n'
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '7\n' })
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
|
||||
headOid: 'head-oid\n',
|
||||
baseOid: 'base-oid\n',
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n',
|
||||
numstat:
|
||||
'10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n',
|
||||
revList: '7\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
@@ -1965,12 +2016,15 @@ describe('getBranchCompare', () => {
|
||||
})
|
||||
|
||||
it('returns invalid-base when the compare ref does not resolve', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockRejectedValueOnce(new Error('missing remote base'))
|
||||
.mockRejectedValueOnce(new Error('missing local base'))
|
||||
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
|
||||
.mockRejectedValueOnce(new Error('missing base'))
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
probe: {
|
||||
'refs/remotes/origin/missing^{commit}': new Error('missing remote base'),
|
||||
'refs/heads/origin/missing^{commit}': new Error('missing local base')
|
||||
},
|
||||
headOid: 'head-oid\n',
|
||||
baseOid: new Error('missing base')
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/missing')
|
||||
|
||||
@@ -1980,11 +2034,13 @@ describe('getBranchCompare', () => {
|
||||
})
|
||||
|
||||
it('returns unborn-head when HEAD cannot be resolved', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
|
||||
.mockRejectedValueOnce(new Error('unborn'))
|
||||
.mockRejectedValueOnce(new Error('missing base'))
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
// Why the probe fails here: a proven base ref would resolve, giving 'ready'.
|
||||
probe: { 'refs/remotes/origin/main^{commit}': new Error('missing base') },
|
||||
headOid: new Error('unborn'),
|
||||
baseOid: new Error('missing base')
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
@@ -1994,11 +2050,12 @@ describe('getBranchCompare', () => {
|
||||
})
|
||||
|
||||
it('treats an unborn branch with a resolvable base as having no committed branch changes', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'feature\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
|
||||
.mockRejectedValueOnce(new Error('unborn'))
|
||||
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
|
||||
mockBranchCompareGit({
|
||||
branch: 'feature\n',
|
||||
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
|
||||
headOid: new Error('unborn'),
|
||||
baseOid: 'base-oid\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
@@ -2016,12 +2073,13 @@ describe('getBranchCompare', () => {
|
||||
})
|
||||
|
||||
it('returns no-merge-base when histories do not intersect', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
|
||||
.mockRejectedValueOnce(new Error('no merge base'))
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
|
||||
headOid: 'head-oid\n',
|
||||
baseOid: 'base-oid\n',
|
||||
mergeBase: new Error('no merge base')
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
@@ -2031,20 +2089,20 @@ describe('getBranchCompare', () => {
|
||||
})
|
||||
|
||||
it('passes core.quotePath=false to diff --name-status and parses UTF-8 paths', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'remote-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' })
|
||||
.mockResolvedValueOnce({ stdout: '2\t1\tdocs/日本語/sample.md\n' })
|
||||
.mockResolvedValueOnce({ stdout: '1\n' })
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
probe: { 'refs/remotes/origin/main^{commit}': 'base-oid\n' },
|
||||
headOid: 'head-oid\n',
|
||||
baseOid: 'base-oid\n',
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: 'M\tdocs/日本語/sample.md\n',
|
||||
numstat: '2\t1\tdocs/日本語/sample.md\n',
|
||||
revList: '1\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
@@ -2062,7 +2120,70 @@ describe('getBranchCompare', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('compares short remote labels through fully qualified remote-tracking refs', async () => {
|
||||
// Why: the base oid now comes from the probe, so the probe must never report a ref it
|
||||
// did not actually resolve -- an empty rev-parse --quiet stdout means "not found".
|
||||
it('treats an empty probe result as an unresolved base ref', async () => {
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
probe: {
|
||||
'refs/remotes/origin/main^{commit}': '\n',
|
||||
'refs/heads/origin/main^{commit}': '\n'
|
||||
},
|
||||
headOid: 'head-oid\n',
|
||||
baseOid: new Error('missing base')
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
expect(result.summary.status).toBe('invalid-base')
|
||||
})
|
||||
|
||||
// Why: the probe tries refs/remotes first, then refs/heads. Reusing "the last probed
|
||||
// oid" rather than the oid for the ref that won would return the wrong commit.
|
||||
it('reuses only the oid of the ref the probe actually resolved', async () => {
|
||||
mockBranchCompareGit({
|
||||
branch: 'feature\n',
|
||||
probe: {
|
||||
'refs/remotes/origin/main^{commit}': new Error('no remote-tracking ref'),
|
||||
'refs/heads/origin/main^{commit}': 'local-branch-oid\n'
|
||||
},
|
||||
headOid: 'head-oid\n',
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: '',
|
||||
numstat: '',
|
||||
revList: '0\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'origin/main')
|
||||
|
||||
expect(result.summary).toMatchObject({
|
||||
baseOid: 'local-branch-oid',
|
||||
status: 'ready'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: an already-qualified base ref skips the probe entirely, so its oid must still
|
||||
// come from rev-parse rather than from a stale or absent probe entry.
|
||||
it('resolves an already-qualified base ref without a probe', async () => {
|
||||
mockBranchCompareGit({
|
||||
branch: 'main\n',
|
||||
headOid: 'head-oid\n',
|
||||
baseOid: 'qualified-base-oid\n',
|
||||
mergeBase: 'merge-base-oid\n',
|
||||
nameStatus: '',
|
||||
numstat: '',
|
||||
revList: '0\n'
|
||||
})
|
||||
|
||||
const result = await getBranchCompare('/repo', 'refs/remotes/origin/main')
|
||||
|
||||
expect(result.summary).toMatchObject({
|
||||
baseOid: 'qualified-base-oid',
|
||||
status: 'ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves remote-tracking refs separately after probing their commit target', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'branch') {
|
||||
return Promise.resolve({ stdout: 'feature\n' })
|
||||
@@ -2072,13 +2193,13 @@ describe('getBranchCompare', () => {
|
||||
args.includes('--quiet') &&
|
||||
args.includes('refs/remotes/origin/main^{commit}')
|
||||
) {
|
||||
return Promise.resolve({ stdout: 'remote-base-oid\n' })
|
||||
return Promise.resolve({ stdout: 'peeled-base-oid\n' })
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
|
||||
return Promise.resolve({ stdout: 'head-oid\n' })
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
|
||||
return Promise.resolve({ stdout: 'base-oid\n' })
|
||||
return Promise.resolve({ stdout: 'raw-base-oid\n' })
|
||||
}
|
||||
if (args[0] === 'merge-base') {
|
||||
return Promise.resolve({ stdout: 'merge-base-oid\n' })
|
||||
@@ -2099,9 +2220,13 @@ describe('getBranchCompare', () => {
|
||||
|
||||
expect(result.summary).toMatchObject({
|
||||
baseRef: 'origin/main',
|
||||
baseOid: 'base-oid',
|
||||
baseOid: 'raw-base-oid',
|
||||
status: 'ready'
|
||||
})
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--verify', '--end-of-options', 'refs/remotes/origin/main'],
|
||||
{ cwd: '/repo' }
|
||||
|
||||
+32
-16
@@ -44,8 +44,9 @@ import {
|
||||
removeSafeUntrackedDiscardTarget,
|
||||
removeSafeUntrackedDiscardTargets
|
||||
} from '../../shared/git-discard-path-safety'
|
||||
import { readBranchCompareHead } from '../../shared/git-branch-compare-head'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
|
||||
import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe'
|
||||
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
@@ -1304,29 +1305,44 @@ export async function getBranchCompare(
|
||||
status: 'loading'
|
||||
}
|
||||
|
||||
const compareRef = await resolveCompareRef(worktreePath, options)
|
||||
// The base-ref probe peels to a commit. Only branch refs are guaranteed to store
|
||||
// commits; remote-tracking refs may store annotated tags whose raw oid must be preserved.
|
||||
const reusableProbedOidByRef = new Map<string, string>()
|
||||
const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({
|
||||
readCompareRef: () => resolveCompareRef(worktreePath, options),
|
||||
resolveBaseRef: () =>
|
||||
// Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref.
|
||||
resolveWorktreeAddBaseRef(baseRef, async (qualifiedRef) => {
|
||||
const oid = await resolveWorktreeBaseCommitOid(worktreePath, qualifiedRef, options)
|
||||
if (oid !== null && qualifiedRef.startsWith('refs/heads/')) {
|
||||
reusableProbedOidByRef.set(qualifiedRef, oid)
|
||||
}
|
||||
return oid !== null
|
||||
}),
|
||||
readHeadOid: () => resolveRefOid(worktreePath, 'HEAD', options),
|
||||
readBaseOid: (ref) => {
|
||||
const reusableOid = reusableProbedOidByRef.get(ref)
|
||||
return reusableOid === undefined
|
||||
? resolveRefOid(worktreePath, ref, options)
|
||||
: Promise.resolve(reusableOid)
|
||||
}
|
||||
})
|
||||
summary.compareRef = compareRef
|
||||
// Why: short refs like "origin/main" can collide with a local branch; use the proven remote-tracking ref.
|
||||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) =>
|
||||
hasWorktreeBaseCommitRef(worktreePath, qualifiedRef, options)
|
||||
)
|
||||
|
||||
let headOid = ''
|
||||
let baseOid = ''
|
||||
try {
|
||||
headOid = await resolveRefOid(worktreePath, 'HEAD', options)
|
||||
if (headOidResult.ok) {
|
||||
headOid = headOidResult.oid
|
||||
summary.headOid = headOid
|
||||
} catch {
|
||||
try {
|
||||
baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options)
|
||||
} else {
|
||||
if (baseOidResult.ok) {
|
||||
baseOid = baseOidResult.oid
|
||||
summary.baseOid = baseOid
|
||||
// Why: an unborn branch (new remote worktree) has no changes yet; a compare error would look broken.
|
||||
summary.changedFiles = 0
|
||||
summary.commitsAhead = 0
|
||||
summary.status = 'ready'
|
||||
return { summary, entries: [] }
|
||||
} catch {
|
||||
// Preserve the unborn-head message when even the base is unresolvable.
|
||||
}
|
||||
summary.status = 'unborn-head'
|
||||
summary.errorMessage =
|
||||
@@ -1334,10 +1350,10 @@ export async function getBranchCompare(
|
||||
return { summary, entries: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
baseOid = await resolveRefOid(worktreePath, resolvedBaseRef, options)
|
||||
if (baseOidResult.ok) {
|
||||
baseOid = baseOidResult.oid
|
||||
summary.baseOid = baseOid
|
||||
} catch {
|
||||
} else {
|
||||
summary.status = 'invalid-base'
|
||||
summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.`
|
||||
return { summary, entries: [] }
|
||||
|
||||
@@ -4,11 +4,17 @@ type GitExecOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
export async function hasWorktreeBaseCommitRef(
|
||||
/**
|
||||
* Returns the probed commit oid, or null when the ref does not resolve.
|
||||
*
|
||||
* Why expose the oid: the probe already prints it, and callers that then need the
|
||||
* same ref's oid were re-spawning `rev-parse` for a value this call threw away.
|
||||
*/
|
||||
export async function resolveWorktreeBaseCommitOid(
|
||||
repoPath: string,
|
||||
qualifiedRef: string,
|
||||
options: GitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
|
||||
@@ -17,8 +23,17 @@ export async function hasWorktreeBaseCommitRef(
|
||||
...options
|
||||
}
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
const oid = stdout.trim()
|
||||
return oid.length > 0 ? oid : null
|
||||
} catch {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasWorktreeBaseCommitRef(
|
||||
repoPath: string,
|
||||
qualifiedRef: string,
|
||||
options: GitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
|
||||
}
|
||||
|
||||
@@ -50,6 +50,56 @@ describe('dashboard payload validation', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts repo icons a pop-out can safely render, and rejects the rest', () => {
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
repoIconsByRepoId: {
|
||||
'repo-1': { type: 'lucide', name: 'Rocket' },
|
||||
'repo-2': null,
|
||||
'repo-3': {
|
||||
type: 'image',
|
||||
src: 'https://github.com/anthropics.png?size=64',
|
||||
source: 'github'
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe(true)
|
||||
// Absent entirely: a pop-out on older code still gets its snapshot.
|
||||
expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: undefined })).toBe(true)
|
||||
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
repoIconsByRepoId: {
|
||||
'repo-1': { type: 'image', src: 'javascript:alert(1)', source: 'file' }
|
||||
}
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
repoIconsByRepoId: { 'repo-1': { type: 'nonsense' } }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(isDashboardSnapshot({ ...SNAPSHOT, repoIconsByRepoId: [] })).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds the conversation name', () => {
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
cards: [{ ...SNAPSHOT.cards[0], conversationName: 'Sparse-checkout parser' }]
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDashboardSnapshot({
|
||||
...SNAPSHOT,
|
||||
cards: [{ ...SNAPSHOT.cards[0], conversationName: 'x'.repeat(1_025) }]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires complete bounded reveal routing', () => {
|
||||
expect(
|
||||
isDashboardRevealAgentArgs({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DashboardRevealAgentArgs, DashboardSnapshot } from '../../shared/dashboard-snapshot'
|
||||
import { sanitizeRepoIcon } from '../../shared/repo-icon'
|
||||
import {
|
||||
AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
|
||||
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from '../../shared/agent-status-types'
|
||||
|
||||
const MAX_DASHBOARD_CARDS = 1_000
|
||||
const MAX_DASHBOARD_REPO_ICONS = 500
|
||||
const MAX_ID_LENGTH = 4_096
|
||||
const MAX_LABEL_LENGTH = 1_024
|
||||
const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'idle'])
|
||||
@@ -50,7 +52,28 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot
|
||||
isFiniteNumber(snapshot.generatedAt) &&
|
||||
Array.isArray(snapshot.cards) &&
|
||||
snapshot.cards.length <= MAX_DASHBOARD_CARDS &&
|
||||
snapshot.cards.every(isDashboardCard)
|
||||
snapshot.cards.every(isDashboardCard) &&
|
||||
isDashboardRepoIcons(snapshot.repoIconsByRepoId)
|
||||
)
|
||||
}
|
||||
|
||||
/** Repo icons reach the pop-out's `<img src>`, so each one must survive the
|
||||
* same sanitizer the settings picker writes through. */
|
||||
function isDashboardRepoIcons(value: unknown): boolean {
|
||||
if (value === undefined) {
|
||||
return true
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
return (
|
||||
entries.length <= MAX_DASHBOARD_REPO_ICONS &&
|
||||
entries.every(
|
||||
([repoId, icon]) =>
|
||||
isBoundedString(repoId, MAX_ID_LENGTH) &&
|
||||
(icon === null || sanitizeRepoIcon(icon) !== undefined)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,6 +103,7 @@ function isDashboardCard(value: unknown): boolean {
|
||||
(card.finishedAt === null || isFiniteNumber(card.finishedAt)) &&
|
||||
isFiniteNumber(card.stateChangedAt) &&
|
||||
typeof card.unseen === 'boolean' &&
|
||||
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH)
|
||||
isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) &&
|
||||
isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Mode2031ReplyScanState } from '../../shared/terminal-color-scheme-protocol'
|
||||
|
||||
export type PendingPtyData = {
|
||||
data: string
|
||||
startSeq?: number
|
||||
@@ -5,6 +7,8 @@ export type PendingPtyData = {
|
||||
transformed?: true
|
||||
containsBackgroundOutput?: boolean
|
||||
droppedOutput?: true
|
||||
droppedMode2031Data?: string
|
||||
droppedMode2031ScanState?: Mode2031ReplyScanState
|
||||
}
|
||||
|
||||
export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked'
|
||||
|
||||
@@ -11006,17 +11006,19 @@ describe('registerPtyHandlers', () => {
|
||||
vi.advanceTimersByTime(1)
|
||||
}
|
||||
|
||||
// Flood past the pending cap WITH an embedded DSR probe — the writing program blocks on the reply (bench DSR timeout).
|
||||
mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`)
|
||||
// While latched, a later probe must also be carved out (bounded).
|
||||
mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`)
|
||||
// Flood past the cap with a DSR probe and a mode-2031 withdrawal split at the chunk edge.
|
||||
mockProc.emitData(
|
||||
`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}\x1b[?2031h prompt \x1b[?20`
|
||||
)
|
||||
// While latched, later queries and the withdrawal continuation must still be carved out.
|
||||
mockProc.emitData(`31l${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`)
|
||||
|
||||
mainWindow.webContents.send.mockClear()
|
||||
ackData(null, { id: spawn.id, charCount: 512 * 1024 })
|
||||
vi.advanceTimersByTime(2)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
|
||||
id: spawn.id,
|
||||
data: '\x1b[6n\x1b[0c',
|
||||
data: '\x1b[6n\x1b[0c\x1b[?2031l',
|
||||
droppedOutput: true
|
||||
})
|
||||
} finally {
|
||||
|
||||
+56
-8
@@ -24,6 +24,11 @@ import type {
|
||||
PtyRendererDeliveryStateReport
|
||||
} from '../../shared/pty-renderer-delivery-health'
|
||||
import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction'
|
||||
import {
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
scanMode2031ReplyDecision,
|
||||
type Mode2031ReplyScanState
|
||||
} from '../../shared/terminal-color-scheme-protocol'
|
||||
import {
|
||||
type PtyMainDeliveryDiagnostics,
|
||||
type PtyPerPtyDeliveryDiagnostics,
|
||||
@@ -2462,6 +2467,29 @@ export function registerPtyHandlers(
|
||||
return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData
|
||||
}
|
||||
|
||||
function scanDroppedMode2031Data(
|
||||
data: string,
|
||||
previous: Mode2031ReplyScanState
|
||||
): { data: string; state: Mode2031ReplyScanState } {
|
||||
const result = scanMode2031ReplyDecision(previous, data)
|
||||
const decisionData =
|
||||
result.decision === 'subscribed'
|
||||
? '\x1b[?2031h'
|
||||
: result.decision === 'unsubscribed'
|
||||
? '\x1b[?2031l'
|
||||
: ''
|
||||
return { data: decisionData, state: result.state }
|
||||
}
|
||||
|
||||
function getDroppedMode2031RendererData(pending: PendingPtyData): string {
|
||||
const state = pending.droppedMode2031ScanState
|
||||
if (!state) {
|
||||
return pending.droppedMode2031Data ?? ''
|
||||
}
|
||||
const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : ''
|
||||
return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail
|
||||
}
|
||||
|
||||
function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData {
|
||||
const capChars = pendingDataCapChars()
|
||||
if (pending.droppedOutput === true || pending.data.length <= capChars) {
|
||||
@@ -2482,10 +2510,13 @@ export function registerPtyHandlers(
|
||||
pendingOverflowMarkedPtys.add(id)
|
||||
}
|
||||
pendingDroppedChars += pending.data.length
|
||||
const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE)
|
||||
// Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along).
|
||||
return {
|
||||
data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS),
|
||||
droppedOutput: true
|
||||
droppedOutput: true,
|
||||
droppedMode2031Data: mode2031.data,
|
||||
droppedMode2031ScanState: mode2031.state
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2501,11 +2532,21 @@ export function registerPtyHandlers(
|
||||
): PendingPtyData {
|
||||
// Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode.
|
||||
if (existing?.droppedOutput === true) {
|
||||
if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) {
|
||||
return existing
|
||||
const mode2031 = scanDroppedMode2031Data(
|
||||
data,
|
||||
existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
)
|
||||
const remainingQueryCapacity = Math.max(
|
||||
0,
|
||||
DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length
|
||||
)
|
||||
const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity)
|
||||
return {
|
||||
...existing,
|
||||
data: existing.data + salvaged,
|
||||
droppedMode2031Data: mode2031.data || existing.droppedMode2031Data,
|
||||
droppedMode2031ScanState: mode2031.state
|
||||
}
|
||||
const salvaged = extractDroppedPtyQueryBytes(data)
|
||||
return salvaged ? { ...existing, data: existing.data + salvaged } : existing
|
||||
}
|
||||
const nextContainsBackgroundOutput =
|
||||
existing?.containsBackgroundOutput === true || containsBackgroundOutput
|
||||
@@ -2623,7 +2664,13 @@ export function registerPtyHandlers(
|
||||
pendingData.remove(selection)
|
||||
updateProducerFlowControl(id)
|
||||
// Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only).
|
||||
if (!sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true })) {
|
||||
if (
|
||||
!sendPtyDataToRenderer(id, {
|
||||
id,
|
||||
data: pending.data + getDroppedMode2031RendererData(pending),
|
||||
droppedOutput: true
|
||||
})
|
||||
) {
|
||||
sendFailed = true
|
||||
break
|
||||
}
|
||||
@@ -2849,7 +2896,8 @@ export function registerPtyHandlers(
|
||||
runtime?.setPtyTransientFactDelegation(
|
||||
payload.id,
|
||||
payload.background,
|
||||
payload.scanSeedAnsi
|
||||
payload.scanSeedAnsi,
|
||||
payload.mode2031PendingSubscribe
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -2930,7 +2978,7 @@ export function registerPtyHandlers(
|
||||
pending.droppedOutput === true &&
|
||||
!overflowMarkedBeforeAppend &&
|
||||
pendingOverflowMarkedPtys.has(payload.id)
|
||||
const nextData = pending.data
|
||||
const nextData = pending.data + getDroppedMode2031RendererData(pending)
|
||||
const isInteractiveOutput = shouldSendInteractiveOutputNow(
|
||||
payload.id,
|
||||
nextData,
|
||||
|
||||
@@ -15,8 +15,15 @@ export type PtyTransientFact =
|
||||
| { kind: 'command-finished'; exitCode: number | null }
|
||||
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
|
||||
| { kind: '2031-subscribe' }
|
||||
| { kind: '2031-unsubscribe' }
|
||||
|
||||
export type PtyBackgroundStreamEvent =
|
||||
| { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string }
|
||||
| {
|
||||
id: string
|
||||
kind: 'backgroundMarker'
|
||||
background: boolean
|
||||
scanSeedAnsi?: string
|
||||
mode2031PendingSubscribe?: true
|
||||
}
|
||||
| { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number }
|
||||
| { id: string; kind: 'transientFact'; fact: PtyTransientFact }
|
||||
|
||||
@@ -102,6 +102,51 @@ describe('MobileNotificationReplayBuffer', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the retained buffer when the watermark came from a previous desktop run', () => {
|
||||
// The #8591 defect: `seq` is per-process and restarts at 0 every launch, but the
|
||||
// client's watermark is persisted. A client holding seq 57 meets a counter at 2,
|
||||
// `57 >= 2` cuts everything, and catch-up stays dead until the new process
|
||||
// dispatches 57 notifications. The epoch is what makes the two distinguishable.
|
||||
const previousRun = new MobileNotificationReplayBuffer()
|
||||
for (let i = 0; i < 57; i++) {
|
||||
dispatch(previousRun, { notificationId: `old:${i}` })
|
||||
}
|
||||
const staleWatermark = 57
|
||||
const staleEpoch = previousRun.epoch
|
||||
|
||||
const afterRestart = new MobileNotificationReplayBuffer()
|
||||
dispatch(afterRestart, { notificationId: 'agent:one' })
|
||||
dispatch(afterRestart, { notificationId: 'agent:two' })
|
||||
|
||||
// Seq-only (the old behaviour) still silently drops everything...
|
||||
expect(afterRestart.getMissedSince(staleWatermark)).toEqual([])
|
||||
// ...but a watermark tagged with the old epoch is recognised as void.
|
||||
expect(
|
||||
afterRestart.getMissedSince(staleWatermark, staleEpoch).map((e) => e.notificationId)
|
||||
).toEqual(['agent:one', 'agent:two'])
|
||||
})
|
||||
|
||||
it('keeps the exact seq cut when the epoch matches the live counter', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
const first = dispatch(buffer, { notificationId: 'agent:one' })
|
||||
dispatch(buffer, { notificationId: 'agent:two' })
|
||||
|
||||
// Same counter: the epoch must not widen the cut into a re-push of delivered events.
|
||||
expect(
|
||||
buffer.getMissedSince(first.notificationSeq, buffer.epoch).map((e) => e.notificationId)
|
||||
).toEqual(['agent:two'])
|
||||
expect(buffer.getMissedSince(2, buffer.epoch)).toEqual([])
|
||||
})
|
||||
|
||||
it('stamps every recorded event with the buffer epoch, and epochs differ per process', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
const event = dispatch(buffer, { notificationId: 'agent:one' })
|
||||
|
||||
expect(event.notificationEpoch).toBe(buffer.epoch)
|
||||
// A restart must produce a different epoch or the watermark stays ambiguous.
|
||||
expect(new MobileNotificationReplayBuffer().epoch).not.toBe(buffer.epoch)
|
||||
})
|
||||
|
||||
it('evicts oldest entries once the capacity is exceeded', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer(2)
|
||||
dispatch(buffer, { notificationId: 'agent:one' })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { MobileNotificationEvent } from './orca-runtime'
|
||||
|
||||
// Why: when a mobile client's socket is reaped (background/sleep, or a warm
|
||||
@@ -22,6 +23,9 @@ import type { MobileNotificationEvent } from './orca-runtime'
|
||||
// single field name end-to-end is what makes live and replay interchangeable.
|
||||
export type ReplayableMobileNotification = MobileNotificationEvent & {
|
||||
notificationSeq: number
|
||||
// The counter lifetime `notificationSeq` belongs to. Lets a client tell "seq 3
|
||||
// is older than my watermark" from "seq 3 came from a counter I've never seen".
|
||||
notificationEpoch: string
|
||||
}
|
||||
|
||||
// Why: bound the buffer. 256 completes a few minutes of real agent activity and
|
||||
@@ -35,17 +39,28 @@ export class MobileNotificationReplayBuffer {
|
||||
private readonly capacity: number
|
||||
private seq = 0
|
||||
private readonly buffer: ReplayableMobileNotification[] = []
|
||||
// Why: `seq` restarts at 0 every desktop launch, but the client's watermark is
|
||||
// persisted and monotonic. After a desktop restart a client holding seq 57 meets
|
||||
// a counter at 3, `57 >= 3` cuts everything, and catch-up stays silently dead
|
||||
// until the new process dispatches 57 notifications. The epoch names the counter's
|
||||
// lifetime so both sides can tell "nothing missed" from "different counter".
|
||||
private readonly epochId: string = randomUUID()
|
||||
|
||||
constructor(capacity: number = DEFAULT_CAPACITY) {
|
||||
this.capacity = capacity
|
||||
}
|
||||
|
||||
// Identifies this counter's lifetime. Changes on every desktop restart.
|
||||
get epoch(): string {
|
||||
return this.epochId
|
||||
}
|
||||
|
||||
// Records a dispatched event and returns the monotonic seq assigned to it.
|
||||
// Callers surface the seq so clients can watermark their last-seen position
|
||||
// (both on the live fan-out and on explicit catch-up requests).
|
||||
record(event: MobileNotificationEvent): number {
|
||||
const seq = ++this.seq
|
||||
this.buffer.push({ ...event, notificationSeq: seq })
|
||||
this.buffer.push({ ...event, notificationSeq: seq, notificationEpoch: this.epochId })
|
||||
if (this.buffer.length > this.capacity) {
|
||||
// Why: insertion-order array; oldest entries sit at the front.
|
||||
this.buffer.splice(0, this.buffer.length - this.capacity)
|
||||
@@ -56,7 +71,16 @@ export class MobileNotificationReplayBuffer {
|
||||
// Returns every recorded event with seq strictly greater than lastSeenSeq.
|
||||
// Because seq is monotonic and global, this is an exact, idempotent cut:
|
||||
// the same lastSeenSeq always yields the same result.
|
||||
getMissedSince(lastSeenSeq: number): ReplayableMobileNotification[] {
|
||||
//
|
||||
// `epoch` is the counter lifetime the caller's watermark came from. A mismatch
|
||||
// means the watermark indexes a counter this process no longer has, so it is
|
||||
// meaningless here and the whole retained buffer is returned instead — every
|
||||
// entry in it postdates the restart, so none can have reached that client.
|
||||
// Omitting `epoch` keeps the seq-only cut, for clients that predate the field.
|
||||
getMissedSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] {
|
||||
if (epoch !== undefined && epoch !== this.epochId) {
|
||||
return [...this.buffer]
|
||||
}
|
||||
if (lastSeenSeq >= this.seq) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -8357,6 +8357,17 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }])
|
||||
})
|
||||
|
||||
it('restores a provisional 2031 subscribe when daemon scan authority returns', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.setPtyTransientFactDelegation('pty-1', true)
|
||||
runtime.setPtyTransientFactDelegation('pty-1', false, '\x1b[?', true)
|
||||
runtime.onPtyData('pty-1', '25h', 100)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }])
|
||||
})
|
||||
|
||||
it('prefers the tracked title over the renderer snapshot lastTitle', async () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
const serializeBuffer = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -2387,6 +2387,7 @@ export type MobileNotificationDispatchEvent = {
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
notificationSeq?: number
|
||||
notificationEpoch?: string
|
||||
}
|
||||
|
||||
export type RuntimeWorktreeLifecycleEvent =
|
||||
@@ -2397,6 +2398,7 @@ export type MobileNotificationDismissEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
notificationEpoch?: string
|
||||
}
|
||||
|
||||
export type MobileNotificationEvent =
|
||||
@@ -8075,7 +8077,12 @@ export class OrcaRuntimeService {
|
||||
* facts itself and the delivered bytes may be gapped — feeding them to
|
||||
* main's transient scanners would mint phantom or duplicate facts. Title
|
||||
* processing stays main-side either way. */
|
||||
setPtyTransientFactDelegation(ptyId: string, delegated: boolean, scanSeedAnsi?: string): void {
|
||||
setPtyTransientFactDelegation(
|
||||
ptyId: string,
|
||||
delegated: boolean,
|
||||
scanSeedAnsi?: string,
|
||||
mode2031PendingSubscribe?: true
|
||||
): void {
|
||||
const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId)
|
||||
entry.tracker.setTransientFactScanningSuppressed(delegated)
|
||||
if (!delegated && scanSeedAnsi) {
|
||||
@@ -8083,7 +8090,10 @@ export class OrcaRuntimeService {
|
||||
// incomplete escape at the handoff position — a sequence split across
|
||||
// the un-background toggle must not mint a phantom bell or lose its
|
||||
// fact. titleScanData:'' keeps titles out (they were never suppressed).
|
||||
entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '' })
|
||||
entry.tracker.handleChunk(scanSeedAnsi, {
|
||||
titleScanData: '',
|
||||
mode2031PendingSubscribe
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8106,6 +8116,9 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
case '2031-subscribe':
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
|
||||
return
|
||||
case '2031-unsubscribe':
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8353,6 +8366,12 @@ export class OrcaRuntimeService {
|
||||
// still sent by the renderer (query authority stays with the view).
|
||||
onMode2031Subscribe: () => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' })
|
||||
},
|
||||
// Why: the gated view never sees the withdrawal bytes either, so the
|
||||
// subscription registry it keeps for theme flips needs this fact to
|
||||
// stay truthful.
|
||||
onMode2031Unsubscribe: () => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' })
|
||||
}
|
||||
}
|
||||
: {})
|
||||
@@ -10203,7 +10222,12 @@ export class OrcaRuntimeService {
|
||||
// delivered and feed it back to getMissedSince on reconnect (idempotent catch-up, no dupes).
|
||||
notifyRuntimeListeners(
|
||||
this.notificationListeners,
|
||||
(listener) => listener({ ...event, notificationSeq: seq }),
|
||||
(listener) =>
|
||||
listener({
|
||||
...event,
|
||||
notificationSeq: seq,
|
||||
notificationEpoch: this.mobileNotificationReplay.epoch
|
||||
}),
|
||||
'mobile-notification'
|
||||
)
|
||||
}
|
||||
@@ -10211,8 +10235,15 @@ export class OrcaRuntimeService {
|
||||
// Returns notifications dispatched after lastSeenSeq. Idempotent: the same
|
||||
// watermark always yields the same set, so a client cannot be re-pushed an
|
||||
// already-delivered event (the adversarial-review gate for #8129).
|
||||
getMissedNotificationsSince(lastSeenSeq: number): ReplayableMobileNotification[] {
|
||||
return this.mobileNotificationReplay.getMissedSince(lastSeenSeq)
|
||||
getMissedNotificationsSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] {
|
||||
return this.mobileNotificationReplay.getMissedSince(lastSeenSeq, epoch)
|
||||
}
|
||||
|
||||
// Why (#8591): the seq counter is per-process and restarts at 0 on every desktop
|
||||
// launch, but the client's watermark is persisted. Clients need the epoch to tell
|
||||
// a stale watermark from a valid one — see MobileNotificationReplayBuffer.
|
||||
getMobileNotificationEpoch(): string {
|
||||
return this.mobileNotificationReplay.epoch
|
||||
}
|
||||
|
||||
dismissMobileNotification(notificationId: string): void {
|
||||
|
||||
@@ -20,8 +20,13 @@ const NotificationUnsubscribeParams = z.object({
|
||||
// exact and idempotent — re-requesting with the same watermark can never
|
||||
// return an already-delivered event, so reconnects never duplicate local
|
||||
// pushes (the adversarial-review gate for #8129).
|
||||
// `epoch` names the counter lifetime lastSeenSeq came from (#8591). The desktop's
|
||||
// seq restarts at 0 on every launch while the client's watermark is persisted, so
|
||||
// without it a post-restart watermark silently cuts away everything. Optional: a
|
||||
// client that predates the field keeps the seq-only cut.
|
||||
const NotificationGetMissedSinceParams = z.object({
|
||||
lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer')
|
||||
lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer'),
|
||||
epoch: z.string().optional()
|
||||
})
|
||||
|
||||
// Why: notifications.subscribe streams desktop notification events to mobile
|
||||
@@ -52,7 +57,9 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
|
||||
connectionId
|
||||
)
|
||||
|
||||
emit({ type: 'ready', subscriptionId })
|
||||
// Why: the epoch rides the ready frame so a reconnecting client learns the
|
||||
// counter lifetime BEFORE it sends its watermark to getMissedSince (#8591).
|
||||
emit({ type: 'ready', subscriptionId, epoch: runtime.getMobileNotificationEpoch() })
|
||||
})
|
||||
}
|
||||
}),
|
||||
@@ -71,8 +78,8 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
|
||||
// the monotonic seq, so this is the single source of truth for what the
|
||||
// client missed while its socket was reaped.
|
||||
handler: async (params, { runtime }) => {
|
||||
const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq)
|
||||
return { notifications: missed }
|
||||
const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq, params.epoch)
|
||||
return { notifications: missed, epoch: runtime.getMobileNotificationEpoch() }
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
encodeTerminalStreamText,
|
||||
type TerminalStreamFrame
|
||||
} from '../../../../shared/terminal-stream-protocol'
|
||||
import {
|
||||
iterateTerminalOutputFrameChunks,
|
||||
type TerminalOutputFrameChunk,
|
||||
type TerminalOutputMeta
|
||||
} from '../terminal-output-frame-chunks'
|
||||
import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry'
|
||||
import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges'
|
||||
import {
|
||||
@@ -24,7 +29,11 @@ import {
|
||||
TERMINAL_INPUT_TOO_LARGE_ERROR,
|
||||
isTerminalInputTooLargeWithYield
|
||||
} from '../../../../shared/terminal-input'
|
||||
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
|
||||
import {
|
||||
measureTerminalStreamByteLength,
|
||||
terminalStreamByteLength,
|
||||
terminalStreamByteLengthExceeds
|
||||
} from '../terminal-stream-byte-length'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
|
||||
import {
|
||||
@@ -49,8 +58,7 @@ import {
|
||||
TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES,
|
||||
TERMINAL_MULTIPLEX_MAX_STREAMS_PER_CONNECTION,
|
||||
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES,
|
||||
TERMINAL_OUTPUT_BATCH_MAX_BYTES,
|
||||
TERMINAL_STREAM_CHUNK_BYTES
|
||||
TERMINAL_OUTPUT_BATCH_MAX_BYTES
|
||||
} from '../../../../shared/terminal-multiplex-flow-control'
|
||||
import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin'
|
||||
|
||||
@@ -139,19 +147,6 @@ type TerminalOutputChunk = {
|
||||
meta?: TerminalOutputMeta
|
||||
}
|
||||
|
||||
type TerminalOutputMeta = {
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
transformed?: boolean
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type TerminalOutputFrameChunk = {
|
||||
bytes: Uint8Array<ArrayBufferLike>
|
||||
seq?: number
|
||||
opcode?: TerminalStreamOpcode
|
||||
}
|
||||
|
||||
function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): {
|
||||
push: (data: string, meta?: TerminalOutputMeta) => void
|
||||
flush: () => void
|
||||
@@ -240,82 +235,6 @@ function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutp
|
||||
}
|
||||
}
|
||||
|
||||
function* iterateTerminalOutputFrameChunks(
|
||||
data: string,
|
||||
meta?: TerminalOutputMeta
|
||||
): Generator<TerminalOutputFrameChunk> {
|
||||
const rawLength = meta?.rawLength ?? data.length
|
||||
if (meta?.transformed || rawLength !== data.length) {
|
||||
yield {
|
||||
opcode: TerminalStreamOpcode.OutputSpan,
|
||||
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
|
||||
seq: meta?.seq
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!terminalStreamByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) {
|
||||
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
|
||||
return
|
||||
}
|
||||
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
|
||||
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
|
||||
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
|
||||
let chunk = ''
|
||||
let chunkBytes = 0
|
||||
let chunkStartOffset = 0
|
||||
let offset = 0
|
||||
let delayedChunk: { text: string; seq?: number } | null = null
|
||||
|
||||
const takeChunk = (): { text: string; seq?: number } | null => {
|
||||
if (!chunk) {
|
||||
return null
|
||||
}
|
||||
const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined
|
||||
const current = { text: chunk, seq: chunkSeq }
|
||||
chunk = ''
|
||||
chunkBytes = 0
|
||||
chunkStartOffset = offset
|
||||
return current
|
||||
}
|
||||
|
||||
for (const part of data) {
|
||||
const partBytes = terminalStreamByteLength(part)
|
||||
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
|
||||
const nextChunk = takeChunk()
|
||||
if (nextChunk) {
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = nextChunk
|
||||
} else {
|
||||
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk += part
|
||||
chunkBytes += partBytes
|
||||
offset += part.length
|
||||
}
|
||||
const finalChunk = takeChunk()
|
||||
if (shouldDelayFinalSeq) {
|
||||
// Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets.
|
||||
if (finalChunk) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = finalChunk
|
||||
}
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (finalChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalInputLockedForClient(
|
||||
runtime: OrcaRuntimeService,
|
||||
ptyId: string,
|
||||
@@ -541,13 +460,6 @@ function trimPendingOutputToBudget(
|
||||
return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 }
|
||||
}
|
||||
|
||||
function measureTerminalStreamByteLength(
|
||||
data: string,
|
||||
options: { stopAfterBytes?: number } = {}
|
||||
): { byteLength: number; exceededLimit: boolean } {
|
||||
return measureClipboardTextByteLength(data, options)
|
||||
}
|
||||
|
||||
function trimPendingOutputCoveredBySnapshot(
|
||||
pendingOutput: TerminalOutputChunk[],
|
||||
snapshotSeq: number | undefined
|
||||
@@ -585,14 +497,6 @@ function trimPendingOutputCoveredBySnapshot(
|
||||
return { chunks, bytes }
|
||||
}
|
||||
|
||||
function terminalStreamByteLength(data: string): number {
|
||||
return measureTerminalStreamByteLength(data).byteLength
|
||||
}
|
||||
|
||||
function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean {
|
||||
return measureTerminalStreamByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
|
||||
}
|
||||
|
||||
function* iterateTerminalStreamTextPayloads(data: string): Generator<Uint8Array<ArrayBufferLike>> {
|
||||
if (!data) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control'
|
||||
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
|
||||
import {
|
||||
iterateTerminalOutputFrameChunks,
|
||||
type TerminalOutputFrameChunk,
|
||||
type TerminalOutputMeta
|
||||
} from './terminal-output-frame-chunks'
|
||||
|
||||
// Byte-for-byte reference: the pre-optimization implementation, copied verbatim.
|
||||
// It accumulates `chunk += part` over `for (const part of data)` and measures each
|
||||
// code point through the shared clipboard measurer.
|
||||
function legacyByteLength(data: string): number {
|
||||
return measureClipboardTextByteLength(data).byteLength
|
||||
}
|
||||
|
||||
function legacyByteLengthExceeds(data: string, maxBytes: number): boolean {
|
||||
return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
|
||||
}
|
||||
|
||||
function* legacyIterateTerminalOutputFrameChunks(
|
||||
data: string,
|
||||
meta?: TerminalOutputMeta
|
||||
): Generator<TerminalOutputFrameChunk> {
|
||||
const rawLength = meta?.rawLength ?? data.length
|
||||
if (meta?.transformed || rawLength !== data.length) {
|
||||
yield {
|
||||
opcode: TerminalStreamOpcode.OutputSpan,
|
||||
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
|
||||
seq: meta?.seq
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES)) {
|
||||
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
|
||||
return
|
||||
}
|
||||
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
|
||||
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
|
||||
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
|
||||
let chunk = ''
|
||||
let chunkBytes = 0
|
||||
let chunkStartOffset = 0
|
||||
let offset = 0
|
||||
let delayedChunk: { text: string; seq?: number } | null = null
|
||||
|
||||
const takeChunk = (): { text: string; seq?: number } | null => {
|
||||
if (!chunk) {
|
||||
return null
|
||||
}
|
||||
const chunkSeq = canPreserveChunkSeq ? startSeq! + chunkStartOffset + chunk.length : undefined
|
||||
const current = { text: chunk, seq: chunkSeq }
|
||||
chunk = ''
|
||||
chunkBytes = 0
|
||||
chunkStartOffset = offset
|
||||
return current
|
||||
}
|
||||
|
||||
for (const part of data) {
|
||||
const partBytes = legacyByteLength(part)
|
||||
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
|
||||
const nextChunk = takeChunk()
|
||||
if (nextChunk) {
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = nextChunk
|
||||
} else {
|
||||
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
|
||||
}
|
||||
}
|
||||
}
|
||||
chunk += part
|
||||
chunkBytes += partBytes
|
||||
offset += part.length
|
||||
}
|
||||
const finalChunk = takeChunk()
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (finalChunk) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = finalChunk
|
||||
}
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text), seq: meta.seq }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (finalChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
|
||||
}
|
||||
}
|
||||
|
||||
type FrameShape = { base64: string; seq: number | 'undefined'; opcode: number | 'undefined' }
|
||||
|
||||
function describeFrames(frames: Iterable<TerminalOutputFrameChunk>): FrameShape[] {
|
||||
const out: FrameShape[] = []
|
||||
for (const frame of frames) {
|
||||
out.push({
|
||||
base64: Buffer.from(frame.bytes).toString('base64'),
|
||||
seq: frame.seq ?? 'undefined',
|
||||
opcode: frame.opcode ?? 'undefined'
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function expectEquivalent(data: string, meta: TerminalOutputMeta | undefined, label: string): void {
|
||||
const legacy = describeFrames(legacyIterateTerminalOutputFrameChunks(data, meta))
|
||||
const next = describeFrames(iterateTerminalOutputFrameChunks(data, meta))
|
||||
expect(next, label).toEqual(legacy)
|
||||
}
|
||||
|
||||
const SURROGATE_PAIR = '\u{1f600}'
|
||||
const LONE_HIGH = '\ud83d'
|
||||
const LONE_LOW = '\ude00'
|
||||
// Extremes of both surrogate ranges: U+10000 (D800 DC00) and U+10FFFF (DBFF DFFF).
|
||||
const FIRST_ASTRAL = '\u{10000}'
|
||||
const LAST_ASTRAL = '\u{10ffff}'
|
||||
const SURROGATE_EDGES = [
|
||||
'\ud800',
|
||||
'\udbff',
|
||||
'\udc00',
|
||||
'\udfff',
|
||||
FIRST_ASTRAL,
|
||||
LAST_ASTRAL,
|
||||
'\udbff\udbff',
|
||||
'\ud800\udbff',
|
||||
'\udfff\udc00'
|
||||
]
|
||||
|
||||
// Meta shapes exercised against every fixture: no meta, seq-preserved (rawLength ===
|
||||
// data.length), the delayed-final-seq path (rawLength !== data.length -> OutputSpan),
|
||||
// transformed, and cwd-only.
|
||||
function metaShapesFor(data: string): { label: string; meta: TerminalOutputMeta | undefined }[] {
|
||||
return [
|
||||
{ label: 'no-meta', meta: undefined },
|
||||
{ label: 'seq-only', meta: { seq: 5_000_000 } },
|
||||
{ label: 'seq+rawLength=len', meta: { seq: 9_000, rawLength: data.length } },
|
||||
{ label: 'seq+rawLength!=len', meta: { seq: 9_000, rawLength: data.length + 7 } },
|
||||
{ label: 'rawLength!=len only', meta: { rawLength: data.length + 3 } },
|
||||
{ label: 'transformed', meta: { seq: 42, rawLength: data.length, transformed: true } },
|
||||
{ label: 'cwd-only', meta: { cwd: '/home/dev/orca' } },
|
||||
{ label: 'seq=0', meta: { seq: 0, rawLength: data.length } }
|
||||
]
|
||||
}
|
||||
|
||||
function sweepAll(data: string, label: string): void {
|
||||
for (const shape of metaShapesFor(data)) {
|
||||
expectEquivalent(data, shape.meta, `${label} [${shape.label}]`)
|
||||
}
|
||||
}
|
||||
|
||||
// A fixture whose seq path is only observable when rawLength maps 1:1 to UTF-16
|
||||
// offsets; forcing that shape is what makes the algebraic collapse testable.
|
||||
function seqPreservingMeta(data: string, seq: number): TerminalOutputMeta {
|
||||
return { seq, rawLength: data.length }
|
||||
}
|
||||
|
||||
function escapeUnits(value: string): string {
|
||||
const units: string[] = []
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
units.push(`U+${value.charCodeAt(index).toString(16).toUpperCase()}`)
|
||||
}
|
||||
return units.join(' ')
|
||||
}
|
||||
|
||||
function repeatToLength(unit: string, codeUnits: number): string {
|
||||
let out = ''
|
||||
while (out.length < codeUnits) {
|
||||
out += unit
|
||||
}
|
||||
return out.slice(0, out.length - (out.length % unit.length))
|
||||
}
|
||||
|
||||
// Deterministic PRNG so a fuzz failure is reproducible from the seed alone.
|
||||
function makeRandom(seed: number): () => number {
|
||||
let state = seed >>> 0 || 1
|
||||
return () => {
|
||||
state ^= state << 13
|
||||
state >>>= 0
|
||||
state ^= state >>> 17
|
||||
state ^= state << 5
|
||||
state >>>= 0
|
||||
return state / 0x1_0000_0000
|
||||
}
|
||||
}
|
||||
|
||||
const FUZZ_ALPHABET = [
|
||||
'a',
|
||||
'z',
|
||||
'\r',
|
||||
'\n',
|
||||
'\u00e9',
|
||||
'\u20ac',
|
||||
SURROGATE_PAIR,
|
||||
LONE_HIGH,
|
||||
LONE_LOW,
|
||||
'\u0000',
|
||||
'\u001b'
|
||||
]
|
||||
|
||||
function randomText(random: () => number, parts: number): string {
|
||||
let out = ''
|
||||
for (let index = 0; index < parts; index += 1) {
|
||||
out += FUZZ_ALPHABET[Math.floor(random() * FUZZ_ALPHABET.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => {
|
||||
it('matches on the small/no-chunking sizes', () => {
|
||||
for (const size of [0, 1, 2, 3, 7, 64, 1024, TERMINAL_STREAM_CHUNK_BYTES - 1]) {
|
||||
sweepAll('a'.repeat(size), `ascii ${size}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches across 1B..200KiB ASCII payloads', () => {
|
||||
for (const size of [
|
||||
1,
|
||||
100,
|
||||
TERMINAL_STREAM_CHUNK_BYTES,
|
||||
TERMINAL_STREAM_CHUNK_BYTES + 1,
|
||||
TERMINAL_STREAM_CHUNK_BYTES * 2,
|
||||
TERMINAL_STREAM_CHUNK_BYTES * 3 + 17,
|
||||
100 * 1024,
|
||||
200 * 1024
|
||||
]) {
|
||||
sweepAll('x'.repeat(size), `ascii ${size}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves legacy addition rounding for unsafe sequence values', () => {
|
||||
const data = 'x'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 1)
|
||||
const seq = 9_007_199_254_740_994
|
||||
const meta = seqPreservingMeta(data, seq)
|
||||
|
||||
expectEquivalent(data, meta, 'unsafe seq rounding')
|
||||
const frames = [...iterateTerminalOutputFrameChunks(data, meta)]
|
||||
expect(frames.at(-1)?.seq).toBe(9_007_199_254_740_992)
|
||||
})
|
||||
|
||||
it('matches on multi-byte-only payloads at every UTF-8 width', () => {
|
||||
// UTF-8 width boundaries: 1|2 at U+0080, 2|3 at U+0800, 3|4 at U+10000.
|
||||
for (const unit of [
|
||||
'\u00e9',
|
||||
'\u20ac',
|
||||
SURROGATE_PAIR,
|
||||
'\u0080',
|
||||
'\u07ff',
|
||||
'\u0800',
|
||||
'\uffff'
|
||||
]) {
|
||||
for (const codeUnits of [
|
||||
TERMINAL_STREAM_CHUNK_BYTES / 2,
|
||||
TERMINAL_STREAM_CHUNK_BYTES,
|
||||
TERMINAL_STREAM_CHUNK_BYTES + 64,
|
||||
90 * 1024
|
||||
]) {
|
||||
const data = repeatToLength(unit, codeUnits)
|
||||
sweepAll(data, `unit=${JSON.stringify(unit)} codeUnits=${codeUnits}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches with lone surrogates, including a trailing lone high surrogate', () => {
|
||||
const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5)
|
||||
for (const data of [
|
||||
LONE_HIGH,
|
||||
LONE_LOW,
|
||||
LONE_HIGH + LONE_HIGH,
|
||||
LONE_LOW + LONE_HIGH,
|
||||
filler + LONE_HIGH,
|
||||
filler + LONE_LOW,
|
||||
LONE_HIGH + filler,
|
||||
LONE_LOW + filler,
|
||||
filler + LONE_HIGH + filler,
|
||||
// Reversed pair: never a valid pair, so both halves must stay 3-byte replacements.
|
||||
filler + LONE_LOW + LONE_HIGH + filler,
|
||||
repeatToLength(LONE_HIGH, 60 * 1024),
|
||||
repeatToLength(LONE_LOW + LONE_HIGH, 60 * 1024)
|
||||
]) {
|
||||
sweepAll(data, `lone-surrogate len=${data.length}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches at both ends of both surrogate ranges (U+D800..U+DBFF, U+DC00..U+DFFF)', () => {
|
||||
const filler = 'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + 5)
|
||||
for (const edge of SURROGATE_EDGES) {
|
||||
for (const data of [
|
||||
edge,
|
||||
filler + edge,
|
||||
edge + filler,
|
||||
filler + edge + filler,
|
||||
repeatToLength(edge, 40 * 1024)
|
||||
]) {
|
||||
sweepAll(data, `surrogate-edge ${escapeUnits(edge)} len=${data.length}`)
|
||||
}
|
||||
// Land the split inside the edge sequence itself.
|
||||
for (let offset = -4; offset <= 4; offset += 1) {
|
||||
const data = `${'a'.repeat(TERMINAL_STREAM_CHUNK_BYTES + offset)}${edge}${'b'.repeat(8)}`
|
||||
expectEquivalent(data, undefined, `surrogate-edge ${escapeUnits(edge)} offset=${offset}`)
|
||||
expectEquivalent(
|
||||
data,
|
||||
seqPreservingMeta(data, 500_000 + data.length),
|
||||
`surrogate-edge ${escapeUnits(edge)} offset=${offset} seq`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sweeps a chunk boundary across a surrogate pair at CHUNK-4..CHUNK+4', () => {
|
||||
for (let offset = -4; offset <= 4; offset += 1) {
|
||||
const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset
|
||||
const data = `${'a'.repeat(prefixBytes)}${SURROGATE_PAIR}${'b'.repeat(64)}`
|
||||
sweepAll(data, `pair boundary offset=${offset}`)
|
||||
expectEquivalent(
|
||||
data,
|
||||
seqPreservingMeta(data, 1_000_000 + data.length),
|
||||
`pair boundary offset=${offset} seq-preserved`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('sweeps a chunk boundary across a multi-byte run at CHUNK-4..CHUNK+4', () => {
|
||||
for (let offset = -4; offset <= 4; offset += 1) {
|
||||
const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset
|
||||
// 3-byte run straddling the cap: the split point cannot land mid-code-point.
|
||||
const data = `${'a'.repeat(prefixBytes)}${'\u20ac'.repeat(32)}${'b'.repeat(16)}`
|
||||
sweepAll(data, `multibyte boundary offset=${offset}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('sweeps a chunk boundary across a lone surrogate at CHUNK-4..CHUNK+4', () => {
|
||||
for (let offset = -4; offset <= 4; offset += 1) {
|
||||
const prefixBytes = TERMINAL_STREAM_CHUNK_BYTES + offset
|
||||
for (const lone of [LONE_HIGH, LONE_LOW]) {
|
||||
const data = `${'a'.repeat(prefixBytes)}${lone}${'b'.repeat(64)}`
|
||||
sweepAll(data, `lone ${lone === LONE_HIGH ? 'high' : 'low'} boundary offset=${offset}`)
|
||||
}
|
||||
// Lone high surrogate as the very last code unit of the payload.
|
||||
const trailing = `${'a'.repeat(prefixBytes)}${LONE_HIGH}`
|
||||
sweepAll(trailing, `trailing lone high offset=${offset}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('sweeps 2-byte and 4-byte code points across a 1-code-unit window at the cap', () => {
|
||||
for (const unit of ['\u00e9', SURROGATE_PAIR]) {
|
||||
for (
|
||||
let pad = TERMINAL_STREAM_CHUNK_BYTES - 6;
|
||||
pad <= TERMINAL_STREAM_CHUNK_BYTES + 2;
|
||||
pad += 1
|
||||
) {
|
||||
const data = `${'a'.repeat(pad)}${unit.repeat(8)}${'z'.repeat(8)}`
|
||||
expectEquivalent(data, undefined, `window unit=${unit} pad=${pad}`)
|
||||
expectEquivalent(
|
||||
data,
|
||||
seqPreservingMeta(data, 777 + data.length),
|
||||
`window unit=${unit} pad=${pad} seq`
|
||||
)
|
||||
expectEquivalent(
|
||||
data,
|
||||
{ seq: 777, rawLength: data.length + 1 },
|
||||
`window unit=${unit} pad=${pad} delayed`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches on the delayed-final-seq path across many chunk counts', () => {
|
||||
// rawLength !== data.length routes to OutputSpan; force the multi-chunk delayed
|
||||
// path by keeping rawLength === data.length but seq present with transformed=false,
|
||||
// then separately assert the true delayed shape (canPreserveChunkSeq=false).
|
||||
for (const chunkCount of [1, 2, 3, 5, 9]) {
|
||||
const data = `${'m'.repeat(TERMINAL_STREAM_CHUNK_BYTES * chunkCount)}${SURROGATE_PAIR}tail`
|
||||
expectEquivalent(data, { seq: 4242 }, `delayed chunks=${chunkCount} seq-only`)
|
||||
expectEquivalent(data, { seq: 4242, rawLength: 1 }, `delayed chunks=${chunkCount} raw=1`)
|
||||
expectEquivalent(data, undefined, `delayed chunks=${chunkCount} no-meta`)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches on realistic mixed terminal output', () => {
|
||||
const line = '\u001b[35m\u273b Thinking\u001b[0m about the \u20ac plan \u{1f600} 42 passed\r\n'
|
||||
for (const repeats of [1, 200, 2000, 6000]) {
|
||||
const data = line.repeat(repeats)
|
||||
sweepAll(data, `mixed repeats=${repeats}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('fuzzes 4000 short random payloads over the surrogate/control alphabet', () => {
|
||||
const random = makeRandom(0x5eed_1234)
|
||||
for (let trial = 0; trial < 4000; trial += 1) {
|
||||
const data = randomText(random, Math.floor(random() * 40))
|
||||
expectEquivalent(data, undefined, `fuzz-small trial=${trial}`)
|
||||
expectEquivalent(
|
||||
data,
|
||||
{ seq: 31337, rawLength: data.length },
|
||||
`fuzz-small seq trial=${trial}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('fuzzes 800 near-cap payloads whose split point lands in the random region', () => {
|
||||
const random = makeRandom(0x1234_5eed)
|
||||
for (let trial = 0; trial < 800; trial += 1) {
|
||||
const fillerLength = TERMINAL_STREAM_CHUNK_BYTES - 6 + Math.floor(random() * 12)
|
||||
const data = 'q'.repeat(fillerLength) + randomText(random, 1 + Math.floor(random() * 24))
|
||||
expectEquivalent(data, undefined, `fuzz-cap trial=${trial}`)
|
||||
expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`)
|
||||
expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every emitted frame within the wire cap and reassembles to the input', () => {
|
||||
const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}`
|
||||
const frames = [...iterateTerminalOutputFrameChunks(data, seqPreservingMeta(data, 999_999))]
|
||||
expect(frames.length).toBeGreaterThan(4)
|
||||
for (const frame of frames) {
|
||||
expect(frame.bytes.byteLength).toBeLessThanOrEqual(TERMINAL_STREAM_CHUNK_BYTES)
|
||||
}
|
||||
expect(Buffer.concat(frames.map((frame) => Buffer.from(frame.bytes))).toString('utf8')).toBe(
|
||||
Buffer.from(new TextEncoder().encode(data)).toString('utf8')
|
||||
)
|
||||
// Seqs must be strictly increasing and end at the meta high-water mark.
|
||||
const seqs = frames.map((frame) => frame.seq!)
|
||||
expect(seqs.every((seq, index) => index === 0 || seq > seqs[index - 1]!)).toBe(true)
|
||||
expect(seqs.at(-1)).toBe(999_999)
|
||||
})
|
||||
|
||||
it('emits exactly one frame when the payload fits the cap in bytes but not naively', () => {
|
||||
// 3-byte code points: 16384 code units = 49152 bytes = exactly the cap.
|
||||
const exact = '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3)
|
||||
expect(Buffer.byteLength(exact, 'utf8')).toBe(TERMINAL_STREAM_CHUNK_BYTES)
|
||||
expect([...iterateTerminalOutputFrameChunks(exact)]).toHaveLength(1)
|
||||
expect([...legacyIterateTerminalOutputFrameChunks(exact)]).toHaveLength(1)
|
||||
const overByOne = `${exact}a`
|
||||
expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1)
|
||||
expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
// The chunking loop is only reached when rawLength === data.length and transformed
|
||||
// is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and
|
||||
// therefore shouldDelayFinalSeq unconditionally false. The branch survives here
|
||||
// (it also survived in the pre-optimization code) purely as defence in depth.
|
||||
it('never reaches the delayed-final-seq branch for any meta shape', () => {
|
||||
for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) {
|
||||
for (const seq of [undefined, 0, 5] as (number | undefined)[]) {
|
||||
for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) {
|
||||
for (const transformed of [undefined, false, true] as (boolean | undefined)[]) {
|
||||
const meta: TerminalOutputMeta = {}
|
||||
if (seq !== undefined) {
|
||||
meta.seq = seq
|
||||
}
|
||||
if (rawDelta !== undefined) {
|
||||
meta.rawLength = data.length + rawDelta
|
||||
}
|
||||
if (transformed !== undefined) {
|
||||
meta.transformed = transformed
|
||||
}
|
||||
const rawLength = meta.rawLength ?? data.length
|
||||
const reachesChunkLoop = !meta.transformed && rawLength === data.length
|
||||
const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length
|
||||
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number'
|
||||
expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control'
|
||||
|
||||
export type TerminalOutputMeta = {
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
transformed?: boolean
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type TerminalOutputFrameChunk = {
|
||||
bytes: Uint8Array<ArrayBufferLike>
|
||||
seq?: number
|
||||
opcode?: TerminalStreamOpcode
|
||||
}
|
||||
|
||||
// Why: UTF-8 length is never below UTF-16 length, so a code-unit count over the cap already proves the byte count is.
|
||||
function exceedsTerminalStreamChunkBytes(data: string): boolean {
|
||||
return (
|
||||
data.length > TERMINAL_STREAM_CHUNK_BYTES ||
|
||||
Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES
|
||||
)
|
||||
}
|
||||
|
||||
export function* iterateTerminalOutputFrameChunks(
|
||||
data: string,
|
||||
meta?: TerminalOutputMeta
|
||||
): Generator<TerminalOutputFrameChunk> {
|
||||
const rawLength = meta?.rawLength ?? data.length
|
||||
if (meta?.transformed || rawLength !== data.length) {
|
||||
yield {
|
||||
opcode: TerminalStreamOpcode.OutputSpan,
|
||||
bytes: encodeTerminalStreamJson({ data, rawLength, transformed: true }),
|
||||
seq: meta?.seq
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!exceedsTerminalStreamChunkBytes(data)) {
|
||||
yield { bytes: encodeTerminalStreamText(data), seq: meta?.seq }
|
||||
return
|
||||
}
|
||||
const canPreserveChunkSeq = typeof meta?.seq === 'number' && rawLength === data.length
|
||||
// Unreachable past the OutputSpan branch above (rawLength === data.length there), but kept
|
||||
// as defence in depth: it is the only shape that can carry a seq the chunk offsets can't map.
|
||||
const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta?.seq === 'number'
|
||||
const startSeq = canPreserveChunkSeq ? meta.seq! - rawLength : undefined
|
||||
let chunkStart = 0
|
||||
let chunkBytes = 0
|
||||
let delayedChunk: { text: string; seq?: number } | null = null
|
||||
|
||||
// Why two offsets instead of an accumulator: the emitted text is always the contiguous
|
||||
// substring from chunkStart to end. Keep the legacy addition order for unsafe sequence values.
|
||||
const takeChunk = (end: number): { text: string; seq?: number } => {
|
||||
const text = data.slice(chunkStart, end)
|
||||
const current = {
|
||||
text,
|
||||
seq: canPreserveChunkSeq ? startSeq! + chunkStart + text.length : undefined
|
||||
}
|
||||
chunkStart = end
|
||||
chunkBytes = 0
|
||||
return current
|
||||
}
|
||||
|
||||
let index = 0
|
||||
while (index < data.length) {
|
||||
const code = data.charCodeAt(index)
|
||||
let width = 1
|
||||
let partBytes = 3
|
||||
if (code < 0x80) {
|
||||
partBytes = 1
|
||||
} else if (code < 0x800) {
|
||||
partBytes = 2
|
||||
} else if (
|
||||
code >= 0xd800 &&
|
||||
code <= 0xdbff &&
|
||||
// Past the end charCodeAt is NaN, which masks to 0 — a trailing lone surrogate stays 3 bytes.
|
||||
(data.charCodeAt(index + 1) & 0xfc00) === 0xdc00
|
||||
) {
|
||||
partBytes = 4
|
||||
width = 2
|
||||
}
|
||||
// Why chunkBytes > 0: keeps a code point wider than the whole cap in its own frame
|
||||
// instead of splitting an empty one forever.
|
||||
if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) {
|
||||
const nextChunk = takeChunk(index)
|
||||
if (shouldDelayFinalSeq) {
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
delayedChunk = nextChunk
|
||||
} else {
|
||||
yield { bytes: encodeTerminalStreamText(nextChunk.text), seq: nextChunk.seq }
|
||||
}
|
||||
}
|
||||
chunkBytes += partBytes
|
||||
index += width
|
||||
}
|
||||
// A split always advances past its boundary, so the tail after the loop is never empty.
|
||||
const finalChunk = takeChunk(data.length)
|
||||
if (shouldDelayFinalSeq) {
|
||||
// Why: only the final frame can safely carry the high-water mark when rawLength can't map back to UTF-16 offsets.
|
||||
if (delayedChunk) {
|
||||
yield { bytes: encodeTerminalStreamText(delayedChunk.text) }
|
||||
}
|
||||
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: meta.seq }
|
||||
return
|
||||
}
|
||||
yield { bytes: encodeTerminalStreamText(finalChunk.text), seq: finalChunk.seq }
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MIN_NATIVE_BYTE_LENGTH_CODE_UNITS,
|
||||
measureTerminalStreamByteLength,
|
||||
terminalStreamByteLength,
|
||||
terminalStreamByteLengthExceeds
|
||||
} from './terminal-stream-byte-length'
|
||||
import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control'
|
||||
|
||||
// Byte-for-byte copy of the pre-change implementation (shared/clipboard-text.ts
|
||||
// measureClipboardTextByteLength), kept here so equivalence is checked against the
|
||||
// ACTUAL old code path rather than a paraphrase of it.
|
||||
function legacyUtf8ByteLengthForCodePoint(codePoint: number): number {
|
||||
if (codePoint <= 0x7f) {
|
||||
return 1
|
||||
}
|
||||
if (codePoint <= 0x7ff) {
|
||||
return 2
|
||||
}
|
||||
if (codePoint <= 0xffff) {
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
function legacyMeasure(
|
||||
text: string,
|
||||
options: { stopAfterBytes?: number } = {}
|
||||
): { byteLength: number; exceededLimit: boolean } {
|
||||
const stopAfterBytes = options.stopAfterBytes
|
||||
let byteLength = 0
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const codePoint = text.codePointAt(index) ?? 0
|
||||
byteLength += legacyUtf8ByteLengthForCodePoint(codePoint)
|
||||
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
|
||||
return { byteLength, exceededLimit: true }
|
||||
}
|
||||
if (codePoint > 0xffff) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return { byteLength, exceededLimit: false }
|
||||
}
|
||||
|
||||
function legacyByteLength(data: string): number {
|
||||
return legacyMeasure(data).byteLength
|
||||
}
|
||||
|
||||
function legacyExceeds(data: string, maxBytes: number): boolean {
|
||||
return legacyMeasure(data, { stopAfterBytes: maxBytes }).exceededLimit
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed generator: ASCII, 2-byte, 3-byte, astral pairs, and LONE surrogates, so the
|
||||
// fixtures exercise every branch of the legacy code-point scan.
|
||||
function randomUnit(random: () => number): string {
|
||||
const roll = random()
|
||||
if (roll < 0.4) {
|
||||
return String.fromCharCode(Math.floor(random() * 0x80))
|
||||
}
|
||||
if (roll < 0.55) {
|
||||
return String.fromCharCode(0x80 + Math.floor(random() * 0x780))
|
||||
}
|
||||
if (roll < 0.72) {
|
||||
return String.fromCharCode(0x800 + Math.floor(random() * 0xd000))
|
||||
}
|
||||
if (roll < 0.88) {
|
||||
return String.fromCodePoint(0x10000 + Math.floor(random() * 0x100000))
|
||||
}
|
||||
return String.fromCharCode(0xd800 + Math.floor(random() * 0x800))
|
||||
}
|
||||
|
||||
function randomString(random: () => number, maxUnits: number): string {
|
||||
const count = Math.floor(random() * maxUnits)
|
||||
let text = ''
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
text += randomUnit(random)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Raw UTF-16 with no code-point discipline at all: catches anything that assumes
|
||||
// well-formedness (unpaired high after high, low before high, etc).
|
||||
function rawUtf16(random: () => number, maxUnits: number): string {
|
||||
const count = Math.floor(random() * maxUnits)
|
||||
let text = ''
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
text += String.fromCharCode(Math.floor(random() * 0x11000))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const EDGE_STRINGS = [
|
||||
'',
|
||||
'a',
|
||||
'\u0000',
|
||||
'\u007f',
|
||||
'',
|
||||
'߿',
|
||||
'ࠀ',
|
||||
'',
|
||||
'�',
|
||||
'\u{10000}',
|
||||
'\u{10ffff}',
|
||||
'\ud800',
|
||||
'\udfff',
|
||||
'\ud800\ud800',
|
||||
'\udc00\ud800',
|
||||
'😀',
|
||||
'😀\ud800',
|
||||
'\ud800😀',
|
||||
'a\ud800b',
|
||||
'\r\n\u001b[0m',
|
||||
'é́',
|
||||
'\u{1f469}\u{1f4bb}',
|
||||
'x'.repeat(1000),
|
||||
'\u{1f600}'.repeat(300),
|
||||
`${'é'.repeat(500)}\ud800`
|
||||
]
|
||||
|
||||
describe('terminal stream byte length equivalence with the legacy code-point scan', () => {
|
||||
it('matches the legacy total byte length on edge strings', () => {
|
||||
for (const text of EDGE_STRINGS) {
|
||||
expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the legacy total byte length over every Unicode code point', () => {
|
||||
for (let codePoint = 0; codePoint <= 0x10ffff; codePoint += 1) {
|
||||
const text = String.fromCodePoint(codePoint)
|
||||
if (terminalStreamByteLength(text) !== legacyByteLength(text)) {
|
||||
throw new Error(`byte length diverged at code point U+${codePoint.toString(16)}`)
|
||||
}
|
||||
}
|
||||
expect(terminalStreamByteLength('\u{10ffff}')).toBe(4)
|
||||
})
|
||||
|
||||
it('matches the legacy total byte length over every lone surrogate', () => {
|
||||
for (let unit = 0xd800; unit <= 0xdfff; unit += 1) {
|
||||
const text = `a${String.fromCharCode(unit)}b`
|
||||
expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the legacy total byte length over fuzzed mixed and raw UTF-16 input', () => {
|
||||
const random = mulberry32(0x5eed01)
|
||||
for (let iteration = 0; iteration < 20000; iteration += 1) {
|
||||
const text = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24)
|
||||
if (terminalStreamByteLength(text) !== legacyByteLength(text)) {
|
||||
throw new Error(`byte length diverged for ${JSON.stringify(text)}`)
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the legacy exceededLimit decision across a dense (string, limit) sweep', () => {
|
||||
const random = mulberry32(0xc0ffee)
|
||||
let compared = 0
|
||||
for (let iteration = 0; iteration < 10000; iteration += 1) {
|
||||
const text = iteration % 2 === 0 ? randomString(random, 16) : rawUtf16(random, 16)
|
||||
const total = legacyByteLength(text)
|
||||
// Sweep every limit from below zero to past the true total so the boundary is hit exactly.
|
||||
for (let limit = -2; limit <= total + 2; limit += 1) {
|
||||
if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) {
|
||||
throw new Error(`exceededLimit diverged for ${JSON.stringify(text)} at limit ${limit}`)
|
||||
}
|
||||
compared += 1
|
||||
}
|
||||
}
|
||||
expect(compared).toBeGreaterThan(100000)
|
||||
})
|
||||
|
||||
it('matches the legacy exceededLimit decision for non-finite and fractional limits', () => {
|
||||
const random = mulberry32(0xfeed42)
|
||||
for (const limit of [
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
0.5,
|
||||
2.5,
|
||||
-0.5,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
]) {
|
||||
for (let iteration = 0; iteration < 400; iteration += 1) {
|
||||
const text = rawUtf16(random, 12)
|
||||
expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit))
|
||||
}
|
||||
for (const text of EDGE_STRINGS) {
|
||||
expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the legacy measurement pair, byteLength included, across a stopAfterBytes sweep', () => {
|
||||
const random = mulberry32(0xa11ce)
|
||||
for (let iteration = 0; iteration < 3000; iteration += 1) {
|
||||
const text = iteration % 2 === 0 ? randomString(random, 20) : rawUtf16(random, 20)
|
||||
const total = legacyByteLength(text)
|
||||
for (let stopAfterBytes = -1; stopAfterBytes <= total + 2; stopAfterBytes += 1) {
|
||||
const actual = measureTerminalStreamByteLength(text, { stopAfterBytes })
|
||||
const expected = legacyMeasure(text, { stopAfterBytes })
|
||||
if (
|
||||
actual.byteLength !== expected.byteLength ||
|
||||
actual.exceededLimit !== expected.exceededLimit
|
||||
) {
|
||||
throw new Error(
|
||||
`measurement diverged for ${JSON.stringify(text)} at stopAfterBytes ${stopAfterBytes}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 })
|
||||
).toEqual(legacyMeasure('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 }))
|
||||
})
|
||||
|
||||
it('keeps the partial byteLength the legacy scan returned when the limit is exceeded', () => {
|
||||
// A drop-in Buffer.byteLength would report 12 here; the legacy scan stops at 8.
|
||||
const measurement = measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', {
|
||||
stopAfterBytes: 5
|
||||
})
|
||||
expect(measurement).toEqual({ byteLength: 8, exceededLimit: true })
|
||||
})
|
||||
|
||||
it('matches the legacy measurement with no stopAfterBytes and with an undefined option bag', () => {
|
||||
const random = mulberry32(0xb0b)
|
||||
for (let iteration = 0; iteration < 2000; iteration += 1) {
|
||||
const text = rawUtf16(random, 32)
|
||||
expect(measureTerminalStreamByteLength(text)).toEqual(legacyMeasure(text))
|
||||
expect(measureTerminalStreamByteLength(text, {})).toEqual(legacyMeasure(text, {}))
|
||||
expect(measureTerminalStreamByteLength(text, { stopAfterBytes: undefined })).toEqual(
|
||||
legacyMeasure(text, { stopAfterBytes: undefined })
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// The code-unit floor routes short inputs back through the scan. Both sides of that
|
||||
// boundary must stay legacy-identical, so sweep it exhaustively rather than by sampling.
|
||||
it('matches the legacy result on both sides of the native-call floor', () => {
|
||||
const random = mulberry32(0xf100a)
|
||||
for (let units = 0; units <= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2; units += 1) {
|
||||
for (let iteration = 0; iteration < 60; iteration += 1) {
|
||||
let text = ''
|
||||
while (text.length < units) {
|
||||
text +=
|
||||
iteration % 2 === 0
|
||||
? randomUnit(random)
|
||||
: String.fromCharCode(Math.floor(random() * 0x11000))
|
||||
}
|
||||
text = text.slice(0, units)
|
||||
const total = legacyByteLength(text)
|
||||
expect(terminalStreamByteLength(text)).toBe(total)
|
||||
for (let limit = -1; limit <= total + 2; limit += 1) {
|
||||
if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) {
|
||||
throw new Error(`exceeds diverged at ${units} units, limit ${limit}`)
|
||||
}
|
||||
const actual = measureTerminalStreamByteLength(text, { stopAfterBytes: limit })
|
||||
const expected = legacyMeasure(text, { stopAfterBytes: limit })
|
||||
if (
|
||||
actual.byteLength !== expected.byteLength ||
|
||||
actual.exceededLimit !== expected.exceededLimit
|
||||
) {
|
||||
throw new Error(`measurement diverged at ${units} units, limit ${limit}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(MIN_NATIVE_BYTE_LENGTH_CODE_UNITS).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Reproduces createTerminalOutputBatcher's byte accounting exactly, under both the
|
||||
// legacy scan and the new measurement, and asserts IDENTICAL flush boundaries. This is
|
||||
// what makes the partial-count behaviour provably unobservable at that call site.
|
||||
function simulateBatcherFlushes(
|
||||
chunks: string[],
|
||||
measure: (
|
||||
data: string,
|
||||
options: { stopAfterBytes?: number }
|
||||
) => { byteLength: number; exceededLimit: boolean }
|
||||
): string[] {
|
||||
const flushes: string[] = []
|
||||
let pending: string[] = []
|
||||
let bytes = 0
|
||||
const flush = (): void => {
|
||||
if (pending.length === 0) {
|
||||
return
|
||||
}
|
||||
flushes.push(pending.join(''))
|
||||
pending = []
|
||||
bytes = 0
|
||||
}
|
||||
for (const data of chunks) {
|
||||
if (!data) {
|
||||
continue
|
||||
}
|
||||
pending.push(data)
|
||||
const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes)
|
||||
const measurement = measure(data, { stopAfterBytes: remainingBudget })
|
||||
bytes += measurement.byteLength
|
||||
if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return flushes
|
||||
}
|
||||
|
||||
describe('terminal output batcher flush boundaries are unchanged', () => {
|
||||
it(
|
||||
'produces identical flush boundaries over randomized multi-chunk runs',
|
||||
{ timeout: 60000 },
|
||||
() => {
|
||||
const random = mulberry32(0x1337)
|
||||
for (let run = 0; run < 300; run += 1) {
|
||||
const chunks: string[] = []
|
||||
const chunkCount = 1 + Math.floor(random() * 40)
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
// Sizes straddle the 64KiB batch cap so single chunks both fit and blow the budget.
|
||||
// Sizes straddle the native-call floor too, so runs mix scan-branch and
|
||||
// native-branch measurements inside one batcher's byte accounting.
|
||||
const scale = random()
|
||||
const maxUnits =
|
||||
scale < 0.25
|
||||
? MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2
|
||||
: scale < 0.5
|
||||
? 64
|
||||
: scale < 0.85
|
||||
? 20000
|
||||
: 90000
|
||||
chunks.push(random() < 0.5 ? randomString(random, maxUnits) : rawUtf16(random, maxUnits))
|
||||
}
|
||||
const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure)
|
||||
const actualFlushes = simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)
|
||||
if (legacyFlushes.length !== actualFlushes.length) {
|
||||
throw new Error(`flush count diverged on run ${run}`)
|
||||
}
|
||||
for (let index = 0; index < legacyFlushes.length; index += 1) {
|
||||
if (legacyFlushes[index] !== actualFlushes[index]) {
|
||||
throw new Error(`flush ${index} diverged on run ${run}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('exercises runs that actually cross the batch budget', () => {
|
||||
const oversized = '\u{1f600}'.repeat(TERMINAL_OUTPUT_BATCH_MAX_BYTES)
|
||||
const chunks = ['a'.repeat(10), oversized, 'b'.repeat(10), oversized, 'c']
|
||||
const legacyFlushes = simulateBatcherFlushes(chunks, legacyMeasure)
|
||||
expect(legacyFlushes.length).toBeGreaterThan(1)
|
||||
expect(simulateBatcherFlushes(chunks, measureTerminalStreamByteLength)).toEqual(legacyFlushes)
|
||||
})
|
||||
})
|
||||
|
||||
// trimPendingOutputCoveredBySnapshot re-measures a sliced chunk with terminalStreamByteLength.
|
||||
// The RPC suites never reach that slice branch (a `data.length` mutant there survives on
|
||||
// unmodified HEAD too), so pin the byte accounting the branch depends on here.
|
||||
describe('resync trim byte accounting for a snapshot-sliced chunk', () => {
|
||||
it('re-measures a sliced chunk in UTF-8 bytes, not UTF-16 code units', () => {
|
||||
const data = '\u{1f600}é走a'
|
||||
const sliced = data.slice(2)
|
||||
expect(terminalStreamByteLength(sliced)).toBe(legacyByteLength(sliced))
|
||||
// Guards the mutant: code-unit length would be 4 here, UTF-8 is 6.
|
||||
expect(terminalStreamByteLength(sliced)).toBe(6)
|
||||
expect(terminalStreamByteLength(sliced)).not.toBe(sliced.length)
|
||||
})
|
||||
|
||||
it('matches the legacy byte length for every suffix slice of multi-byte terminal text', () => {
|
||||
const random = mulberry32(0x51ced)
|
||||
for (let iteration = 0; iteration < 2000; iteration += 1) {
|
||||
const data = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24)
|
||||
for (let offset = 0; offset <= data.length; offset += 1) {
|
||||
const sliced = data.slice(offset)
|
||||
if (terminalStreamByteLength(sliced) !== legacyByteLength(sliced)) {
|
||||
throw new Error(`sliced byte length diverged for ${JSON.stringify(data)} at ${offset}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { measureClipboardTextByteLength } from '../../../shared/clipboard-text'
|
||||
|
||||
export type TerminalStreamByteLengthMeasurement = {
|
||||
byteLength: number
|
||||
exceededLimit: boolean
|
||||
}
|
||||
|
||||
// UTF-8 encodes a UTF-16 code unit in at most 3 bytes (BMP scalar, or U+FFFD for a lone
|
||||
// surrogate; a surrogate pair is 4 bytes across 2 units), and never in fewer than 1. So
|
||||
// `length <= byteLength <= 3 * length` bounds the byte count without touching the string.
|
||||
const MAX_UTF8_BYTES_PER_CODE_UNIT = 3
|
||||
|
||||
// Buffer.byteLength's fixed call cost (~14ns) buys nothing until it replaces enough scan
|
||||
// iterations (~1.5ns each) to pay for itself; measured crossover is 8-12 code units. Below
|
||||
// this the native call is a REGRESSION on interactive keystroke-echo chunks, so keep the scan.
|
||||
export const MIN_NATIVE_BYTE_LENGTH_CODE_UNITS = 16
|
||||
|
||||
export function terminalStreamByteLength(data: string): number {
|
||||
if (data.length < MIN_NATIVE_BYTE_LENGTH_CODE_UNITS) {
|
||||
return measureClipboardTextByteLength(data).byteLength
|
||||
}
|
||||
return Buffer.byteLength(data, 'utf8')
|
||||
}
|
||||
|
||||
export function terminalStreamByteLengthExceeds(data: string, maxBytes: number): boolean {
|
||||
if (data.length === 0 || !Number.isFinite(maxBytes)) {
|
||||
return false
|
||||
}
|
||||
// Sound: UTF-8 is never shorter than UTF-16 code-unit count, so this needs no scan at all.
|
||||
if (data.length > maxBytes) {
|
||||
return true
|
||||
}
|
||||
if (data.length < MIN_NATIVE_BYTE_LENGTH_CODE_UNITS) {
|
||||
return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit
|
||||
}
|
||||
return Buffer.byteLength(data, 'utf8') > maxBytes
|
||||
}
|
||||
|
||||
export function measureTerminalStreamByteLength(
|
||||
data: string,
|
||||
options: { stopAfterBytes?: number } = {}
|
||||
): TerminalStreamByteLengthMeasurement {
|
||||
const stopAfterBytes = options.stopAfterBytes
|
||||
if (!Number.isFinite(stopAfterBytes)) {
|
||||
return { byteLength: terminalStreamByteLength(data), exceededLimit: false }
|
||||
}
|
||||
// Why: over the limit the callers keep the scan's TRUNCATED running total, so only take the
|
||||
// native count when the upper bound proves it can't trip the limit — otherwise both would run.
|
||||
if (
|
||||
data.length >= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS &&
|
||||
data.length * MAX_UTF8_BYTES_PER_CODE_UNIT <= (stopAfterBytes as number)
|
||||
) {
|
||||
return { byteLength: Buffer.byteLength(data, 'utf8'), exceededLimit: false }
|
||||
}
|
||||
return measureClipboardTextByteLength(data, options)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { clearRuntimeMetadata, readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata'
|
||||
import {
|
||||
shouldReclaimRuntimeMetadata,
|
||||
watchRuntimeMetadataOwnership,
|
||||
type RuntimeMetadataOwnershipWatch
|
||||
} from './runtime-metadata-ownership-watch'
|
||||
|
||||
const OWNED_PID = 4242
|
||||
const OWNED_RUNTIME_ID = 'rt_owner'
|
||||
const FOREIGN_LIVE_PID = 5151
|
||||
const FOREIGN_DEAD_PID = 5252
|
||||
|
||||
function record(overrides: Partial<RuntimeMetadata> = {}): RuntimeMetadata {
|
||||
return {
|
||||
runtimeId: OWNED_RUNTIME_ID,
|
||||
pid: OWNED_PID,
|
||||
transports: [{ kind: 'unix', endpoint: '/tmp/orca-owner.sock' }],
|
||||
authToken: 'secret',
|
||||
startedAt: 100,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const isProcessRunning = (pid: number): boolean => pid === OWNED_PID || pid === FOREIGN_LIVE_PID
|
||||
|
||||
describe('shouldReclaimRuntimeMetadata', () => {
|
||||
it('leaves the record alone while it still describes this runtime', () => {
|
||||
expect(
|
||||
shouldReclaimRuntimeMetadata(record(), OWNED_PID, OWNED_RUNTIME_ID, isProcessRunning)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reclaims a missing record', () => {
|
||||
expect(shouldReclaimRuntimeMetadata(null, OWNED_PID, OWNED_RUNTIME_ID, isProcessRunning)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('reclaims a record left behind by a dead runtime', () => {
|
||||
expect(
|
||||
shouldReclaimRuntimeMetadata(
|
||||
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' }),
|
||||
OWNED_PID,
|
||||
OWNED_RUNTIME_ID,
|
||||
isProcessRunning
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('yields to another live runtime so two instances cannot ping-pong the record', () => {
|
||||
expect(
|
||||
shouldReclaimRuntimeMetadata(
|
||||
record({ pid: FOREIGN_LIVE_PID, runtimeId: 'rt_second_instance' }),
|
||||
OWNED_PID,
|
||||
OWNED_RUNTIME_ID,
|
||||
isProcessRunning
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reclaims a foreign runtimeId stamped on this pid', () => {
|
||||
// Why: only this process can be this pid, so the record is a recycled-pid leftover.
|
||||
expect(
|
||||
shouldReclaimRuntimeMetadata(
|
||||
record({ runtimeId: 'rt_previous_process' }),
|
||||
OWNED_PID,
|
||||
OWNED_RUNTIME_ID,
|
||||
isProcessRunning
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchRuntimeMetadataOwnership', () => {
|
||||
const watches: RuntimeMetadataOwnershipWatch[] = []
|
||||
const userDataPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const watch of watches.splice(0)) {
|
||||
watch.stop()
|
||||
}
|
||||
for (const dir of userDataPaths.splice(0)) {
|
||||
clearRuntimeMetadata(dir)
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function armWatch(userDataPath: string, pollIntervalMs = 10): RuntimeMetadataOwnershipWatch {
|
||||
const watch = watchRuntimeMetadataOwnership({
|
||||
userDataPath,
|
||||
ownedPid: OWNED_PID,
|
||||
ownedRuntimeId: OWNED_RUNTIME_ID,
|
||||
pollIntervalMs,
|
||||
isProcessRunning,
|
||||
republish: () => writeRuntimeMetadata(userDataPath, record())
|
||||
})
|
||||
watches.push(watch)
|
||||
return watch
|
||||
}
|
||||
|
||||
function makeUserDataPath(): string {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-ownership-'))
|
||||
userDataPaths.push(userDataPath)
|
||||
return userDataPath
|
||||
}
|
||||
|
||||
it('republishes after a second instance clobbers the record and exits', () => {
|
||||
const userDataPath = makeUserDataPath()
|
||||
writeRuntimeMetadata(userDataPath, record())
|
||||
const watch = armWatch(userDataPath)
|
||||
|
||||
writeRuntimeMetadata(
|
||||
userDataPath,
|
||||
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' })
|
||||
)
|
||||
watch.check()
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({
|
||||
pid: OWNED_PID,
|
||||
runtimeId: OWNED_RUNTIME_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('republishes a record that was deleted underneath the runtime', () => {
|
||||
const userDataPath = makeUserDataPath()
|
||||
writeRuntimeMetadata(userDataPath, record())
|
||||
const watch = armWatch(userDataPath)
|
||||
|
||||
clearRuntimeMetadata(userDataPath)
|
||||
watch.check()
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
|
||||
})
|
||||
|
||||
it('replaces an unreadable record', () => {
|
||||
const userDataPath = makeUserDataPath()
|
||||
const watch = armWatch(userDataPath)
|
||||
writeFileSync(getRuntimeMetadataPath(userDataPath), '{ truncated')
|
||||
|
||||
watch.check()
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
|
||||
})
|
||||
|
||||
it('leaves a live sibling runtime in place', () => {
|
||||
const userDataPath = makeUserDataPath()
|
||||
const watch = armWatch(userDataPath)
|
||||
writeRuntimeMetadata(
|
||||
userDataPath,
|
||||
record({ pid: FOREIGN_LIVE_PID, runtimeId: 'rt_second_instance' })
|
||||
)
|
||||
|
||||
watch.check()
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_LIVE_PID })
|
||||
})
|
||||
|
||||
it('reclaims on the poll interval without an explicit check', () => {
|
||||
vi.useFakeTimers()
|
||||
const userDataPath = makeUserDataPath()
|
||||
armWatch(userDataPath, 1_000)
|
||||
writeRuntimeMetadata(
|
||||
userDataPath,
|
||||
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' })
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(1_000)
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
|
||||
})
|
||||
|
||||
it('stops reclaiming once the watch is stopped', () => {
|
||||
vi.useFakeTimers()
|
||||
const userDataPath = makeUserDataPath()
|
||||
const watch = armWatch(userDataPath, 1_000)
|
||||
|
||||
watch.stop()
|
||||
writeRuntimeMetadata(
|
||||
userDataPath,
|
||||
record({ pid: FOREIGN_DEAD_PID, runtimeId: 'rt_second_instance' })
|
||||
)
|
||||
vi.advanceTimersByTime(5_000)
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: FOREIGN_DEAD_PID })
|
||||
})
|
||||
|
||||
it('keeps polling after a republish failure', () => {
|
||||
vi.useFakeTimers()
|
||||
const userDataPath = makeUserDataPath()
|
||||
const republish = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
.mockImplementation(() => writeRuntimeMetadata(userDataPath, record()))
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const watch = watchRuntimeMetadataOwnership({
|
||||
userDataPath,
|
||||
ownedPid: OWNED_PID,
|
||||
ownedRuntimeId: OWNED_RUNTIME_ID,
|
||||
pollIntervalMs: 1_000,
|
||||
isProcessRunning,
|
||||
republish
|
||||
})
|
||||
watches.push(watch)
|
||||
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(republish).toHaveBeenCalledTimes(2)
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ pid: OWNED_PID })
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { getRuntimeMetadataPath, type RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { readRuntimeMetadata } from './runtime-metadata'
|
||||
|
||||
/**
|
||||
* Why: `orca-runtime.json` is the CLI's only pointer at a live runtime, and it
|
||||
* can stop describing this process while this process is still serving RPC —
|
||||
* a second instance that slipped past the single-instance lock publishes its
|
||||
* own pid and then exits, leaving the CLI on a dead pid (`stale_bootstrap`)
|
||||
* against a healthy app (#7848). Chromium's lock is defeated on macOS whenever
|
||||
* `SingletonSocket`/`SingletonCookie` go missing, and its socket lives under
|
||||
* `$TMPDIR` (`/var/folders/.../T`), which macOS itself purges after 3 days.
|
||||
*
|
||||
* The owner therefore watches its own record and reclaims it once no live
|
||||
* runtime is described. Reclaiming only a dead pid is deliberate: two live
|
||||
* runtimes sharing a profile would otherwise ping-pong the file forever.
|
||||
*/
|
||||
export const RUNTIME_METADATA_OWNERSHIP_POLL_MS = 10_000
|
||||
|
||||
export type RuntimeMetadataOwnershipWatch = {
|
||||
/** Runs one ownership check immediately; exposed for tests and eager repair. */
|
||||
check: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
export type RuntimeMetadataOwnershipWatchOptions = {
|
||||
userDataPath: string
|
||||
ownedPid: number
|
||||
ownedRuntimeId: string
|
||||
republish: () => void
|
||||
pollIntervalMs?: number
|
||||
isProcessRunning?: (pid: number) => boolean
|
||||
onReclaim?: (previous: RuntimeMetadata | null) => void
|
||||
}
|
||||
|
||||
export function shouldReclaimRuntimeMetadata(
|
||||
current: RuntimeMetadata | null,
|
||||
ownedPid: number,
|
||||
ownedRuntimeId: string,
|
||||
isProcessRunning: (pid: number) => boolean
|
||||
): boolean {
|
||||
if (!current) {
|
||||
return true
|
||||
}
|
||||
if (current.pid === ownedPid && current.runtimeId === ownedRuntimeId) {
|
||||
return false
|
||||
}
|
||||
// Why: only this process can legitimately claim this pid, so a foreign
|
||||
// runtimeId on it is a leftover from a recycled pid, not a live sibling.
|
||||
if (current.pid === ownedPid) {
|
||||
return true
|
||||
}
|
||||
return !isProcessRunning(current.pid)
|
||||
}
|
||||
|
||||
export function watchRuntimeMetadataOwnership(
|
||||
options: RuntimeMetadataOwnershipWatchOptions
|
||||
): RuntimeMetadataOwnershipWatch {
|
||||
const isProcessRunning = options.isProcessRunning ?? isPidRunning
|
||||
const check = (): void => {
|
||||
const current = tryReadRuntimeMetadata(options.userDataPath)
|
||||
if (
|
||||
!shouldReclaimRuntimeMetadata(
|
||||
current,
|
||||
options.ownedPid,
|
||||
options.ownedRuntimeId,
|
||||
isProcessRunning
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
options.republish()
|
||||
} catch (error) {
|
||||
// Why: a transient write failure must not kill the watch; the next tick retries.
|
||||
console.error('[runtime] Failed to reclaim runtime metadata ownership:', error)
|
||||
return
|
||||
}
|
||||
options.onReclaim?.(current)
|
||||
}
|
||||
|
||||
const timer = setInterval(check, options.pollIntervalMs ?? RUNTIME_METADATA_OWNERSHIP_POLL_MS)
|
||||
// Why: discovery bookkeeping must never be the reason the process stays alive.
|
||||
timer.unref?.()
|
||||
return {
|
||||
check,
|
||||
stop: () => clearInterval(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function tryReadRuntimeMetadata(userDataPath: string): RuntimeMetadata | null {
|
||||
try {
|
||||
return readRuntimeMetadata(userDataPath)
|
||||
} catch (error) {
|
||||
// Why: an unparseable record is as useless to the CLI as a missing one, so treat it as reclaimable.
|
||||
console.warn(
|
||||
`[runtime] Ignoring unreadable ${getRuntimeMetadataPath(userDataPath)}:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isPidRunning(pid: number): boolean {
|
||||
if (!pid || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Why: only ESRCH proves the pid is gone; EPERM means a foreign owner holds it (same rule as the socket sweep).
|
||||
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import Database from '../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import * as runtimeMetadataModule from './runtime-metadata'
|
||||
import { readRuntimeMetadata } from './runtime-metadata'
|
||||
import { readRuntimeMetadata, writeRuntimeMetadata } from './runtime-metadata'
|
||||
import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
@@ -362,6 +362,81 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reclaims runtime metadata clobbered by a second instance that has since died', async () => {
|
||||
// Why: #7848 — a launch that slips past the single-instance lock republishes
|
||||
// orca-runtime.json with its own pid, so the CLI reports stale_bootstrap
|
||||
// against this still-serving runtime once that instance exits.
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
await server.start()
|
||||
const published = readRuntimeMetadata(userDataPath)
|
||||
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_second_instance',
|
||||
pid: 99999999,
|
||||
transports: [{ kind: 'unix', endpoint: join(userDataPath, 'o-99999999-rt2.sock') }],
|
||||
authToken: 'second-instance-token',
|
||||
startedAt: 1
|
||||
})
|
||||
server.checkRuntimeMetadataOwnership()
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toEqual(published)
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('leaves runtime metadata owned by a live sibling runtime untouched', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
// Why: a synthetic owned pid frees the always-alive process.pid to stand in for
|
||||
// the sibling — Windows never assigns pid 1, so hardcoding it there reads as dead.
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
pid: 4242
|
||||
})
|
||||
await server.start()
|
||||
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_live_sibling',
|
||||
pid: process.pid,
|
||||
transports: [{ kind: 'unix', endpoint: join(userDataPath, `o-${process.pid}-rt2.sock`) }],
|
||||
authToken: 'sibling-token',
|
||||
startedAt: 1
|
||||
})
|
||||
server.checkRuntimeMetadataOwnership()
|
||||
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_live_sibling' })
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('stops reclaiming runtime metadata after the server is stopped', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const server = new OrcaRuntimeRpcServer({ runtime: new OrcaRuntimeService(), userDataPath })
|
||||
await server.start()
|
||||
const watch = server['metadataOwnershipWatch']
|
||||
if (!watch) {
|
||||
throw new Error('start() must arm the metadata ownership watch')
|
||||
}
|
||||
// Why: the republish guard alone would keep this test green, so assert the timer teardown itself.
|
||||
const watchStop = vi.spyOn(watch, 'stop')
|
||||
await server.stop()
|
||||
|
||||
writeRuntimeMetadata(userDataPath, {
|
||||
runtimeId: 'rt_second_instance',
|
||||
pid: 99999999,
|
||||
transports: [],
|
||||
authToken: 'second-instance-token',
|
||||
startedAt: 1
|
||||
})
|
||||
server.checkRuntimeMetadataOwnership()
|
||||
|
||||
expect(watchStop).toHaveBeenCalledTimes(1)
|
||||
expect(server['metadataOwnershipWatch']).toBeNull()
|
||||
expect(readRuntimeMetadata(userDataPath)).toMatchObject({ runtimeId: 'rt_second_instance' })
|
||||
})
|
||||
|
||||
it('creates a pairing offer for the active WebSocket transport', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService()
|
||||
|
||||
@@ -6,6 +6,11 @@ import { join } from 'node:path'
|
||||
import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { writeRuntimeMetadata } from './runtime-metadata'
|
||||
import {
|
||||
RUNTIME_METADATA_OWNERSHIP_POLL_MS,
|
||||
watchRuntimeMetadataOwnership,
|
||||
type RuntimeMetadataOwnershipWatch
|
||||
} from './runtime-metadata-ownership-watch'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest, RpcResponse } from './rpc/core'
|
||||
import { errorResponse } from './rpc/errors'
|
||||
@@ -57,6 +62,8 @@ type OrcaRuntimeRpcServerOptions = {
|
||||
// Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1).
|
||||
keepaliveIntervalMs?: number
|
||||
longPollCap?: number
|
||||
// Why: test-only override for the ownership reclaim cadence.
|
||||
metadataOwnershipPollMs?: number
|
||||
}
|
||||
|
||||
export type PairingOfferUnavailableReason =
|
||||
@@ -450,6 +457,7 @@ export class OrcaRuntimeRpcServer {
|
||||
private readonly authToken = randomBytes(24).toString('hex')
|
||||
private readonly keepaliveIntervalMs: number
|
||||
private readonly longPollCap: number
|
||||
private readonly metadataOwnershipPollMs: number
|
||||
private readonly askLongPollCap: number
|
||||
private readonly relayRevokeOutbox: RelayRevokeOutbox
|
||||
private deviceRegistry: DeviceRegistry | null = null
|
||||
@@ -458,6 +466,7 @@ export class OrcaRuntimeRpcServer {
|
||||
private tlsFingerprint: string | null = null
|
||||
private activeTransports: RpcTransport[] = []
|
||||
private transports: RuntimeTransportMetadata[] = []
|
||||
private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null
|
||||
private mobileSocketWiring: MobileSocketWiring | null = null
|
||||
private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null
|
||||
private onUnpairedDeviceAuthFailure: (() => void) | null = null
|
||||
@@ -485,7 +494,8 @@ export class OrcaRuntimeRpcServer {
|
||||
preferPinnedWsPort = false,
|
||||
webClientRoot,
|
||||
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
|
||||
longPollCap = LONG_POLL_CAP
|
||||
longPollCap = LONG_POLL_CAP,
|
||||
metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS
|
||||
}: OrcaRuntimeRpcServerOptions) {
|
||||
this.runtime = runtime
|
||||
this.dispatcher = new RpcDispatcher({ runtime })
|
||||
@@ -498,6 +508,7 @@ export class OrcaRuntimeRpcServer {
|
||||
this.webClientRoot = webClientRoot
|
||||
this.keepaliveIntervalMs = keepaliveIntervalMs
|
||||
this.longPollCap = longPollCap
|
||||
this.metadataOwnershipPollMs = metadataOwnershipPollMs
|
||||
// Why: derived, not configurable — the reservation must hold for whatever cap a caller picks.
|
||||
this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE))
|
||||
this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath)
|
||||
@@ -1003,12 +1014,38 @@ export class OrcaRuntimeRpcServer {
|
||||
await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
|
||||
this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({
|
||||
userDataPath: this.userDataPath,
|
||||
ownedPid: this.pid,
|
||||
ownedRuntimeId: this.runtime.getRuntimeId(),
|
||||
pollIntervalMs: this.metadataOwnershipPollMs,
|
||||
republish: () => {
|
||||
// Why: never advertise endpoints we already tore down.
|
||||
if (this.activeTransports.length === 0) {
|
||||
return
|
||||
}
|
||||
this.writeMetadata()
|
||||
},
|
||||
onReclaim: (previous) => {
|
||||
console.warn(
|
||||
`[runtime] Reclaimed orca-runtime.json from a dead runtime (pid ${previous?.pid ?? 'none'}); republished pid ${this.pid}.`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Why: test-only seam — runs one ownership check instead of waiting out the poll interval. */
|
||||
checkRuntimeMetadataOwnership(): void {
|
||||
this.metadataOwnershipWatch?.check()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const transports = this.activeTransports
|
||||
this.activeTransports = []
|
||||
this.transports = []
|
||||
this.metadataOwnershipWatch?.stop()
|
||||
this.metadataOwnershipWatch = null
|
||||
this.mobileSocketWiring = null
|
||||
if (transports.length === 0) {
|
||||
return
|
||||
|
||||
@@ -160,7 +160,8 @@ function installSftpHandlers(sftp: SFTPWrapper, backingRoot: string, operations:
|
||||
async function startSftpWireServer(backingRoot: string): Promise<SftpWireServer> {
|
||||
const operations: string[] = []
|
||||
const connections = new Set<Connection>()
|
||||
const hostKey = utils.generateKeyPairSync('ed25519').private
|
||||
// Ed25519 keygen can produce an invalid 31-byte key; ECDSA points always start with 0x04.
|
||||
const hostKey = utils.generateKeyPairSync('ecdsa', { bits: 256 }).private
|
||||
const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => {
|
||||
connections.add(connection)
|
||||
connection.on('error', () => {})
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { branchCompare, type GitExec } from './git-handler-ops'
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('relay branchCompare', () => {
|
||||
it('launches independent Git reads before waiting for any result', async () => {
|
||||
const branch = deferred<{ stdout: string; stderr: string }>()
|
||||
const head = deferred<{ stdout: string; stderr: string }>()
|
||||
const base = deferred<{ stdout: string; stderr: string }>()
|
||||
const changes = deferred<Record<string, unknown>[]>()
|
||||
const git = vi.fn<GitExec>((args) => {
|
||||
if (args[0] === 'branch') {
|
||||
return branch.promise
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD')) {
|
||||
return head.promise
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
return base.promise
|
||||
}
|
||||
if (args[0] === 'merge-base') {
|
||||
return Promise.resolve({ stdout: 'merge-base\n', stderr: '' })
|
||||
}
|
||||
if (args[0] === 'rev-list') {
|
||||
return Promise.resolve({ stdout: '1\n', stderr: '' })
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
const loadBranchChanges = vi.fn(() => changes.promise)
|
||||
|
||||
const pending = branchCompare(git, '/repo', 'origin/main', loadBranchChanges)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(git.mock.calls.map(([args]) => args.join(' '))).toEqual([
|
||||
'branch --show-current',
|
||||
'rev-parse --verify HEAD',
|
||||
'rev-parse --verify origin/main'
|
||||
])
|
||||
|
||||
branch.resolve({ stdout: 'feature\n', stderr: '' })
|
||||
head.resolve({ stdout: 'head\n', stderr: '' })
|
||||
base.resolve({ stdout: 'base\n', stderr: '' })
|
||||
await vi.waitFor(() => expect(loadBranchChanges).toHaveBeenCalledOnce())
|
||||
expect(git.mock.calls.some(([args]) => args[0] === 'rev-list')).toBe(true)
|
||||
changes.resolve([{ path: 'file.ts' }])
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
summary: { compareRef: 'feature', changedFiles: 1, commitsAhead: 1, status: 'ready' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -154,27 +154,29 @@ export async function branchCompare(
|
||||
status: 'loading'
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: branchOut } = await git(['branch', '--show-current'], worktreePath)
|
||||
const branch = branchOut.trim()
|
||||
if (branch) {
|
||||
summary.compareRef = branch
|
||||
}
|
||||
} catch {
|
||||
/* keep HEAD */
|
||||
}
|
||||
|
||||
let headOid: string
|
||||
let baseOid = ''
|
||||
try {
|
||||
const { stdout } = await git(['rev-parse', '--verify', 'HEAD'], worktreePath)
|
||||
headOid = stdout.trim()
|
||||
summary.headOid = headOid
|
||||
} catch {
|
||||
const readCompareRef = async (): Promise<string> => {
|
||||
try {
|
||||
const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath)
|
||||
baseOid = stdout.trim()
|
||||
summary.baseOid = baseOid
|
||||
const { stdout } = await git(['branch', '--show-current'], worktreePath)
|
||||
return stdout.trim() || 'HEAD'
|
||||
} catch {
|
||||
return 'HEAD'
|
||||
}
|
||||
}
|
||||
const readOid = (ref: string) =>
|
||||
git(['rev-parse', '--verify', ref], worktreePath).then(
|
||||
({ stdout }) => ({ ok: true as const, oid: stdout.trim() }),
|
||||
(error) => ({ ok: false as const, error })
|
||||
)
|
||||
const [compareRef, headOidResult, baseOidResult] = await Promise.all([
|
||||
readCompareRef(),
|
||||
readOid('HEAD'),
|
||||
readOid(baseRef)
|
||||
])
|
||||
summary.compareRef = compareRef
|
||||
|
||||
if (!headOidResult.ok) {
|
||||
if (baseOidResult.ok) {
|
||||
summary.baseOid = baseOidResult.oid
|
||||
// Why: new remote worktrees can be on an unborn branch until the first
|
||||
// commit. There are no committed branch changes yet; surfacing this as a
|
||||
// compare error makes the source-control panel look broken.
|
||||
@@ -182,9 +184,6 @@ export async function branchCompare(
|
||||
summary.commitsAhead = 0
|
||||
summary.status = 'ready'
|
||||
return { summary, entries: [] }
|
||||
} catch {
|
||||
// Preserve the existing unborn-head message when even the base is not
|
||||
// resolvable; callers cannot compare or present a useful empty state.
|
||||
}
|
||||
summary.status = 'unborn-head'
|
||||
summary.errorMessage =
|
||||
@@ -192,15 +191,15 @@ export async function branchCompare(
|
||||
return { summary, entries: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await git(['rev-parse', '--verify', baseRef], worktreePath)
|
||||
baseOid = stdout.trim()
|
||||
summary.baseOid = baseOid
|
||||
} catch {
|
||||
const headOid = headOidResult.oid
|
||||
summary.headOid = headOid
|
||||
if (!baseOidResult.ok) {
|
||||
summary.status = 'invalid-base'
|
||||
summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.`
|
||||
return { summary, entries: [] }
|
||||
}
|
||||
const baseOid = baseOidResult.oid
|
||||
summary.baseOid = baseOid
|
||||
|
||||
let mergeBase: string
|
||||
try {
|
||||
@@ -214,11 +213,10 @@ export async function branchCompare(
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await loadBranchChanges(mergeBase, headOid)
|
||||
const { stdout: countOut } = await git(
|
||||
['rev-list', '--count', `${baseOid}..${headOid}`],
|
||||
worktreePath
|
||||
)
|
||||
const [entries, { stdout: countOut }] = await Promise.all([
|
||||
loadBranchChanges(mergeBase, headOid),
|
||||
git(['rev-list', '--count', `${baseOid}..${headOid}`], worktreePath)
|
||||
])
|
||||
summary.changedFiles = entries.length
|
||||
summary.commitsAhead = Number.parseInt(countOut.trim(), 10) || 0
|
||||
summary.status = 'ready'
|
||||
|
||||
@@ -782,14 +782,16 @@ export class GitHandler {
|
||||
const gitBound = this.git.bind(this)
|
||||
return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => {
|
||||
// Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8; without it parseBranchDiff would get C-style octal-escaped paths.
|
||||
const { stdout } = await gitBound(
|
||||
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
|
||||
worktreePath
|
||||
)
|
||||
const { stdout: numstat } = await gitBound(
|
||||
['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid],
|
||||
worktreePath
|
||||
)
|
||||
const [{ stdout }, { stdout: numstat }] = await Promise.all([
|
||||
gitBound(
|
||||
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
|
||||
worktreePath
|
||||
),
|
||||
gitBound(
|
||||
['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid],
|
||||
worktreePath
|
||||
)
|
||||
])
|
||||
return parseBranchDiff(stdout, parseNumstat(numstat))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ import { translate } from '@/i18n/i18n'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host'
|
||||
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
|
||||
import {
|
||||
combineTerminalWorktreeParkIds,
|
||||
useManualTerminalWorktreeParking
|
||||
} from './terminal-pane/use-manual-terminal-worktree-parking'
|
||||
|
||||
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
|
||||
|
||||
@@ -738,6 +742,15 @@ function Terminal(): React.JSX.Element | null {
|
||||
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const manuallyParkedTerminalWorktreeIds = useManualTerminalWorktreeParking({
|
||||
activeView,
|
||||
renderedActiveWorktreeId
|
||||
})
|
||||
const effectiveParkedTerminalWorktreeIds = useMemo(
|
||||
() =>
|
||||
combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds),
|
||||
[manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds]
|
||||
)
|
||||
// Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs.
|
||||
const backgroundMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
// Why: only cold-activation deferral (not targeted mounts, which share the map above) creates watcher coverage for every unmounted tab.
|
||||
@@ -1055,9 +1068,9 @@ function Terminal(): React.JSX.Element | null {
|
||||
const coldWorktreePresentationTargets = useMemo(
|
||||
() =>
|
||||
activeView === 'terminal' && activeTabType === 'terminal'
|
||||
? parkedTerminalWorktreeIds
|
||||
? effectiveParkedTerminalWorktreeIds
|
||||
: new Set<string>(),
|
||||
[activeTabType, activeView, parkedTerminalWorktreeIds]
|
||||
[activeTabType, activeView, effectiveParkedTerminalWorktreeIds]
|
||||
)
|
||||
const {
|
||||
presentationByScope: worktreePresentationByScope,
|
||||
@@ -1088,7 +1101,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const parked =
|
||||
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
|
||||
!shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id)
|
||||
if (parked) {
|
||||
for (const tab of tabs) {
|
||||
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
|
||||
@@ -1135,7 +1148,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
backgroundMountRevision,
|
||||
getEffectiveLayoutForWorktree,
|
||||
groupsByWorktree,
|
||||
parkedTerminalWorktreeIds,
|
||||
effectiveParkedTerminalWorktreeIds,
|
||||
pendingStartupByTabId,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
@@ -2107,9 +2120,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
!shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${workspace.id}`}
|
||||
@@ -2159,9 +2170,8 @@ function Terminal(): React.JSX.Element | null {
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
effectiveParkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '@testing-library/jest-dom/vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { AgentKanbanBoard } from './AgentKanbanBoard'
|
||||
|
||||
// Stub the card and dialog so the board test stays free of xterm / Radix
|
||||
@@ -11,10 +12,12 @@ import { AgentKanbanBoard } from './AgentKanbanBoard'
|
||||
vi.mock('./AgentKanbanCard', () => ({
|
||||
AgentKanbanCard: ({
|
||||
card,
|
||||
repoIcon,
|
||||
now,
|
||||
onOpenTerminal
|
||||
}: {
|
||||
card: DashboardCard
|
||||
repoIcon?: RepoIcon | null
|
||||
now: number
|
||||
onOpenTerminal: (card: DashboardCard) => void
|
||||
}) => (
|
||||
@@ -23,6 +26,7 @@ vi.mock('./AgentKanbanCard', () => ({
|
||||
data-bucket={card.bucket}
|
||||
data-unseen={card.unseen}
|
||||
data-now={now}
|
||||
data-repo-icon={repoIcon === null ? 'none' : JSON.stringify(repoIcon)}
|
||||
onClick={() => onOpenTerminal(card)}
|
||||
>
|
||||
{card.worktreeName}
|
||||
@@ -70,8 +74,11 @@ function card(overrides: Partial<DashboardCard>): DashboardCard {
|
||||
}
|
||||
}
|
||||
|
||||
function renderBoard(cards: DashboardCard[]): void {
|
||||
const snapshot: DashboardSnapshot = { generatedAt: 1, cards }
|
||||
function renderBoard(
|
||||
cards: DashboardCard[],
|
||||
repoIconsByRepoId?: Record<string, RepoIcon | null>
|
||||
): void {
|
||||
const snapshot: DashboardSnapshot = { generatedAt: 1, cards, repoIconsByRepoId }
|
||||
render(<AgentKanbanBoard snapshot={snapshot} />)
|
||||
}
|
||||
|
||||
@@ -108,6 +115,30 @@ describe('AgentKanbanBoard', () => {
|
||||
expect(screen.getByText('3 total')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('leaves every column border neutral now that cards carry the state color', () => {
|
||||
renderBoard([card({ bucket: 'attention' })])
|
||||
for (const column of document.querySelectorAll('section')) {
|
||||
expect(column.className).toContain('border-border/60')
|
||||
expect(column.className).not.toContain('amber')
|
||||
}
|
||||
})
|
||||
|
||||
it('routes each card its own repo icon', () => {
|
||||
renderBoard(
|
||||
[
|
||||
card({ repoId: 'r1', worktreeName: 'from-r1' }),
|
||||
card({ repoId: 'r2', worktreeName: 'from-r2' }),
|
||||
card({ repoId: 'r3', worktreeName: 'from-r3' })
|
||||
],
|
||||
{ r1: { type: 'lucide', name: 'Rocket' }, r2: null }
|
||||
)
|
||||
|
||||
expect(screen.getByText('from-r1').dataset.repoIcon).toBe('{"type":"lucide","name":"Rocket"}')
|
||||
expect(screen.getByText('from-r2').dataset.repoIcon).toBe('none')
|
||||
// Unknown repo → the card's own default glyph, never another repo's icon.
|
||||
expect(screen.getByText('from-r3').dataset.repoIcon).toBe('none')
|
||||
})
|
||||
|
||||
it('shows "None" for empty columns', () => {
|
||||
renderBoard([card({ bucket: 'working' })])
|
||||
// attention and idle are empty → two "None" placeholders.
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
type DashboardCard,
|
||||
type DashboardSnapshot
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { AgentKanbanCard } from './AgentKanbanCard'
|
||||
import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog'
|
||||
@@ -58,22 +60,20 @@ function groupByBucket(cards: DashboardCard[]): Record<DashboardBucket, Dashboar
|
||||
function KanbanColumn({
|
||||
bucket,
|
||||
cards,
|
||||
repoIconsByRepoId,
|
||||
now,
|
||||
onOpenTerminal
|
||||
}: {
|
||||
bucket: DashboardBucket
|
||||
cards: DashboardCard[]
|
||||
repoIconsByRepoId: Record<string, RepoIcon | null> | undefined
|
||||
now: number
|
||||
onOpenTerminal: (card: DashboardCard) => void
|
||||
}): React.JSX.Element {
|
||||
const highlight = bucket === 'attention' && cards.length > 0
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'flex min-w-[264px] flex-1 flex-col rounded-xl border bg-muted/30',
|
||||
highlight ? 'border-amber-500/40' : 'border-border/60'
|
||||
)}
|
||||
>
|
||||
// Why: attention no longer tints the whole column — the cards inside carry
|
||||
// their own state color, so a column border would double-signal it.
|
||||
<section className="flex min-w-[264px] flex-1 flex-col rounded-xl border border-border/60 bg-muted/30">
|
||||
<header className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{bucketLabel(bucket)}
|
||||
@@ -92,6 +92,7 @@ function KanbanColumn({
|
||||
<AgentKanbanCard
|
||||
key={card.paneKey}
|
||||
card={card}
|
||||
repoIcon={repoIconsByRepoId?.[card.repoId] ?? null}
|
||||
now={now}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
@@ -192,54 +193,60 @@ export function AgentKanbanBoard({
|
||||
}, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<h1 className="text-[13px] font-semibold">
|
||||
{translate('dashboardPopout.title', 'Agents')}
|
||||
</h1>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{translate('dashboardPopout.total', '{{count}} total', {
|
||||
count: snapshot.cards.length
|
||||
})}
|
||||
</span>
|
||||
{headerActions || onClose ? (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{headerActions}
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={translate('dashboardPopout.close', 'Close dashboard')}
|
||||
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
|
||||
{/* Why: columns share the window width up to a readable cap; mx-auto
|
||||
// Why: the pop-out is its own React root with no app-level provider, and the
|
||||
// card's repo tooltip needs one in both hosts. Nesting inside the main
|
||||
// window's provider is harmless.
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<h1 className="text-[13px] font-semibold">
|
||||
{translate('dashboardPopout.title', 'Agents')}
|
||||
</h1>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{translate('dashboardPopout.total', '{{count}} total', {
|
||||
count: snapshot.cards.length
|
||||
})}
|
||||
</span>
|
||||
{headerActions || onClose ? (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{headerActions}
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={translate('dashboardPopout.close', 'Close dashboard')}
|
||||
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
|
||||
{/* Why: columns share the window width up to a readable cap; mx-auto
|
||||
centers the capped board so leftover space splits evenly instead of
|
||||
pooling on the right. In overflow the auto margins collapse to 0,
|
||||
keeping the left edge reachable while scrolling. */}
|
||||
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
|
||||
{DASHBOARD_BUCKET_ORDER.map((bucket) => (
|
||||
<KanbanColumn
|
||||
key={bucket}
|
||||
bucket={bucket}
|
||||
cards={grouped[bucket]}
|
||||
now={now}
|
||||
onOpenTerminal={handleOpenTerminal}
|
||||
/>
|
||||
))}
|
||||
<div className="mx-auto flex w-full max-w-[1280px] gap-3">
|
||||
{DASHBOARD_BUCKET_ORDER.map((bucket) => (
|
||||
<KanbanColumn
|
||||
key={bucket}
|
||||
bucket={bucket}
|
||||
cards={grouped[bucket]}
|
||||
repoIconsByRepoId={snapshot.repoIconsByRepoId}
|
||||
now={now}
|
||||
onOpenTerminal={handleOpenTerminal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AgentTerminalDialog
|
||||
card={dialogCard}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onReveal={onRevealAgent}
|
||||
/>
|
||||
</div>
|
||||
<AgentTerminalDialog
|
||||
card={dialogCard}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
onReveal={onRevealAgent}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AgentKanbanCard } from './AgentKanbanCard'
|
||||
|
||||
const agentIconRender = vi.fn()
|
||||
@@ -42,6 +44,24 @@ function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(props: {
|
||||
card: DashboardCard
|
||||
now: number
|
||||
repoIcon?: RepoIcon | null
|
||||
onOpenTerminal?: () => void
|
||||
}): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={props.card}
|
||||
repoIcon={props.repoIcon}
|
||||
now={props.now}
|
||||
onOpenTerminal={props.onOpenTerminal ?? vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AgentKanbanCard', () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
@@ -53,9 +73,7 @@ describe('AgentKanbanCard', () => {
|
||||
})
|
||||
|
||||
it('does not render an invented age when the start time is unknown', () => {
|
||||
render(
|
||||
<AgentKanbanCard card={card({ startedAt: 0 })} now={2_000_000_000} onOpenTerminal={vi.fn()} />
|
||||
)
|
||||
renderCard({ card: card({ startedAt: 0 }), now: 2_000_000_000 })
|
||||
|
||||
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -66,47 +84,160 @@ describe('AgentKanbanCard', () => {
|
||||
dotState: 'waiting',
|
||||
askSummary: 'Approve deploy?'
|
||||
})
|
||||
const { container, rerender } = render(
|
||||
<AgentKanbanCard card={attentionCard} now={2_000} onOpenTerminal={vi.fn()} />
|
||||
)
|
||||
const { container, rerender } = renderCard({ card: attentionCard, now: 2_000 })
|
||||
|
||||
expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1)
|
||||
|
||||
rerender(
|
||||
<AgentKanbanCard
|
||||
card={{ ...attentionCard, askSummary: undefined }}
|
||||
now={2_000}
|
||||
onOpenTerminal={vi.fn()}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...attentionCard, askSummary: undefined }}
|
||||
now={2_000}
|
||||
onOpenTerminal={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.getByTestId('state-dot')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull()
|
||||
})
|
||||
|
||||
it('tints attention amber and done green, leaving every other state neutral', () => {
|
||||
const { container: attention } = renderCard({
|
||||
card: card({ bucket: 'attention', dotState: 'waiting' }),
|
||||
now: 2_000
|
||||
})
|
||||
expect(attention.querySelector('button')?.className).toContain('border-amber-500/40')
|
||||
|
||||
cleanup()
|
||||
const { container: done } = renderCard({
|
||||
card: card({ bucket: 'idle', dotState: 'done' }),
|
||||
now: 2_000
|
||||
})
|
||||
expect(done.querySelector('button')?.className).toContain('border-emerald-500/40')
|
||||
|
||||
cleanup()
|
||||
const { container: idle } = renderCard({
|
||||
card: card({ bucket: 'idle', dotState: 'idle' }),
|
||||
now: 2_000
|
||||
})
|
||||
const idleClassName = idle.querySelector('button')?.className ?? ''
|
||||
expect(idleClassName).toContain('border-border/60')
|
||||
expect(idleClassName).not.toContain('emerald')
|
||||
expect(idleClassName).not.toContain('amber')
|
||||
})
|
||||
|
||||
it('heads the card with the conversation name and drops the worktree to the footer', () => {
|
||||
const { container } = renderCard({
|
||||
card: card({ lastUserMessage: 'ship it', conversationName: 'Sparse-checkout parser' }),
|
||||
now: 2_000
|
||||
})
|
||||
|
||||
const [header, footer] = [
|
||||
container.querySelector('button')!.firstElementChild!,
|
||||
container.querySelector('button')!.lastElementChild!
|
||||
]
|
||||
expect(header).toHaveTextContent('Sparse-checkout parser')
|
||||
expect(header).not.toHaveTextContent('dashboard-review')
|
||||
expect(footer).toHaveTextContent('dashboard-review')
|
||||
// The message line is attributed to the user again — the name moved up.
|
||||
expect(screen.getByText('You')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('heads the card with the worktree when no name resolves, without repeating it', () => {
|
||||
const { container } = renderCard({ card: card({ lastUserMessage: 'ship it' }), now: 2_000 })
|
||||
|
||||
expect(screen.getAllByText('dashboard-review')).toHaveLength(1)
|
||||
expect(container.querySelector('button')!.firstElementChild).toHaveTextContent(
|
||||
'dashboard-review'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the repo as an icon labelled with its name instead of inline text', () => {
|
||||
renderCard({
|
||||
card: card(),
|
||||
now: 2_000,
|
||||
repoIcon: { type: 'emoji', emoji: '🐳' }
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Orca')).toBeInTheDocument()
|
||||
expect(screen.getByText('🐳')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips structured-clone rerenders until visible card data or its age changes', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
const initial = card({ startedAt: 1_000 })
|
||||
const repoIcon: RepoIcon = { type: 'lucide', name: 'Rocket' }
|
||||
const { rerender } = render(
|
||||
<AgentKanbanCard card={initial} now={61_500} onOpenTerminal={onOpenTerminal} />
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={initial}
|
||||
repoIcon={repoIcon}
|
||||
now={61_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('1m')).toBeInTheDocument()
|
||||
|
||||
rerender(<AgentKanbanCard card={{ ...initial }} now={62_000} onOpenTerminal={onOpenTerminal} />)
|
||||
// A fresh structured clone of identical data — including the repo icon.
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial }}
|
||||
repoIcon={{ ...repoIcon }}
|
||||
now={62_000}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<AgentKanbanCard card={{ ...initial }} now={121_500} onOpenTerminal={onOpenTerminal} />
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial }}
|
||||
repoIcon={{ ...repoIcon }}
|
||||
now={121_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByText('2m')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates the relative age when the UI language changes', async () => {
|
||||
render(
|
||||
<AgentKanbanCard card={card({ startedAt: 1_000 })} now={121_500} onOpenTerminal={vi.fn()} />
|
||||
it('rerenders when the repo icon changes', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
const initial = card({ startedAt: 1_000 })
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={initial}
|
||||
repoIcon={{ type: 'lucide', name: 'Rocket' }}
|
||||
now={61_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<AgentKanbanCard
|
||||
card={{ ...initial }}
|
||||
repoIcon={{ type: 'lucide', name: 'Database' }}
|
||||
now={61_500}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(agentIconRender).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('updates the relative age when the UI language changes', async () => {
|
||||
renderCard({ card: card({ startedAt: 1_000 }), now: 121_500 })
|
||||
expect(screen.getByText('2m')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -4,8 +4,11 @@ import { MessageCircleQuestion } from 'lucide-react'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import { AgentStateDot } from '@/components/AgentStateDot'
|
||||
import { RepoIconGlyph } from '@/components/repo/repo-icon'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
/** Compact "started N ago" (the card is glanceable — coarse units are fine). */
|
||||
@@ -53,12 +56,34 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean {
|
||||
a.finishedAt === b.finishedAt &&
|
||||
a.stateChangedAt === b.stateChangedAt &&
|
||||
a.unseen === b.unseen &&
|
||||
a.askSummary === b.askSummary
|
||||
a.askSummary === b.askSummary &&
|
||||
a.conversationName === b.conversationName
|
||||
)
|
||||
}
|
||||
|
||||
/** Structural — the icon arrives inside a fresh structured clone each publish,
|
||||
* so identity alone would re-render every card several times a second. */
|
||||
function sameRepoIcon(a: RepoIcon | null | undefined, b: RepoIcon | null | undefined): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (!a || !b || a.type !== b.type) {
|
||||
return false
|
||||
}
|
||||
if (a.type === 'lucide') {
|
||||
return a.name === (b as typeof a).name
|
||||
}
|
||||
if (a.type === 'emoji') {
|
||||
return a.emoji === (b as typeof a).emoji
|
||||
}
|
||||
const image = b as typeof a
|
||||
return a.src === image.src && a.source === image.source && a.label === image.label
|
||||
}
|
||||
|
||||
type AgentKanbanCardProps = {
|
||||
card: DashboardCard
|
||||
/** The card repo's icon. null renders the default folder glyph. */
|
||||
repoIcon?: RepoIcon | null
|
||||
now: number
|
||||
/** Opens the board-level terminal dialog. The dialog is NOT owned by the
|
||||
* card: bucket moves remount the card, and an embedded dialog would close
|
||||
@@ -68,8 +93,23 @@ type AgentKanbanCardProps = {
|
||||
|
||||
/** One agent on the kanban board. Clicking opens the board's live terminal dialog. */
|
||||
export const AgentKanbanCard = memo(
|
||||
function AgentKanbanCard({ card, now, onOpenTerminal }: AgentKanbanCardProps): React.JSX.Element {
|
||||
function AgentKanbanCard({
|
||||
card,
|
||||
repoIcon = null,
|
||||
now,
|
||||
onOpenTerminal
|
||||
}: AgentKanbanCardProps): React.JSX.Element {
|
||||
useTranslation()
|
||||
// Why: the two outcomes worth scanning for get a tinted card — amber for
|
||||
// "answer me", green for "finished, look at it". Everything else stays
|
||||
// neutral so the tint keeps meaning something.
|
||||
const needsYou = card.bucket === 'attention'
|
||||
const isDone = card.dotState === 'done'
|
||||
// Why: the session's own name heads the card. Without one the worktree is
|
||||
// the best heading left — and then the footer drops it rather than say it
|
||||
// twice.
|
||||
const heading = card.conversationName ?? card.worktreeName
|
||||
const worktreeInFooter = card.conversationName !== undefined
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -80,9 +120,13 @@ export const AgentKanbanCard = memo(
|
||||
// paneKey has ':'/'/' which aren't valid in a custom-ident, so slugify.
|
||||
style={{ viewTransitionName: `agentcard-${card.paneKey.replace(/[^a-zA-Z0-9]/g, '-')}` }}
|
||||
className={cn(
|
||||
'group flex w-full flex-col gap-1.5 rounded-lg border border-border/60 bg-card p-2.5 text-left',
|
||||
'transition-colors hover:border-border hover:bg-accent/40',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
'group flex w-full flex-col gap-1.5 rounded-lg border p-2.5 text-left',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
needsYou
|
||||
? 'border-amber-500/40 bg-amber-500/[0.06] hover:border-amber-500/60 hover:bg-amber-500/10'
|
||||
: isDone
|
||||
? 'border-emerald-500/40 bg-emerald-500/[0.06] hover:border-emerald-500/60 hover:bg-emerald-500/10'
|
||||
: 'border-border/60 bg-card hover:border-border hover:bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -99,7 +143,7 @@ export const AgentKanbanCard = memo(
|
||||
card.unseen ? 'font-semibold text-foreground' : 'font-normal text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{card.worktreeName}
|
||||
{heading}
|
||||
</span>
|
||||
{/* The summary pill already carries the attention glyph. */}
|
||||
{card.askSummary ? null : <AgentStateDot state={card.dotState} className="ml-auto" />}
|
||||
@@ -109,6 +153,7 @@ export const AgentKanbanCard = memo(
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{card.lastUserMessage ? (
|
||||
<div className="line-clamp-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{/* Why: plain "You" again — the session's name now heads the card. */}
|
||||
<span className="font-medium text-foreground/45">
|
||||
{translate('dashboardPopout.card.you', 'You')}
|
||||
</span>{' '}
|
||||
@@ -128,17 +173,34 @@ export const AgentKanbanCard = memo(
|
||||
<div className="line-clamp-2 text-xs leading-snug text-foreground/90">{card.task}</div>
|
||||
) : null}
|
||||
|
||||
{/* Why: the card behind it is amber now, so the pill needs its own edge
|
||||
to stay a distinct chip instead of a flat block of tint. */}
|
||||
{card.askSummary ? (
|
||||
<div className="flex items-start gap-1 rounded-md bg-amber-500/10 px-1.5 py-1 text-[11px] text-amber-600 dark:text-amber-400">
|
||||
<div className="flex items-start gap-1 rounded-md bg-amber-500/15 px-1.5 py-1 text-[11px] text-amber-600 ring-1 ring-inset ring-amber-500/25 dark:text-amber-400">
|
||||
<MessageCircleQuestion className="mt-px size-3 shrink-0" aria-hidden />
|
||||
<span className="line-clamp-2">{card.askSummary}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate font-mono">{card.repoName}</span>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{/* Why: the project reads as an icon so its name can't crowd the
|
||||
worktree sitting next to it; the name lives in the tooltip. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex size-[18px] shrink-0 items-center justify-center rounded-[5px] bg-muted-foreground/10 text-muted-foreground transition-colors group-hover:text-foreground"
|
||||
aria-label={card.repoName}
|
||||
>
|
||||
<RepoIconGlyph repoIcon={repoIcon} className="size-3" iconClassName="size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{card.repoName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{worktreeInFooter ? <span className="truncate">{card.worktreeName}</span> : null}
|
||||
{displayTimestamp(card) > 0 ? (
|
||||
<span className="ml-auto shrink-0 tabular-nums">
|
||||
<span className="ml-auto shrink-0 pl-1 tabular-nums">
|
||||
{formatStartedAgo(displayTimestamp(card), now)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -149,6 +211,7 @@ export const AgentKanbanCard = memo(
|
||||
(previous, next) =>
|
||||
previous.onOpenTerminal === next.onOpenTerminal &&
|
||||
sameCard(previous.card, next.card) &&
|
||||
sameRepoIcon(previous.repoIcon, next.repoIcon) &&
|
||||
(displayTimestamp(previous.card) <= 0 ||
|
||||
formatStartedAgo(displayTimestamp(previous.card), previous.now) ===
|
||||
formatStartedAgo(displayTimestamp(next.card), next.now))
|
||||
|
||||
@@ -114,6 +114,73 @@ describe('buildDashboardSnapshot', () => {
|
||||
expect(card.unseen).toBe(true)
|
||||
})
|
||||
|
||||
it('carries the tab conversation name and drops status-only titles', () => {
|
||||
const named = buildDashboardSnapshot(
|
||||
baseState({
|
||||
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
|
||||
tabsByWorktree: { w1: [{ ...tab(), customTitle: 'Sparse-checkout parser' }] }
|
||||
}),
|
||||
NOW
|
||||
)
|
||||
expect(named.cards[0].conversationName).toBe('Sparse-checkout parser')
|
||||
|
||||
// The fixture tab's title is the 'agent' placeholder — not a name.
|
||||
const unnamed = buildDashboardSnapshot(
|
||||
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
|
||||
NOW
|
||||
)
|
||||
expect(unnamed.cards[0].conversationName).toBeUndefined()
|
||||
})
|
||||
|
||||
it('withholds generated titles until the setting enables them', () => {
|
||||
const tabs = { w1: [{ ...tab(), generatedTitle: 'Fix the flaky pty test' }] }
|
||||
const off = buildDashboardSnapshot(
|
||||
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, tabsByWorktree: tabs }),
|
||||
NOW
|
||||
)
|
||||
expect(off.cards[0].conversationName).toBeUndefined()
|
||||
|
||||
const on = buildDashboardSnapshot(
|
||||
baseState({
|
||||
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
|
||||
tabsByWorktree: tabs,
|
||||
settings: { tabAutoGenerateTitle: true }
|
||||
} as unknown as Partial<DashboardSnapshotState>),
|
||||
NOW
|
||||
)
|
||||
expect(on.cards[0].conversationName).toBe('Fix the flaky pty test')
|
||||
})
|
||||
|
||||
it('ships one icon per card-bearing repo, and none for repos without cards', () => {
|
||||
const snapshot = buildDashboardSnapshot(
|
||||
baseState({
|
||||
repos: [
|
||||
{
|
||||
id: 'r1',
|
||||
path: '/r1',
|
||||
displayName: 'Repo One',
|
||||
badgeColor: '#000',
|
||||
repoIcon: { type: 'lucide', name: 'Rocket' }
|
||||
},
|
||||
{ id: 'r2', path: '/r2', displayName: 'Repo Two', badgeColor: '#000' }
|
||||
],
|
||||
worktreesByRepo: { r1: [worktree()], r2: [worktree('w2', 'wt-two')] },
|
||||
agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
|
||||
} as unknown as Partial<DashboardSnapshotState>),
|
||||
NOW
|
||||
)
|
||||
// r2 has a worktree but no agent card, so its icon never ships.
|
||||
expect(snapshot.repoIconsByRepoId).toEqual({ r1: { type: 'lucide', name: 'Rocket' } })
|
||||
})
|
||||
|
||||
it('records a null icon for a card-bearing repo that has none', () => {
|
||||
const snapshot = buildDashboardSnapshot(
|
||||
baseState({ agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }),
|
||||
NOW
|
||||
)
|
||||
expect(snapshot.repoIconsByRepoId).toEqual({ r1: null })
|
||||
})
|
||||
|
||||
it('nulls ptyId when the layout entry points at a dead pty', () => {
|
||||
const snapshot = buildDashboardSnapshot(
|
||||
baseState({
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
DashboardCardDotState,
|
||||
DashboardSnapshot
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
import type { RepoIcon } from '../../../../shared/repo-icon'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
|
||||
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
|
||||
import { applyAgentRowLineage } from './agent-row-lineage'
|
||||
import { lastEnteredDoneAt } from './agent-finished-timestamp'
|
||||
@@ -43,6 +45,7 @@ export type DashboardSnapshotState = Pick<
|
||||
| 'ptyIdsByTabId'
|
||||
| 'runtimePaneTitlesByTabId'
|
||||
| 'acknowledgedAgentsByPaneKey'
|
||||
| 'settings'
|
||||
>
|
||||
|
||||
function bucketForState(state: DashboardAgentRow['state']): DashboardBucket {
|
||||
@@ -70,6 +73,24 @@ function nonEmpty(value: string | undefined): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
/** Mirrors useAgentRowConversationName so the board and the sidebar label the
|
||||
* same agent with the same name. */
|
||||
function rowConversationName(
|
||||
row: DashboardAgentRow,
|
||||
generatedTitlesEnabled: boolean
|
||||
): string | undefined {
|
||||
const parentPaneKey = row.entry.orchestration?.parentPaneKey
|
||||
// Why: a child row rendered on its parent's tab does not own that tab's name.
|
||||
if (
|
||||
row.lineage?.depth === 1 &&
|
||||
parentPaneKey !== undefined &&
|
||||
parsePaneKey(parentPaneKey)?.tabId === row.tab.id
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the serializable dashboard snapshot from the live renderer store.
|
||||
* Reuses the exact per-worktree row machinery the sidebar uses
|
||||
@@ -82,6 +103,8 @@ export function buildDashboardSnapshot(
|
||||
now: number
|
||||
): DashboardSnapshot {
|
||||
const cards: DashboardCard[] = []
|
||||
const repoIconsByRepoId: Record<string, RepoIcon | null> = {}
|
||||
const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true
|
||||
const activeWorktrees: {
|
||||
repo: AppState['repos'][number]
|
||||
worktree: AppState['worktreesByRepo'][string][number]
|
||||
@@ -169,6 +192,8 @@ export function buildDashboardSnapshot(
|
||||
: null
|
||||
const dotState = row.state as DashboardCardDotState
|
||||
const bucket = bucketForState(row.state)
|
||||
// Only repos that actually contribute a card ship their icon.
|
||||
repoIconsByRepoId[repo.id] = repo.repoIcon ?? null
|
||||
|
||||
cards.push({
|
||||
paneKey: row.paneKey,
|
||||
@@ -193,10 +218,11 @@ export function buildDashboardSnapshot(
|
||||
unseen:
|
||||
!isTitleDerived &&
|
||||
(state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt,
|
||||
askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined
|
||||
askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined,
|
||||
conversationName: rowConversationName(row, generatedTitlesEnabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { generatedAt: now, cards }
|
||||
return { generatedAt: now, cards, repoIconsByRepoId }
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ export function useAgentBucketCounts(): AgentBucketCounts {
|
||||
ptyIdsByTabId,
|
||||
runtimePaneTitlesByTabId,
|
||||
// Counts do not render acknowledgement state, so avoid subscribing the sidebar to it.
|
||||
acknowledgedAgentsByPaneKey: {}
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
// Same: counts never render a card's conversation name, so the
|
||||
// generated-title gate is moot and the sidebar stays off settings.
|
||||
settings: null
|
||||
},
|
||||
Date.now()
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
|
||||
ptyIdsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
settings: null,
|
||||
agentStatusEpoch: 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export function dashboardSnapshotInputsChanged(
|
||||
state.ptyIdsByTabId !== previousState.ptyIdsByTabId ||
|
||||
state.runtimePaneTitlesByTabId !== previousState.runtimePaneTitlesByTabId ||
|
||||
state.acknowledgedAgentsByPaneKey !== previousState.acknowledgedAgentsByPaneKey ||
|
||||
// Why: tabAutoGenerateTitle decides whether cards may show generated names.
|
||||
state.settings !== previousState.settings ||
|
||||
// Why: freshness can change a bucket without replacing any backing map.
|
||||
state.agentStatusEpoch !== previousState.agentStatusEpoch
|
||||
)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { GlobalSettings, Repo, TerminalTab, Worktree } from '../../../../shared/types'
|
||||
import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
|
||||
|
||||
const NOW = 1_000_000_000
|
||||
const TAB_ID = 'tab-1'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(initialAppState, true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialAppState, true)
|
||||
})
|
||||
|
||||
function repo(): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo One',
|
||||
badgeColor: '#000',
|
||||
repoIcon: { type: 'lucide', name: 'Rocket' },
|
||||
addedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function worktree(): Worktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt-1',
|
||||
head: 'abc123',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'wt-one',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: NOW
|
||||
}
|
||||
}
|
||||
|
||||
function tab(): TerminalTab {
|
||||
return {
|
||||
id: TAB_ID,
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt-1',
|
||||
title: 'agent',
|
||||
customTitle: null,
|
||||
generatedTitle: 'Fix the flaky pty test',
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW
|
||||
} as TerminalTab
|
||||
}
|
||||
|
||||
function entry(): AgentStatusEntry {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'do the thing',
|
||||
updatedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
stateHistory: [],
|
||||
agentType: 'claude',
|
||||
tabId: TAB_ID,
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
}
|
||||
|
||||
function seed(settings: Partial<GlobalSettings> | null): void {
|
||||
useAppStore.setState({
|
||||
repos: [repo()],
|
||||
worktreesByRepo: { 'repo-1': [worktree()] },
|
||||
tabsByWorktree: { 'wt-1': [tab()] },
|
||||
agentStatusByPaneKey: { [PANE_KEY]: entry() },
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { [TAB_ID]: ['pty-1'] },
|
||||
settings: settings as GlobalSettings | null
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the in-window drawer derives its own snapshot instead of receiving the
|
||||
// relayed one, so anything the builder reads has to be threaded in by hand —
|
||||
// a dropped slice silently blanks the field rather than failing loudly.
|
||||
describe('useLiveDashboardSnapshot', () => {
|
||||
it('feeds the builder the settings that gate generated conversation names', () => {
|
||||
seed({ tabAutoGenerateTitle: true })
|
||||
const withTitles = renderHook(() => useLiveDashboardSnapshot())
|
||||
expect(withTitles.result.current.cards[0].conversationName).toBe('Fix the flaky pty test')
|
||||
|
||||
seed({ tabAutoGenerateTitle: false })
|
||||
const withoutTitles = renderHook(() => useLiveDashboardSnapshot())
|
||||
expect(withoutTitles.result.current.cards[0].conversationName).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries repo icons through to the drawer', () => {
|
||||
seed({ tabAutoGenerateTitle: false })
|
||||
const { result } = renderHook(() => useLiveDashboardSnapshot())
|
||||
expect(result.current.repoIconsByRepoId).toEqual({
|
||||
'repo-1': { type: 'lucide', name: 'Rocket' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
|
||||
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
|
||||
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
|
||||
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
|
||||
// Why: gates generated tab titles in the cards' conversation names.
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
// Why: freshness can flip a bucket without any backing map changing; the epoch
|
||||
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
|
||||
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
|
||||
@@ -43,7 +45,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
|
||||
terminalLayoutsByTabId,
|
||||
ptyIdsByTabId,
|
||||
runtimePaneTitlesByTabId,
|
||||
acknowledgedAgentsByPaneKey
|
||||
acknowledgedAgentsByPaneKey,
|
||||
settings
|
||||
},
|
||||
Date.now()
|
||||
),
|
||||
@@ -60,6 +63,7 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot {
|
||||
ptyIdsByTabId,
|
||||
runtimePaneTitlesByTabId,
|
||||
acknowledgedAgentsByPaneKey,
|
||||
settings,
|
||||
agentStatusEpoch
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
type DocLinkTextNodeContext = {
|
||||
marks: readonly { type: { name: string } }[]
|
||||
}
|
||||
|
||||
type DocLinkParentContext = {
|
||||
type: { spec: { code?: boolean } }
|
||||
}
|
||||
|
||||
// Why: inline/fenced code content is literal markdown; auto-converting it would
|
||||
// corrupt examples and commands that intentionally contain [[...]] text.
|
||||
export function isDocLinkLiteralCodeTextNode(
|
||||
node: DocLinkTextNodeContext,
|
||||
parent: DocLinkParentContext | null
|
||||
): boolean {
|
||||
return parent?.type.spec.code === true || node.marks.some((mark) => mark.type.name === 'code')
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const DOC_LINK_PATTERN = /\[\[([^[\]\r\n]+)\]\]/g
|
||||
const DOC_LINK_OPEN = '[['
|
||||
|
||||
type DocLinkTextNodeContext = {
|
||||
type: { name: string }
|
||||
text?: string
|
||||
marks: readonly { type: { name: string } }[]
|
||||
}
|
||||
|
||||
type DocLinkParentContext = {
|
||||
type: { spec: { code?: boolean } }
|
||||
}
|
||||
|
||||
export function isDocLinkLiteralCodeTextNode(
|
||||
node: Pick<DocLinkTextNodeContext, 'marks'>,
|
||||
parent: DocLinkParentContext | null
|
||||
): boolean {
|
||||
return parent?.type.spec.code === true || node.marks.some((mark) => mark.type.name === 'code')
|
||||
}
|
||||
|
||||
// A link match requires the exact ASCII opener, including for non-ASCII targets.
|
||||
export function canHoldDocLink(
|
||||
node: DocLinkTextNodeContext,
|
||||
parent: DocLinkParentContext | null
|
||||
): node is DocLinkTextNodeContext & { text: string } {
|
||||
return (
|
||||
node.type.name === 'text' &&
|
||||
!!node.text &&
|
||||
node.text.includes(DOC_LINK_OPEN) &&
|
||||
!isDocLinkLiteralCodeTextNode(node, parent)
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
|
||||
import { canHoldDocLink, DOC_LINK_PATTERN } from './rich-markdown-doc-link-scan'
|
||||
|
||||
function textContext(markNames: string[] = []) {
|
||||
function textContext(text: string, markNames: string[] = []) {
|
||||
return {
|
||||
type: { name: 'text' },
|
||||
text,
|
||||
marks: markNames.map((name) => ({ type: { name } }))
|
||||
}
|
||||
}
|
||||
@@ -15,16 +17,30 @@ function parentContext(code: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
describe('isDocLinkLiteralCodeTextNode', () => {
|
||||
it('allows doc link conversion in ordinary prose text', () => {
|
||||
expect(isDocLinkLiteralCodeTextNode(textContext(), parentContext(false))).toBe(false)
|
||||
function scan(text: string, markNames: string[] = [], parentCode = false): string[] {
|
||||
const node = textContext(text, markNames)
|
||||
if (!canHoldDocLink(node, parentContext(parentCode))) {
|
||||
return []
|
||||
}
|
||||
return [...node.text.matchAll(DOC_LINK_PATTERN)].map((match) => match[1])
|
||||
}
|
||||
|
||||
describe('production doc-link scan predicate', () => {
|
||||
it('finds links in ordinary prose, including non-ASCII targets', () => {
|
||||
expect(scan('See [[Guide]] and [[文档/😀]].')).toEqual(['Guide', '文档/😀'])
|
||||
})
|
||||
|
||||
it('skips doc link conversion for inline code marks', () => {
|
||||
expect(isDocLinkLiteralCodeTextNode(textContext(['code']), parentContext(false))).toBe(true)
|
||||
it('rejects ordinary prose without the exact opener', () => {
|
||||
expect(scan('See [Guide], plain prose, and closed brackets]].')).toEqual([])
|
||||
})
|
||||
|
||||
it('skips doc link conversion for fenced code block text', () => {
|
||||
expect(isDocLinkLiteralCodeTextNode(textContext(), parentContext(true))).toBe(true)
|
||||
it('skips inline and fenced code', () => {
|
||||
expect(scan('`[[inline]]`', ['code'])).toEqual([])
|
||||
expect(scan('[[fenced]]', [], true)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not match links across CR or LF boundaries', () => {
|
||||
expect(scan('[[carriage\rreturn]]')).toEqual([])
|
||||
expect(scan('[[line\nfeed]]')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,16 +9,12 @@ import {
|
||||
parseMarkdownDocLink,
|
||||
resolveMarkdownDocLink
|
||||
} from './markdown-doc-links'
|
||||
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
|
||||
import {
|
||||
isReservedRichMarkdownTransportBody,
|
||||
type RichMarkdownSourceTransport
|
||||
} from './rich-markdown-source-transport'
|
||||
import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
|
||||
|
||||
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
|
||||
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
|
||||
const DOC_LINK_PATTERN = /\[\[([^[\]\r\n]+)\]\]/g
|
||||
import { canHoldDocLink, DOC_LINK_PATTERN } from './rich-markdown-doc-link-scan'
|
||||
|
||||
const docLinkDissolveKey = new PluginKey('docLinkDissolve')
|
||||
const docLinkAutoConvertKey = new PluginKey('docLinkAutoConvert')
|
||||
@@ -50,10 +46,7 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
|
||||
const index = getDocIndex(storage)
|
||||
const cursor = state.selection.from
|
||||
state.doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name !== 'text' || !node.text) {
|
||||
return
|
||||
}
|
||||
if (isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
if (!canHoldDocLink(node, parent)) {
|
||||
return
|
||||
}
|
||||
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
|
||||
@@ -279,10 +272,7 @@ export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
|
||||
let modified = false
|
||||
|
||||
newState.doc.descendants((node, pos, parent) => {
|
||||
if (node.type.name !== 'text' || !node.text) {
|
||||
return
|
||||
}
|
||||
if (isDocLinkLiteralCodeTextNode(node, parent)) {
|
||||
if (!canHoldDocLink(node, parent)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { DiffComment } from '../../../../shared/types'
|
||||
import {
|
||||
countRichMarkdownReviewMarkdownLines,
|
||||
getRichMarkdownAnnotationButtonLeft,
|
||||
getRichMarkdownAnnotationButtonTop
|
||||
getRichMarkdownAnnotationButtonTop,
|
||||
getRichMarkdownAnnotationHighlightRanges,
|
||||
getRichMarkdownCommentAtPos
|
||||
} from './rich-markdown-review-annotations'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -47,3 +51,77 @@ describe('getRichMarkdownAnnotationButtonLeft', () => {
|
||||
expect(getRichMarkdownAnnotationButtonLeft(72)).toBe(40)
|
||||
})
|
||||
})
|
||||
|
||||
// Why count serializes: resolving a comment's block re-serializes the document,
|
||||
// so doing it per comment made these O(comments x document). A call count is
|
||||
// deterministic where a wall-clock threshold would be flaky.
|
||||
describe('rich markdown annotation block reuse', () => {
|
||||
function makeEditor(nodeCount: number): { editor: Editor; serializeCalls: () => number } {
|
||||
let serializeCalls = 0
|
||||
const content = Array.from({ length: nodeCount }, (_value, index) => ({
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: `paragraph ${index}` }]
|
||||
}))
|
||||
const doc = {
|
||||
forEach(callback: (node: unknown, offset: number, index: number) => void): void {
|
||||
content.forEach((node, index) => callback(node, index * 10, index))
|
||||
},
|
||||
// The text-range search walks the doc to locate the selected text; these
|
||||
// fixtures never match, so it only needs to be traversable.
|
||||
nodesBetween(): void {},
|
||||
content: { size: nodeCount * 10 }
|
||||
}
|
||||
const editor = {
|
||||
getJSON: () => ({ content }),
|
||||
state: { doc },
|
||||
markdown: {
|
||||
serialize: (value: { content?: unknown[] }) => {
|
||||
serializeCalls += 1
|
||||
return (value.content ?? []).map((_node, index) => `line ${index}`).join('\n')
|
||||
}
|
||||
}
|
||||
} as unknown as Editor
|
||||
return { editor, serializeCalls: () => serializeCalls }
|
||||
}
|
||||
|
||||
function makeComments(count: number): DiffComment[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_value, index) => ({ lineNumber: index + 1, selectedText: 'nothing-matches' }) as DiffComment
|
||||
)
|
||||
}
|
||||
|
||||
// One block build over NODE_COUNT nodes: each node serialized alone, plus each
|
||||
// adjacent pair. Pinned absolutely so "both arms build twice" can't pass as equal.
|
||||
const NODE_COUNT = 12
|
||||
const ONE_BUILD_SERIALIZE_CALLS = NODE_COUNT + (NODE_COUNT - 1)
|
||||
|
||||
it('serializes the document once regardless of comment count', () => {
|
||||
const single = makeEditor(NODE_COUNT)
|
||||
getRichMarkdownAnnotationHighlightRanges(single.editor, makeComments(1), 0)
|
||||
|
||||
const many = makeEditor(NODE_COUNT)
|
||||
getRichMarkdownAnnotationHighlightRanges(many.editor, makeComments(8), 0)
|
||||
|
||||
expect(single.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
|
||||
expect(many.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
|
||||
})
|
||||
|
||||
it('serializes the document once when locating the comment at a position', () => {
|
||||
const single = makeEditor(NODE_COUNT)
|
||||
getRichMarkdownCommentAtPos(single.editor, makeComments(1), 0, 5)
|
||||
|
||||
const many = makeEditor(NODE_COUNT)
|
||||
getRichMarkdownCommentAtPos(many.editor, makeComments(8), 0, 5)
|
||||
|
||||
expect(single.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
|
||||
expect(many.serializeCalls()).toBe(ONE_BUILD_SERIALIZE_CALLS)
|
||||
})
|
||||
|
||||
it('does no work at all with no comments', () => {
|
||||
const none = makeEditor(12)
|
||||
expect(getRichMarkdownAnnotationHighlightRanges(none.editor, [], 0)).toEqual([])
|
||||
expect(getRichMarkdownCommentAtPos(none.editor, [], 0, 5)).toBeNull()
|
||||
expect(none.serializeCalls()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,17 +119,29 @@ export function getRichMarkdownAnnotationHighlightRanges(
|
||||
comments: readonly DiffComment[],
|
||||
markdownSourceLineOffset: number
|
||||
): RichMarkdownAnnotationHighlightRange[] {
|
||||
if (comments.length === 0) {
|
||||
return []
|
||||
}
|
||||
// Why once: block resolution re-serializes the doc; per comment it was O(n*doc).
|
||||
const blocks = buildRichMarkdownCommentBlocks(editor)
|
||||
return comments.flatMap((comment) =>
|
||||
getRichMarkdownAnnotationHighlightRangesForComment(editor, comment, markdownSourceLineOffset)
|
||||
getRichMarkdownAnnotationHighlightRangesForComment(
|
||||
editor,
|
||||
comment,
|
||||
markdownSourceLineOffset,
|
||||
blocks
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function getRichMarkdownAnnotationHighlightRangesForComment(
|
||||
editor: Editor,
|
||||
comment: DiffComment,
|
||||
markdownSourceLineOffset: number
|
||||
markdownSourceLineOffset: number,
|
||||
// Why optional: callers looping over comments pass one shared build.
|
||||
prebuiltBlocks?: RichMarkdownCommentBlock[]
|
||||
): RichMarkdownAnnotationHighlightRange[] {
|
||||
const blocks = buildRichMarkdownCommentBlocks(editor)
|
||||
const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor)
|
||||
const selectedText = comment.selectedText?.trim()
|
||||
if (!selectedText) {
|
||||
return []
|
||||
@@ -158,12 +170,17 @@ export function getRichMarkdownCommentAtPos(
|
||||
markdownSourceLineOffset: number,
|
||||
pos: number
|
||||
): DiffComment | null {
|
||||
if (comments.length === 0) {
|
||||
return null
|
||||
}
|
||||
const blocks = buildRichMarkdownCommentBlocks(editor)
|
||||
return (
|
||||
comments.find((comment) =>
|
||||
getRichMarkdownAnnotationHighlightRangesForComment(
|
||||
editor,
|
||||
comment,
|
||||
markdownSourceLineOffset
|
||||
markdownSourceLineOffset,
|
||||
blocks
|
||||
).some((range) => range.from <= pos && pos <= range.to)
|
||||
) ?? null
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-st
|
||||
import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu'
|
||||
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
|
||||
import { WorktreeParentPickerPopover } from './WorktreeParentPickerPopover'
|
||||
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
|
||||
import { getEligibleWorktreeParents } from './worktree-parent-candidates'
|
||||
import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -924,6 +925,12 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isMultiContext ? (
|
||||
<>
|
||||
<WorktreeDeveloperMenu worktreeId={worktree.id} disabled={isDeleting} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu'
|
||||
|
||||
type MenuItemProps = {
|
||||
children?: ReactNode
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
const menuItems = vi.hoisted(() => ({ values: [] as MenuItemProps[] }))
|
||||
const requestManualPark = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', async () => {
|
||||
const React_ = await import('react')
|
||||
const passthrough = ({ children }: { children?: ReactNode }) =>
|
||||
React_.createElement(React_.Fragment, null, children)
|
||||
return {
|
||||
DropdownMenuSub: passthrough,
|
||||
DropdownMenuSubContent: passthrough,
|
||||
DropdownMenuSubTrigger: passthrough,
|
||||
DropdownMenuItem: (props: MenuItemProps) => {
|
||||
menuItems.values.push(props)
|
||||
return React_.createElement(React_.Fragment, null, props.children)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/lib/manual-terminal-worktree-parking', () => ({
|
||||
requestManualTerminalWorktreePark: requestManualPark
|
||||
}))
|
||||
|
||||
describe('WorktreeDeveloperMenu', () => {
|
||||
beforeEach(() => {
|
||||
menuItems.values = []
|
||||
requestManualPark.mockReset()
|
||||
})
|
||||
|
||||
it('shows the developer submenu with the parking action', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('Developer')
|
||||
expect(markup).toContain('Park terminal')
|
||||
expect(markup).toContain('lucide-code-xml')
|
||||
expect(markup).toContain('lucide-square-parking')
|
||||
})
|
||||
|
||||
it('requests parking for the context worktree', () => {
|
||||
renderToStaticMarkup(<WorktreeDeveloperMenu worktreeId="worktree-1" disabled={false} />)
|
||||
|
||||
menuItems.values[0]?.onSelect?.()
|
||||
|
||||
expect(requestManualPark).toHaveBeenCalledWith('worktree-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { CodeXml, SquareParking } from 'lucide-react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { requestManualTerminalWorktreePark } from '@/lib/manual-terminal-worktree-parking'
|
||||
|
||||
export function WorktreeDeveloperMenu({
|
||||
worktreeId,
|
||||
disabled
|
||||
}: {
|
||||
worktreeId: string
|
||||
disabled: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={disabled}>
|
||||
<CodeXml className="size-3.5" />
|
||||
{translate('auto.components.sidebar.WorktreeDeveloperMenu.developer', 'Developer')}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-44">
|
||||
<DropdownMenuItem onSelect={() => requestManualTerminalWorktreePark(worktreeId)}>
|
||||
<SquareParking className="size-3.5" />
|
||||
{translate('auto.components.sidebar.WorktreeDeveloperMenu.parkTerminal', 'Park terminal')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Why: a parked tab has no xterm, so this byte sidecar is the only thing that
|
||||
// answers DECSET 2031. fish toggles 2031 on and off around every prompt, so
|
||||
// answering the sticky "an h appeared" flag writes `?997;1n` into a shell that
|
||||
// already handed the tty to a child (#9993).
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ESC = '\x1b'
|
||||
const PTY_ID = 'pty-parked-2031'
|
||||
|
||||
// One fish prompt cycle: subscribe, paint, hand off the tty.
|
||||
const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l`
|
||||
|
||||
let sidecarWatcher: ((data: string) => void) | null = null
|
||||
const unsubscribe = vi.fn()
|
||||
|
||||
vi.mock('./pty-data-sidecar-subscriptions', () => ({
|
||||
subscribeToPtyData: (_ptyId: string, watcher: (data: string) => void) => {
|
||||
sidecarWatcher = watcher
|
||||
return unsubscribe
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({ settings: { theme: 'dark' as const } })
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-theme', () => ({
|
||||
getSystemPrefersDark: () => true
|
||||
}))
|
||||
|
||||
const { startParkedTerminalMode2031Responder } =
|
||||
await import('./parked-terminal-mode2031-responder')
|
||||
|
||||
function startResponder(): { sendInput: ReturnType<typeof vi.fn>; feed: (data: string) => void } {
|
||||
const sendInput = vi.fn()
|
||||
startParkedTerminalMode2031Responder({ ptyId: PTY_ID, sendInput })
|
||||
return {
|
||||
sendInput,
|
||||
feed: (data: string) => sidecarWatcher?.(data)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sidecarWatcher = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('parked-tab DECSET 2031 responder honors the chunk-final state (#9993)', () => {
|
||||
it('does not reply to a subscribe the same chunk withdrew', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(FISH_PROMPT_HANDOFF)
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replies once to a TUI that subscribes and keeps listening', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(`${ESC}[?2031h`)
|
||||
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith(`${ESC}[?997;1n`)
|
||||
})
|
||||
|
||||
it('stays silent across a run of fish prompts', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(FISH_PROMPT_HANDOFF)
|
||||
feed(FISH_PROMPT_HANDOFF)
|
||||
feed(FISH_PROMPT_HANDOFF)
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replies when a TUI subscribes after a prompt cycle in the same chunk', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(`${FISH_PROMPT_HANDOFF}${ESC}[?2031h`)
|
||||
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still answers a subscribe split across two chunks', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(`${ESC}[?20`)
|
||||
feed('31h')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not reply when the withdrawal is split across two chunks', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(`${ESC}[?2031h prompt ${ESC}[?20`)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
feed('31l')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reply when an unrelated private mode appends a split withdrawal', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(`${ESC}[?2031h prompt ${ESC}[?25`)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
feed(';2031l')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replies after an ambiguous tail resolves to another mode', () => {
|
||||
const { sendInput, feed } = startResponder()
|
||||
|
||||
feed(`${ESC}[?2031h drawing ${ESC}[?20`)
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
feed('25h')
|
||||
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -15,9 +15,10 @@
|
||||
* consumed while the gate is ON — this sidecar stays the only answerer here.
|
||||
*/
|
||||
import {
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode,
|
||||
scanMode2031Sequences
|
||||
scanMode2031ReplyDecision
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
@@ -33,13 +34,11 @@ export function startParkedTerminalMode2031Responder(
|
||||
options: ParkedTerminalMode2031ResponderOptions
|
||||
): () => void {
|
||||
const { ptyId, sendInput } = options
|
||||
// Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
|
||||
// carries a bounded tail between chunks so split sequences still match.
|
||||
let scanTail = ''
|
||||
let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
return subscribeToPtyData(ptyId, (data) => {
|
||||
const scan = scanMode2031Sequences(scanTail, data)
|
||||
scanTail = scan.tail
|
||||
if (!scan.subscribe) {
|
||||
const result = scanMode2031ReplyDecision(scanState, data)
|
||||
scanState = result.state
|
||||
if (result.decision !== 'subscribed') {
|
||||
return
|
||||
}
|
||||
// Why: reply with the resolved theme so TUIs that subscribe while parked
|
||||
|
||||
@@ -6631,6 +6631,53 @@ describe('connectPanePty', () => {
|
||||
expect(deps.updateTabPtyId).not.toHaveBeenCalledWith('tab-1', otherTabPtyId)
|
||||
})
|
||||
|
||||
it('fresh-spawns a shell into any PTY-less tab, so agent launches must never publish one', async () => {
|
||||
// Why: #2989 depends on PTY-less tabs taking this legitimate fresh-shell path.
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async (opts: { sessionId?: string }) => {
|
||||
if (opts.sessionId) {
|
||||
return { id: opts.sessionId }
|
||||
}
|
||||
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
|
||||
| ((ptyId: string) => void)
|
||||
| undefined
|
||||
onPtySpawn?.('stray-shell-pty')
|
||||
return 'stray-shell-pty'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
// Reproduce the pre-fix gap between createTab and PTY binding.
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
agentLaunchConfigByPaneKey: {
|
||||
[`tab-1:${LEAF_1}`]: {
|
||||
launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} },
|
||||
identity: { agentType: 'claude' }
|
||||
}
|
||||
}
|
||||
} as StoreState
|
||||
const deps = createDeps()
|
||||
|
||||
const pane = createPane(1)
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks()
|
||||
|
||||
// Launch registration alone cannot identify a PTY to attach.
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '', cols: expect.any(Number) })
|
||||
)
|
||||
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'stray-shell-pty')
|
||||
})
|
||||
|
||||
it('spawns a fresh PTY when a restored daemon split session cannot reattach', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
@@ -9923,14 +9970,62 @@ describe('connectPanePty', () => {
|
||||
expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark')
|
||||
})
|
||||
|
||||
it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => {
|
||||
it('retires the fact-registered subscription when the TUI withdraws it', async () => {
|
||||
// The counterpart to the test above. A gated pane never sees the withdrawal bytes
|
||||
// (main drops them) and both the chunk scanner and xterm's CSI handler are disabled
|
||||
// for it, so this fact is the only observer that can retire the subscription. Left
|
||||
// registered, the next theme flip pushes CSI 997 at the shell that replaced the TUI.
|
||||
enableMainAuthority()
|
||||
const paneMode2031Ref = { current: new Map<number, boolean>() }
|
||||
const paneLastThemeModeRef = { current: new Map<number, 'dark' | 'light'>() }
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false },
|
||||
paneMode2031Ref,
|
||||
paneLastThemeModeRef,
|
||||
// Exactly what use-terminal-pane-lifecycle wires up for this callback.
|
||||
recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => {
|
||||
paneMode2031Ref.current.set(paneId, true)
|
||||
paneLastThemeModeRef.current.set(paneId, repliedMode)
|
||||
}
|
||||
})
|
||||
await connectHiddenPane(deps)
|
||||
const transportOptions = createdTransportOptions.at(-1) as {
|
||||
onPtySpawn?: (ptyId: string) => void
|
||||
}
|
||||
transportOptions.onPtySpawn?.('pty-id')
|
||||
const factsHandler = await import('./terminal-side-effect-facts-handler')
|
||||
factsHandler._dispatchTerminalSideEffectBatchForTest({
|
||||
ptyId: 'pty-id',
|
||||
seq: 12,
|
||||
facts: [{ kind: '2031-subscribe' }]
|
||||
})
|
||||
expect(paneMode2031Ref.current.get(1)).toBe(true)
|
||||
|
||||
factsHandler._dispatchTerminalSideEffectBatchForTest({
|
||||
ptyId: 'pty-id',
|
||||
seq: 24,
|
||||
facts: [{ kind: '2031-unsubscribe' }]
|
||||
})
|
||||
|
||||
expect(paneMode2031Ref.current.get(1)).toBeUndefined()
|
||||
expect(paneLastThemeModeRef.current.get(1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves the chunk scanner silent on a gate-managed PTY', async () => {
|
||||
// Main's '2031-subscribe' fact already answers these; a chunk-boundary reply here
|
||||
// would answer the same subscribe a second time.
|
||||
enableMainAuthority()
|
||||
const deps = createDeps({ isVisibleRef: { current: false } })
|
||||
const { binding } = await connectHiddenPane(deps)
|
||||
const bindingWithPredicate = binding as typeof binding & {
|
||||
isHiddenDeliveryGateManagedPty: () => boolean
|
||||
}
|
||||
expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true)
|
||||
const { transport, dataCallback } = await connectHiddenPane(deps)
|
||||
const before = transport.sendInput.mock.calls.length
|
||||
|
||||
dataCallback('\x1b[?2031h')
|
||||
|
||||
const replies = transport.sendInput.mock.calls
|
||||
.slice(before)
|
||||
.flat()
|
||||
.filter((arg) => String(arg).includes('997'))
|
||||
expect(replies).toEqual([])
|
||||
})
|
||||
|
||||
it('declares hidden-at-spawn on connect for hidden panes', async () => {
|
||||
@@ -9970,13 +10065,7 @@ describe('connectPanePty', () => {
|
||||
terminalHiddenDeliveryGate: false
|
||||
} as StoreState['settings']
|
||||
const deps = createDeps({ isVisibleRef: { current: false } })
|
||||
const { transport, dataCallback, binding } = await connectHiddenPane(deps)
|
||||
// Why: the lifecycle's xterm CSI observer consults this predicate — kill switch off keeps the legacy xterm reply path.
|
||||
expect(
|
||||
(
|
||||
binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean }
|
||||
).isHiddenDeliveryGateManagedPty()
|
||||
).toBe(false)
|
||||
const { transport, dataCallback } = await connectHiddenPane(deps)
|
||||
const transportOptions = createdTransportOptions.at(-1) as {
|
||||
onPtySpawn?: (ptyId: string) => void
|
||||
}
|
||||
@@ -10244,10 +10333,11 @@ describe('connectPanePty', () => {
|
||||
pane: ReturnType<typeof createPane>
|
||||
dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void
|
||||
getMainBufferSnapshot: ReturnType<typeof vi.fn>
|
||||
transport: MockTransport
|
||||
}> {
|
||||
enableMainAuthority()
|
||||
const deps = createDeps({ isVisibleRef: { current: true } })
|
||||
const { pane, dataCallback } = await connectHiddenPane(deps)
|
||||
const { pane, dataCallback, transport } = await connectHiddenPane(deps)
|
||||
const transportOptions = createdTransportOptions.at(-1) as {
|
||||
onPtySpawn?: (ptyId: string) => void
|
||||
}
|
||||
@@ -10275,7 +10365,7 @@ describe('connectPanePty', () => {
|
||||
expect.any(Function)
|
||||
)
|
||||
pane.terminal.write.mockClear()
|
||||
return { pane, dataCallback, getMainBufferSnapshot }
|
||||
return { pane, dataCallback, getMainBufferSnapshot, transport }
|
||||
}
|
||||
|
||||
function writtenData(pane: ReturnType<typeof createPane>): string {
|
||||
@@ -10296,6 +10386,26 @@ describe('connectPanePty', () => {
|
||||
expect(writtenData(pane)).toContain('NEW')
|
||||
})
|
||||
|
||||
it('answers a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => {
|
||||
// Why the scan runs before reconciliation: the snapshot restore replays bytes
|
||||
// into xterm without answering queries, so this live delivery is the only
|
||||
// chance to reply. Dropping the chunk as a duplicate must not drop the query.
|
||||
// Gate off so the chunk scanner (not main's fact) owns this pane's reply.
|
||||
enableMainAuthority()
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalHiddenDeliveryGate: false
|
||||
} as StoreState['settings']
|
||||
const { pane, dataCallback, transport } = await restoreVisiblePaneToBaseline()
|
||||
transport.sendInput.mockClear()
|
||||
|
||||
dataCallback(`SUB\x1b[?2031h`, { seq: 60, rawLength: 11 })
|
||||
await flushAsyncTicks(8)
|
||||
|
||||
expect(writtenData(pane)).not.toContain('SUB')
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
})
|
||||
|
||||
it('slices a partial overlap when raw and clean lengths match', async () => {
|
||||
const { pane, dataCallback } = await restoreVisiblePaneToBaseline()
|
||||
|
||||
@@ -10987,7 +11097,7 @@ describe('connectPanePty', () => {
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => {
|
||||
it('answers a mode 2031 subscribe once, from the raw chunk boundary', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
@@ -11015,7 +11125,11 @@ describe('connectPanePty', () => {
|
||||
capturedDataCallback.current?.('\x1b[?2031h')
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
// Why the scanner and not xterm's CSI handler: xterm batches PTY chunks into one
|
||||
// parse, so only this layer knows the chunk ended still subscribed (#9993).
|
||||
expect(transport.sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n')
|
||||
// The bytes still reach xterm so the emulator tracks the mode itself.
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
@@ -11024,6 +11138,168 @@ describe('connectPanePty', () => {
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
// Why these three: fish 4.7.1 enables and disables 2031 around *every* prompt with no
|
||||
// opt-out, so back-to-back chunks each carrying a toggle are the normal case, not an edge
|
||||
// case. Each chunk owes exactly one decision, and xterm cannot make it — it parses several
|
||||
// chunks in one synchronous batch, so a reply deferred to the parser is either dropped or
|
||||
// answered against a subscription a later chunk already withdrew (#9993).
|
||||
describe('mode 2031 replies are decided per raw PTY chunk', () => {
|
||||
async function connectVisiblePane(): Promise<{
|
||||
transport: ReturnType<typeof createMockTransport>
|
||||
deps: ReturnType<typeof createDeps>
|
||||
emit: (data: string, meta?: { droppedOutput?: boolean }) => void
|
||||
dispose: () => void
|
||||
}> {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
const captured: {
|
||||
current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
captured.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
settings: { ...mockStoreState.settings, theme: 'light' }
|
||||
}
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({ isVisibleRef: { current: true } })
|
||||
const binding = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
return {
|
||||
transport,
|
||||
deps,
|
||||
emit: (data: string, meta?: { droppedOutput?: boolean }) => captured.current?.(data, meta),
|
||||
dispose: () => binding.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] =>
|
||||
transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997'))
|
||||
|
||||
it('answers a chunk that ends subscribed even when the next chunk withdraws', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
// Two separate PTY chunks. xterm would parse both in one batch and see only the net
|
||||
// result; the first chunk still owes a reply.
|
||||
emit('\x1b[?2031h')
|
||||
emit('\x1b[?2031l')
|
||||
expect(replies(transport)).toEqual(['\x1b[?997;2n'])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers each chunk that ends subscribed', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
emit('\x1b[?2031h')
|
||||
emit('\x1b[?2031l\x1b[?2031h')
|
||||
expect(replies(transport)).toHaveLength(2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('stays silent when one chunk both subscribes and withdraws', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
// The fish prompt case: the subscription is gone before the program could read a reply.
|
||||
emit('\x1b[?2031h prompt \x1b[?2031l')
|
||||
expect(replies(transport)).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('stays silent when the withdrawal is split across two chunks', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
emit('\x1b[?2031h prompt \x1b[?20')
|
||||
expect(replies(transport)).toEqual([])
|
||||
emit('31l')
|
||||
expect(replies(transport)).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('stays silent when an unrelated private mode appends a split withdrawal', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
emit('\x1b[?2031h prompt \x1b[?25')
|
||||
expect(replies(transport)).toEqual([])
|
||||
emit(';2031l')
|
||||
expect(replies(transport)).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers after an ambiguous tail resolves to another mode', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
emit('\x1b[?2031h drawing \x1b[?20')
|
||||
expect(replies(transport)).toEqual([])
|
||||
emit('25h')
|
||||
expect(replies(transport)).toEqual(['\x1b[?997;2n'])
|
||||
dispose()
|
||||
})
|
||||
|
||||
// One fish prompt cycle, exactly as fish's tty_handoff.rs emits it.
|
||||
const FISH_PROMPT_HANDOFF = '\x1b[?2031h\x1b[0m~/orca \x1b[32m❯\x1b[0m \x1b[?2031l'
|
||||
|
||||
it('stays silent across three fish prompts', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
emit(FISH_PROMPT_HANDOFF)
|
||||
emit(FISH_PROMPT_HANDOFF)
|
||||
emit(FISH_PROMPT_HANDOFF)
|
||||
expect(replies(transport)).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('leaves no stale subscription behind after a fish prompt cycle', async () => {
|
||||
const { deps, emit, dispose } = await connectVisiblePane()
|
||||
emit(FISH_PROMPT_HANDOFF)
|
||||
// A later theme flip must not push CSI 997 at a shell that unsubscribed.
|
||||
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers once when a TUI subscribes at the end of a fish prompt chunk', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`)
|
||||
expect(replies(transport)).toHaveLength(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers a subscribe whose withdrawal never arrives', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
// A real TUI: subscribe now, unsubscribe minutes later on exit.
|
||||
emit('\x1b[?2031h')
|
||||
emit('painting the ui')
|
||||
expect(replies(transport)).toHaveLength(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('drops a live subscription when a later chunk withdraws it', async () => {
|
||||
const { deps, emit, dispose } = await connectVisiblePane()
|
||||
// A TUI that exits: the earlier chunk really did register, so the withdrawal has
|
||||
// state to undo — otherwise a theme flip pushes CSI 997 at the shell that replaced it.
|
||||
emit('\x1b[?2031h')
|
||||
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
|
||||
emit('\x1b[?2031l')
|
||||
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
|
||||
expect(deps.paneLastThemeModeRef.current.get(1)).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('discards a half-read escape prefix when the pane swaps PTYs', async () => {
|
||||
const { transport, emit, dispose } = await connectVisiblePane()
|
||||
// Why: the tail is a byte range from the old stream. Splicing it onto the first
|
||||
// chunk of a replacement PTY fabricates a subscribe no program ever sent.
|
||||
transport.serializeBuffer = vi.fn().mockResolvedValue(null)
|
||||
emit('\x1b[?20')
|
||||
// droppedOutput latches the hidden-restore PTY id, which is what detects the swap.
|
||||
emit('', { droppedOutput: true })
|
||||
transport.getPtyId.mockReturnValue('pty-id-2')
|
||||
|
||||
emit('31h')
|
||||
|
||||
expect(replies(transport)).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('answers hidden Codex mode 2031 subscribes split across becoming visible', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-id')
|
||||
@@ -11605,10 +11881,11 @@ describe('connectPanePty', () => {
|
||||
getMainBufferSnapshot.mockClear()
|
||||
|
||||
// Main hit the per-PTY pending cap and sent the droppedOutput sentinel: the stream has a gap, so repaint from the authoritative main-owned buffer.
|
||||
capturedDataCallback.current?.('', { droppedOutput: true })
|
||||
capturedDataCallback.current?.('\x1b[?2031h', { droppedOutput: true })
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalled()
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('healed from snapshot'),
|
||||
expect.any(Function)
|
||||
|
||||
@@ -122,9 +122,10 @@ import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-sta
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import {
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode,
|
||||
scanMode2031Sequences
|
||||
scanMode2031ReplyDecision
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
|
||||
import { subscribeToTerminalUserInput } from './terminal-user-input-signal'
|
||||
@@ -664,11 +665,6 @@ type PanePtyBinding = IDisposable & {
|
||||
requestDroidReconfirmation: () => void
|
||||
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
|
||||
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
|
||||
/** True when the hidden-delivery gate structurally manages the pane's
|
||||
* current PTY. The lifecycle's xterm CSI ?2031h observer consults this to
|
||||
* stay silent — main's '2031-subscribe' fact is the sole responder for
|
||||
* gate-managed PTYs. */
|
||||
isHiddenDeliveryGateManagedPty: () => boolean
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteNotificationEnabled(): boolean {
|
||||
@@ -2251,7 +2247,10 @@ export function connectPanePty(
|
||||
// Why: gated hidden panes never see the subscribe bytes; the fact
|
||||
// replaces the byte scan (and the old post-latch subscribe drop).
|
||||
...(hiddenDeliveryGateActive
|
||||
? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact }
|
||||
? {
|
||||
onMode2031Subscribe: handleHiddenMode2031SubscribeFact,
|
||||
onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact
|
||||
}
|
||||
: {})
|
||||
},
|
||||
restoreTitleOnRegister: true
|
||||
@@ -3603,6 +3602,20 @@ export function connectPanePty(
|
||||
deps.recordPaneMode2031Subscription?.(pane.id, mode)
|
||||
recordHiddenMode2031Reply()
|
||||
}
|
||||
// Why (gate mode only): the counterpart to the subscribe fact. These panes never
|
||||
// receive the withdrawal bytes — main drops them before delivery — and both the
|
||||
// chunk scanner and the xterm CSI handler are disabled for them, so this fact is
|
||||
// the ONLY observer that can retire the subscription. Without it a TUI that exits
|
||||
// while hidden leaves paneMode2031 set, and the next theme flip pushes CSI 997
|
||||
// into the shell that replaced it (#9993 via maybePushMode2031Flip). No reply is
|
||||
// sent: a withdrawal is not a query.
|
||||
const handleHiddenMode2031UnsubscribeFact = (): void => {
|
||||
if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
|
||||
return
|
||||
}
|
||||
deps.paneMode2031Ref.current.delete(pane.id)
|
||||
deps.paneLastThemeModeRef.current.delete(pane.id)
|
||||
}
|
||||
deps.paneTransportsRef.current.set(pane.id, transport)
|
||||
const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({
|
||||
terminal: pane.terminal,
|
||||
@@ -5493,7 +5506,7 @@ export function connectPanePty(
|
||||
let foregroundImmediateBudgetWindowStart = 0
|
||||
let foregroundRewriteChunkEndedWithCarriageReturn = false
|
||||
let foregroundRewriteCsiScanTail = ''
|
||||
let hiddenMode2031ScanTail = ''
|
||||
let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup)
|
||||
let hiddenStartupRendererQueryPending = ''
|
||||
let hiddenRendererStateDirty = false
|
||||
@@ -5815,19 +5828,30 @@ export function connectPanePty(
|
||||
}
|
||||
}
|
||||
|
||||
function respondToSkippedMode2031Subscribe(data: string): void {
|
||||
const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data)
|
||||
hiddenMode2031ScanTail = scan.tail
|
||||
if (scan.finalState === 'unsubscribed') {
|
||||
// Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one
|
||||
// synchronous parse, so a handler cannot tell where a chunk ended. fish enables and
|
||||
// disables 2031 around every prompt, so answering a subscribe the same chunk withdraws
|
||||
// pushes `?997;1n` into the prompt or a child's stdin as literal text (#9993). One raw
|
||||
// chunk in, one order-aware decision out.
|
||||
function observeLiveMode2031Chunk(data: string): void {
|
||||
// Main's '2031-subscribe' fact is the sole responder for gate-managed PTYs; a second
|
||||
// reply from here would answer one subscribe twice.
|
||||
if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
|
||||
return
|
||||
}
|
||||
const result = scanMode2031ReplyDecision(mode2031ReplyScanState, data)
|
||||
mode2031ReplyScanState = result.state
|
||||
if (result.decision === 'unsubscribed') {
|
||||
deps.paneMode2031Ref.current.delete(pane.id)
|
||||
deps.paneLastThemeModeRef.current.delete(pane.id)
|
||||
}
|
||||
if (scan.finalState !== 'subscribed') {
|
||||
if (result.decision !== 'subscribed') {
|
||||
return
|
||||
}
|
||||
const settings = useAppStore.getState().settings
|
||||
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
|
||||
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes; answer immediately so the reply can't outlive the program's read window.
|
||||
// Why immediate: the reply must land inside the program's read window, and hidden
|
||||
// snapshot-backed panes skip xterm.write for PTY bytes entirely.
|
||||
deps.paneMode2031Ref.current.set(pane.id, true)
|
||||
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
|
||||
deps.paneLastThemeModeRef.current.set(pane.id, mode)
|
||||
@@ -5905,9 +5929,6 @@ export function connectPanePty(
|
||||
const synchronizedFrameLatencySensitive =
|
||||
synchronizedForegroundOutput && synchronizedForegroundFrameInteractive
|
||||
synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive
|
||||
if (!foreground && hiddenMode2031ScanTail) {
|
||||
respondToSkippedMode2031Subscribe(data)
|
||||
}
|
||||
writeTerminalOutput(pane.terminal, data, {
|
||||
foreground: foregroundOutput,
|
||||
beforeWrite: beforeTerminalOutputWrite,
|
||||
@@ -6077,7 +6098,6 @@ export function connectPanePty(
|
||||
|
||||
function skipHiddenRendererOutput(data: string): void {
|
||||
writeHiddenStartupRendererQueries(data)
|
||||
respondToSkippedMode2031Subscribe(data)
|
||||
markHiddenOutputRestoreNeeded()
|
||||
hiddenRendererStateDirty = true
|
||||
if (hiddenOutputRestoreInFlight) {
|
||||
@@ -6564,6 +6584,9 @@ export function connectPanePty(
|
||||
function clearPaneMode2031State(): void {
|
||||
deps.paneMode2031Ref.current.delete(pane.id)
|
||||
deps.paneLastThemeModeRef.current.delete(pane.id)
|
||||
// A partial CSI prefix belongs to the stream that produced it; carrying it into a
|
||||
// replacement PTY would splice two unrelated byte ranges into one sequence.
|
||||
mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
|
||||
}
|
||||
|
||||
function pulseVisibleLocalPtySizeForTuiRepaint(ptyId: string): void {
|
||||
@@ -6586,7 +6609,6 @@ export function connectPanePty(
|
||||
|
||||
function skipBackgroundAlternateScreenOutput(data: string): void {
|
||||
writeHiddenStartupRendererQueries(data)
|
||||
respondToSkippedMode2031Subscribe(data)
|
||||
resetSkippedHiddenRendererRiskState()
|
||||
hiddenRendererStateDirty = true
|
||||
recordHiddenRendererSkip(data.length)
|
||||
@@ -7063,6 +7085,7 @@ export function connectPanePty(
|
||||
}
|
||||
observeStartupDraftPasteReadiness(data)
|
||||
resetHiddenOutputRestoreIfPtyChanged()
|
||||
observeLiveMode2031Chunk(data)
|
||||
if (meta?.droppedOutput === true) {
|
||||
// Why gated (rc.7.perf loop): a visible pane's cap-drop during its own restore is self-caused backpressure; defer to one post-flood repaint instead of re-arming per sentinel.
|
||||
if (meta?.background !== true && isForegroundRestoreBackpressureContext()) {
|
||||
@@ -7094,9 +7117,6 @@ export function connectPanePty(
|
||||
if (!foreground) {
|
||||
syncHiddenRendererPtyDelivery()
|
||||
}
|
||||
if (foreground && hiddenMode2031ScanTail) {
|
||||
respondToSkippedMode2031Subscribe(data)
|
||||
}
|
||||
// Post-restore reconciliation: drop chunks the snapshot covers, force a fresh restore for unmappable seq gaps; runs after byte observers, before any xterm write.
|
||||
const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta)
|
||||
if (reconciliation.action === 'drop-duplicate') {
|
||||
@@ -8261,9 +8281,6 @@ export function connectPanePty(
|
||||
// Why: the hidden-delivery gate must follow every pane visibility flip.
|
||||
syncHiddenRendererPtyDelivery()
|
||||
},
|
||||
isHiddenDeliveryGateManagedPty() {
|
||||
return isHiddenDeliveryGateManagedPty(transport.getPtyId())
|
||||
},
|
||||
// Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback.
|
||||
noteVisibilityResume() {
|
||||
ptySizeReassertion.request({ fit: false })
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import {
|
||||
applyTerminalAppearance,
|
||||
hexToRgba,
|
||||
installMode2031Handlers,
|
||||
publishTerminalViewAttributesAtAppStart
|
||||
} from './terminal-appearance'
|
||||
import { maybePushMode2031Flip } from './terminal-mode-2031-replies'
|
||||
import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
|
||||
import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher'
|
||||
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
|
||||
|
||||
@@ -138,219 +135,6 @@ describe('maybePushMode2031Flip', () => {
|
||||
expect(last.get(2)).toBe('dark')
|
||||
})
|
||||
})
|
||||
describe('installMode2031Handlers', () => {
|
||||
// Regression coverage for the "random characters on restart" bug: a replayed `CSI ?2031h` pushed `CSI ?997;1n` into the fresh shell.
|
||||
|
||||
function writeSync(term: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => term.write(data, resolve))
|
||||
}
|
||||
|
||||
function makeReplayingRef(): ReplayingPanesRef {
|
||||
return { current: new Map() } as ReplayingPanesRef
|
||||
}
|
||||
|
||||
function setup(paneId = 1): {
|
||||
term: Terminal
|
||||
pane: ManagedPane
|
||||
replayingPanesRef: ReplayingPanesRef
|
||||
onSubscribe: ReturnType<typeof vi.fn>
|
||||
paneMode2031: Map<number, boolean>
|
||||
paneLastThemeMode: Map<number, 'dark' | 'light'>
|
||||
dispose: () => void
|
||||
} {
|
||||
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
|
||||
const pane = { id: paneId, terminal: term } as unknown as ManagedPane
|
||||
const replayingPanesRef = makeReplayingRef()
|
||||
const paneMode2031 = new Map<number, boolean>()
|
||||
const paneLastThemeMode = new Map<number, 'dark' | 'light'>()
|
||||
const onSubscribe = vi.fn()
|
||||
const disposables = installMode2031Handlers({
|
||||
paneId,
|
||||
parser: term.parser,
|
||||
onSubscribe,
|
||||
isReplaying: () => (replayingPanesRef.current.get(paneId) ?? 0) > 0,
|
||||
paneMode2031,
|
||||
paneLastThemeMode
|
||||
})
|
||||
return {
|
||||
term,
|
||||
pane,
|
||||
replayingPanesRef,
|
||||
onSubscribe,
|
||||
paneMode2031,
|
||||
paneLastThemeMode,
|
||||
dispose: () => {
|
||||
for (const d of disposables) {
|
||||
d.dispose()
|
||||
}
|
||||
term.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('records subscribe and fires onSubscribe on a live `CSI ?2031h`', async () => {
|
||||
const h = setup()
|
||||
try {
|
||||
await writeSync(h.term, '\x1b[?2031h')
|
||||
expect(h.paneMode2031.get(1)).toBe(true)
|
||||
expect(h.onSubscribe).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
h.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does NOT fire onSubscribe or record state when the sequence arrives during replay', async () => {
|
||||
// On cold restore the replay guard is set before xterm parses, so the handler must skip both the push and the bookkeeping.
|
||||
const h = setup()
|
||||
try {
|
||||
replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h')
|
||||
// write() is async: the replay guard stays engaged until the write-completion callback fires.
|
||||
await new Promise<void>((resolve) => h.term.write('', resolve))
|
||||
|
||||
expect(h.onSubscribe).not.toHaveBeenCalled()
|
||||
expect(h.paneMode2031.has(1)).toBe(false)
|
||||
expect(h.paneLastThemeMode.has(1)).toBe(false)
|
||||
// Once the replay window closes, the pane is not marked replaying.
|
||||
expect(h.replayingPanesRef.current.get(1) ?? 0).toBe(0)
|
||||
} finally {
|
||||
h.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('still honors a real `CSI ?2031h` received after a replay window closes', async () => {
|
||||
// A real `?2031h` from a TUI relaunched after cold restore must take effect normally.
|
||||
const h = setup()
|
||||
try {
|
||||
replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031h')
|
||||
await new Promise<void>((resolve) => h.term.write('', resolve))
|
||||
expect(h.onSubscribe).not.toHaveBeenCalled()
|
||||
|
||||
await writeSync(h.term, '\x1b[?2031h')
|
||||
expect(h.paneMode2031.get(1)).toBe(true)
|
||||
expect(h.onSubscribe).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
h.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears subscribe state on `CSI ?2031l` regardless of replay state', async () => {
|
||||
// The `l` (unsubscribe) branch is intentionally not replay-guarded: clearing is harmless since we only send on subscribe.
|
||||
const h = setup()
|
||||
try {
|
||||
// Non-replay path: subscribe then unsubscribe clears state.
|
||||
await writeSync(h.term, '\x1b[?2031h')
|
||||
h.paneLastThemeMode.set(1, 'dark')
|
||||
expect(h.paneMode2031.get(1)).toBe(true)
|
||||
|
||||
await writeSync(h.term, '\x1b[?2031l')
|
||||
expect(h.paneMode2031.has(1)).toBe(false)
|
||||
expect(h.paneLastThemeMode.has(1)).toBe(false)
|
||||
|
||||
// Replay path: the `l` handler must still clear even during a replay window.
|
||||
await writeSync(h.term, '\x1b[?2031h')
|
||||
h.paneLastThemeMode.set(1, 'dark')
|
||||
expect(h.paneMode2031.get(1)).toBe(true)
|
||||
|
||||
replayIntoTerminal(h.pane, h.replayingPanesRef, '\x1b[?2031l')
|
||||
await new Promise<void>((resolve) => h.term.write('', resolve))
|
||||
expect(h.paneMode2031.has(1)).toBe(false)
|
||||
expect(h.paneLastThemeMode.has(1)).toBe(false)
|
||||
} finally {
|
||||
h.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns `false` so compound DEC private modes still reach xterm', async () => {
|
||||
// Why: handlers return `false` so compound sequences like `CSI ?25;2031h` still reach xterm's built-in DEC private mode handler.
|
||||
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
|
||||
const paneMode2031 = new Map<number, boolean>()
|
||||
const paneLastThemeMode = new Map<number, 'dark' | 'light'>()
|
||||
const onSubscribe = vi.fn()
|
||||
const returnValues: boolean[] = []
|
||||
// Cast: parser cb returns plain `boolean` but `Mode2031Parser` reflects xterm's `boolean | Promise<boolean>` (handlers here are sync).
|
||||
const spyParser: Parameters<typeof installMode2031Handlers>[0]['parser'] = {
|
||||
registerCsiHandler: (id, cb) =>
|
||||
term.parser.registerCsiHandler(id, (params) => {
|
||||
const r = cb(params) as boolean
|
||||
returnValues.push(r)
|
||||
return r
|
||||
})
|
||||
}
|
||||
const disposables = installMode2031Handlers({
|
||||
paneId: 1,
|
||||
parser: spyParser,
|
||||
onSubscribe,
|
||||
isReplaying: () => false,
|
||||
paneMode2031,
|
||||
paneLastThemeMode
|
||||
})
|
||||
try {
|
||||
// Compound: ?25 (cursor show) + ?2031 (color-scheme subscribe).
|
||||
await writeSync(term, '\x1b[?25;2031h')
|
||||
// Our 2031 recording fired:
|
||||
expect(paneMode2031.get(1)).toBe(true)
|
||||
expect(onSubscribe).toHaveBeenCalledTimes(1)
|
||||
// Every handler invocation returned `false`, so xterm's built-in DEC private mode handler still processes the sequence.
|
||||
expect(returnValues.length).toBeGreaterThan(0)
|
||||
expect(returnValues.every((v) => v === false)).toBe(true)
|
||||
} finally {
|
||||
for (const d of disposables) {
|
||||
d.dispose()
|
||||
}
|
||||
term.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps per-pane state isolated when two panes share the parser API', async () => {
|
||||
// The subscribe bookkeeping map is shared across panes, so a replay on pane 1 must not leak into pane 2's live subscribe.
|
||||
const shared2031 = new Map<number, boolean>()
|
||||
const sharedLast = new Map<number, 'dark' | 'light'>()
|
||||
const replayingPanesRef = makeReplayingRef()
|
||||
|
||||
const term1 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
|
||||
const term2 = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
|
||||
const pane1 = { id: 1, terminal: term1 } as unknown as ManagedPane
|
||||
const onSub1 = vi.fn()
|
||||
const onSub2 = vi.fn()
|
||||
|
||||
const d1 = installMode2031Handlers({
|
||||
paneId: 1,
|
||||
parser: term1.parser,
|
||||
onSubscribe: onSub1,
|
||||
isReplaying: () => (replayingPanesRef.current.get(1) ?? 0) > 0,
|
||||
paneMode2031: shared2031,
|
||||
paneLastThemeMode: sharedLast
|
||||
})
|
||||
const d2 = installMode2031Handlers({
|
||||
paneId: 2,
|
||||
parser: term2.parser,
|
||||
onSubscribe: onSub2,
|
||||
isReplaying: () => (replayingPanesRef.current.get(2) ?? 0) > 0,
|
||||
paneMode2031: shared2031,
|
||||
paneLastThemeMode: sharedLast
|
||||
})
|
||||
|
||||
try {
|
||||
// Replay on pane 1 must not subscribe.
|
||||
replayIntoTerminal(pane1, replayingPanesRef, '\x1b[?2031h')
|
||||
await new Promise<void>((resolve) => term1.write('', resolve))
|
||||
expect(onSub1).not.toHaveBeenCalled()
|
||||
expect(shared2031.has(1)).toBe(false)
|
||||
|
||||
// Live on pane 2 must subscribe normally.
|
||||
await writeSync(term2, '\x1b[?2031h')
|
||||
expect(onSub2).toHaveBeenCalledTimes(1)
|
||||
expect(shared2031.get(2)).toBe(true)
|
||||
} finally {
|
||||
for (const d of [...d1, ...d2]) {
|
||||
d.dispose()
|
||||
}
|
||||
term1.dispose()
|
||||
term2.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTerminalAppearance theme assignment', () => {
|
||||
// xterm rebuilds the palette on any new theme-object identity (wiping OSC color mutations), so the assignment must be value-gated.
|
||||
function makePane(id: number): ManagedPane {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user