Merge remote-tracking branch 'origin/main' into fix-20324

This commit is contained in:
Neil
2026-09-12 20:03:41 -07:00
395 changed files with 24621 additions and 1794 deletions
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
const parsed = finalizeSession(
createAccumulator({
agent: 'codex',
sessionId: 'session',
file: {
path: '/home/ada/.codex/rollout-session.jsonl',
mtimeMs: 1000,
modifiedAt: '1970-01-01T00:00:01.000Z'
}
}),
'linux'
)
if (!parsed) {
throw new Error('Expected a session fixture')
}
return Object.freeze({ ...parsed, ...overrides })
}
function checkBatches(batches: AiVaultSession[][]): AiVaultSession[] {
const collection = new CodexSessionCollection()
let expected: AiVaultSession[] = []
for (const batch of batches) {
expected = dedupeCodexSessionsBySessionId([...expected, ...batch])
for (const value of batch) {
collection.add(value)
}
const actual = [...collection.values()]
expect(collection.size).toBe(expected.length)
expect(actual).toHaveLength(expected.length)
actual.forEach((value, index) => expect(value).toBe(expected[index]))
}
return [...collection.values()]
}
describe('CodexSessionCollection', () => {
it('keeps winner occurrences in input order across replacements and batches', () => {
const other = session({ agent: 'claude' })
const real = session()
const managed = session({ codexHome: '/tmp/codex-runtime-home/home' })
const custom = session({ codexHome: '/tmp/custom' })
expect(
checkBatches([
[custom, other, custom],
[managed, other, managed],
[custom],
[real, other, real]
])
).toEqual([other, other, real, other, real])
})
it('keeps identical winning objects, not distinct tied objects', () => {
const first = session()
const tied = session()
expect(
checkBatches([
[first, tied, first],
[tied, first]
])
).toEqual([first, first, first])
})
it('preserves order as winning rows alternate between single and repeated occurrences', () => {
const other = session({ agent: 'claude' })
const custom = session({ codexHome: '/tmp/custom' })
const managed = session({ codexHome: '/tmp/codex-runtime-home/home' })
const newerManaged = session({ ...managed, updatedAt: '1970-01-01T00:00:03Z' })
const real = session()
const newerReal = session({ updatedAt: '1970-01-01T00:00:05Z' })
const tied = session({ ...newerReal })
expect(
checkBatches([
[custom, other],
[managed],
[managed, other, managed],
[newerManaged],
[real],
[real],
[real, other],
[newerReal],
[newerReal],
[tied]
])
).toEqual([other, other, other, newerReal, newerReal])
})
it('retains non-Codex and non-rollout occurrences unchanged', () => {
const claude = session({ agent: 'claude' })
const otherFile = session({ filePath: '/tmp/session.jsonl' })
expect(
checkBatches([
[claude, otherFile],
[claude, otherFile]
])
).toEqual([claude, otherFile, claude, otherFile])
})
it('preserves timestamp, root, path tie-breaks and invalid-date comparisons', () => {
const older = session({ updatedAt: '1970-01-01T00:00:00Z' })
const newer = session({ updatedAt: '1970-01-01T00:00:03Z' })
const smallerPath = session({ ...newer, filePath: '/a/rollout-session.jsonl' })
const invalid = session({ modifiedAt: 'invalid' })
const account = session({ codexHome: '/tmp/codex-accounts/account/home' })
const custom = session({ codexHome: '/tmp/custom' })
expect(checkBatches([[custom], [account], [older], [newer], [smallerPath]])).toEqual([
smallerPath
])
expect(
checkBatches([
[invalid, older],
[newer, smallerPath]
])
).toEqual([invalid])
expect(checkBatches([[older], [invalid], [newer]])).toEqual([newer])
})
it('isolates execution hosts, WSL distros, parsed ids and rollout names', () => {
const native = session()
const ssh = session({ executionHostId: 'ssh:dev' })
const ubuntu = session({ filePath: '\\\\wsl$\\Ubuntu\\home\\ada\\rollout-session.jsonl' })
const ubuntuAlias = session({
filePath: '\\\\wsl.localhost\\ubuntu\\home\\ada\\rollout-session.jsonl',
codexHome: '/custom'
})
const debian = session({ filePath: '\\\\wsl$\\Debian\\home\\ada\\rollout-session.jsonl' })
const otherId = session({ sessionId: 'other' })
const otherName = session({ filePath: '/tmp/rollout-other.jsonl' })
expect(
checkBatches([
[native, ssh, ubuntu],
[ubuntuAlias, debian, otherId, otherName]
])
).toEqual([native, ssh, ubuntu, debian, otherId, otherName])
})
it('does not rescan retained rows on admission', () => {
let pathReads = 0
const collection = new CodexSessionCollection()
for (let index = 0; index < 1000; index++) {
const value = session({ sessionId: `session-${index}` })
collection.add({
...value,
get filePath() {
pathReads++
return value.filePath
}
})
}
expect(collection.size).toBe(1000)
expect([...collection.values()]).toHaveLength(1000)
expect(pathReads).toBeLessThanOrEqual(2000)
})
})
@@ -252,6 +252,55 @@ export function dedupeCodexSessionsBySessionId(
})
}
/** Scan-local accumulation; parsed rows must not be mutated after admission. */
export class CodexSessionCollection {
private readonly sessions = new Map<number, AiVaultSession>()
private readonly bestByKey = new Map<
string,
{ session: AiVaultSession; indices: number | number[] }
>()
private nextIndex = 0
get size(): number {
return this.sessions.size
}
values(): IterableIterator<AiVaultSession> {
return this.sessions.values()
}
add(session: AiVaultSession): void {
const key = codexSessionAliasKey(session)
const index = this.nextIndex++
if (key) {
const best = this.bestByKey.get(key)
if (best?.session === session) {
// The batch filter retains every occurrence of the winning object.
if (typeof best.indices === 'number') {
best.indices = [best.indices, index]
} else {
best.indices.push(index)
}
} else {
if (best) {
if (!codexSessionAliasBeats(session, best.session)) {
return
}
if (typeof best.indices === 'number') {
this.sessions.delete(best.indices)
} else {
for (const previousIndex of best.indices) {
this.sessions.delete(previousIndex)
}
}
}
this.bestByKey.set(key, { session, indices: index })
}
}
this.sessions.set(index, session)
}
}
function codexSessionAliasKey(session: AiVaultSession): string | null {
if (session.agent !== 'codex') {
return null
+11 -25
View File
@@ -8,6 +8,7 @@ import type { ExecutionHostId } from '../../shared/execution-host'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import {
CodexSessionCollection,
codexRolloutHardlinkIdentity,
dedupeCodexRolloutFileAliases,
dedupeCodexSessionsBySessionId
@@ -30,6 +31,7 @@ import { errorMessage } from './session-scanner-values'
import { mapRemoteScanBatches } from './remote-session-scan-batching'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { recordSessionScanIssue } from './session-scan-issues'
import { canStopParsingSessions } from './session-scan-cutoff'
import { refreshCodexTitleFromIndex } from './session-scanner-codex-cached-title'
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth'
@@ -139,17 +141,17 @@ async function parseRemoteSessionCandidates(args: {
issues: AiVaultScanIssue[]
limit: number
}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set<string> }> {
const sessions: AiVaultSession[] = []
const sessions = new CodexSessionCollection()
const parsedFilePaths = new Set<string>()
let index = 0
while (index < args.candidates.length) {
if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
break
}
const remaining = args.candidates.length - index
const needed = Math.max(args.limit - sessions.length, 1)
const needed = Math.max(args.limit - sessions.size, 1)
const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
for (const candidate of batch) {
@@ -159,9 +161,11 @@ async function parseRemoteSessionCandidates(args: {
const results = await Promise.all(
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
)
sessions.push(...results.filter(isAiVaultSession))
const uniqueSessions = dedupeCodexSessionsBySessionId(sessions)
sessions.splice(0, sessions.length, ...uniqueSessions)
for (const session of results) {
if (session) {
sessions.add(session)
}
}
index += batchSize
await yieldToEventLoop()
}
@@ -169,7 +173,7 @@ async function parseRemoteSessionCandidates(args: {
// The loop can terminate on the yield after its final batch, so re-check
// rather than letting a cancelled scan return a partial parse as a success.
throwIfAiVaultScanCancelled(args.context.signal)
return { sessions, parsedFilePaths }
return { sessions: [...sessions.values()], parsedFilePaths }
}
async function scanRemoteInScopeSessions(args: {
@@ -308,24 +312,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
}
function canStopParsingRemoteSessions(
sessions: AiVaultSession[],
limit: number,
nextCandidateMtimeMs: number | undefined
): boolean {
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
return false
}
const visibleCutoff = sessions
.map(sessionSortTime)
.sort((left, right) => right - left)
.at(limit - 1)
// Transcript mtimes bound the remaining candidate order; once the visible
// cutoff is newer, older files cannot enter the unscoped top-N result.
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
}
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
return Boolean(session)
}
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { CodexSessionCollection } from './codex-session-root-dedup'
import { canStopParsingSessions } from './session-scan-cutoff'
import { createAccumulator, finalizeSession, sessionSortTime } from './session-scanner-accumulator'
function session(time: number | string, overrides: Partial<AiVaultSession> = {}): AiVaultSession {
const parsed = finalizeSession(
createAccumulator({
agent: 'claude',
sessionId: 'session',
file: { path: '/sessions/session.jsonl', mtimeMs: 0, modifiedAt: new Date(0).toISOString() }
}),
'linux'
)
if (!parsed) {
throw new Error('Expected a session fixture')
}
return Object.freeze({
...parsed,
updatedAt: typeof time === 'number' ? new Date(time).toISOString() : time,
...overrides
})
}
function collection(rows: AiVaultSession[]): CodexSessionCollection {
const result = new CodexSessionCollection()
rows.forEach((row) => result.add(row))
return result
}
function sortedReference(rows: AiVaultSession[], limit: number, next: number | undefined): boolean {
if (rows.length < limit || typeof next !== 'number') {
return false
}
const cutoff = rows
.map(sessionSortTime)
.sort((left, right) => right - left)
.at(limit - 1)
return typeof cutoff === 'number' && next < cutoff
}
afterEach(() => vi.restoreAllMocks())
describe('canStopParsingSessions', () => {
it('counts strictly newer rows without sorting or mutating their order', () => {
const rows = [session(8), session(2), session(6), session(4)]
const sessions = collection(rows)
const sort = vi.spyOn(Array.prototype, 'sort')
expect(canStopParsingSessions(sessions, 2, 5)).toBe(true)
expect(canStopParsingSessions(sessions, 2, 6)).toBe(false)
expect(canStopParsingSessions(sessions, 4, 1)).toBe(true)
expect(canStopParsingSessions(sessions, 4, 2)).toBe(false)
expect(sort).not.toHaveBeenCalled()
expect([...sessions.values()]).toEqual(rows)
})
it('does not visit rows before the unique-session budget is met', () => {
const sessions = collection([session(5)])
const values = vi.spyOn(sessions, 'values')
expect(canStopParsingSessions(sessions, 2, 0)).toBe(false)
expect(canStopParsingSessions(sessions, Number.POSITIVE_INFINITY, 0)).toBe(false)
expect(canStopParsingSessions(sessions, 1, undefined)).toBe(false)
expect(values).not.toHaveBeenCalled()
})
it('recounts a preferred alias even when replacement lowers its timestamp', () => {
const alias = {
agent: 'codex' as const,
sessionId: 'same',
filePath: '/sessions/rollout-same.jsonl'
}
const sessions = collection([session(100, { ...alias, codexHome: '/custom' }), session(100)])
expect(canStopParsingSessions(sessions, 2, 50)).toBe(true)
const preferred = session(10, { ...alias, codexHome: null })
sessions.add(preferred)
expect(sessions.size).toBe(2)
expect(canStopParsingSessions(sessions, 2, 50)).toBe(false)
sessions.add(preferred)
expect(sessions.size).toBe(3)
expect(canStopParsingSessions(sessions, 3, 9)).toBe(true)
})
it('preserves the legacy sort result when a later timestamp is invalid', () => {
const rows = [session(0), session('invalid'), session(20)]
const sessions = collection(rows)
expect(canStopParsingSessions(sessions, 1, 10)).toBe(false)
for (const candidate of [rows, rows.toReversed(), [rows[2], rows[0], rows[1]]]) {
for (const limit of [1, 2, 3]) {
for (const next of [-1, 0, 10, 20]) {
expect(canStopParsingSessions(collection(candidate), limit, next)).toBe(
sortedReference(candidate, limit, next)
)
}
}
}
})
it('parses timestamps only once when an invalid date appears at the end', () => {
const sessions = collection([session(10), session(5), session('invalid')])
const parse = vi.spyOn(Date, 'parse')
canStopParsingSessions(sessions, 1, 0)
expect(parse).toHaveBeenCalledTimes(3)
})
it('uses the same nullish modified-time fallback and numeric limit semantics', () => {
const rows = [
session('', { updatedAt: null, modifiedAt: '1970-01-01T00:00:02+00:00' }),
session('-000001-01-01T00:00:00Z'),
session('+010000-01-01T00:00:00Z'),
session(0)
]
for (const limit of [0, -1, -5, 0.5, 1.5, Number.NaN, Infinity, -Infinity, 1, 2, 4, 5]) {
for (const next of [undefined, Number.NaN, -Infinity, Infinity, -1, 0, 1, 2000]) {
expect(canStopParsingSessions(collection(rows), limit, next)).toBe(
sortedReference(rows, limit, next)
)
}
}
})
})
+41
View File
@@ -0,0 +1,41 @@
import type { CodexSessionCollection } from './codex-session-root-dedup'
import { sessionSortTime } from './session-scanner-accumulator'
type ScanSessions = Pick<CodexSessionCollection, 'size' | 'values'>
function sortedCutoffIsNewer(
times: number[],
limit: number,
nextCandidateMtimeMs: number
): boolean {
const visibleCutoff = times.sort((left, right) => right - left).at(limit - 1)
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
}
export function canStopParsingSessions(
sessions: ScanSessions,
limit: number,
nextCandidateMtimeMs: number | undefined
): boolean {
if (sessions.size < limit || typeof nextCandidateMtimeMs !== 'number') {
return false
}
const times = Array.from(sessions.values(), sessionSortTime)
if (!Number.isInteger(limit) || limit <= 0) {
return sortedCutoffIsNewer(times, limit, nextCandidateMtimeMs)
}
// The top-N cutoff is newer exactly when N retained sessions beat the next mtime.
let newerCount = 0
for (const time of times) {
if (Number.isNaN(time)) {
// NaN makes the old comparator inconsistent; preserve its ordering verbatim.
return sortedCutoffIsNewer(times, limit, nextCandidateMtimeMs)
}
if (time > nextCandidateMtimeMs) {
newerCount += 1
}
}
// Check every timestamp before deciding: a later NaN requires the legacy sort.
return newerCount >= limit
}
@@ -64,6 +64,7 @@ export function createAccumulator(args: {
lastUserPrompt: null,
queuedMessageCount: 0,
subagentTranscriptCount: 0,
earliestTimestampMs: 0,
latestTimestampMs: 0
}
}
@@ -191,10 +192,12 @@ export function updateTimeline(accumulator: SessionAccumulator, timestamp: unkno
return
}
const iso = new Date(parsed).toISOString()
if (!accumulator.createdAt || parsed < Date.parse(accumulator.createdAt)) {
if (!accumulator.createdAt || parsed < accumulator.earliestTimestampMs) {
accumulator.createdAt = iso
accumulator.earliestTimestampMs = Math.trunc(parsed)
}
if (!accumulator.updatedAt || parsed >= Date.parse(accumulator.updatedAt)) {
// ISO serialization truncates fractional milliseconds; latestTimestampMs retains them.
if (!accumulator.updatedAt || parsed >= Math.trunc(accumulator.latestTimestampMs)) {
accumulator.updatedAt = iso
accumulator.latestTimestampMs = parsed
}
@@ -0,0 +1,77 @@
import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
const tempRoots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('session scanner cutoff', () => {
it.each(['native', 'remote'] as const)(
'%s does not sort timestamps at every post-limit candidate',
async (host) => {
const count = 128
const limit = count / 2
const provider = new MemoryRemoteProvider()
const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(roots.codexSessionsDir, { recursive: true })
for (let index = 0; index < count; index++) {
const name = `rollout-session-${index}.jsonl`
const content = jsonLines([
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
{
type: 'event_msg',
timestamp: new Date(index).toISOString(),
payload: { type: 'user_message', message: 'Check this session' }
}
])
const mtime = 10_000 - index
if (host === 'native') {
const filePath = join(roots.codexSessionsDir, name)
await writeFile(filePath, content)
await utimes(filePath, new Date(mtime), new Date(mtime))
} else {
provider.addFile(`/home/ada/.codex/sessions/${name}`, content, mtime)
}
}
let numericSorts = 0
const originalSort = Array.prototype.sort
vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) {
if (typeof this[0] === 'number') {
numericSorts++
}
return originalSort.call(this, compare)
})
const scan = () =>
host === 'native'
? scanAiVaultSessions({ ...roots, limit })
: scanRemoteAiVaultSessions({
provider,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
executionHostId: 'ssh:scan-cutoff',
limit
})
for (let pass = 0; pass < 2; pass++) {
numericSorts = 0
const result = await scan()
expect(result.issues).toEqual([])
expect(result.sessions.map((row) => row.sessionId)).toEqual(
Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`)
)
expect(numericSorts).toBe(0)
}
}
)
})
@@ -0,0 +1,110 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
const tempRoots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('session scan batch deduplication', () => {
it.each(['native', 'remote'] as const)(
'%s does not rederive every retained rollout alias after each batch',
async (host) => {
const count = 128
const provider = new MemoryRemoteProvider()
const root = await mkdtemp(join(tmpdir(), 'orca-session-dedup-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(roots.codexSessionsDir, { recursive: true })
for (let index = 0; index < count; index++) {
const name = `rollout-session-${index}.jsonl`
const content = jsonLines([
{ type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } },
{ type: 'event_msg', payload: { type: 'user_message', message: 'Check this session' } }
])
if (host === 'native') {
await writeFile(join(roots.codexSessionsDir, name), content)
} else {
provider.addFile(`/home/ada/.codex/sessions/${name}`, content, count - index)
}
}
let aliasChecks = 0
const originalTest = RegExp.prototype.test
vi.spyOn(RegExp.prototype, 'test').mockImplementation(function (this: RegExp, value) {
if (this.source === '^rollout-.+\\.jsonl$') {
aliasChecks++
}
return originalTest.call(this, value)
})
const scan = () =>
host === 'native'
? scanAiVaultSessions({ ...roots, unlimited: true })
: scanRemoteAiVaultSessions({
provider,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
executionHostId: 'ssh:dedup-batches',
unlimited: true
})
for (let pass = 0; pass < 2; pass++) {
aliasChecks = 0
const result = await scan()
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(count)
expect(new Set(result.sessions.map((session) => session.sessionId)).size).toBe(count)
expect(aliasChecks).toBeLessThanOrEqual(count * 8)
}
}
)
it('replaces aliases across remote batches without consuming the unique-session budget', async () => {
const provider = new MemoryRemoteProvider()
const managedHome = '/home/ada/.local/share/orca/codex-runtime-home/home'
const content = (id: string) =>
jsonLines([
{ type: 'session_meta', payload: { id, cwd: '/repo/folder' } },
{ type: 'event_msg', payload: { type: 'user_message', message: 'Session' } }
])
for (let index = 0; index < 8; index++) {
const name = `rollout-${index}.jsonl`
provider.addFile(`/home/ada/.codex/sessions/${name}`, content(`${index}`), 1000 - index)
provider.addFile(`${managedHome}/sessions/${name}`, content(`${index}`), 500 - index)
}
provider.addFile('/home/ada/.codex/sessions/rollout-unique.jsonl', content('unique'), 100)
const tooOld = '/home/ada/.codex/sessions/rollout-too-old.jsonl'
provider.addFile(tooOld, content('too-old'), 50)
const reads = vi.spyOn(provider, 'readFile')
const result = await scanRemoteAiVaultSessions({
provider,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64'),
executionHostId: 'ssh:batch-replacements',
limit: 9
})
expect(result.issues).toEqual([])
expect(result.sessions.map((session) => session.sessionId)).toEqual([
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'unique'
])
expect(result.sessions.slice(0, 8).every((session) => session.codexHome === managedHome)).toBe(
true
)
expect(reads).not.toHaveBeenCalledWith(tooOld)
})
})
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import Database from '../sqlite/sync-database'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list'
@@ -20,6 +20,7 @@ let tempDirs: string[] = []
let lockHolders: Worker[] = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(lockHolders.splice(0).map((worker) => worker.terminate()))
lockHolders = []
for (const dir of tempDirs) {
@@ -66,20 +67,29 @@ const LOCK_HOLDER_SOURCE = `
db.exec('BEGIN EXCLUSIVE')
db.exec("INSERT INTO session (id, time_created, time_updated) VALUES ('locked-write', 1, 1)")
parentPort.postMessage('locked')
setTimeout(() => {
db.exec('ROLLBACK')
db.close()
parentPort.postMessage('released')
}, workerData.holdMs)
parentPort.once('message', (message) => {
if (message !== 'reader-started') {
throw new Error('Unexpected lock-holder message')
}
setTimeout(() => {
db.exec('ROLLBACK')
db.close()
parentPort.postMessage('released')
}, workerData.releaseDelayMs)
})
`
async function holdWriteLock(path: string, holdMs: number): Promise<void> {
const worker = new Worker(LOCK_HOLDER_SOURCE, { eval: true, workerData: { path, holdMs } })
async function holdWriteLock(path: string, releaseDelayMs: number): Promise<Worker> {
const worker = new Worker(LOCK_HOLDER_SOURCE, {
eval: true,
workerData: { path, releaseDelayMs }
})
lockHolders.push(worker)
await new Promise<void>((resolve, reject) => {
worker.once('message', () => resolve())
worker.once('error', reject)
})
return worker
}
describe('listOpenCodeSqliteSessions against a database OpenCode is writing to', () => {
@@ -117,9 +127,9 @@ describe('listOpenCodeSqliteSessions against a database OpenCode is writing to',
it('reads the sessions once the write finishes inside the busy timeout', async () => {
const path = seededDatabase('opencode.db', 'session-a')
// Long enough that only a real busy timeout — not a lucky fast open — survives it.
await holdWriteLock(path, 900)
const worker = await holdWriteLock(path, 200)
const issues: AiVaultScanIssue[] = []
worker.postMessage('reader-started')
const candidates = await listOpenCodeSqliteSessions({ dbPaths: [path], limit: 10, issues })
@@ -146,6 +156,63 @@ describe('readOpenCodeDatabase', () => {
expect(() => captured!.prepare('SELECT 1')).toThrow(/not open/i)
})
it('closes the handle when query_only setup fails', () => {
const path = seededDatabase('opencode.db', 'session-a')
const setupError = new Error('query_only setup failed')
const originalClose = Database.prototype.close
const pragmaSpy = vi.spyOn(Database.prototype, 'pragma').mockImplementationOnce(() => {
throw setupError
})
const closeSpy = vi.spyOn(Database.prototype, 'close')
const read = vi.fn()
try {
expect(() => readOpenCodeDatabase({ dbPath: path, read })).toThrow(setupError)
expect(read).not.toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalledOnce()
expect(() => (pragmaSpy.mock.contexts[0] as Database).prepare('SELECT 1')).toThrow(
/not open/i
)
} finally {
try {
originalClose.call(pragmaSpy.mock.contexts[0] as Database)
} catch {
// Keep the regression safe to run against the leaking implementation too.
}
}
})
it('preserves the setup error when closing also fails', () => {
const path = seededDatabase('opencode.db', 'session-a')
const setupError = new Error('query_only setup failed')
const closeError = new Error('close failed')
const originalClose = Database.prototype.close
vi.spyOn(Database.prototype, 'pragma').mockImplementationOnce(() => {
throw setupError
})
vi.spyOn(Database.prototype, 'close').mockImplementationOnce(function (this: Database) {
originalClose.call(this)
throw closeError
})
const read = vi.fn()
expect(() => readOpenCodeDatabase({ dbPath: path, read })).toThrow(setupError)
expect(Database.prototype.close).toHaveBeenCalledOnce()
expect(read).not.toHaveBeenCalled()
})
it('keeps the query-only guard enabled for successful reads', () => {
const path = seededDatabase('opencode.db', 'session-a')
readOpenCodeDatabase({
dbPath: path,
read: (db) => {
expect(db.pragma('query_only', { simple: true })).toBe(1)
expect(() => db.exec('DELETE FROM session')).toThrow(/readonly/i)
expect(db.prepare('SELECT id FROM session').all()).toEqual([{ id: 'session-a' }])
}
})
})
it('closes the handle when the read throws', () => {
const path = seededDatabase('opencode.db', 'session-a')
let captured: Database.Database | null = null
@@ -41,8 +41,17 @@ function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase {
fileMustExist: true,
timeout: openCodeBusyTimeoutMs(dbPath)
})
db.pragma('query_only = ON')
return db
try {
db.pragma('query_only = ON')
return db
} catch (error) {
try {
db.close()
} catch {
// Why: close must not hide the query_only setup failure.
}
throw error
}
}
/**
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from 'vitest'
import {
cloneSessionAccumulator,
createAccumulator,
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
function accumulator() {
return createAccumulator({
agent: 'claude',
sessionId: 'timeline-test',
file: { path: 'transcript.jsonl', mtimeMs: 0, modifiedAt: '2026-01-01T00:00:00.000Z' }
})
}
describe('session timeline bounds', () => {
it('retains earliest and latest timestamps despite duplicates and out-of-order records', () => {
const state = accumulator()
for (const timestamp of [
'2026-01-03T01:00:00+01:00',
'2026-01-01T00:00:00Z',
'2026-01-04T00:00:00Z',
'2026-01-02T00:00:00Z',
'2026-01-04T00:00:00Z'
]) {
updateTimeline(state, timestamp)
}
expect(finalizeSession(state, 'linux')).toMatchObject({
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-04T00:00:00.000Z'
})
expect(state.latestTimestampMs).toBe(Date.parse('2026-01-04T00:00:00Z'))
})
it('compares fractional numeric timestamps against the rounded ISO bound', () => {
const state = accumulator()
const base = 1_700_000_000_000
updateTimeline(state, base + 0.9)
updateTimeline(state, base + 0.1)
expect(state.latestTimestampMs).toBe(base + 0.1)
expect(state.createdAt).toBe(new Date(base).toISOString())
updateTimeline(state, base - 0.1)
expect(state.createdAt).toBe(new Date(base - 1).toISOString())
expect(state.latestTimestampMs).toBe(base + 0.1)
})
it('preserves pre-epoch and extended-year ISO timestamps', () => {
const state = accumulator()
updateTimeline(state, '+010000-01-01T00:00:00.000Z')
updateTimeline(state, '-000001-01-01T00:00:00.000Z')
updateTimeline(state, '1969-12-31T23:59:59.999Z')
expect(state.createdAt).toBe('-000001-01-01T00:00:00.000Z')
expect(state.updatedAt).toBe('+010000-01-01T00:00:00.000Z')
})
it('ignores invalid timestamps and retains the existing out-of-range error', () => {
const state = accumulator()
for (const timestamp of [null, undefined, '', 'bad-date', 0, -1, Number.NaN, Infinity]) {
updateTimeline(state, timestamp)
}
expect(state.createdAt).toBeNull()
expect(state.updatedAt).toBeNull()
expect(() => updateTimeline(state, 8_640_000_000_000_001)).toThrow(RangeError)
expect(state.createdAt).toBeNull()
expect(state.updatedAt).toBeNull()
})
it('keeps cloned parse-state bounds independent', () => {
const state = accumulator()
updateTimeline(state, '2026-01-02T00:00:00Z')
const clone = cloneSessionAccumulator(state)
updateTimeline(clone, '2026-01-01T00:00:00Z')
updateTimeline(clone, '2026-01-03T00:00:00Z')
expect(state.createdAt).toBe('2026-01-02T00:00:00.000Z')
expect(state.updatedAt).toBe('2026-01-02T00:00:00.000Z')
expect(clone.createdAt).toBe('2026-01-01T00:00:00.000Z')
expect(clone.updatedAt).toBe('2026-01-03T00:00:00.000Z')
})
it('does not reparse accumulated bounds for every numeric record', () => {
const state = accumulator()
const spy = vi.spyOn(Date, 'parse')
let parseCalls: number
try {
for (let index = 0; index < 1000; index += 1) {
updateTimeline(state, 1_700_000_000_000 + index)
}
parseCalls = spy.mock.calls.length
} finally {
spy.mockRestore()
}
expect(state.latestTimestampMs).toBe(1_700_000_000_999)
expect(parseCalls).toBe(0)
})
})
@@ -144,6 +144,7 @@ export type SessionAccumulator = {
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
queuedMessageCount: number
subagentTranscriptCount: number
earliestTimestampMs: number
latestTimestampMs: number
}
+6 -28
View File
@@ -6,7 +6,7 @@ import type {
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { withSpan } from '../observability/tracer'
import { sessionSortTime } from './session-scanner-accumulator'
import { dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import { CodexSessionCollection, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup'
import {
createAntigravityWorkspaceResolver,
readLocalAntigravityHistory,
@@ -23,6 +23,7 @@ import {
type SessionParseStats
} from './session-scanner-parse-cache'
import { recordSessionScanIssue } from './session-scan-issues'
import { canStopParsingSessions } from './session-scan-cutoff'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import { discoverAiVaultSessionSources } from './session-scanner-source-discovery'
import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta'
@@ -211,7 +212,7 @@ async function parseSessionCandidates(args: {
signal?: AbortSignal
antigravityWorkspaceResolver?: AntigravityWorkspaceResolver
}): Promise<AiVaultSession[]> {
const sessions: AiVaultSession[] = []
const sessions = new CodexSessionCollection()
let index = 0
while (index < args.candidates.length) {
@@ -221,7 +222,7 @@ async function parseSessionCandidates(args: {
}
const remaining = args.candidates.length - index
const needed = Math.max(args.limit - sessions.length, 1)
const needed = Math.max(args.limit - sessions.size, 1)
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
const batch = args.candidates.slice(index, index + batchSize)
const results = await Promise.all(
@@ -241,22 +242,17 @@ async function parseSessionCandidates(args: {
recordSessionScanIssue(args.issues, result.issue)
}
if (result.session) {
sessions.push(result.session)
sessions.add(result.session)
}
}
// Why: cross-volume backfill copies have no shared inode, so collapse
// parsed aliases before they can crowd the unique-session parse budget.
const uniqueSessions = dedupeCodexSessionsBySessionId(sessions)
sessions.splice(0, sessions.length, ...uniqueSessions)
index += batchSize
}
// An abort can land while the final batch settles; observe it here so a
// partial parse is never cached or returned as a complete scan.
throwIfAiVaultScanCancelled(args.signal)
return sessions
return [...sessions.values()]
}
async function parseSessionCandidate(
@@ -301,21 +297,3 @@ function withSessionExecutionHost(
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
}
}
function canStopParsingSessions(
sessions: AiVaultSession[],
limit: number,
nextCandidateMtimeMs: number | undefined
): boolean {
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
return false
}
const visibleCutoff = sessions
.map(sessionSortTime)
.sort((left, right) => right - left)
.at(limit - 1)
// Transcript mtime is already our discovery bound and fallback sort key; older
// files cannot displace the current visible set once the cutoff is newer.
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
}
@@ -277,6 +277,31 @@ Run summary: monitor automation completed successfully.
expect(fakePrepareSqls.some((sql) => sql.includes('FROM messages'))).toBe(false)
})
it('skips date sorting for counts while keeping paginated runs newest first', async () => {
const home = await createHermesHome()
await writeFile(join(home, 'state.db'), '')
fakeDbRows.sessions = [
{ id: 'cron_job-1_older', started_at: 1000 },
{ id: 'cron_job-1_newer', started_at: 2000 }
]
const { readHermesCronOutputRunsPage } = await loadReader()
const parse = vi.spyOn(Date, 'parse')
try {
await expect(
readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 0 })
).resolves.toEqual({
total: 2,
runs: []
})
expect(parse).not.toHaveBeenCalled()
} finally {
parse.mockRestore()
}
const page = await readHermesCronOutputRunsPage('job-1', { page: 1, pageSize: 1 })
expect(page.total).toBe(2)
expect(page.runs).toMatchObject([{ id: 'cron_job-1_newer' }])
})
it('caches count-only reads until the cache is cleared', async () => {
const home = await createHermesHome()
const outputDir = join(home, 'cron', 'output', 'job-1')
+9 -10
View File
@@ -65,16 +65,7 @@ export async function readHermesCronOutputRuns(jobId: string): Promise<unknown[]
async function readHermesCronOutputRunRefs(jobId: string): Promise<HermesMergedRunRef[]> {
const outputRuns = await readHermesOutputFileRunRefs(jobId)
return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId)).sort(
(a, b) => {
const aTime = getRawRunTime(a)
const bTime = getRawRunTime(b)
if (Number.isFinite(aTime) && Number.isFinite(bTime)) {
return bTime - aTime
}
return getRawRunId(b).localeCompare(getRawRunId(a))
}
)
return mergeHermesOutputAndSessionRunRefs(outputRuns, readHermesSessionDbRunRefs(jobId))
}
// Why: opening the Automations page calls readHermesCronOutputRunsPage with
@@ -127,6 +118,14 @@ export async function readHermesCronOutputRunsPage(
return { total: await readHermesCronOutputRunCount(jobId), runs: [] }
}
const runRefs = await readHermesCronOutputRunRefs(jobId)
runRefs.sort((a, b) => {
const aTime = getRawRunTime(a)
const bTime = getRawRunTime(b)
if (Number.isFinite(aTime) && Number.isFinite(bTime)) {
return bTime - aTime
}
return getRawRunId(b).localeCompare(getRawRunId(a))
})
const start = (safePage - 1) * safePageSize
const pageRefs = runRefs.slice(start, start + safePageSize)
return {
+17 -53
View File
@@ -1,3 +1,4 @@
import { HermesSessionRunIndex } from '../../shared/hermes-session-run-index'
import { open, readFile, realpath, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
@@ -152,59 +153,21 @@ function mergeOutputAndSessionContent(
return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}`
}
function findMatchingSessionRunIndex(
outputRun: unknown,
sessionRuns: unknown[],
usedSessionRunIndexes: Set<number>
): number | null {
const outputRunKey = getRunKey(outputRun)
const exactMatchIndex = sessionRuns.findIndex(
(sessionRun, index) =>
!usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey
)
if (exactMatchIndex !== -1) {
return exactMatchIndex
}
const outputTime = sortableTimeFromRunKey(outputRunKey)
if (!Number.isFinite(outputTime)) {
return null
}
let bestIndex: number | null = null
let bestGap = Number.POSITIVE_INFINITY
for (let index = 0; index < sessionRuns.length; index += 1) {
if (usedSessionRunIndexes.has(index)) {
continue
}
const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index]))
if (!Number.isFinite(sessionTime)) {
continue
}
const gap = outputTime - sessionTime
if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) {
continue
}
bestIndex = index
bestGap = gap
}
return bestIndex
}
export function mergeHermesOutputAndSessionRuns(
outputRuns: unknown[],
sessionRuns: unknown[]
): unknown[] {
const usedSessionRunIndexes = new Set<number>()
const sessionIndex = new HermesSessionRunIndex(
outputRuns.length > 0 ? sessionRuns.map(getRunKey) : [],
sortableTimeFromRunKey,
MAX_SESSION_OUTPUT_GAP_MS
)
const usedSessionRunIndexes = sessionIndex.used
const mergedOutputRuns = outputRuns.map((outputRun) => {
if (!isRecord(outputRun)) {
return outputRun
}
const sessionRunIndex = findMatchingSessionRunIndex(
outputRun,
sessionRuns,
usedSessionRunIndexes
)
const sessionRunIndex = sessionIndex.find(getRunKey(outputRun))
if (sessionRunIndex === null) {
return outputRun
}
@@ -212,7 +175,7 @@ export function mergeHermesOutputAndSessionRuns(
if (!isRecord(sessionRun)) {
return outputRun
}
usedSessionRunIndexes.add(sessionRunIndex)
sessionIndex.use(sessionRunIndex)
// Hermes writes the markdown output at completion, while state.db keeps
// the actual turn-by-turn transcript under the cron session start time.
return {
@@ -234,16 +197,17 @@ export function mergeHermesOutputAndSessionRunRefs(
outputRefs: HermesOutputRunRef[],
sessionRefs: HermesSessionRunRef[]
): HermesMergedRunRef[] {
const usedSessionRunIndexes = new Set<number>()
const sessionIndex = new HermesSessionRunIndex(
outputRefs.length > 0 ? sessionRefs.map(getRunKey) : [],
sortableTimeFromRunKey,
MAX_SESSION_OUTPUT_GAP_MS
)
const usedSessionRunIndexes = sessionIndex.used
const mergedOutputRefs = outputRefs.map((outputRef) => {
const sessionRunIndex = findMatchingSessionRunIndex(
outputRef,
sessionRefs,
usedSessionRunIndexes
)
const sessionRunIndex = sessionIndex.find(getRunKey(outputRef))
const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex]
if (sessionRunIndex !== null) {
usedSessionRunIndexes.add(sessionRunIndex)
sessionIndex.use(sessionRunIndex)
}
return {
id: outputRef.id,
@@ -6,14 +6,10 @@ import type {
} from '../../shared/runtime-types'
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
import { BrowserError } from './cdp-bridge'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
import { focusedValueSetExpression } from './agent-browser-bridge-input'
import {
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES,
EMBEDDED_NAVIGATION_TIMEOUT_MS
} from './agent-browser-bridge-types'
import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types'
import {
isAbortedNavigationError,
waitForAbortedNavigationReplacement
@@ -157,23 +153,10 @@ export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQ
async (sessionName) => {
if (!(await this.isExplicitContentEditableTarget(sessionName, element))) {
await this.execAgentBrowser(sessionName, ['focus', element])
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(''))
])
for (const chunk of iterateBrowserTextInsertionChunks(
value,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
)) {
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(chunk), { append: true })
])
}
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true })
])
// One stdin edit avoids argv limits and repeated copying of the growing field value.
await this.execAgentBrowser(sessionName, ['eval', '--stdin'], {
stdinText: focusedValueSetExpression(JSON.stringify(value), { dispatchEvents: true })
})
return { filled: element } as BrowserFillResult
}
@@ -278,8 +278,9 @@ describe('AgentBrowserBridge', () => {
)
expect(evalCall).toBeDefined()
const args = evalCall![1] as string[]
const expression = args[args.indexOf('eval') + 1]
expect(() => new Function(expression)).not.toThrow()
expect(args[args.indexOf('eval') + 1]).toBe('--stdin')
expect(stdinWrites).toHaveLength(1)
expect(() => new Function(stdinWrites[0])).not.toThrow()
})
it('replaces contenteditable text through the browser editing pipeline', async () => {
@@ -359,12 +360,7 @@ describe('AgentBrowserBridge', () => {
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const expressions = stdinWrites
const input = createFillEvalNode({ tagName: 'INPUT' })
const wrapper = createFillEvalNode({
@@ -389,12 +385,7 @@ describe('AgentBrowserBridge', () => {
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const expressions = stdinWrites
const input = createFillEvalNode({ tagName: 'INPUT' })
const wrapper = createFillEvalNode({
@@ -419,12 +410,7 @@ describe('AgentBrowserBridge', () => {
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const expressions = stdinWrites
const input = createFillEvalNode({ tagName: 'INPUT' })
const controlled = createFillEvalNode({ tagName: 'DIV', descendant: input.node })
@@ -452,12 +438,7 @@ describe('AgentBrowserBridge', () => {
await bridge.fill('@spinbutton', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const expressions = stdinWrites
const hiddenInput = createFillEvalNode({ tagName: 'INPUT', type: 'hidden' })
const numberInput = createFillEvalNode({ tagName: 'INPUT', type: 'number' })
@@ -485,12 +466,7 @@ describe('AgentBrowserBridge', () => {
await bridge.fill('@input', '200')
const expressions = execFileMock.mock.calls
.filter((call: unknown[]) => (call[1] as string[]).includes('eval'))
.map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
})
const expressions = stdinWrites
const input = createFillEvalNode({ tagName: 'INPUT' })
@@ -503,8 +479,8 @@ describe('AgentBrowserBridge', () => {
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
})
it('chunks large agent-browser fill values before eval transport', async () => {
const text = ['x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES), 'tail'].join('')
it('fills large plain fields with one stdin edit and one event pair', async () => {
const text = `${'é\n'.repeat(512 * 1024)}tail'\\`
succeedWith({ ok: true })
await bridge.fill('@textarea', text)
@@ -512,15 +488,17 @@ describe('AgentBrowserBridge', () => {
const evalCalls = execFileMock.mock.calls.filter((call: unknown[]) =>
(call[1] as string[]).includes('eval')
)
const appendExpressions = evalCalls.slice(1, -1).map((call: unknown[]) => {
const args = call[1] as string[]
return args[args.indexOf('eval') + 1]
expect(evalCalls).toHaveLength(1)
expect(evalCalls[0][1]).toContain('--stdin')
expect(stdinWrites).toHaveLength(1)
expect((evalCalls[0][1] as string[]).join('')).not.toContain(text)
const input = createFillEvalNode({ tagName: 'TEXTAREA' })
runFillEvalExpressions(stdinWrites, {
activeElement: input.node,
getElementById: () => null
})
expect(appendExpressions).toHaveLength(2)
expect(appendExpressions.some((expression) => expression.includes(text))).toBe(false)
expect(appendExpressions[0]).toContain('x'.repeat(AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES))
expect(appendExpressions[1]).toContain('tail')
expect(input.value).toBe(text)
expect(input.events.map((event) => event.type)).toEqual(['input', 'change'])
})
it.each([
@@ -3,6 +3,12 @@ import type { BrowserClientHostedPageInventory } from '../../shared/browser-clie
import { prepareBrowserClientPageInventoryForAttach } from './browser-client-page-inventory'
describe('browser client page inventory', () => {
it('keeps URLs and duplicate-page validation when the inventory fits', () => {
const page = { ...inventoryPage('page-a'), currentUrl: 'https://example.test/' }
expect(prepareBrowserClientPageInventoryForAttach([page])).toEqual([page])
expect(prepareBrowserClientPageInventoryForAttach([page, page])).toBeUndefined()
})
it('uses codepoint order to break equal URL-compaction ties across input order', () => {
const pageIds = [
'ä-page',
@@ -103,6 +103,10 @@ export function prepareBrowserClientPageInventoryForAttach(
inventory.push(parsed.data)
}
let inventoryBytes = browserClientHostedPageInventoryByteLength(inventory)
if (inventoryBytes <= BROWSER_CLIENT_HOST_PAGE_INVENTORY_MAX_BYTES) {
const prepared = BrowserClientHostedPageInventoryList.safeParse(inventory)
return prepared.success ? prepared.data : undefined
}
const optionalUrls = inventory
.flatMap((page, index) => {
if (page.currentUrl === undefined) {
@@ -171,8 +171,9 @@ describe('removeTransplantableCookies — preserved families on a POPULATED jar'
})
it('preserves a family named by an IPv4 literal', async () => {
// Why: psl reads 127.0.0.1 as the dotted DNS name '0.1'. If registrableFamily returned that,
// the live 127.0.0.1 session would not match the preserve set and would be erased.
// Why: an IPv4 literal has no registrable domain, so the family must come from the IP branch.
// If registrableFamily fell through to the suffix parser, the live 127.0.0.1 session would not
// match the preserve set and would be erased.
const target = jar([cookie('127.0.0.1', 'loopback-session'), cookie('.other.example', 'stale')])
await removeTransplantableCookies(
@@ -1,6 +1,6 @@
import { isIP } from 'node:net'
import type { Cookie, Cookies } from 'electron'
import { parse as parseDomain } from 'psl'
import { parse as parseDomain } from 'tldts'
// Why: type-only, so this does not create a runtime cycle with the clear module.
import type { CookieClearIdentity } from './browser-cookie-import-clear'
@@ -41,13 +41,24 @@ export function normalizeCookieDomain(domain: string): string | null {
}
}
// Why allowPrivateDomains: the PSL's PRIVATE section is what keeps one tenant's cookies out of
// another's — without it `foo.github.io` and `bar.github.io` collapse to the same family, and a
// replace-mode import for one would clear the other. tldts defaults this off; cookie scoping needs
// it on.
const PUBLIC_SUFFIX_OPTIONS = { allowPrivateDomains: true } as const
// psl exposed a single `listed` flag; tldts splits the same question across the two list sections.
function isListedSuffix(parsed: { isIcann: boolean | null; isPrivate: boolean | null }): boolean {
return parsed.isIcann === true || parsed.isPrivate === true
}
// Why (STA-4300): one definition of "family" for every consumer of the partition skip set — the
// planner, the per-coordinate removal filter, and the path A domain comparison. Deriving it inline
// in several places is what let the removal scope and the write set disagree (STA-4090, STA-4170).
//
// The IP test MUST run on normalizeCookieDomain's output, never the raw string: Chromium accepts
// many spellings of one address and psl mangles all of them (psl.parse('2130706433').domain is
// null, psl.parse('127.0.0.1').domain is '0.1'). normalizeCookieDomain runs the value through
// many spellings of one address and the suffix parser mangles all of them (tldts.parse('2130706433')
// .domain is null, tldts.parse('127.1').domain is '127.1'). normalizeCookieDomain runs the value through
// `new URL()`, which canonicalises 127.1 / 2130706433 / 0x7f.1 / 010.0.0.1 / a trailing dot to a
// dotted quad first, so isIP() then recognises every one of them.
//
@@ -65,12 +76,12 @@ export function registrableFamily(domain: string): string | null {
if (host.startsWith('[') && host.endsWith(']') && isIP(host.slice(1, -1)) === 6) {
return host
}
const parsed = parseDomain(host)
if ('error' in parsed) {
const parsed = parseDomain(host, PUBLIC_SUFFIX_OPTIONS)
if (parsed.hostname === null) {
return host
}
if (parsed.domain === null) {
return parsed.listed ? null : host
return isListedSuffix(parsed) ? null : host
}
return parsed.domain
}
@@ -80,11 +91,11 @@ export function normalizeCookieImportDomain(domain: string): string | null {
if (!normalized) {
return null
}
const parsed = parseDomain(normalized)
if ('error' in parsed) {
const parsed = parseDomain(normalized, PUBLIC_SUFFIX_OPTIONS)
if (parsed.hostname === null) {
return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null
}
if (parsed.domain === null && parsed.listed) {
if (parsed.domain === null && isListedSuffix(parsed)) {
return null
}
return normalized
@@ -129,8 +140,8 @@ function domainSuffixes(domain: string): string[] {
}
function importDomainAncestors(domain: string): string[] {
const parsed = parseDomain(domain)
const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain)
const parsed = parseDomain(domain, PUBLIC_SUFFIX_OPTIONS)
const boundary = parsed.hostname === null ? domain : (parsed.domain ?? domain)
const ancestors: string[] = []
for (const suffix of domainSuffixes(domain)) {
ancestors.push(suffix)
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
domainIsInImportedScope,
importedDomainScope,
normalizeCookieImportDomain,
registrableFamily
} from './browser-cookie-import-policy'
// Why this file exists: the public-suffix engine decides which cookies share a removal scope, so a
// library swap silently re-partitions the jar. These cases pin the boundaries that moved (or had to
// be held) when this moved off `psl`.
describe('registrable family across public-suffix sections', () => {
it('keeps each PRIVATE-section tenant in its own family', () => {
// psl and tldts disagree here unless allowPrivateDomains is set; without it every
// *.github.io tenant collapses into one family and a replace-mode import clears siblings.
expect(registrableFamily('foo.github.io')).toBe('foo.github.io')
expect(registrableFamily('bar.github.io')).toBe('bar.github.io')
expect(registrableFamily('bar.s3.amazonaws.com')).toBe('bar.s3.amazonaws.com')
expect(registrableFamily('foo.vercel.app')).toBe('foo.vercel.app')
})
it('refuses to name a bare public suffix as a family', () => {
expect(registrableFamily('com')).toBeNull()
expect(registrableFamily('co.uk')).toBeNull()
expect(registrableFamily('github.io')).toBeNull()
// Absent from psl 1.15.0's 2024 snapshot; naming it a family would preserve a whole suffix.
expect(registrableFamily('api.br')).toBeNull()
expect(registrableFamily('seg.ar')).toBeNull()
})
it('resolves ICANN suffixes to the registrable domain', () => {
expect(registrableFamily('a.b.example.co.uk')).toBe('example.co.uk')
expect(registrableFamily('www.example.com')).toBe('example.com')
expect(registrableFamily('foo.example.api.br')).toBe('example.api.br')
})
it('returns the canonicalised address for every IP spelling', () => {
expect(registrableFamily('127.0.0.1')).toBe('127.0.0.1')
expect(registrableFamily('127.1')).toBe('127.0.0.1')
expect(registrableFamily('2130706433')).toBe('127.0.0.1')
expect(registrableFamily('[::1]')).toBe('[::1]')
})
it('treats an unlisted suffix as its own boundary', () => {
expect(registrableFamily('example.notaruleatall')).toBe('example.notaruleatall')
})
it('rejects a bare suffix as an import domain but keeps real hosts', () => {
expect(normalizeCookieImportDomain('co.uk')).toBeNull()
expect(normalizeCookieImportDomain('api.br')).toBeNull()
expect(normalizeCookieImportDomain('.example.com')).toBe('example.com')
expect(normalizeCookieImportDomain('foo.github.io')).toBe('foo.github.io')
})
})
// Why: `.local` is absent from the PSL, and the two libraries disagreed about what that means. psl
// returned an all-null parse, so every `*.orca.local` host was its own family; tldts applies the
// default single-label rule and stops at `orca.local`, which is what Chromium treats as registrable.
// The widening is deliberate, so it is pinned here rather than left to the next library bump.
describe('unlisted .local suffix', () => {
it('stops at the two-label boundary', () => {
expect(registrableFamily('app.orca.local')).toBe('orca.local')
expect(registrableFamily('orca.local')).toBe('orca.local')
})
// The consequence of the boundary move: a replace-mode import of one host now also clears
// non-host-only cookies scoped to `.orca.local`, which every sibling `*.orca.local` host shares.
it('pulls the shared parent into the removal scope', () => {
const scope = importedDomainScope(['app.orca.local'])
expect(domainIsInImportedScope(scope, 'orca.local', false)).toBe(true)
expect(domainIsInImportedScope(scope, 'orca.local', true)).toBe(false)
})
})
// Why: psl's 2024 snapshot carried `compute.amazonaws.com` as a literal PRIVATE suffix; the current
// list only has the `*.compute.amazonaws.com` wildcard, so the bare host is an ordinary ICANN domain
// now. That moves a real host shape from "no family" to `amazonaws.com`.
describe('suffix entries that changed shape upstream', () => {
it('reads bare compute.amazonaws.com as a registrable domain', () => {
expect(registrableFamily('compute.amazonaws.com')).toBe('amazonaws.com')
expect(normalizeCookieImportDomain('compute.amazonaws.com')).toBe('compute.amazonaws.com')
})
it('still refuses the wildcard child and the sibling private suffix', () => {
expect(registrableFamily('foo.compute.amazonaws.com')).toBeNull()
expect(registrableFamily('s3.amazonaws.com')).toBeNull()
})
})
@@ -21,9 +21,10 @@ describe('registrableFamily', () => {
expect(registrableFamily(host)).toBe(expected)
})
// Why: psl treats an IPv4 literal as a dotted DNS name — psl.parse('127.0.0.1').domain is '0.1'.
// These pass only because the IP check runs on normalizeCookieDomain's canonicalised output.
// Moving the check before normalisation reintroduces a wrong, destructive family.
// Why: the suffix parser reads a non-dotted-quad IPv4 spelling as a DNS name —
// tldts.parse('127.1').domain is '127.1' and tldts.parse('2130706433').domain is null. These pass
// only because the IP check runs on normalizeCookieDomain's canonicalised output. Moving the check
// before normalisation reintroduces a wrong, destructive family.
it.each([
['127.0.0.1', '127.0.0.1'],
['192.168.1.1', '192.168.1.1'],
@@ -32,15 +33,15 @@ describe('registrableFamily', () => {
['2130706433', '127.0.0.1'],
['0x7f.1', '127.0.0.1'],
['127.0.0.1.', '127.0.0.1'],
// Octal, and 8.0.0.1 is the correct reading — psl would have produced '0.1'.
// Octal, and 8.0.0.1 is the correct reading — unnormalised, this parses as a DNS name.
['010.0.0.1', '8.0.0.1']
])('recognises the IPv4 literal %s as %s', (host, expected) => {
expect(registrableFamily(host)).toBe(expected)
})
// Why: isIP('[::1]') is 0, so the bracketed form needs its own branch. Without it these fall
// through to psl, which throws, which happens to return the host — right answer, wrong reason,
// and it stops being right the moment the error branch is touched.
// through to the parser, which strips the brackets and reports no suffix — the unlisted path then
// happens to return the host. Right answer, wrong reason, and only while that path is untouched.
it.each([
['[::1]', '[::1]'],
['[2001:db8::1]', '[2001:db8::1]']
@@ -0,0 +1,191 @@
import { describe, expect, it, vi } from 'vitest'
import type { AXNode } from './snapshot-ax-tree-walk'
import { buildSnapshot, type CdpCommandSender } from './snapshot-engine'
function buttonTree(count: number, name = 'Submit'): AXNode[] {
const buttons = Array.from({ length: count }, (_, i) => ({
nodeId: String(i + 2),
backendDOMNodeId: i + 10,
role: { type: 'role', value: 'button' },
name: { type: 'computedString', value: name }
}))
return [
{
nodeId: '1',
role: { type: 'role', value: 'WebArea' },
childIds: buttons.map((n) => n.nodeId)
},
...buttons
]
}
function sender(nodes: AXNode[], cursor = false): CdpCommandSender {
return vi.fn(async (method, params) => {
if (method === 'Accessibility.enable') {
return {}
}
if (method === 'Accessibility.getFullAXTree') {
return { nodes }
}
if (method === 'DOM.describeNode') {
return { node: { backendNodeId: 100 } }
}
if (method === 'Runtime.evaluate') {
if (params?.expression === 'window.__orcaCursorInteractive[0]') {
return { result: { objectId: 'cursor-object' } }
}
return { result: { value: JSON.stringify(cursor ? [{ text: 'Cursor', tag: 'div' }] : []) } }
}
throw new Error(`Unexpected CDP method: ${method}`)
})
}
describe('buildSnapshot iframe sessions', () => {
it('preserves ref order, within-frame duplicate names and session ownership', async () => {
const parent = sender(buttonTree(1, 'Parent'), true)
const frameA = sender(buttonTree(2, 'Frame A'))
const frameB = sender(buttonTree(1, 'Frame B'))
const empty = sender([])
const stale = vi.fn(async () => {
throw new Error('Session closed')
})
const senders = new Map<string, CdpCommandSender>([
['session-a', frameA],
['session-empty', empty],
['session-stale', stale],
['session-b', frameB]
])
const makeIframeSender = vi.fn((sessionId: string) => senders.get(sessionId)!)
const sessions = new Map([
['frame-a', 'session-a'],
['frame-empty', 'session-empty'],
['frame-stale', 'session-stale'],
['frame-b', 'session-b']
])
const result = await buildSnapshot(parent, sessions, makeIframeSender)
expect(result.snapshot).toBe(
[
'[@e1] button "Parent"',
'[@e2] clickable "Cursor"',
' [@e3] button "Frame A"',
' [@e4] button "Frame A (2nd)"',
' [@e5] button "Frame B"'
].join('\n')
)
expect(result.refs).toEqual([
{ ref: '@e1', role: 'button', name: 'Parent' },
{ ref: '@e2', role: 'clickable', name: 'Cursor' },
{ ref: '@e3', role: 'button', name: 'Frame A' },
{ ref: '@e4', role: 'button', name: 'Frame A (2nd)' },
{ ref: '@e5', role: 'button', name: 'Frame B' }
])
expect([...result.refMap]).toEqual([
[
'@e1',
{
backendDOMNodeId: 10,
role: 'button',
name: 'Parent',
sessionId: undefined,
nth: undefined
}
],
[
'@e2',
{
backendDOMNodeId: 100,
role: 'clickable',
name: 'Cursor',
sessionId: undefined,
nth: undefined
}
],
[
'@e3',
{ backendDOMNodeId: 10, role: 'button', name: 'Frame A', sessionId: 'session-a', nth: 1 }
],
[
'@e4',
{ backendDOMNodeId: 11, role: 'button', name: 'Frame A', sessionId: 'session-a', nth: 2 }
],
[
'@e5',
{
backendDOMNodeId: 10,
role: 'button',
name: 'Frame B',
sessionId: 'session-b',
nth: undefined
}
]
])
expect(makeIframeSender.mock.calls.flat()).toEqual([...sessions.values()])
for (const frame of [frameA, empty, frameB]) {
expect(vi.mocked(frame).mock.calls.map(([method]) => method)).toEqual([
'Accessibility.enable',
'Accessibility.getFullAXTree'
])
}
expect(stale).toHaveBeenCalledExactlyOnceWith('Accessibility.enable')
})
it('does not reuse session mappings across snapshots', async () => {
const sessions = new Map([['frame', 'session-a']])
const withFrame = await buildSnapshot(sender(buttonTree(1)), sessions, () =>
sender(buttonTree(1))
)
const withoutFrame = await buildSnapshot(sender(buttonTree(2)))
expect(withFrame.refMap.get('@e2')?.sessionId).toBe('session-a')
expect(withoutFrame.refMap.get('@e2')?.sessionId).toBeUndefined()
})
it.each([0, 100, 1000])(
'uses one indexed lookup per emitted ref with %i iframe refs',
async (iframeCount) => {
const parentCount = 100
const sessions = new Map([
['frame-a', 'session-a'],
['frame-b', 'session-b']
])
let lookups = 0
const originalGet = Map.prototype.get
const getSpy = vi.spyOn(Map.prototype, 'get').mockImplementation(function (
this: Map<unknown, unknown>,
key: unknown
) {
if (typeof key === 'string' && key.startsWith('@e')) {
lookups++
}
return originalGet.call(this, key)
})
let result: Awaited<ReturnType<typeof buildSnapshot>>
try {
result = await buildSnapshot(sender(buttonTree(parentCount)), sessions, () =>
sender(buttonTree(iframeCount / 2))
)
} finally {
getSpy.mockRestore()
}
expect(result.refs).toHaveLength(parentCount + iframeCount)
expect(lookups).toBe(parentCount + iframeCount)
const legacySessions = Array.from({ length: iframeCount }, (_, i) => ({
ref: `@e${parentCount + i + 1}`,
sessionId: i < iframeCount / 2 ? 'session-a' : 'session-b'
}))
let legacyComparisons = 0
for (const [ref, entry] of result.refMap) {
const legacySession = legacySessions.find((candidate) => {
legacyComparisons++
return candidate.ref === ref
})
expect(entry.sessionId).toBe(legacySession?.sessionId)
}
expect(legacyComparisons).toBe(
parentCount * iframeCount + (iframeCount * (iframeCount + 1)) / 2
)
}
)
})
+3 -4
View File
@@ -61,7 +61,7 @@ export async function buildSnapshot(
// Why: cross-origin iframes have their own AX trees accessible only through
// their dedicated CDP session. Append their elements after the parent tree
// so the agent can see and interact with iframe content.
const iframeRefSessions: { ref: string; sessionId: string }[] = []
const iframeRefSessions = new Map<string, string>()
if (iframeSessions && makeIframeSender && iframeSessions.size > 0) {
for (const [_frameId, sessionId] of iframeSessions) {
try {
@@ -82,7 +82,7 @@ export async function buildSnapshot(
const startRef = refCounter
walkTree(iframeRoot, iframeNodeById, 1, entries, () => refCounter++)
for (let i = startRef; i < refCounter; i++) {
iframeRefSessions.push({ ref: `@e${i}`, sessionId })
iframeRefSessions.set(`@e${i}`, sessionId)
}
}
} catch {
@@ -120,12 +120,11 @@ export async function buildSnapshot(
}
lines.push(`${indent}[${entry.ref}] ${entry.role} "${displayName}"`)
refs.push({ ref: entry.ref, role: entry.role, name: displayName })
const iframeSession = iframeRefSessions.find((s) => s.ref === entry.ref)
refMap.set(entry.ref, {
backendDOMNodeId: entry.backendDOMNodeId,
role: entry.role,
name: entry.name,
sessionId: iframeSession?.sessionId,
sessionId: iframeRefSessions.get(entry.ref),
nth: total > 1 ? nth : undefined
})
} else {
@@ -0,0 +1,83 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { claudeConfigDirKeychainAliases } from './keychain'
let directory: string
let canonical: string
let linked: string
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'orca-claude-keychain-alias-'))
canonical = join(directory, 'canonical')
linked = join(directory, 'linked')
mkdirSync(canonical)
symlinkSync(canonical, linked, process.platform === 'win32' ? 'junction' : 'dir')
canonical = realpathSync(canonical)
})
afterEach(() => {
rmSync(directory, { recursive: true, force: true })
})
describe('Claude config directory Keychain aliases', () => {
it.each([{ segments: ['.claude'] }, { segments: ['removed', '.claude'] }])(
'resolves a missing config below a symlinked ancestor without creating it ($segments)',
({ segments }) => {
const configDir = join(linked, ...segments)
expect(claudeConfigDirKeychainAliases(configDir)).toEqual([
configDir,
join(canonical, ...segments)
])
expect(existsSync(configDir)).toBe(false)
expect(existsSync(join(linked, segments[0]))).toBe(false)
}
)
it('retains the canonical alias for an existing directory', () => {
const configDir = join(linked, '.claude')
mkdirSync(configDir)
expect(claudeConfigDirKeychainAliases(configDir)).toEqual([
configDir,
join(canonical, '.claude')
])
})
it('keeps a missing path with parent traversal raw instead of guessing through a symlink', () => {
const child = join(canonical, 'child')
const childLink = join(directory, 'child-link')
mkdirSync(child)
symlinkSync(child, childLink, process.platform === 'win32' ? 'junction' : 'dir')
const configDir = [childLink, '..', '.claude'].join(sep)
expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir])
expect(existsSync(join(canonical, '.claude'))).toBe(false)
})
it('does not duplicate an already canonical missing path', () => {
const configDir = join(canonical, '.claude')
expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir])
})
it.skipIf(process.platform === 'win32')('does not invent an alias for a broken symlink', () => {
const configDir = join(linked, '.claude')
symlinkSync(join(directory, 'missing-target'), configDir, 'dir')
expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir])
})
it('does not invent an alias below a file', () => {
const file = join(linked, 'file')
writeFileSync(file, '')
const configDir = join(file, '.claude')
expect(claudeConfigDirKeychainAliases(configDir)).toEqual([configDir])
})
})
+6 -8
View File
@@ -1,5 +1,8 @@
import { createHash } from 'node:crypto'
import { execFile } from 'node:child_process'
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
deleteActiveClaudeKeychainCredentials,
@@ -19,6 +22,7 @@ const originalUser = process.env.USER
const originalUsername = process.env.USERNAME
const TEST_USER = 'orca-test-user'
const SSO_USER = 'sso.user@example.com'
let configDir: string
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
@@ -44,6 +48,7 @@ function invokeExecFileCallback(
describe('Claude Keychain credentials', () => {
beforeEach(() => {
configDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-claude-keychain-')))
setPlatform('darwin')
execFileMock.mockReset()
process.env.USER = TEST_USER
@@ -51,6 +56,7 @@ describe('Claude Keychain credentials', () => {
})
afterEach(() => {
rmSync(configDir, { recursive: true, force: true })
vi.useRealTimers()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
@@ -68,7 +74,6 @@ describe('Claude Keychain credentials', () => {
})
it('reads config-scoped Claude Code 2.1 credentials before legacy credentials', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '{"claudeAiOauth":{"accessToken":"scoped"}}\n', '')
@@ -91,7 +96,6 @@ describe('Claude Keychain credentials', () => {
})
it('falls back to the legacy unsuffixed Claude Code credentials service', async () => {
const configDir = '/tmp/orca-claude-login-test'
const notFound = Object.assign(new Error('not found'), { code: 44 })
execFileMock
.mockImplementationOnce((_file, _args, _options, callback) => {
@@ -116,7 +120,6 @@ describe('Claude Keychain credentials', () => {
})
it('writes active credentials to the config-scoped Claude Code service', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '', '')
@@ -138,7 +141,6 @@ describe('Claude Keychain credentials', () => {
})
it('writes runtime credentials to scoped and legacy services for old Claude Code compatibility', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementation((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '', '')
@@ -172,7 +174,6 @@ describe('Claude Keychain credentials', () => {
})
it('strictly reads only the requested active credentials service', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementationOnce((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, 'scoped\n', '')
@@ -194,7 +195,6 @@ describe('Claude Keychain credentials', () => {
it('rejects when a keychain read never reports completion', async () => {
vi.useFakeTimers()
const configDir = '/tmp/orca-claude-login-test'
const killMock = vi.fn()
execFileMock.mockImplementationOnce(() => ({ kill: killMock }) as never)
@@ -223,7 +223,6 @@ describe('Claude Keychain credentials', () => {
})
it('deletes both scoped and legacy active credentials for config-dir cleanup', async () => {
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementation((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '', '')
@@ -260,7 +259,6 @@ describe('Claude Keychain credentials', () => {
it('cleans both Claude Code and raw $USER Keychain accounts after a failed SSO login', async () => {
process.env.USER = SSO_USER
const configDir = '/tmp/orca-claude-login-test'
const scopedService = serviceForConfigDir(configDir)
execFileMock.mockImplementation((_file, _args, _options, callback) => {
invokeExecFileCallback(callback, null, '', '')
+41 -7
View File
@@ -1,7 +1,8 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { lstatSync, realpathSync } from 'node:fs'
import { userInfo } from 'node:os'
import { basename, dirname, join } from 'node:path'
const ACTIVE_CLAUDE_SERVICE = 'Claude Code-credentials'
const ORCA_CLAUDE_SERVICE = 'Orca Claude Code Managed Credentials'
@@ -131,13 +132,46 @@ function getActiveClaudeService(configDir?: string): string {
export function claudeConfigDirKeychainAliases(configDir: string): string[] {
const aliases = [configDir]
try {
const canonical = realpathSync(configDir)
if (canonical !== configDir) {
aliases.push(canonical)
const missingSegments: string[] = []
let existingPath = configDir
while (true) {
try {
const canonical = join(realpathSync(existingPath), ...missingSegments)
if (canonical !== configDir) {
aliases.push(canonical)
}
break
} catch (error) {
// Missing paths with parent traversal cannot prove an alias across symlinks.
if (
!(error instanceof Error) ||
!('code' in error) ||
error.code !== 'ENOENT' ||
configDir.split(/[\\/]/).includes('..')
) {
break
}
try {
// A broken symlink has no known canonical target; do not guess its alias.
lstatSync(existingPath)
break
} catch (missingError) {
if (
!(missingError instanceof Error) ||
!('code' in missingError) ||
missingError.code !== 'ENOENT'
) {
break
}
}
const parent = dirname(existingPath)
if (parent === existingPath) {
break
}
// Preserve canonical Keychain lookup without recreating a removed config directory.
missingSegments.unshift(basename(existingPath))
existingPath = parent
}
} catch {
// Login temp dirs can vanish before capture; keep the raw path.
}
return aliases
}
@@ -0,0 +1,58 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
const testState = {
fakeHomeDir: '',
previousConfigDir: undefined as string | undefined
}
vi.mock('electron', () => ({ app: { getPath: () => testState.fakeHomeDir } }))
vi.mock('node:os', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
const actual = await vi.importActual<typeof import('node:os')>('node:os')
return { ...actual, homedir: () => testState.fakeHomeDir }
})
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
beforeEach(() => {
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-rate-limit-path-'))
testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR
delete process.env.CLAUDE_CONFIG_DIR
})
afterEach(() => {
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
if (testState.previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir
}
testState.fakeHomeDir = ''
})
describe('Claude runtime auth path materialization', () => {
it('does not create the config directory while preparing a background usage fetch', async () => {
const settings = {
...getDefaultSettings(testState.fakeHomeDir),
disabledTuiAgents: ['claude'] as const,
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null
}
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn()
}
const service = new ClaudeRuntimeAuthService(store as never)
const preparation = await service.prepareForRateLimitFetch()
expect(preparation.configDir).toBe(join(testState.fakeHomeDir, '.claude'))
expect(preparation.provenance).toBe('system')
expect(existsSync(preparation.configDir)).toBe(false)
})
})
@@ -53,8 +53,9 @@ describe('ClaudeRuntimeAuthService', () => {
cleanupRuntimeAuthTestState()
})
it('rematerializes unchanged managed credentials when the runtime file is missing', async () => {
it('creates and recreates the runtime directory when materializing managed credentials', async () => {
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true })
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed')
const managedAuthPath = createManagedClaudeAuth(
testState.userDataDir,
@@ -73,7 +74,7 @@ describe('ClaudeRuntimeAuthService', () => {
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials)
rmSync(runtimeCredentialsPath, { force: true })
rmSync(expectedRuntimeConfigDir(), { recursive: true, force: true })
await service.prepareForClaudeLaunch()
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials)
@@ -0,0 +1,113 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const testState = {
fakeHomeDir: '',
previousConfigDir: undefined as string | undefined
}
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
return { ...actual, mkdirSync: vi.fn(actual.mkdirSync) }
})
vi.mock('node:os', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
const actual = await vi.importActual<typeof import('node:os')>('node:os')
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
const { ClaudeRuntimePathResolver } = await import('./runtime-paths')
beforeEach(() => {
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-claude-runtime-paths-'))
testState.previousConfigDir = process.env.CLAUDE_CONFIG_DIR
delete process.env.CLAUDE_CONFIG_DIR
})
afterEach(() => {
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
if (testState.previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = testState.previousConfigDir
}
testState.fakeHomeDir = ''
})
describe('ClaudeRuntimePathResolver', () => {
it.each([false, true])(
'does no mkdir work for repeated reads (directory exists: %s)',
(exists) => {
if (exists) {
mkdirSync(join(testState.fakeHomeDir, '.claude'), { recursive: true })
}
vi.mocked(mkdirSync).mockClear()
const resolver = new ClaudeRuntimePathResolver()
for (let index = 0; index < 1000; index += 1) {
resolver.getRuntimePaths()
}
expect(mkdirSync).not.toHaveBeenCalled()
}
)
it('leaves the default config directory alone while resolving paths', () => {
const paths = new ClaudeRuntimePathResolver().getRuntimePaths()
expect(paths.configDir).toBe(join(testState.fakeHomeDir, '.claude'))
// Why: background rate-limit refreshes resolve these paths even when Claude
// is disabled, so resolving must never materialize the directory (#12181).
expect(existsSync(paths.configDir)).toBe(false)
})
it('leaves an inherited CLAUDE_CONFIG_DIR alone while resolving paths', () => {
const inherited = join(testState.fakeHomeDir, 'inherited-claude')
process.env.CLAUDE_CONFIG_DIR = inherited
const paths = new ClaudeRuntimePathResolver().getRuntimePaths()
expect(paths.configDir).toBe(inherited)
expect(existsSync(inherited)).toBe(false)
expect(paths.envPatch).toEqual({ CLAUDE_CONFIG_DIR: inherited })
})
it('resolves credentials next to the config directory', () => {
const paths = new ClaudeRuntimePathResolver().getRuntimePaths()
expect(paths.credentialsPath).toBe(join(testState.fakeHomeDir, '.claude', '.credentials.json'))
})
it('falls back to the home config file when no colocated config exists', () => {
const paths = new ClaudeRuntimePathResolver().getRuntimePaths()
expect(paths.configPath).toBe(join(testState.fakeHomeDir, '.claude.json'))
expect(paths.envPatch).toEqual({})
})
it('prefers a colocated config file once it exists', () => {
const configDir = join(testState.fakeHomeDir, '.claude')
mkdirSync(configDir, { recursive: true })
writeFileSync(join(configDir, '.claude.json'), '{}')
const paths = new ClaudeRuntimePathResolver().getRuntimePaths()
expect(paths.configPath).toBe(join(configDir, '.claude.json'))
})
it('keeps the inherited config file colocated even before it exists', () => {
const inherited = join(testState.fakeHomeDir, 'inherited-claude')
process.env.CLAUDE_CONFIG_DIR = inherited
const paths = new ClaudeRuntimePathResolver().getRuntimePaths()
expect(paths.configPath).toBe(join(inherited, '.claude.json'))
})
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync } from 'node:fs'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { ClaudeEnvPatch } from './environment'
@@ -13,8 +13,8 @@ export type ClaudeRuntimePaths = {
export class ClaudeRuntimePathResolver {
getRuntimePaths(): ClaudeRuntimePaths {
const inheritedConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || null
// Why: disabled Claude still reaches this resolver through background usage refreshes.
const configDir = inheritedConfigDir || join(homedir(), '.claude')
mkdirSync(configDir, { recursive: true })
return {
configDir,
@@ -1,6 +1,23 @@
import { describe, expect, it } from 'vitest'
import { estimateCostUsd } from './claude-model-pricing'
describe('model pricing name matching', () => {
it.each([
[' ANTHROPIC/claude-opus-4.1-thinking ', 15],
['claude-opus-4.10', 5],
['claude-opus-4-20250514', 15],
['claude-opus-4.20250514', 15],
['claude.opus.4.9', null],
['claude-opus-4.9', 5],
['claude-sonnet-50', null],
['claude-sonnet-5-thinking', 2],
['claude-sonnet-5-opus-5', 5],
['claude-3.5-sonnet-20241022', 3]
])('preserves version boundaries and match priority for %s', (model, inputPrice) => {
expect(estimateCostUsd(model, 1_000_000, 0, 0, 0)).toBe(inputPrice)
})
})
describe('estimateCostUsd cache-write TTL rates', () => {
it('bills 5-minute cache writes at 1.25x base input', () => {
expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25)
+13 -18
View File
@@ -88,13 +88,7 @@ const MODEL_ALIASES: Record<string, string> = {
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6'
}
function hasClaudeModelVersion(model: string, family: string, version: string): boolean {
const normalized = model.replace(/\./g, '-')
return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized)
}
function isLegacyBaseOpus4Model(model: string): boolean {
const normalized = model.replace(/\./g, '-')
function isLegacyBaseOpus4Model(normalized: string): boolean {
return /opus-4(?:$|-thinking$|-20\d{6}(?:-thinking)?$|@20\d{6}$)/.test(normalized)
}
@@ -110,28 +104,29 @@ function normalizeModelForPricing(model: string | null): string | null {
if (alias) {
return alias
}
if (hasClaudeModelVersion(lower, 'fable', '5')) {
const normalized = lower.replace(/\./g, '-')
if (/fable-5(?:$|[^0-9])/.test(normalized)) {
return 'claude-fable-5'
}
if (hasClaudeModelVersion(lower, 'opus', '5')) {
if (/opus-5(?:$|[^0-9])/.test(normalized)) {
return 'claude-opus-5'
}
if (hasClaudeModelVersion(lower, 'opus', '4-8')) {
if (/opus-4-8(?:$|[^0-9])/.test(normalized)) {
return 'claude-opus-4-8'
}
if (hasClaudeModelVersion(lower, 'opus', '4-7')) {
if (/opus-4-7(?:$|[^0-9])/.test(normalized)) {
return 'claude-opus-4-7'
}
if (hasClaudeModelVersion(lower, 'opus', '4-6')) {
if (/opus-4-6(?:$|[^0-9])/.test(normalized)) {
return 'claude-opus-4-6'
}
if (hasClaudeModelVersion(lower, 'opus', '4-5')) {
if (/opus-4-5(?:$|[^0-9])/.test(normalized)) {
return 'claude-opus-4-5'
}
if (hasClaudeModelVersion(lower, 'opus', '4-1')) {
if (/opus-4-1(?:$|[^0-9])/.test(normalized)) {
return 'claude-opus-4-1'
}
if (isLegacyBaseOpus4Model(lower)) {
if (isLegacyBaseOpus4Model(normalized)) {
return 'claude-opus-4'
}
if (lower.includes('opus-4')) {
@@ -139,13 +134,13 @@ function normalizeModelForPricing(model: string | null): string | null {
// avoid overbilling unknown future Claude Code model IDs as legacy Opus 4.
return 'claude-opus-4-8'
}
if (hasClaudeModelVersion(lower, 'sonnet', '5')) {
if (/sonnet-5(?:$|[^0-9])/.test(normalized)) {
return 'claude-sonnet-5'
}
if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) {
if (/sonnet-4-6(?:$|[^0-9])/.test(normalized)) {
return 'claude-sonnet-4-6'
}
if (hasClaudeModelVersion(lower, 'sonnet', '4-5')) {
if (/sonnet-4-5(?:$|[^0-9])/.test(normalized)) {
return 'claude-sonnet-4-5'
}
if (lower.includes('sonnet-4')) {
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import {
restoreClaudeStructuredSessionOptions,
setClaudeStructuredOption
} from './claude-structured-options'
import type { ClaudeSession } from './claude-structured-session-state'
/** Verbatim row shapes from Claude Code 2.1.260's list_models response. */
const DEFAULT_ROW = { value: 'default', resolvedModel: 'claude-opus-5', displayName: 'Default' }
const SONNET = { value: 'sonnet', resolvedModel: 'claude-sonnet-5', displayName: 'Sonnet' }
const HAIKU = {
value: 'haiku',
resolvedModel: 'claude-haiku-4-5-20251001',
displayName: 'Haiku'
}
function sessionWith(catalog: readonly Record<string, unknown>[] | 'unavailable') {
const calls: string[] = []
return {
session: {
options: new Map<string, string>(),
reportedOptions: {} as { model?: string; effort?: string },
optionMutationSequence: 0,
reportedModelMutation: 0,
confirmedOptions: new Set<string>(),
restoreSkippedOptions: new Set<string>(),
connection: {
supportedModels: async () => {
calls.push('list_models')
if (catalog === 'unavailable') {
throw new Error('this CLI predates list_models')
}
return [...catalog]
},
setModel: async (model: string) => {
calls.push(`set_model:${model}`)
}
}
} as unknown as ClaudeSession,
calls
}
}
describe('Claude model pre-flight against the catalog the CLI listed', () => {
it('refuses a model the provider does not list', async () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'not-a-real-model-xyz' }, undefined)
).rejects.toBeInstanceOf(AgentSessionOptionRejectedError)
// Measured on Claude Code 2.1.260: set_model resolves for an unlisted id and
// every later turn returns is_error with zero tokens. Nothing undoes the
// write, so the refusal has to land before it.
expect(calls).toEqual(['list_models'])
expect(session.options.has('model')).toBe(false)
})
it('refuses an unlisted model replayed by restore, and skips it', async () => {
// Needs no user error: a model valid when it was persisted can be retired.
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET])
session.options.set('model', 'claude-opus-4-retired')
await restoreClaudeStructuredSessionOptions(session, undefined)
expect(calls).toEqual(['list_models'])
expect(session.options.has('model')).toBe(false)
expect([...session.restoreSkippedOptions]).toEqual(['model'])
})
it('applies a model the provider lists', async () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET, HAIKU])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'haiku' }, undefined)
).resolves.toEqual({ model: 'haiku' })
expect(calls).toEqual(['list_models', 'set_model:haiku'])
})
it('applies a resolved model id the catalog carries only under its alias', async () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'claude-sonnet-5' }, undefined)
).resolves.toEqual({ model: 'claude-sonnet-5' })
expect(calls).toEqual(['list_models', 'set_model:claude-sonnet-5'])
})
it('refuses nothing when list_models is unavailable', async () => {
// A CLI predating list_models would otherwise have every model refused, and
// restore swallows the rejection, so the user's pick would vanish silently.
const { session, calls } = sessionWith('unavailable')
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined)
).resolves.toEqual({ model: 'sonnet' })
expect(calls).toEqual(['list_models', 'set_model:sonnet'])
})
it('refuses nothing when the listed catalog is empty', async () => {
// An empty answer identifies no model, so it is not evidence against one.
const { session, calls } = sessionWith([])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined)
).resolves.toEqual({ model: 'sonnet' })
expect(calls).toEqual(['list_models', 'set_model:sonnet'])
})
it('refuses nothing when the catalog carries only the synthetic default row', async () => {
// listedModels drops that row, leaving a list that identifies no model.
const { session, calls } = sessionWith([DEFAULT_ROW])
await expect(
setClaudeStructuredOption(session, { key: 'model', value: 'sonnet' }, undefined)
).resolves.toEqual({ model: 'sonnet' })
expect(calls).toEqual(['list_models', 'set_model:sonnet'])
})
it('leaves a restored model the provider lists in place', async () => {
const { session, calls } = sessionWith([DEFAULT_ROW, SONNET])
session.options.set('model', 'sonnet')
await restoreClaudeStructuredSessionOptions(session, undefined)
expect(calls).toEqual(['list_models', 'set_model:sonnet'])
expect(session.options.get('model')).toBe('sonnet')
expect([...session.restoreSkippedOptions]).toEqual([])
})
})
@@ -6,7 +6,12 @@ import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog'
function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSession {
return {
connection: { setModel } as ClaudeSession['connection'],
// An empty catalog identifies no model, so the pre-flight refuses nothing and
// this stays a test about fencing.
connection: {
setModel,
supportedModels: async (): Promise<unknown[]> => []
} as ClaudeSession['connection'],
providerSessionId: 'provider-session',
claudeConfigDir: '/accounts/claude',
leafUuid: null,
@@ -5,6 +5,7 @@ import {
isAgentSessionOptionRejectedError
} from '../native-chat/agent-session-wire/structured-agent-session-option-error'
import {
claudeCatalogAdmitsModel,
readClaudeCurrentModel,
readClaudeModelEffortLevels,
readClaudeSettingsEffort
@@ -66,6 +67,13 @@ export async function setClaudeStructuredOption(
)
}
}
// set_model resolves for a model the provider never lists and the session then
// fails every turn with zero tokens, so the acceptance proves nothing and only
// the catalog does. Restore replays a pick the provider may since have retired,
// which reaches here with no user error at all.
if (input.key === 'model' && !(await claudeCatalogAdmitsModel(session, input.value, timeoutMs))) {
throw new AgentSessionOptionRejectedError(`claude does not list a model named ${input.value}`)
}
const modelWasConfirmed = readClaudeCurrentModel(session).confirmed
const mutationSequence = ++session.optionMutationSequence
// Only a model write can stale the model report — an effort or permission-mode
@@ -80,8 +80,11 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => {
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 })
).resolves.toEqual({ model: 'sonnet' })
expect(claude.connections[0].calls.slice(-2)).toEqual([
// The model write pre-flights the catalog first; this CLI lists nothing, which
// identifies no model and so refuses none.
expect(claude.connections[0].calls.slice(-3)).toEqual([
{ subtype: 'interrupt', params: {} },
{ subtype: 'list_models' },
{ subtype: 'set_model', params: { model: 'sonnet' } }
])
@@ -149,6 +149,28 @@ export async function readClaudeModelEffortLevels(
}
}
/**
* Whether the catalog admits the model, matched by alias or resolved id so a pick
* stored as either one is found. The permissive case lives here rather than at the
* call site: every caller must treat an unidentified catalog the same way, and one
* that forgot would refuse every model on a CLI that cannot answer.
*/
export async function claudeCatalogAdmitsModel(
session: ClaudeSession,
modelId: string,
timeoutMs: number | undefined
): Promise<boolean> {
const catalog = await session.connection.supportedModels({ timeoutMs }).catch(() => null)
const models = listedModels(catalog ? { models: catalog } : null)
// An empty list identifies no model, so it is not evidence against one — a live
// CLI predating `list_models` would otherwise have every model refused under it.
// Do not turn this into a refusal.
return (
models.length === 0 ||
models.some((model) => model.id === modelId || model.resolvedModel === modelId)
)
}
export async function readClaudeStructuredSessionOptions(
session: ClaudeSession,
timeoutMs: number | undefined
@@ -111,6 +111,9 @@ export function buildBreakdown(
): CodexUsageBreakdownRow[] {
const rows = new Map<string, CodexUsageBreakdownRow>()
const filteredDaily = getFilteredDaily(state, scope, range)
if (filteredDaily.length === 0) {
return []
}
const filteredSessions = getFilteredSessions(state, scope, range)
for (const daily of filteredDaily) {
@@ -59,6 +59,105 @@ describe('DesktopScriptServeChannel', () => {
expect(handlers.onGone).toHaveBeenCalledWith('code 1: it broke')
})
it('reassembles chunked responses with split UTF-8 and CRLF boundaries', () => {
const { child, handlers } = createChannel()
const payload = Buffer.from('hello 😀\r\n\nnext\ntrailing', 'utf8')
for (const byte of payload) {
child.stdout.emit('data', Buffer.from([byte]))
}
expect(handlers.onLine.mock.calls.map(([line]) => line)).toEqual(['hello 😀', 'next'])
child.stdout.emit('data', '\n')
expect(handlers.onLine).toHaveBeenLastCalledWith('trailing')
})
it('enforces the buffer cap before a terminating newline arrives', () => {
const { child, handlers } = createChannel()
const chunk = 'a'.repeat(1024 * 1024)
for (let index = 0; index < 20; index += 1) {
child.stdout.emit('data', chunk)
}
expect(handlers.onOverflow).not.toHaveBeenCalled()
child.stdout.emit('data', 'a')
expect(handlers.onOverflow).toHaveBeenCalledOnce()
expect(handlers.onLine).not.toHaveBeenCalled()
child.stdout.emit('data', 'recovered\n')
expect(handlers.onLine).toHaveBeenCalledWith('recovered')
})
it('stops delivering a chunk when its line handler closes the channel', () => {
const { channel, child, handlers } = createChannel()
handlers.onLine.mockImplementation(() => channel.stop())
child.stdout.emit('data', 'first\nsecond\n')
expect(handlers.onLine.mock.calls.map(([line]) => line)).toEqual(['first'])
})
it('keeps the retained tail free of newlines after every drain', () => {
const { channel, child } = createChannel()
const retained = channel as unknown as { buffer: string }
for (const chunk of ['a\nb', 'c\r\n\n\nd\ne', '\n', 'f\n\ng', Buffer.from('h\r\ni😀')]) {
child.stdout.emit('data', chunk)
// The fast path in readStdout scans only the new chunk, which is sound only if this holds.
expect(retained.buffer).not.toContain('\n')
}
expect(retained.buffer).toBe('i😀')
})
it('scans only the new chunk for the first newline of a pending line', () => {
const { child, handlers } = createChannel()
const pending = 'p'.repeat(1024 * 1024)
child.stdout.emit('data', pending)
const chunk = 'q\n'
const indexOf = vi.spyOn(String.prototype, 'indexOf')
let scanned: number[]
try {
child.stdout.emit('data', chunk)
scanned = indexOf.mock.contexts.map((self) => String(self).length)
} finally {
indexOf.mockRestore()
}
expect(handlers.onLine).toHaveBeenCalledWith(`${pending}q`)
// Locating the delimiter must not rescan the megabytes already known to hold none.
expect(scanned.length).toBeGreaterThan(0)
expect(Math.max(...scanned)).toBeLessThanOrEqual(chunk.length)
})
it('releases the drained response that a retained tail was sliced from', () => {
const gc = (globalThis as { gc?: () => void }).gc
if (!gc) {
throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc')
}
const collectHeap = (): number => {
gc()
gc()
return process.memoryUsage().heapUsed
}
const tails: string[] = []
const feed = (index: number): void => {
const child = new FakeChild()
const channel = new DesktopScriptServeChannel(child as unknown as RuntimeChildProcess, {
onLine: () => {},
onGone: () => {},
onOverflow: () => {}
})
const line = String.fromCharCode(65 + (index % 26)).repeat(1024 * 1024)
child.stdout.emit('data', `${line}\n{"partial":${index}`)
tails.push((channel as unknown as { buffer: string }).buffer)
}
for (let index = 0; index < 8; index += 1) {
feed(index)
}
tails.length = 0
const before = collectHeap()
for (let index = 0; index < 32; index += 1) {
feed(index)
}
const used = collectHeap() - before
expect(tails).toHaveLength(32)
expect(tails[5]).toBe('{"partial":5')
// 32 pending tails, each sliced from a 1 Mi-char line; an un-owned tail pins the whole line.
expect(used).toBeLessThan(4 * 1024 * 1024)
})
describe('once stopped', () => {
/**
* The channel's half of the stale-callback guard, pinned here rather than
@@ -1,6 +1,7 @@
import { StringDecoder } from 'node:string_decoder'
import type { ProcessSpec } from '../../shared/child-process/process-spec'
import type { spawnProcess } from '../../shared/child-process/run-process'
import { ownRetainedString } from '../../shared/own-retained-string'
/** The all-pipes child `spawnProcess` returns; avoids a node:child_process import. */
export type RuntimeChildProcess = ReturnType<typeof spawnProcess>
@@ -112,13 +113,20 @@ export class DesktopScriptServeChannel {
if (this.closed) {
return
}
this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
const decoded = typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
const retainedLength = this.buffer.length
this.buffer += decoded
if (this.buffer.length > MAX_RESPONSE_CHARS) {
this.buffer = ''
this.handlers.onOverflow()
return
}
for (let newline = this.buffer.indexOf('\n'); newline >= 0;) {
// The retained tail has no newline, so only the new chunk needs scanning for the first one.
const firstNewline = decoded.indexOf('\n')
if (firstNewline === -1) {
return
}
for (let newline = retainedLength + firstNewline; newline >= 0;) {
// Slice a trailing CR off by index; trimming copies the whole payload.
const end = newline > 0 && this.buffer.charCodeAt(newline - 1) === 13 ? newline - 1 : newline
const line = this.buffer.slice(0, end)
@@ -133,6 +141,8 @@ export class DesktopScriptServeChannel {
}
newline = this.buffer.indexOf('\n')
}
// Why own: the tail is a slice that would pin the whole drained buffer until the next newline.
this.buffer = ownRetainedString(this.buffer)
}
}
@@ -117,6 +117,76 @@ describe('MacOSNativeProviderClient', () => {
vi.useRealTimers()
})
it('does not rescan a growing fragmented screenshot reply', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
const call = client.snapshot({ app: 'fixture' })
await vi.waitFor(() => expect(sockets).toHaveLength(1))
const socket = sockets[0]!
await vi.waitFor(() => expect(socket.writes).toHaveLength(1))
const handshake = JSON.parse(socket.writes[0]!) as { id: number }
socket.emit(
'data',
`${JSON.stringify({ id: handshake.id, ok: true, result: macOSProviderCapabilities() })}\n`
)
await vi.waitFor(() => expect(socket.writes).toHaveLength(2))
const request = JSON.parse(socket.writes[1]!) as { id: number }
const result = { screenshot: { data: 'A'.repeat(1_200_000) }, text: 'fixture' }
const reply = `${JSON.stringify({ id: request.id, ok: true, result })}\n`
const originalIndexOf = String.prototype.indexOf
const originalIncludes = String.prototype.includes
let searchedUnits = 0
const search = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function (
this: string,
needle: string,
fromIndex?: number
) {
if (needle === '\n') {
searchedUnits += Math.max(0, this.length - (fromIndex ?? 0))
}
return originalIndexOf.call(this, needle, fromIndex)
})
const includes = vi.spyOn(String.prototype, 'includes').mockImplementation(function (
this: string,
needle: string,
fromIndex?: number
) {
if (needle === '\n') {
searchedUnits += Math.max(0, this.length - (fromIndex ?? 0))
}
return originalIncludes.call(this, needle, fromIndex)
})
try {
for (let offset = 0; offset < reply.length; offset += 4096) {
socket.emit('data', reply.slice(offset, offset + 4096))
}
} finally {
search.mockRestore()
includes.mockRestore()
}
await expect(call).resolves.toEqual(result)
expect(searchedUnits).toBeLessThanOrEqual(reply.length * 3)
client.shutdown()
})
it('retries buffered replies on an empty chunk after a malformed reply throws', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
void client.capabilities().catch(() => {})
const secondCall = client.capabilities()
await vi.waitFor(() => expect(sockets).toHaveLength(1))
const socket = sockets[0]!
await vi.waitFor(() => expect(socket.writes).toHaveLength(2))
const first = JSON.parse(socket.writes[0]!) as { id: number }
const second = JSON.parse(socket.writes[1]!) as { id: number }
const malformed = JSON.stringify({ id: first.id, ok: false })
const valid = JSON.stringify({ id: second.id, ok: true, result: macOSProviderCapabilities() })
expect(() => socket.emit('data', `${malformed}\n${valid}\n`)).toThrow(TypeError)
socket.emit('data', '')
await expect(secondCall).resolves.toEqual(macOSProviderCapabilities())
client.shutdown()
})
it('ignores stale socket data, close, and error after a replacement socket starts', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
@@ -128,6 +198,7 @@ describe('MacOSNativeProviderClient', () => {
await vi.waitFor(() => expect(sockets).toHaveLength(1))
const firstSocket = sockets[0]!
firstSocket.emit('data', '{"id":999,"result":"partial')
await vi.advanceTimersByTimeAsync(60_000)
await firstRejection
expect(firstSocket.destroyed).toBe(true)
@@ -170,6 +241,7 @@ describe('MacOSNativeProviderClient', () => {
const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
await vi.waitFor(() => expect(firstSocket.writes).toHaveLength(1))
firstSocket.emit('data', '{"id":999,"result":"partial')
firstSocket.emit('error', new Error('active helper failed'))
await firstRejection
expect(firstSocket.destroyed).toBe(true)
@@ -20,7 +20,7 @@ import {
import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths'
import {
attachMacOSNativeProviderSocketListeners,
consumeNativeProviderLines,
NativeProviderLineBuffer,
startMacOSNativeProviderSocket
} from './macos-native-provider-transport'
import { validateComputerProviderActionParams } from './computer-provider-action-validation'
@@ -37,7 +37,7 @@ export class MacOSNativeProviderClient {
private socketToken: string | null = null
private nextId = 1
private pending = new Map<number, PendingNativeRequest>()
private socketBuffer = ''
private readonly socketBuffer = new NativeProviderLineBuffer()
private providerCapabilities: ComputerProviderCapabilities | null = null
private socketListenerCleanup: (() => void) | null = null
private socketStartGeneration = 0
@@ -70,7 +70,7 @@ export class MacOSNativeProviderClient {
this.socketStartPromise = null
this.socketStartGeneration++
this.providerCapabilities = null
this.socketBuffer = ''
this.socketBuffer.clear()
this.cleanupActiveSocketListeners()
if (socket && !socket.destroyed) {
const id = this.nextId++
@@ -199,7 +199,7 @@ export class MacOSNativeProviderClient {
this.socketToken = started.socketToken
const socket = started.socket
socket.setEncoding('utf8')
this.socketBuffer = ''
this.socketBuffer.clear()
this.socketListenerCleanup = attachMacOSNativeProviderSocketListeners(socket, {
data: (chunk) => this.handleSocketData(socket, chunk),
close: () => this.handleSocketClose(socket),
@@ -214,10 +214,7 @@ export class MacOSNativeProviderClient {
if (this.socket !== socket) {
return
}
this.socketBuffer += chunk
this.socketBuffer = consumeNativeProviderLines(this.socketBuffer, (line) =>
this.handleLine(line)
)
this.socketBuffer.push(chunk, (line) => this.handleLine(line))
}
private handleLine(line: string): void {
let response: NativeResponse
@@ -245,7 +242,7 @@ export class MacOSNativeProviderClient {
}
this.cleanupActiveSocketListeners()
this.socket = null
this.socketBuffer = ''
this.socketBuffer.clear()
this.cleanupSocketDirectory()
this.rejectPending(
new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed')
@@ -259,7 +256,7 @@ export class MacOSNativeProviderClient {
this.cleanupActiveSocketListeners()
// Why: an active transport error makes the helper socket unreliable for the next request.
this.socket = null
this.socketBuffer = ''
this.socketBuffer.clear()
if (!socket.destroyed) {
socket.destroy()
}
@@ -275,7 +272,7 @@ export class MacOSNativeProviderClient {
}
this.cleanupActiveSocketListeners()
this.socket = null
this.socketBuffer = ''
this.socketBuffer.clear()
if (!socket.destroyed) {
socket.destroy()
}
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import { NativeProviderLineBuffer } from './macos-native-provider-transport'
describe('NativeProviderLineBuffer', () => {
it('keeps partial lines and original whitespace while omitting blank lines', () => {
const buffer = new NativeProviderLineBuffer()
const lines: string[] = []
const record = (line: string): void => {
lines.push(line)
}
buffer.push(' \t\r\n first\r\nsecond', record)
expect(lines).toEqual([' first\r'])
buffer.push(' half\n\nthird\npartial', record)
expect(lines).toEqual([' first\r', 'second half', 'third'])
buffer.push('', record)
buffer.push(' tail\n', record)
expect(lines).toEqual([' first\r', 'second half', 'third', 'partial tail'])
})
it('preserves split surrogate pairs and lone surrogate code units', () => {
const buffer = new NativeProviderLineBuffer()
const lines: string[] = []
for (const chunk of ['\ud83d', '\ude00\n\ud83d', '\n\udc00', '\n']) {
buffer.push(chunk, (line) => {
lines.push(line)
})
}
expect(lines).toEqual(['😀', '\ud83d', '\udc00'])
})
it.each(['', 'suffix'])('retries all complete lines after callback failure on %j', (suffix) => {
const buffer = new NativeProviderLineBuffer()
const lines: string[] = []
expect(() =>
buffer.push('first\nsecond\npartial', (line) => {
lines.push(line)
if (line === 'second') {
throw new Error('callback failure')
}
})
).toThrow('callback failure')
buffer.push(suffix, (line) => {
lines.push(line)
})
buffer.push('\n', (line) => {
lines.push(line)
})
expect(lines).toEqual(['first', 'second', 'first', 'second', `partial${suffix}`])
})
it('clears both partial and callback-failed buffers', () => {
const buffer = new NativeProviderLineBuffer()
buffer.push('old partial', () => {
throw new Error('unexpected line')
})
buffer.clear()
expect(() =>
buffer.push('failed\n', () => {
throw new Error('callback failure')
})
).toThrow()
buffer.clear()
const lines: string[] = []
buffer.push('new', (line) => {
lines.push(line)
})
expect(lines).toEqual([])
buffer.push('\n', (line) => {
lines.push(line)
})
expect(lines).toEqual(['new'])
})
it('preserves reentrant feed ordering and the outer call remainder', () => {
const buffer = new NativeProviderLineBuffer()
const lines: string[] = []
let reentered = false
const record = (line: string): void => {
lines.push(line)
if (!reentered) {
reentered = true
buffer.push('extra\n', record)
}
}
buffer.push('first\nsecond\npartial', record)
buffer.push('\n', record)
expect(lines).toEqual(['first', 'first', 'second', 'partialextra', 'second', 'partial'])
})
it('preserves a reentrant clear and partial feed when the outer callback throws', () => {
const buffer = new NativeProviderLineBuffer()
const lines: string[] = []
const record = (line: string): void => {
lines.push(line)
}
expect(() =>
buffer.push('old\npartial', () => {
buffer.clear()
buffer.push('new', record)
throw new Error('callback failure')
})
).toThrow('callback failure')
buffer.push(' tail', record)
expect(lines).toEqual([])
buffer.push('\n', record)
expect(lines).toEqual(['new tail'])
})
})
@@ -45,6 +45,27 @@ export function attachMacOSNativeProviderSocketListeners(
}
}
export class NativeProviderLineBuffer {
private pending = ''
private hasCompleteLine = false
push(chunk: string, handleLine: (line: string) => void): void {
this.pending += chunk
this.hasCompleteLine ||= chunk.endsWith('\n') || chunk.includes('\n')
if (!this.hasCompleteLine) {
return
}
// Keep complete lines retryable if a callback throws.
this.pending = consumeNativeProviderLines(this.pending, handleLine)
this.hasCompleteLine = false
}
clear(): void {
this.pending = ''
this.hasCompleteLine = false
}
}
export function consumeNativeProviderLines(
buffer: string,
handleLine: (line: string) => void
@@ -86,7 +86,7 @@ export class DaemonStreamDataBatcher {
if (
options.flushImmediately === true &&
this.queuedCharsForSession(batch, sessionId) <=
this.queuedCharsForSession(batch, sessionId, options.flushMaxChars) <=
(options.flushMaxChars ?? Number.POSITIVE_INFINITY)
) {
this.flushSession(clientId, sessionId)
@@ -249,11 +249,18 @@ export class DaemonStreamDataBatcher {
})
}
private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number {
private queuedCharsForSession(
batch: PendingStreamDataBatch,
sessionId: string,
stopAfter = Number.POSITIVE_INFINITY
): number {
let chars = 0
for (const entry of batch.queue) {
if (entry.sessionId === sessionId) {
chars += entry.data.length
if (chars > stopAfter) {
return chars
}
}
}
return chars
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Socket } from 'node:net'
import {
encodeStreamDataEvent,
splitStreamDataForNdjson,
writeStreamDataEvents
} from './daemon-stream-data-split'
import { encodeNdjson } from './ndjson'
vi.mock('./ndjson', async (importOriginal) => {
const actual = await importOriginal<{ encodeNdjson: typeof encodeNdjson }>()
return { ...actual, encodeNdjson: vi.fn(actual.encodeNdjson) }
})
function write(
data: string,
maxLineBytes: number,
rawLength = data.length,
seq?: number,
transformed = false,
sessionId = 'session-1'
): string[] {
const lines: string[] = []
const socket: Pick<Socket, 'write'> = {
write: vi.fn((line: string) => {
lines.push(line)
return true
})
}
writeStreamDataEvents(socket, sessionId, data, maxLineBytes, rawLength, seq, transformed)
return lines
}
// Preserve the pre-optimization writer as a byte-for-byte oracle.
function previousWrites(
data: string,
maxLineBytes: number,
rawLength = data.length,
seq?: number,
transformed = false,
sessionId = 'session-1'
): string[] {
const explicitRawLength = rawLength === data.length ? undefined : rawLength
if (transformed) {
return [encodeStreamDataEvent(sessionId, data, rawLength, seq, true)]
}
const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
const chunks = splitStreamDataForNdjson(
sessionId,
data,
carriesMetadata ? Math.max(1, maxLineBytes - 96) : maxLineBytes,
explicitRawLength
)
let consumed = 0
return chunks.map((chunk) => {
consumed += chunk.length
const chunkEndSeq = seq === undefined ? undefined : seq - (data.length - consumed)
const chunkRawLength = explicitRawLength === 0 ? 0 : carriesMetadata ? chunk.length : undefined
return encodeStreamDataEvent(sessionId, chunk, chunkRawLength, chunkEndSeq)
})
}
beforeEach(() => {
vi.mocked(encodeNdjson).mockClear()
})
describe('writeStreamDataEvents serialization budget', () => {
it.each(['', 'x', '\x1b[2K\rredraw', '"\\\n\t\u0000', 'é中🐙', '\ud800x\udc00'])(
'encodes an unsplit metadata-free frame once: %j',
(data) => {
const expected = previousWrites(data, 4096)
expect(encodeNdjson).toHaveBeenCalledTimes(2)
vi.mocked(encodeNdjson).mockClear()
expect(write(data, 4096)).toEqual(expected)
expect(encodeNdjson).toHaveBeenCalledTimes(1)
}
)
it('reuses the encoded frame exactly at the inclusive byte cap', () => {
const data = 'é🐙\x1b[0m'
const line = encodeStreamDataEvent('session-1', data)
vi.mocked(encodeNdjson).mockClear()
expect(write(data, Buffer.byteLength(line))).toEqual([line])
expect(encodeNdjson).toHaveBeenCalledTimes(1)
})
it('does not add a duplicate full-data sizing probe to oversized writes', () => {
const data = '🐙\x1b[0m'.repeat(100)
const expected = previousWrites(data, 160)
const previousCount = vi.mocked(encodeNdjson).mock.calls.length
vi.mocked(encodeNdjson).mockClear()
expect(write(data, 160)).toEqual(expected)
expect(encodeNdjson).toHaveBeenCalledTimes(previousCount)
})
it('keeps transformed writes at one encode without applying the ordinary byte cap', () => {
const data = '🐙'.repeat(100)
const lines = write(data, 1, 1234, 5000, true)
expect(encodeNdjson).toHaveBeenCalledTimes(1)
expect(lines).toEqual([encodeStreamDataEvent('session-1', data, 1234, 5000, true)])
})
})
describe('writeStreamDataEvents wire parity', () => {
it('preserves exact frames, chunk boundaries and metadata across payloads and caps', () => {
const payloads = [
'',
'x',
'plain output\r\n'.repeat(24),
'"\\\n\t\u0000'.repeat(40),
'é中🐙'.repeat(40),
'\ud800x\udc00🐙'.repeat(20)
]
for (const sessionId of ['session-1', 'ssh/"中🐙']) {
for (const data of payloads) {
for (const maxLineBytes of [1, 96, 160, 256, 4096]) {
for (const [rawLength, seq, transformed] of [
[data.length, undefined, false],
[data.length, 0, false],
[data.length, 9000, false],
[0, 9000, false],
[7, undefined, false],
[data.length + 99, 9000, false],
[1234, 9000, true]
] as const) {
const expected = previousWrites(
data,
maxLineBytes,
rawLength,
seq,
transformed,
sessionId
)
expect(write(data, maxLineBytes, rawLength, seq, transformed, sessionId)).toEqual(
expected
)
}
}
}
}
})
it('keeps JSON escaping, Unicode and newline framing byte-for-byte', () => {
expect(write('"\\\n\t\u0000é中🐙', 4096)).toEqual([
'{"type":"event","event":"data","sessionId":"session-1","payload":{"data":"\\\"\\\\\\n\\t\\u0000é中🐙"}}\n'
])
})
it('keeps split frames within the byte cap and preserves code points and sequence spans', () => {
const data = '🐙é中\x1b[0m"\\\n'.repeat(100)
for (const seq of [undefined, 9000]) {
const lines = write(data, 256, data.length, seq)
expect(lines.length).toBeGreaterThan(1)
let consumed = 0
const chunks = lines.map((line) => {
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(256)
expect(line.endsWith('\n')).toBe(true)
const { payload } = JSON.parse(line)
expect(payload.data).not.toMatch(/^[\udc00-\udfff]|[\ud800-\udbff]$/)
consumed += payload.data.length
if (seq !== undefined) {
expect(payload.seq).toBe(seq - data.length + consumed)
expect(payload.rawLength).toBe(payload.data.length)
expect(payload.sequenceChars).toBe(payload.data.length)
} else {
expect(Object.keys(payload)).toEqual(['data'])
}
return payload.data as string
})
expect(chunks.join('')).toBe(data)
}
})
})
+25 -6
View File
@@ -70,6 +70,15 @@ export function splitStreamDataForNdjson(
return [data]
}
return splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes, sequenceChars)
}
function splitOversizedStreamDataForNdjson(
sessionId: string,
data: string,
maxLineBytes: number,
sequenceChars?: number
): string[] {
const chunks: string[] = []
let start = 0
while (start < data.length) {
@@ -118,12 +127,22 @@ export function writeStreamDataEvents(
return
}
const carriesMetadata = explicitRawLength !== undefined || seq !== undefined
const chunks = splitStreamDataForNdjson(
sessionId,
data,
carriesMetadata ? Math.max(1, maxLineBytes - 96) : maxLineBytes,
explicitRawLength
)
let chunks: string[]
if (!carriesMetadata) {
const line = encodeStreamDataEvent(sessionId, data)
if (Buffer.byteLength(line, 'utf8') <= maxLineBytes) {
streamSocket.write(line)
return
}
chunks = splitOversizedStreamDataForNdjson(sessionId, data, maxLineBytes)
} else {
chunks = splitStreamDataForNdjson(
sessionId,
data,
Math.max(1, maxLineBytes - 96),
explicitRawLength
)
}
let consumed = 0
for (const chunk of chunks) {
consumed += chunk.length
+31 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
@@ -429,6 +429,36 @@ describe('HistoryReader', () => {
})
describe('TUI truncation (scrollback.bin fallback path)', () => {
it.each(['\x1b[?1049h', '\x1b[?1049l'])(
'does not rescan the remaining history for each %j marker',
async (marker) => {
const scrollback = `normal\r\n${`output${marker}`.repeat(1000)}`
writeSessionWithScrollback(dir, 'repeated-switch', makeMeta(), scrollback)
let searchedCharacters = 0
const originalIndexOf = String.prototype.indexOf
const spy = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function (
this: string,
search: string,
position?: number
) {
const found = originalIndexOf.call(this, search, position)
if (search === '\x1b[?1049h' || search === '\x1b[?1049l') {
searchedCharacters +=
(found < 0 ? this.length : found + search.length) - (position ?? 0)
}
return found
})
let info
try {
info = await reader.detectColdRestore('repeated-switch')
} finally {
spy.mockRestore()
}
expect(info?.snapshotAnsi).toBe(marker.endsWith('h') ? 'normal\r\noutput' : scrollback)
expect(searchedCharacters).toBeLessThanOrEqual(2 * scrollback.length)
}
)
it('preserves content when alt-screen is properly closed', async () => {
const scrollback = [
'before vim\r\n',
@@ -53,26 +53,20 @@ function truncateAltScreen(data: string): string {
let depth = 0
let outermostUnmatchedOnIdx = -1
let searchFrom = 0
while (searchFrom < data.length) {
const onIdx = data.indexOf(ALT_SCREEN_ON, searchFrom)
const offIdx = data.indexOf(ALT_SCREEN_OFF, searchFrom)
if (onIdx === -1 && offIdx === -1) {
break
}
let onIdx = data.indexOf(ALT_SCREEN_ON)
let offIdx = data.indexOf(ALT_SCREEN_OFF)
while (onIdx !== -1 || offIdx !== -1) {
if (onIdx !== -1 && (offIdx === -1 || onIdx < offIdx)) {
if (depth === 0) {
outermostUnmatchedOnIdx = onIdx
}
depth++
searchFrom = onIdx + ALT_SCREEN_ON.length
onIdx = data.indexOf(ALT_SCREEN_ON, onIdx + ALT_SCREEN_ON.length)
} else {
if (depth > 0) {
depth--
}
searchFrom = offIdx + ALT_SCREEN_OFF.length
offIdx = data.indexOf(ALT_SCREEN_OFF, offIdx + ALT_SCREEN_OFF.length)
}
}
@@ -0,0 +1,172 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ScrcpyStreamSession } from './scrcpy-stream-session'
const io = vi.hoisted(() => ({ spawn: vi.fn(), connect: vi.fn() }))
vi.mock('node:child_process', () => ({ spawn: io.spawn }))
vi.mock('node:net', () => ({ connect: io.connect }))
vi.mock('../emulator-probe', () => ({ emulatorProbe: vi.fn(), emulatorProbeError: vi.fn() }))
class TestSocket extends EventEmitter {
destroy = vi.fn()
setTimeout = vi.fn()
}
function packet(size: number, meta = 123n): Buffer {
const result = Buffer.alloc(12 + size, 7)
result.writeBigUInt64BE(meta, 0)
result.writeUInt32BE(size, 8)
return result
}
function handshake(): Buffer {
const result = Buffer.alloc(77)
result.write('test-device', 1)
result.write('h264', 65)
result.writeUInt32BE(1080, 69)
result.writeUInt32BE(2400, 73)
return result
}
async function startSession() {
const video = new TestSocket()
const control = new TestSocket()
const server = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
kill: vi.fn()
})
io.spawn.mockReturnValue(server)
io.connect.mockReturnValueOnce(video).mockReturnValueOnce(control)
const callbacks = { onMeta: vi.fn(), onFrame: vi.fn(), onError: vi.fn(), onClose: vi.fn() }
const runner = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' })
const started = ScrcpyStreamSession.start(
{
runner,
sdk: { sdkRoot: 'sdk', adb: 'adb', emulator: 'emulator', avdmanager: 'avdmanager' },
serial: 'test-device',
localJarPath: 'server.jar',
localPort: 12345
},
callbacks
)
await vi.waitFor(() => expect(io.connect).toHaveBeenCalledTimes(1))
return { video, control, server, callbacks, started }
}
beforeEach(() => {
io.spawn.mockReset()
io.connect.mockReset()
})
describe('ScrcpyStreamSession video buffering', () => {
it('accepts bytewise handshake and frames, including empty chunks and empty frames', async () => {
const { video, callbacks, started } = await startSession()
const header = handshake()
for (let index = 0; index < header.length - 1; index += 1) {
video.emit('data', header.subarray(index, index + 1))
}
expect(callbacks.onMeta).not.toHaveBeenCalled()
video.emit('data', header.subarray(-1))
const session = await started
expect(callbacks.onMeta).toHaveBeenCalledWith({ codecId: 'h264', width: 1080, height: 2400 })
const stream = Buffer.concat([packet(0, 1n << 63n), packet(3, (1n << 62n) | 5n)])
for (const byte of stream) {
video.emit('data', Buffer.alloc(0))
video.emit('data', Buffer.from([byte]))
}
expect(callbacks.onFrame.mock.calls.map(([frame]) => frame)).toEqual([
{ config: true, keyFrame: false, pts: 0n, data: Buffer.alloc(0) },
{ config: false, keyFrame: true, pts: 5n, data: Buffer.alloc(3, 7) }
])
session.close()
})
it('owns pending bytes and emitted frames independently of input chunks', async () => {
const { video, callbacks, started } = await startSession()
video.emit('data', handshake())
const session = await started
const first = packet(4)
const second = packet(6, 456n)
const chunk = Buffer.concat([first, second.subarray(0, 14)])
video.emit('data', chunk)
chunk.fill(0)
const tail = Buffer.from(second.subarray(14))
video.emit('data', tail)
tail.fill(0)
expect(callbacks.onFrame.mock.calls.map(([frame]) => frame)).toEqual([
{ config: false, keyFrame: false, pts: 123n, data: Buffer.alloc(4, 7) },
{ config: false, keyFrame: false, pts: 456n, data: Buffer.alloc(6, 7) }
])
session.close()
})
it('emits initial metadata and frames before startup resolves', async () => {
const { video, callbacks, started } = await startSession()
const events: string[] = []
callbacks.onMeta.mockImplementation(() => events.push('meta'))
callbacks.onFrame.mockImplementation(() => events.push('frame'))
const ready = started.then((session) => {
events.push('ready')
return session
})
video.emit('data', Buffer.concat([handshake(), packet(3), packet(5)]))
expect(events).toEqual(['meta', 'frame', 'frame'])
const session = await ready
expect(events).toEqual(['meta', 'frame', 'frame', 'ready'])
session.close()
})
it('fails an already started session on a corrupt batch without delivering partial results', async () => {
const { video, callbacks, started } = await startSession()
video.emit('data', handshake())
const session = await started
const corrupt = packet(0)
corrupt.writeUInt32BE(16 * 1024 * 1024 + 1, 8)
video.emit('data', Buffer.concat([packet(1), corrupt]))
expect(callbacks.onFrame).not.toHaveBeenCalled()
expect(callbacks.onError).toHaveBeenCalledExactlyOnceWith(expect.stringMatching(/desynced/))
expect(callbacks.onClose).toHaveBeenCalledTimes(1)
session.close()
expect(callbacks.onClose).toHaveBeenCalledTimes(1)
})
it('does not resolve startup or deliver earlier frames if the first batch is desynced', async () => {
const { video, callbacks, started, server } = await startSession()
const corrupt = packet(0)
corrupt.writeUInt32BE(16 * 1024 * 1024 + 1, 8)
const rejected = expect(started).rejects.toThrow(/desynced/)
video.emit('data', Buffer.concat([handshake(), packet(1), corrupt]))
await rejected
expect(callbacks.onMeta).toHaveBeenCalledTimes(1)
expect(callbacks.onFrame).not.toHaveBeenCalled()
expect(callbacks.onError).toHaveBeenCalledTimes(1)
expect(callbacks.onClose).toHaveBeenCalledTimes(1)
expect(server.kill).toHaveBeenCalledTimes(1)
expect(video.destroy).toHaveBeenCalledTimes(1)
})
it('does not repeatedly concatenate a growing fragmented frame', async () => {
const { video, callbacks, started } = await startSession()
video.emit('data', handshake())
const session = await started
const frame = packet(1024 * 1024)
const concat = Buffer.concat
let concatenatedBytes = 0
const spy = vi.spyOn(Buffer, 'concat').mockImplementation((buffers, length) => {
concatenatedBytes += length ?? buffers.reduce((sum, part) => sum + part.length, 0)
return concat(buffers, length)
})
try {
for (let offset = 0; offset < frame.length; offset += 4096) {
video.emit('data', frame.subarray(offset, offset + 4096))
}
} finally {
spy.mockRestore()
session.close()
}
expect(callbacks.onFrame).toHaveBeenCalledTimes(1)
expect(callbacks.onFrame.mock.calls[0][0].data).toEqual(frame.subarray(12))
expect(concatenatedBytes).toBeLessThanOrEqual(frame.length * 2)
})
})
@@ -1,6 +1,7 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { connect, type Socket } from 'node:net'
import { randomBytes } from 'node:crypto'
import { RelayFrameBuffer } from '../../../shared/relay-frame-buffer'
import type { AndroidCommandRunner } from './android-command-runner'
import type { AndroidSdkPaths } from './android-sdk-discovery'
import { ensureAdbOk } from './android-adb-result'
@@ -14,7 +15,6 @@ import {
import {
parseScrcpyVideoFrames,
parseScrcpyVideoMeta,
type ScrcpyFrameParseResult,
type ScrcpyVideoFrame,
type ScrcpyVideoMeta
} from './scrcpy-video-frame-parser'
@@ -57,7 +57,7 @@ export class ScrcpyStreamSession {
private server: ChildProcess | null = null
private videoSocket: Socket | null = null
private controlSocket: Socket | null = null
private pendingVideo: Buffer = Buffer.alloc(0)
private readonly pendingVideo = new RelayFrameBuffer()
private metaSeen = false
private headerStripped = false
private closed = false
@@ -206,47 +206,48 @@ export class ScrcpyStreamSession {
}
private handleVideoChunk(chunk: Buffer): void {
let buffer = Buffer.concat([this.pendingVideo, chunk])
const buffer = this.pendingVideo
if (chunk.length > 0) {
// Socket chunks and emitted frames must not share mutable pending storage.
buffer.append(Buffer.from(chunk))
}
// The first socket carries a 1-byte readiness marker + the 64-byte device name.
if (!this.headerStripped) {
const headerLen = DUMMY_BYTE + DEVICE_NAME_BYTES
if (buffer.length < headerLen) {
this.pendingVideo = buffer
return
}
buffer = Buffer.from(buffer.subarray(headerLen))
buffer.discard(headerLen)
this.headerStripped = true
}
let shouldResolveReady = false
if (!this.metaSeen) {
const meta = parseScrcpyVideoMeta(buffer)
if (!meta) {
this.pendingVideo = buffer
if (buffer.length < 12) {
return
}
const meta = parseScrcpyVideoMeta(buffer.peek(12))!
this.metaSeen = true
emulatorProbe('scrcpy.meta', meta)
this.callbacks.onMeta(meta)
shouldResolveReady = true
buffer = Buffer.from(buffer.subarray(12))
buffer.discard(12)
}
// The parser throws on a desynced stream (e.g. an absurd frame size); catch
// it here so it fails the session via the normal teardown path rather than
// surfacing as an unhandled exception in this socket 'data' listener.
let result: ScrcpyFrameParseResult
let frames: ScrcpyVideoFrame[]
try {
result = parseScrcpyVideoFrames(Buffer.alloc(0), buffer)
frames = parseScrcpyVideoFrames(buffer)
} catch (error) {
this.fail(error instanceof Error ? error.message : String(error))
return
}
this.pendingVideo = result.pending
if (shouldResolveReady) {
this.resolveReady?.()
this.resolveReady = null
this.rejectReady = null
}
for (const frame of result.frames) {
for (const frame of frames) {
this.callbacks.onFrame(frame)
}
}
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { parseScrcpyVideoFrames, parseScrcpyVideoMeta } from './scrcpy-video-frame-parser'
import { RelayFrameBuffer } from '../../../shared/relay-frame-buffer'
import {
MAX_PENDING_CHUNKS,
parseScrcpyVideoFrames,
parseScrcpyVideoMeta
} from './scrcpy-video-frame-parser'
const CONFIG = 1n << 63n
const KEY = 1n << 62n
@@ -28,7 +33,9 @@ describe('parseScrcpyVideoMeta', () => {
describe('parseScrcpyVideoFrames', () => {
it('extracts config and key frames with their flags and data', () => {
const stream = Buffer.concat([frame(CONFIG, [0, 0, 0, 1]), frame(KEY | 123n, [1, 2, 3])])
const { frames, pending } = parseScrcpyVideoFrames(Buffer.alloc(0), stream)
const pending = new RelayFrameBuffer()
pending.append(stream)
const frames = parseScrcpyVideoFrames(pending)
expect(pending.length).toBe(0)
expect(frames).toHaveLength(2)
expect(frames[0]).toMatchObject({ config: true, keyFrame: false })
@@ -39,19 +46,89 @@ describe('parseScrcpyVideoFrames', () => {
it('buffers a partial frame across chunks', () => {
const full = frame(5n, [9, 9, 9, 9])
const r1 = parseScrcpyVideoFrames(Buffer.alloc(0), full.subarray(0, 14))
expect(r1.frames).toHaveLength(0)
expect(r1.pending.length).toBe(14)
const r2 = parseScrcpyVideoFrames(r1.pending, full.subarray(14))
expect(r2.frames).toHaveLength(1)
expect([...r2.frames[0].data]).toEqual([9, 9, 9, 9])
expect(r2.pending.length).toBe(0)
const pending = new RelayFrameBuffer()
pending.append(full.subarray(0, 14))
expect(parseScrcpyVideoFrames(pending)).toHaveLength(0)
expect(pending.length).toBe(14)
pending.append(full.subarray(14))
const frames = parseScrcpyVideoFrames(pending)
expect(frames).toHaveLength(1)
expect([...frames[0].data]).toEqual([9, 9, 9, 9])
expect(pending.length).toBe(0)
})
it('does not retain a consumed large packet behind a one-byte pending suffix', () => {
const first = Buffer.alloc(4 * 1024 * 1024 + 12, 7)
first.writeBigUInt64BE(123n, 0)
first.writeUInt32BE(first.length - 12, 8)
const second = frame(KEY | 456n, [1, 2, 3])
const chunk = Buffer.concat([first, second.subarray(0, 1)])
const pending = new RelayFrameBuffer()
pending.append(chunk)
const frames = parseScrcpyVideoFrames(pending)
expect(frames).toHaveLength(1)
expect(frames[0]).toMatchObject({ config: false, keyFrame: false, pts: 123n })
expect(frames[0].data.equals(first.subarray(12))).toBe(true)
expect(pending.length).toBe(1)
expect(pending.peek(1)).toEqual(second.subarray(0, 1))
expect(pending.peek(1).buffer === chunk.buffer).toBe(false)
expect(pending.peek(1).buffer.byteLength).toBeLessThan(chunk.length)
chunk.fill(0xff)
pending.append(second.subarray(1))
expect(parseScrcpyVideoFrames(pending)).toEqual([
{ config: false, keyFrame: true, pts: 456n, data: Buffer.from([1, 2, 3]) }
])
expect(pending.length).toBe(0)
})
it('keeps mostly live chunk storage instead of recopying a large pending frame', () => {
const first = frame(123n, [1, 2, 3])
const second = Buffer.alloc(4 * 1024 * 1024 + 12, 7)
second.writeBigUInt64BE(KEY | 456n, 0)
second.writeUInt32BE(second.length - 12, 8)
const split = 3 * 1024 * 1024
const chunk = Buffer.concat([first, second.subarray(0, split)])
const pending = new RelayFrameBuffer()
pending.append(chunk)
expect(parseScrcpyVideoFrames(pending)).toHaveLength(1)
expect(pending.length).toBe(split)
expect(pending.peek(1).buffer === chunk.buffer).toBe(true)
pending.append(second.subarray(split))
const frames = parseScrcpyVideoFrames(pending)
expect(frames).toHaveLength(1)
expect(frames[0]).toMatchObject({ config: false, keyFrame: true, pts: 456n })
expect(frames[0].data.equals(second.subarray(12))).toBe(true)
expect(pending.length).toBe(0)
})
it('bounds queued fragment count for a large frame delivered one byte at a time', () => {
const full = Buffer.alloc(256 * 1024 + 12, 7)
full.writeBigUInt64BE(KEY | 789n, 0)
full.writeUInt32BE(full.length - 12, 8)
const pending = new RelayFrameBuffer()
let maxChunks = 0
let frames: ReturnType<typeof parseScrcpyVideoFrames> = []
for (const byte of full) {
pending.append(Buffer.from([byte]))
frames = parseScrcpyVideoFrames(pending)
maxChunks = Math.max(maxChunks, pending.chunkCount)
}
expect(maxChunks).toBeLessThanOrEqual(MAX_PENDING_CHUNKS)
expect(frames).toHaveLength(1)
expect(frames[0]).toMatchObject({ config: false, keyFrame: true, pts: 789n })
expect(frames[0].data.equals(full.subarray(12))).toBe(true)
expect(pending.length).toBe(0)
})
it('holds an incomplete header until more bytes arrive', () => {
const result = parseScrcpyVideoFrames(Buffer.alloc(0), Buffer.from([0, 1, 2]))
expect(result.frames).toHaveLength(0)
expect(result.pending.length).toBe(3)
const pending = new RelayFrameBuffer()
pending.append(Buffer.from([0, 1, 2]))
expect(parseScrcpyVideoFrames(pending)).toHaveLength(0)
expect(pending.length).toBe(3)
})
it('throws on a desynced frame size instead of buffering toward OOM', () => {
@@ -59,6 +136,8 @@ describe('parseScrcpyVideoFrames', () => {
// never be satisfied, leaving the whole buffer pending forever.
const header = Buffer.alloc(12)
header.writeUInt32BE(64 * 1024 * 1024, 8)
expect(() => parseScrcpyVideoFrames(Buffer.alloc(0), header)).toThrow(/desynced/)
const pending = new RelayFrameBuffer()
pending.append(header)
expect(() => parseScrcpyVideoFrames(pending)).toThrow(/desynced/)
})
})
@@ -3,11 +3,15 @@
// socket. The socket reader (scrcpy-stream-session) feeds chunks here; this file
// has no I/O so the framing is unit-testable.
import type { RelayFrameBuffer } from '../../../shared/relay-frame-buffer'
const FRAME_HEADER_SIZE = 12
const CODEC_META_SIZE = 12
// scrcpy frames are well under this at the configured max_size; a larger
// size means a desynced stream — fail fast instead of buffering toward OOM.
const MAX_FRAME_BYTES = 16 * 1024 * 1024
// Caps per-object overhead when a socket delivers one frame as many tiny chunks.
export const MAX_PENDING_CHUNKS = 1024
// Top two bits of the 64-bit PTS field carry packet flags.
const CONFIG_FLAG = 1n << 63n
const KEY_FRAME_FLAG = 1n << 62n
@@ -47,33 +51,38 @@ export type ScrcpyVideoFrame = {
data: Buffer
}
export type ScrcpyFrameParseResult = { frames: ScrcpyVideoFrame[]; pending: Buffer }
// Extracts complete frames from `pending + chunk`, returning the leftover bytes
// of any partially-received frame so the caller can prepend them to the next chunk.
export function parseScrcpyVideoFrames(pending: Buffer, chunk: Buffer): ScrcpyFrameParseResult {
const buffer = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk
// Leave partial frames queued so fragmented payloads are not recopied on every chunk.
export function parseScrcpyVideoFrames(buffer: RelayFrameBuffer): ScrcpyVideoFrame[] {
const frames: ScrcpyVideoFrame[] = []
let offset = 0
while (buffer.length - offset >= FRAME_HEADER_SIZE) {
const meta = buffer.readBigUInt64BE(offset)
const size = buffer.readUInt32BE(offset + 8)
while (buffer.length >= FRAME_HEADER_SIZE) {
const header = buffer.peek(FRAME_HEADER_SIZE)
const meta = header.readBigUInt64BE(0)
const size = header.readUInt32BE(8)
if (size > MAX_FRAME_BYTES) {
throw new Error(`scrcpy frame size ${size} exceeds ${MAX_FRAME_BYTES}; stream desynced`)
}
const dataStart = offset + FRAME_HEADER_SIZE
if (buffer.length - dataStart < size) {
if (buffer.length < FRAME_HEADER_SIZE + size) {
break
}
const packet = buffer.take(FRAME_HEADER_SIZE + size)
frames.push({
config: (meta & CONFIG_FLAG) !== 0n,
keyFrame: (meta & KEY_FRAME_FLAG) !== 0n,
pts: meta & PTS_MASK,
data: Buffer.from(buffer.subarray(dataStart, dataStart + size))
data: Buffer.from(packet.subarray(FRAME_HEADER_SIZE))
})
offset = dataStart + size
}
return { frames, pending: offset > 0 ? Buffer.from(buffer.subarray(offset)) : buffer }
if (frames.length > 0 && buffer.length > 0) {
const pendingHead = buffer.peek(1)
// Compact only mostly consumed allocations larger than the reusable Buffer slab.
if (pendingHead.buffer.byteLength > Math.max(Buffer.poolSize, pendingHead.length * 2)) {
buffer.append(Buffer.from(buffer.drain()))
}
}
if (buffer.chunkCount > MAX_PENDING_CHUNKS) {
buffer.append(buffer.drain())
}
return frames
}
+2 -2
View File
@@ -13,7 +13,7 @@ let warnedMarkerFallbackThisSession = false
/** Check if a path is a valid git repository (regular or bare). */
export function isGitRepo(path: string): boolean {
try {
if (!existsSync(path) || !statSync(path).isDirectory()) {
if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) {
return false
}
} catch {
@@ -104,7 +104,7 @@ function canonicalizeGitDirPath(path: string): string {
/** Return the main-checkout path only when `path` is a linked worktree. */
export function getLinkedWorktreeMainRepoRoot(path: string): string | null {
try {
if (!existsSync(path) || !statSync(path).isDirectory()) {
if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) {
return null
}
if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') {
@@ -117,12 +117,14 @@ export async function bulkDiscardChanges(
}
const trackedPathSpecs = await listTrackedPathSpecs(worktreePath, filePaths, options)
const trackedPaths = filePaths.filter((filePath) =>
isTrackedPathSpec(filePath, trackedPathSpecs)
)
const untrackedPaths = filePaths.filter(
(filePath) => !isTrackedPathSpec(filePath, trackedPathSpecs)
)
const trackedPaths: string[] = []
const untrackedPaths: string[] = []
filePaths.forEach((filePath) => {
const targetPaths = isTrackedPathSpec(filePath, trackedPathSpecs)
? trackedPaths
: untrackedPaths
targetPaths.push(filePath)
})
await removeSafeUntrackedDiscardTargets(
worktreePath,
untrackedPaths,
+6 -4
View File
@@ -132,12 +132,14 @@ export async function listQuickOpenFiles(
if (maxResults !== undefined && files.size >= maxResults) {
return true
}
const nextBytes = serializedQuickOpenPathBytes(relPath) + (files.size === 0 ? 0 : 1)
if (maxSerializedBytes !== undefined && serializedBytes + nextBytes > maxSerializedBytes) {
return true
if (maxSerializedBytes !== undefined) {
const nextBytes = serializedQuickOpenPathBytes(relPath) + (files.size === 0 ? 0 : 1)
if (serializedBytes + nextBytes > maxSerializedBytes) {
return true
}
serializedBytes += nextBytes
}
files.add(relPath)
serializedBytes += nextBytes
return maxResults !== undefined && files.size >= maxResults
}
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Event as WatcherEvent } from '@parcel/watcher'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window'
import {
WATCH_BATCH_MAX_WAIT_MS,
WATCH_BATCH_TRAILING_MS
} from '../../shared/filesystem-watch-batch-window'
const { statMock, subscribeMock } = vi.hoisted(() => ({
statMock: vi.fn(),
@@ -13,6 +16,11 @@ vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: subscri
import { createLocalWatcher } from './filesystem-watcher-local-events'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
import {
subscribeLocalWatcher,
unsubscribeLocalWatcher
} from './filesystem-watcher-local-subscription'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
@@ -46,6 +54,93 @@ describe('local filesystem watcher flush serialization', () => {
})
})
it('extends the trailing window from the latest batch', async () => {
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
vi.advanceTimersByTime(100)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS - 1)
await flushMicrotasks()
expect(sender.send).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(1)
expect(root.batch.timer).toBeNull()
})
it('flushes sustained batches at the maximum wait', async () => {
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
for (let elapsed = 100; elapsed <= WATCH_BATCH_MAX_WAIT_MS; elapsed += 100) {
vi.advanceTimersByTime(100)
expect(sender.send).not.toHaveBeenCalled()
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
}
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(1)
expect(root.batch.timer).toBeNull()
})
it('cancels a refreshed trailing window without a later flush', async () => {
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
vi.advanceTimersByTime(100)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
cancelLocalBatchFlush(root)
vi.advanceTimersByTime(WATCH_BATCH_MAX_WAIT_MS)
await flushMicrotasks()
expect(sender.send).not.toHaveBeenCalled()
expect(root.batch.timer).toBeNull()
})
it('discards queued and late events after a terminal watcher error', async () => {
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
watcherCallback?.(new Error('watcher interrupted'), [])
expect(sender.send).toHaveBeenCalledTimes(1)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(1)
expect(root.batch.cancelled).toBe(true)
expect(root.batch.events).toEqual([])
expect(root.batch.timer).toBeNull()
} finally {
errorLog.mockRestore()
}
})
it('suppresses an inflight batch and its queued drain after a terminal watcher error', async () => {
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
const pendingStat = deferred<{ isDirectory: () => boolean }>()
statMock.mockReturnValueOnce(pendingStat.promise)
try {
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
watcherCallback?.(null, [{ type: 'update', path: '/repo/first.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(1)
watcherCallback?.(null, [{ type: 'update', path: '/repo/queued.ts' }])
watcherCallback?.(new Error('watcher interrupted'), [])
pendingStat.resolve({ isDirectory: () => false })
vi.advanceTimersByTime(WATCH_BATCH_MAX_WAIT_MS)
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(1)
expect(statMock).toHaveBeenCalledTimes(1)
expect(root.batch.events).toEqual([])
expect(root.batch.timer).toBeNull()
} finally {
errorLog.mockRestore()
}
})
it('serializes an inflight flush and drains one follow-up without overlap', async () => {
const firstStat = deferred<{ isDirectory: () => boolean }>()
const secondStat = deferred<{ isDirectory: () => boolean }>()
@@ -236,4 +331,26 @@ describe('local filesystem watcher flush serialization', () => {
{ kind: 'update', absolutePath: otherPath, isDirectory: false }
])
})
it('re-arms the debounce window after a re-subscribe inside the teardown grace period', async () => {
// Why real timers: fake-timers' refresh() revives a cleared handle, but Node's is a no-op — the bug only shows on real Timeouts.
vi.useRealTimers()
statMock.mockResolvedValue({ isDirectory: () => true })
const listener = { ...sender, id: 7, once: vi.fn() }
try {
await subscribeLocalWatcher('/repo', listener as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
unsubscribeLocalWatcher('/repo', listener.id)
await subscribeLocalWatcher('/repo', listener as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
await new Promise((resolve) => setTimeout(resolve, WATCH_BATCH_TRAILING_MS + 50))
expect(sender.send).toHaveBeenCalledTimes(1)
} finally {
for (const teardown of watcherLifecycleState.pendingTeardowns.values()) {
clearTimeout(teardown)
}
watcherLifecycleState.pendingTeardowns.clear()
watcherLifecycleState.watchedRoots.clear()
}
})
})
@@ -16,7 +16,7 @@ import {
retainLocalWatcherPhysicalFailure,
trackDetachedLocalUnsubscribe
} from './filesystem-watcher-listener-lifecycle'
import { createDebouncedBatch } from './filesystem-watcher-batch-control'
import { cancelLocalBatchFlush, createDebouncedBatch } from './filesystem-watcher-batch-control'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
// Why: matches the watcher subprocess budget in parcel-watcher-event-delivery.ts.
@@ -210,7 +210,8 @@ export function scheduleLocalBatchFlush(root: WatchedRoot): void {
// Trailing-edge debounce: reset timer on each new event
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer.refresh()
return
}
// Why: clear the handle as it fires so `batch.timer` means "a debounce window is still open", which gates the queued drain.
root.batch.timer = setTimeout(() => {
@@ -257,9 +258,7 @@ export async function createLocalWatcher(
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
emitOverflowPayload(root)
// Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3).
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
cancelLocalBatchFlush(root)
// Why: error callback can fire before subscribe() assigns root.subscription; guard against null so cleanup doesn't crash.
if (root.subscription) {
retainLocalWatcherPhysicalFailure(rootKey, err)
@@ -199,6 +199,8 @@ export function unsubscribeLocalWatcher(worktreePath: string, senderId: number):
if (root.listeners.size === 0) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
// Why: a cleared handle can't be refresh()ed; null it so a grace-window re-subscribe arms a fresh window.
root.batch.timer = null
}
// Why: duplicate unwatch calls for a root would leak overwritten grace timers; keep just one.
if (watcherLifecycleState.pendingTeardowns.has(rootKey)) {
+10 -3
View File
@@ -82,9 +82,16 @@ export class SshPtySourceAckCoalescer {
}
const providerGeneration = this.pending.values().next().value!.publication
.identity.providerGeneration
const selected = Array.from(this.pending.entries())
.filter(([, entry]) => entry.publication.identity.providerGeneration === providerGeneration)
.slice(0, MAX_PTY_ACK_ENTRIES)
const selected: [string, CoalescedEntry][] = []
for (const pair of this.pending) {
if (pair[1].publication.identity.providerGeneration !== providerGeneration) {
continue
}
selected.push(pair)
if (selected.length === MAX_PTY_ACK_ENTRIES) {
break
}
}
for (const [key] of selected) {
this.pending.delete(key)
}
@@ -64,6 +64,95 @@ function commitSpan(
}
describe('SshPtySourceObligationLedger', () => {
it('skips the terminal prefix while successful ACK publication is delayed', () => {
const count = 1_024
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
let endReads = 0
for (let index = 0; index < count; index += 1) {
const original = span(owner, `span-${index}`, index, 'x')
commitSpan(
ledger,
owner,
Object.freeze({
...original,
get sourceEndSu() {
endReads += 1
return original.sourceEndSu
}
})
)
}
endReads = 0
for (let index = 0; index < count; index += 1) {
ledger.settle(`span-${index}`, 'model', 'accepted')
ledger.settle(`span-${index}`, 'desktop', 'parsed')
}
expect(endReads).toBeLessThanOrEqual(count * 32)
expect(ledger.snapshot(owner)).toMatchObject({
obligationsTerminalEndSu: count,
ackPublishedEndSu: 0,
openSpans: count
})
ledger.queueAck(owner)!.onSettled({ ok: false, error: new Error('write failed') })
expect(ledger.hasRetainedSpan('span-0')).toBe(true)
ledger.retryQueuedAck(owner)!.onSettled({ ok: true })
expect(ledger.snapshot(owner)).toMatchObject({ ackPublishedEndSu: count, openSpans: 0 })
})
it('keeps an open gap authoritative across late settlements and prefix reclamation', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner, 100)
for (let index = 0; index < 8; index += 1) {
commitSpan(ledger, owner, span(owner, `span-${index}`, 100 + index, 'x'))
ledger.settle(`span-${index}`, 'model', 'accepted')
}
for (const index of [0, 1, 7, 6, 5, 4]) {
ledger.settle(`span-${index}`, 'desktop', 'parsed')
}
const earlyAck = ledger.queueAck(owner)!
earlyAck.onSettled({ ok: true })
expect(ledger.snapshot(owner)).toMatchObject({
obligationsTerminalEndSu: 102,
ackPublishedEndSu: 102,
openSpans: 6
})
ledger.beginTransfer('span-2', 'desktop', 'model', 'hidden')
ledger.commitTransfer('span-2', 'desktop')
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(103)
ledger.beginTransfer('span-3', 'desktop', 'model', 'hidden')
ledger.rollbackTransfer('span-3', 'desktop')
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(103)
ledger.settle('span-3', 'desktop', 'parsed')
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(108)
ledger.queueAck(owner)!.onSettled({ ok: true })
earlyAck.onSettled({ ok: true })
expect(ledger.snapshot(owner)).toMatchObject({ ackPublishedEndSu: 108, openSpans: 0 })
})
it('preserves zero-width span skipping and committed-tail rollback', () => {
const ledger = new SshPtySourceObligationLedger()
const owner = identity()
ledger.open(owner)
commitSpan(ledger, owner, span(owner, 'empty-start', 0, ''))
commitSpan(ledger, owner, span(owner, 'first', 0, 'x'))
commitSpan(ledger, owner, span(owner, 'empty-middle', 1, ''))
const tail = commitSpan(ledger, owner, span(owner, 'tail', 1, 'x'))
ledger.settle('first', 'model', 'accepted')
ledger.settle('first', 'desktop', 'parsed')
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(1)
expect(ledger.rollbackCommitted(tail)).toBe(true)
commitSpan(ledger, owner, span(owner, 'replacement', 1, 'yy'))
ledger.settle('replacement', 'desktop', 'parsed')
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(1)
ledger.settle('replacement', 'model', 'accepted')
expect(ledger.snapshot(owner).obligationsTerminalEndSu).toBe(3)
ledger.queueAck(owner)!.onSettled({ ok: true })
expect(ledger.snapshot(owner).openSpans).toBe(0)
})
it('looks up retained spans directly by ID as the ledger grows', () => {
const spanCount = 1_024
const ledger = new SshPtySourceObligationLedger()
@@ -133,7 +133,21 @@ export function snapshotSourceToken(token: TokenRecord): SshPtySourceTokenSnapsh
export function advanceSourceTerminalEnd(token: TokenRecord): void {
let endSu = token.obligationsTerminalEndSu
for (const record of token.spans) {
let low = 0
let high = token.spans.length
// Committed spans are contiguous; skip the terminal prefix retained until ACK publication.
if (token.spans[0]?.span.sourceEndSu <= endSu) {
while (low < high) {
const middle = low + Math.floor((high - low) / 2)
if (token.spans[middle]!.span.sourceEndSu <= endSu) {
low = middle + 1
} else {
high = middle
}
}
}
for (let index = low; index < token.spans.length; index += 1) {
const record = token.spans[index]!
if (record.span.sourceEndSu <= endSu) {
continue
}
@@ -120,9 +120,12 @@ export class TerminalPreviewOutputStream {
}
completeSnapshot(snapshotSeq?: number): TerminalPreviewReplayChunk[] {
const replay = this.initialPending.flatMap((output) => {
const replay: TerminalPreviewReplayChunk[] = []
this.initialPending.forEach((output) => {
const uncovered = outputAfterSnapshotSeq(output, snapshotSeq)
return uncovered && uncovered.data.length > 0 ? [uncovered] : []
if (uncovered && uncovered.data.length > 0) {
replay.push(uncovered)
}
})
this.initialPending = []
this.initialPendingBytes = 0
@@ -626,8 +626,8 @@ describe('worktree git-common narrow watch (local native platforms)', () => {
expect(statCalls.filter((path) => path === worktreesDir)).toHaveLength(1)
await vi.waitFor(() => {
expect(subscribeMock).toHaveBeenCalledTimes(2)
expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir })
})
expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir })
})
it('resumes polling when the dir is still absent on show', async () => {
@@ -8,7 +8,6 @@
import { existsSync } from 'node:fs'
import type Database from '../../sqlite/sync-database'
import { findSequenceGap } from './journal-cursor'
import { openJournalDatabase } from './journal-database'
import { journalDatabaseFile } from './journal-paths'
import {
@@ -17,7 +16,7 @@ import {
type JournalReducerState
} from './journal-reducer'
import {
readJournalEpochRows,
iterateJournalEpochRows,
readJournalRowsAfter,
readJournalSessionEpoch
} from './journal-row-table'
@@ -59,108 +58,69 @@ export function replayJournal(
return null
}
const state = createJournalReducerState(sessionId, epoch)
const stored = readJournalEpochRows(db, sessionId, epoch)
// A partial repair keeps its prefix, so the surviving rows look contiguous and
// anchored however much of the timeline it deleted. Its marker is what still
// says otherwise, naming the sequence past which the epoch would be its own
// history again.
const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch)
const rows: JournalRow[] = []
let expectedSequence = FIRST_JOURNAL_SEQUENCE
let gapSequence: number | undefined
let unanchoredSequence: number | undefined
let anchor: Extract<JournalRow, { kind: 'epoch' }> | undefined
let repairHasContent = false
let providerHasContent = false
let malformedRows = 0
let latched = false
let truncateFrom: number | undefined
for (const entry of stored) {
for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) {
const parsed = parseJournalRow(entry.rowJson)
if (parsed.ok) {
rows.push(parsed.row)
if (!parsed.ok) {
truncateFrom = entry.seq
latched = parsed.unreadable
malformedRows = parsed.unreadable ? 0 : 1
break
}
const row = parsed.row
// Parse past a gap so an unreadable future row still latches read-only.
if (gapSequence !== undefined) {
continue
}
// Reading STOPS at the first row this build cannot represent. A future
// version latches read-only; anything else is one skipped row, disclosed.
truncateFrom = entry.seq
if (parsed.unreadable) {
latched = true
} else {
malformedRows = 1
if (row.seq !== expectedSequence) {
gapSequence = row.seq
continue
}
break
}
// Anchored at 1, never at the first row that HAPPENS to remain: nothing trims
// a prefix, so a missing epoch row is a hole like any other and everything
// behind it is unanchored. Validating from `rows[0].seq` would call the
// leftovers contiguous and leave them out of the repair that runs before
// provider history replaces the epoch.
const gap = findSequenceGap(
rows.map((row) => row.seq),
FIRST_JOURNAL_SEQUENCE
)
if (gap) {
const firstBad = rows.findIndex((row, index) => row.seq !== FIRST_JOURNAL_SEQUENCE + index)
if (firstBad !== -1) {
truncateFrom = rows[firstBad]?.seq ?? truncateFrom
rows.length = firstBad
expectedSequence += 1
if (row.seq === FIRST_JOURNAL_SEQUENCE) {
if (row.kind === 'epoch') {
anchor = row
} else {
unanchoredSequence = row.seq
}
}
if (!anchor) {
continue
}
}
// Contiguity from 1 is not the whole invariant: sequence 1 has to BE the epoch
// row. An ordinary row there is an epoch nothing anchors, and replaying it as
// clean is how a repaired journal silently adopts a timeline whose real
// history was never rebuilt.
if (rows.length > 0 && rows[0]?.kind !== 'epoch') {
truncateFrom = rows[0]?.seq ?? truncateFrom
rows.length = 0
}
for (const row of rows) {
applyJournalRow(state, row)
const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID
if (!disclosure) {
repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom
providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1
}
}
// Anchor rejection takes precedence over a gap, which takes precedence over malformed rows.
truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom
state.oldestSequence = FIRST_JOURNAL_SEQUENCE
// A latched journal reduces to nothing by design; only a writable one can be
// held to the anchor.
const unanchored = !latched && rows[0]?.kind !== 'epoch'
return {
state,
readOnly: latched,
corrupt:
Boolean(gap) ||
gapSequence !== undefined ||
malformedRows > 0 ||
unanchored ||
(repairedFrom !== null && awaitsRebuild(rows, repairedFrom)) ||
awaitsProviderHistory(rows),
(!latched && !anchor) ||
(repairedFrom !== null && !repairHasContent) ||
(anchor?.reason === 'unreconcilable_prefix' && !providerHasContent),
malformedRows,
...(truncateFrom !== undefined && !latched ? { truncateFrom } : {})
}
}
/**
* The epoch a total repair published, still holding nothing but its own anchor
* and disclosure. The rows it dropped were never reconstructed, so provider
* history has to be retried rather than this being called a clean timeline.
*/
function awaitsProviderHistory(rows: readonly JournalRow[]): boolean {
const anchor = rows[0]
if (anchor?.kind !== 'epoch' || anchor.reason !== 'unreconcilable_prefix') {
return false
}
// The anchor sits at sequence 1, so content of the epoch's own starts at 2.
return awaitsRebuild(rows, FIRST_JOURNAL_SEQUENCE + 1)
}
/**
* True while everything at or above `contentFrom` is the repair's own
* bookkeeping: the deleted history was never rebuilt, so the provider has to be
* asked again. The moment the session writes content of its own past that
* sequence the epoch IS its own history, and the retry stops rather than a
* later import replacing rows the user has since seen.
*/
function awaitsRebuild(rows: readonly JournalRow[], contentFrom: number): boolean {
return rows.every(
(row) =>
row.seq < contentFrom ||
(row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID)
)
}
/** Rows after a cursor, in sequence order. Stops at the first row this build
* cannot parse, exactly as replay does. */
export function readJournalRowsAfterCursor(
@@ -55,6 +55,29 @@ export function readJournalEpochRows(
return toStoredRows(db.prepare(SELECT_EPOCH_ROWS).all(sessionId, epoch))
}
// Why pages, not `.iterate()`: a lazily consumed cursor pins a read snapshot for as long as the
// consumer reduces, and a WAL checkpoint cannot pass an open snapshot. Each page is one completed
// statement, so the consumer's memory is bounded by a page while no snapshot outlives a fetch.
const EPOCH_ROW_PAGE_SIZE = 128
/** Epoch rows in sequence order, fetched one completed statement at a time. */
export function* iterateJournalEpochRows(
db: Database.Database,
sessionId: string,
epoch: string
): Generator<JournalStoredRow> {
let afterSeq = Number.MIN_SAFE_INTEGER
for (;;) {
const page = readJournalRowsAfter(db, sessionId, epoch, afterSeq, EPOCH_ROW_PAGE_SIZE)
yield* page
const last = page.at(-1)
if (page.length < EPOCH_ROW_PAGE_SIZE || last === undefined) {
return
}
afterSeq = last.seq
}
}
export function readJournalRowsAfter(
db: Database.Database,
sessionId: string,
@@ -0,0 +1,155 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
import { openJournalDatabase, type OpenJournalDatabase } from './journal-database'
import { journalDatabaseFile } from './journal-paths'
import { replayJournal } from './journal-open'
import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table'
import type { JournalRow } from './journal-row-schema'
import * as reducer from './journal-reducer'
let root: string
let opened: OpenJournalDatabase
const sessionId = 'streaming-session'
const epoch = 'epoch-1'
function anchor(): JournalRow {
return {
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
kind: 'epoch',
epoch,
seq: 1,
ts: 1,
fence: 1,
reason: 'session_created',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
}
function revision(seq: number, text = 'content'): JournalRow {
return {
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
kind: 'item',
epoch,
seq,
ts: seq,
fence: 1,
itemId: 'message-1',
revision: seq,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }
}
}
function put(row: JournalRow): void {
insertJournalRow(opened.db, sessionId, row)
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-stream-replay-'))
opened = openJournalDatabase(journalDatabaseFile(root))
upsertJournalSessionRow(opened.db, sessionId, epoch, 1)
})
afterEach(async () => {
vi.restoreAllMocks()
opened.db.close()
await rm(root, { recursive: true, force: true })
})
describe('streaming journal replay', () => {
it('releases superseded revision bodies while reducing a long journal', () => {
const gc = global.gc
if (!gc) {
throw new Error('Run retention tests with --expose-gc')
}
opened.db.exec('BEGIN')
put(anchor())
for (let seq = 2; seq <= 2049; seq++) {
put(revision(seq, `${'x'.repeat(16384)}:${seq}`))
}
opened.db.exec('COMMIT')
gc()
const initial = process.memoryUsage().heapUsed
let peak = initial
let applied = 0
const apply = reducer.applyJournalRow
const spy = vi.spyOn(reducer, 'applyJournalRow')
spy.mockImplementation((state, row) => {
// The probe must not retain old row bodies in Vitest's call history.
spy.mockClear()
applied += 1
if (row.seq % 256 === 0) {
gc()
peak = Math.max(peak, process.memoryUsage().heapUsed)
}
apply(state, row)
})
const loaded = replayJournal(opened.db, false, sessionId)!
expect(loaded.state.items.size).toBe(1)
expect(loaded.state.items.get('message-1')?.revision).toBe(2049)
expect(loaded.state.lastSequence).toBe(2049)
// The probe must have measured every row, or the heap bound above is vacuous.
expect(applied).toBe(2049)
expect(peak - initial).toBeLessThan(8 * 1024 * 1024)
})
it('holds no read snapshot while reducing, so a checkpoint can pass mid-replay', () => {
put(anchor())
for (let seq = 2; seq <= 300; seq++) {
put(revision(seq))
}
const apply = reducer.applyJournalRow
const checkpoints: { busy: number }[] = []
vi.spyOn(reducer, 'applyJournalRow').mockImplementation((state, row) => {
if (row.seq === 2 || row.seq === 200) {
checkpoints.push(...(opened.db.pragma('wal_checkpoint(PASSIVE)') as { busy: number }[]))
}
apply(state, row)
})
const loaded = replayJournal(opened.db, false, sessionId)!
expect(loaded.state.lastSequence).toBe(300)
expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0])
})
it('keeps the prefix but latches read-only for a future row beyond a gap', () => {
put(anchor())
put(revision(2))
put(revision(4))
put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })
const loaded = replayJournal(opened.db, false, sessionId)!
expect(loaded).toMatchObject({ readOnly: true, corrupt: true, malformedRows: 0 })
expect(loaded.truncateFrom).toBeUndefined()
expect(loaded.state.items.get('message-1')?.revision).toBe(2)
expect(loaded.state.lastSequence).toBe(2)
const checkpoint = opened.db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[]
expect(checkpoint[0].busy).toBe(0)
})
it('keeps gap repair precedence when a later row is malformed', () => {
put(anchor())
put(revision(2))
put(revision(4))
opened.db
.prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)')
.run(sessionId, epoch, 5, 5, '{')
const loaded = replayJournal(opened.db, false, sessionId)!
expect(loaded).toMatchObject({
readOnly: false,
corrupt: true,
malformedRows: 1,
truncateFrom: 4
})
expect(loaded.state.lastSequence).toBe(2)
})
it('rejects an unanchored prefix before a later gap', () => {
put(revision(1))
put(revision(3))
const loaded = replayJournal(opened.db, false, sessionId)!
expect(loaded).toMatchObject({ readOnly: false, corrupt: true, truncateFrom: 1 })
expect(loaded.state.items.size).toBe(0)
expect(loaded.state.lastSequence).toBe(0)
})
})
@@ -165,7 +165,8 @@ export function createAgentSessionDeltaCoalescer(
} else if (deps.isProtected?.(key)) {
evictable.delete(key)
}
stream.observedBytes += Buffer.byteLength(delta, 'utf8')
const deltaBytes = Buffer.byteLength(delta, 'utf8')
stream.observedBytes += deltaBytes
if (!stream.truncated) {
const availableTotal = Math.max(0, maxTotalRetainedBytes - totalRetainedBytes)
const streamLimit = Math.min(maxRetainedBytes, stream.retainedBytes + availableTotal)
@@ -173,6 +174,7 @@ export function createAgentSessionDeltaCoalescer(
stream.chunks,
stream.retainedBytes,
delta,
deltaBytes,
streamLimit
)
totalRetainedBytes += next.retainedBytes - stream.retainedBytes
@@ -221,17 +223,17 @@ function appendWithinUtf8ByteLimit(
current: string[],
currentBytes: number,
delta: string,
deltaBytes: number,
maxBytes: number
): { chunks: string[]; retainedBytes: number; truncated: boolean } {
const available = Math.max(0, maxBytes - currentBytes)
const deltaBuffer = Buffer.from(delta, 'utf8')
if (deltaBuffer.byteLength <= available) {
if (deltaBytes <= available) {
// The caller owns the per-stream array; append in place so each token is
// amortized O(1) instead of copying the complete prefix on every delta.
current.push(delta)
return {
chunks: current,
retainedBytes: currentBytes + deltaBuffer.byteLength,
retainedBytes: currentBytes + deltaBytes,
truncated: false
}
}
@@ -239,7 +241,7 @@ function appendWithinUtf8ByteLimit(
const headBytes = Math.max(0, maxBytes - marker.byteLength)
const combined = Buffer.concat([
...current.map((chunk) => Buffer.from(chunk, 'utf8')),
deltaBuffer
Buffer.from(delta, 'utf8')
])
let end = Math.min(combined.byteLength, headBytes)
while (end > 0 && (combined[end] & 0b1100_0000) === 0b1000_0000) {
@@ -0,0 +1,96 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { readAgentSessionHistory } from './agent-session-history-page'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
const journals = createTrackedJournalOpener()
let root: string
let clock = 1_000
let epochs = 0
let journal: AgentSessionJournal
function tick(): number {
clock += 1
return clock
}
function item(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
function body(text: string): AgentJournalItemBody {
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }
}
async function appendItems(count: number, text: string): Promise<void> {
for (let ordinal = 1; ordinal <= count; ordinal += 1) {
await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { fence: 1 })
}
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-wire-history-'))
clock = 1_000
epochs = 0
journal = await journals.open({
identity: IDENTITY,
journalDir: root,
now: tick,
mintEpoch: () => {
epochs += 1
return `epoch-${epochs}`
}
})
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
it.each([1, 100, 200])('serializes each of %i unchanged forward page items once', async (count) => {
const cursor = journal.cursor()
await appendItems(count, 'x'.repeat(8_000))
const snapshot = journal.snapshot()
const stringify = JSON.stringify
let itemSerializations = 0
JSON.stringify = ((value: unknown, ...args: unknown[]) => {
if (value && typeof value === 'object' && 'itemId' in value && 'body' in value) {
itemSerializations++
}
return Reflect.apply(stringify, JSON, [value, ...args])
}) as typeof JSON.stringify
try {
const result = readAgentSessionHistory(
journal,
{
sessionId: 'session-1',
direction: 'after',
limit: count,
cursor
},
snapshot
)
expect(result.ok).toBe(true)
expect(result.page.items).toHaveLength(count)
expect(itemSerializations).toBe(count)
} finally {
JSON.stringify = stringify
}
})
@@ -196,11 +196,8 @@ function readForward(
if (!projected.ok) {
return historyReset(snapshot, projected.reset)
}
while (
rows.length > 1 &&
pageContentBytes(projected.batch.items, projected.batch.removedItemIds) >
HISTORY_PAGE_CONTENT_BUDGET_BYTES
) {
let contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds)
while (rows.length > 1 && contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) {
rows = rows.slice(0, Math.ceil(rows.length / 2))
const shrunk = projectJournalBatch({
rows,
@@ -212,19 +209,18 @@ function readForward(
return historyReset(snapshot, shrunk.reset)
}
projected = shrunk
contentBytes = pageContentBytes(projected.batch.items, projected.batch.removedItemIds)
}
// One row can still touch an over-budget item; degrade it visibly.
const items =
pageContentBytes(projected.batch.items, projected.batch.removedItemIds) >
HISTORY_PAGE_CONTENT_BUDGET_BYTES
? projected.batch.items.map((item) => {
const bytes = historyEntryBytes(item, submissionBytes)
return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES
? oversizedHistoryItem(item, bytes)
: item
})
: projected.batch.items
if (pageContentBytes(items, projected.batch.removedItemIds) > HISTORY_PAGE_CONTENT_BUDGET_BYTES) {
let items = projected.batch.items
if (contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) {
items = items.map((item) => {
const bytes = historyEntryBytes(item, submissionBytes)
return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item, bytes) : item
})
contentBytes = pageContentBytes(items, projected.batch.removedItemIds)
}
if (contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) {
// A single row's semantic payload — in practice a pre-bounding oversized
// removal id — can never fit any page, and truncating a removal id would
// break the client's keying. A bounded tail replaces the client's state
@@ -1,5 +1,5 @@
import { Readable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { decodeTranscriptStream } from './transcript-stream-lines'
const decode = (line: string, id: string) => ({
@@ -11,6 +11,113 @@ const decode = (line: string, id: string) => ({
})
describe('decodeTranscriptStream', () => {
it.each([true, false])('preserves chunked record offsets with trailing=%s', async (trailing) => {
const first = `${'long record '.repeat(10_000)}😀`
const prefix = `\r\n${first}\r\n\n`
const partial = 'unfinished é'
const bytes = Buffer.from(prefix + partial)
const chunks: Buffer[] = []
for (let offset = 0; offset < bytes.length; offset += 1024) {
chunks.push(bytes.subarray(offset, offset + 1024))
}
const result = await decodeTranscriptStream(
Readable.from(chunks),
'/chat.jsonl',
100,
decode,
trailing
)
expect(result.messages.map((message) => message.blocks[0])).toEqual([
{ type: 'text', text: first },
...(trailing ? [{ type: 'text', text: partial }] : [])
])
expect(result.messages[0]?.id).toBe('/chat.jsonl:0000000000000102')
expect(result.consumedBytes).toBe(trailing ? bytes.length : Buffer.byteLength(prefix))
})
it('searches each chunk once when a line spans many chunks', async () => {
const input = `${'x'.repeat(256 * 1024)}\n`
const chunks: string[] = []
for (let offset = 0; offset < input.length; offset += 4096) {
chunks.push(input.slice(offset, offset + 4096))
}
let searchedCharacters = 0
const originalIndexOf = String.prototype.indexOf
const spy = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function (
this: string,
search: string,
position?: number
) {
const found = originalIndexOf.call(this, search, position)
if (search === '\n') {
searchedCharacters += (found < 0 ? this.length : found + 1) - (position ?? 0)
}
return found
})
let result
try {
result = await decodeTranscriptStream(Readable.from(chunks), '/chat.jsonl', 0, decode, true)
} finally {
spy.mockRestore()
}
expect(result.messages[0]?.blocks[0]).toEqual({ type: 'text', text: input.slice(0, -1) })
expect(result.consumedBytes).toBe(input.length)
expect(searchedCharacters).toBeLessThanOrEqual(input.length * 2)
})
it('joins split UTF-16 surrogate pairs before deriving byte offsets', async () => {
const chunks = ['a\ud83d', '\ude00', '\r', '\n\n', 'tail\r']
const actual = await decodeTranscriptStream(
Readable.from(chunks),
'/chat.jsonl',
123,
decode,
true
)
const expected = await decodeTranscriptStream(
Readable.from([chunks.join('')]),
'/chat.jsonl',
123,
decode,
true
)
expect(actual).toEqual(expected)
expect(actual.messages).toHaveLength(2)
expect(actual.consumedBytes).toBe(Buffer.byteLength(chunks.join('')))
})
it.each([false, true])(
'preserves decoder tail handling with includeTrailingLine=%s',
async (includeTrailingLine) => {
const chunks = [Buffer.from('line\r\n'), Buffer.from([0xf0, 0x9f])]
const actual = await decodeTranscriptStream(
Readable.from(chunks),
'/chat.jsonl',
20,
decode,
includeTrailingLine
)
expect(actual.messages.map((message) => message.blocks[0])).toEqual([
{ type: 'text', text: 'line' },
...(includeTrailingLine ? [{ type: 'text', text: '\ufffd' }] : [])
])
expect(actual.consumedBytes).toBe(6 + (includeTrailingLine ? 3 : 0))
}
)
it('closes the source when a decoder throws', async () => {
const error = new Error('decode failed')
const stream = Readable.from(['partial', ' line\nsecond\n'])
const failingDecode = vi.fn(() => {
throw error
})
await expect(
decodeTranscriptStream(stream, '/chat.jsonl', 0, failingDecode, true)
).rejects.toBe(error)
expect(failingDecode).toHaveBeenCalledOnce()
expect(stream.destroyed).toBe(true)
})
it('uses identical absolute byte ids for full and incremental reads', async () => {
const prefix = '{"first":"é"}\r\n'
const appended = '{"second":true}\n'
@@ -16,25 +16,40 @@ export async function decodeTranscriptStream(
// Why: a Buffer chunk can end mid-codepoint, and decoding it standalone would
// both corrupt the line and shift `consumedBytes` (which seeds fallback ids).
const decoder = new StringDecoder('utf8')
let pending = ''
let pending: string[] = []
let consumedBytes = 0
for await (const chunk of stream) {
pending += typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk))
let newlineIndex = pending.indexOf('\n')
const text = typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk))
// Only the new chunk is scanned; partial records wait in `pending` unrescanned.
let lineStart = 0
let newlineIndex = text.indexOf('\n')
while (newlineIndex !== -1) {
const segment = pending.slice(0, newlineIndex + 1)
let segment = text.slice(lineStart, newlineIndex + 1)
if (pending.length > 0) {
// Join a fragmented record only once, including split string surrogate pairs.
pending.push(segment)
segment = pending.join('')
pending = []
}
decodeLine(segment.slice(0, -1), consumedBytes)
consumedBytes += Buffer.byteLength(segment, 'utf8')
pending = pending.slice(newlineIndex + 1)
newlineIndex = pending.indexOf('\n')
lineStart = newlineIndex + 1
newlineIndex = text.indexOf('\n', lineStart)
}
if (lineStart < text.length) {
pending.push(text.slice(lineStart))
}
}
pending += decoder.end()
const tail = decoder.end()
if (tail) {
pending.push(tail)
}
if (includeTrailingLine && pending.length > 0) {
decodeLine(pending, consumedBytes)
consumedBytes += Buffer.byteLength(pending, 'utf8')
const line = pending.join('')
decodeLine(line, consumedBytes)
consumedBytes += Buffer.byteLength(line, 'utf8')
}
return { messages, consumedBytes }
+19
View File
@@ -58,6 +58,25 @@ describe('bundle — submission ID', () => {
})
describe('bundle — collection', () => {
it.each([
{ kind: 'empty', names: [] },
{ kind: 'ASCII', names: ['plain'] },
{ kind: 'Unicode', names: ['漢字🙂', '\ud800'] },
{ kind: 'capped', names: Array.from({ length: 600 }, () => '漢字🙂'.repeat(1000)) }
])('reports the exact UTF-8 payload size for $kind records', ({ names }) => {
writeFileSync(traceFile, makeNDJSON(names.map((name) => makeSpan({ name }))))
const bundle = collectBundle({
traceFilePath: traceFile,
maxFiles: 1,
appVersion: '1',
platform: 'win32',
arch: 'x64',
osRelease: 'test',
orcaChannel: 'dev'
})
expect(bundle.bytes).toBe(Buffer.byteLength(bundle.payload))
})
it('emits a header line with bundle_submission_id, app_version, platform', () => {
writeFileSync(traceFile, makeNDJSON([makeSpan()]))
const bundle = collectBundle({
+1 -1
View File
@@ -167,7 +167,7 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle {
return {
bundleSubmissionId,
payload,
bytes: Buffer.byteLength(payload),
bytes: currentBytes,
spanCount
}
}
@@ -23,6 +23,22 @@ class FakeResponse extends EventEmitter {
}
describe('diagnostic upload HTTP', () => {
it('reports only the status for an error response with an invalid JSON body', async () => {
const request = new FakeRequest()
const response = new FakeResponse()
response.statusCode = 503
httpRequestMock.mockImplementationOnce((_options, callback) => {
callback(response)
return request
})
const result = postJsonForJson('http://diagnostics.example/upload', {}, 1000)
response.emit('data', Buffer.from('private backend details: not JSON'))
response.emit('end')
await expect(result).rejects.toThrow(/^HTTP 503$/)
expect(response.listenerCount('data')).toBe(0)
expect(request.listenerCount('error')).toBe(0)
})
it('removes request and response listeners after a successful response', async () => {
const request = new FakeRequest()
const response = new FakeResponse()
@@ -89,8 +89,8 @@ function postRaw(
}
function onResponseEnd(): void {
const status = res?.statusCode ?? 0
const text = Buffer.concat(chunks).toString('utf8')
if (status >= 200 && status < 300) {
const text = Buffer.concat(chunks).toString('utf8')
try {
resolveOnce(text.length > 0 ? JSON.parse(text) : {})
} catch {
@@ -0,0 +1,104 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
const { createConnection } = vi.hoisted(() => ({ createConnection: vi.fn() }))
vi.mock('node:net', () => ({ createConnection }))
import { sendOrcadSidecarRequest } from './orcad-sidecar-runtime-client'
function startRequest(timeout = 1000) {
const socket = Object.assign(new EventEmitter(), {
setEncoding: vi.fn(),
write: vi.fn(),
end: vi.fn(),
destroy: vi.fn()
})
createConnection.mockReturnValue(socket)
const metadata: RuntimeMetadata = {
runtimeId: 'test',
pid: 1,
startedAt: 0,
authToken: null,
transports: [{ kind: 'named-pipe', endpoint: 'test-pipe' }]
}
const result = sendOrcadSidecarRequest(metadata, 'browser.screenshot', {}, timeout)
socket.emit('connect')
const request = JSON.parse(socket.write.mock.calls[0][0]) as { id: string }
return { socket, result, id: request.id }
}
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('sidecar response framing', () => {
it('does not rescan the accumulated response for each partial chunk', async () => {
const { socket, result, id } = startRequest()
const wire = `${JSON.stringify({ id, ok: true, result: 'x'.repeat(1024 * 1024) })}\n`
const originalIndexOf = String.prototype.indexOf
let searchedCharacters = 0
const search = vi
.spyOn(String.prototype, 'indexOf')
.mockImplementation(function (this: string, value, position) {
if (value === '\n') {
searchedCharacters += this.length - (position ?? 0)
}
return originalIndexOf.call(this, value, position)
})
try {
for (let offset = 0; offset < wire.length; offset += 256) {
socket.emit('data', wire.slice(offset, offset + 256))
}
} finally {
search.mockRestore()
}
await expect(result).resolves.toHaveLength(1024 * 1024)
expect(searchedCharacters).toBe(wire.length)
})
it('assembles a large response after fragmented keepalive and empty lines', async () => {
const { socket, result, id } = startRequest()
const expected = { image: 'A'.repeat(1024 * 1024), text: '😀é' }
const wire = `\n${JSON.stringify({ _keepalive: true })}\n${JSON.stringify({ id, ok: true, result: expected })}\r\n`
for (let offset = 0; offset < wire.length; offset += 8192) {
socket.emit('data', wire.slice(offset, offset + 8192))
}
await expect(result).resolves.toEqual(expected)
expect(socket.end).toHaveBeenCalledOnce()
})
it('refreshes the deadline for completed keepalive frames', async () => {
vi.useFakeTimers()
const { socket, result, id } = startRequest()
await vi.advanceTimersByTimeAsync(600)
socket.emit('data', '{"_keepalive":')
socket.emit('data', 'true}\n')
await vi.advanceTimersByTimeAsync(600)
expect(socket.destroy).not.toHaveBeenCalled()
socket.emit('data', `${JSON.stringify({ id, ok: true, result: 'done' })}\n`)
await expect(result).resolves.toBe('done')
})
it('rejects oversized unterminated data before waiting for a newline', async () => {
const { socket, result } = startRequest()
const rejected = expect(result).rejects.toThrow('response is too large')
const chunk = 'a'.repeat(1024 * 1024)
for (let index = 0; index < 64; index += 1) {
socket.emit('data', chunk)
}
expect(socket.destroy).not.toHaveBeenCalled()
socket.emit('data', 'a')
await rejected
expect(socket.destroy).toHaveBeenCalledOnce()
})
it('rejects a fragmented response carrying another request id', async () => {
const { socket, result } = startRequest()
const rejected = expect(result).rejects.toThrow('invalid response')
socket.emit('data', '{"id":"other",')
socket.emit('data', '"ok":true,"result":null}\n')
await rejected
})
})
@@ -79,6 +79,10 @@ export async function sendOrcadSidecarRequest(
finish(new BrowserError('browser_error', 'Electron browser sidecar response is too large.'))
return
}
// The retained tail has no newline; avoid flattening it for each partial chunk.
if (!chunk.includes('\n')) {
return
}
let newline = buffer.indexOf('\n')
while (newline !== -1 && !settled) {
const line = buffer.slice(0, newline)
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
@@ -37,6 +37,91 @@ function commandPlugin(
}
describe('PluginCommandRegistry', () => {
it('reads binding command IDs once while preserving declaration and binding order', () => {
const commands = Array.from({ length: 256 }, (_, index) => ({
id: `command-${index}`,
title: `Command ${index}`,
action: 'view.tasks'
}))
const keys = Array.from(
{ length: 104 },
(_, index) =>
`Mod+${Math.floor(index / 26) & 1 ? 'Alt+' : ''}${Math.floor(index / 26) & 2 ? 'Shift+' : ''}${String.fromCharCode(65 + (index % 26))}`
)
// Distinct physical chords, with two bindings belonging to the same command.
const uniqueKeys = [...new Set(keys)]
const plugin = commandPlugin('many-commands', {
commands,
keybindings: uniqueKeys.map((key, index) => ({ command: `command-${index % 32}`, key }))
})
let reads = 0
for (const binding of plugin.manifest.contributes.keybindings) {
const command = binding.command
Object.defineProperty(binding, 'command', {
get: () => {
reads++
return command
}
})
}
const registry = new PluginCommandRegistry()
registry.reconcile([plugin], () => false)
const preview = registry.preview(plugin.pluginKey)
expect(preview.map((command) => command.id)).toEqual(commands.map((command) => command.id))
expect(preview[0].keybindings.map((binding) => binding.key)).toEqual(
plugin.manifest.contributes.keybindings
.filter((_, index) => index % 32 === 0)
.map((binding) => binding.key)
)
expect(preview[255].keybindings).toEqual([])
expect(registry.list()).toEqual([])
expect(reads).toBe(uniqueKeys.length)
})
it('records each conflicting owner once instead of every pair', () => {
const plugins = Array.from({ length: 128 }, (_, index) =>
commandPlugin(`plugin-${index}`, {
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
})
)
const registry = new PluginCommandRegistry()
const errors = (registry as unknown as { errors: Map<string, string> }).errors
const writes = vi.spyOn(errors, 'set')
registry.reconcile(plugins, () => true)
expect(registry.list()).toEqual([])
for (const plugin of plugins) {
expect(registry.preview(plugin.pluginKey)).toHaveLength(1)
expect(registry.error(plugin.pluginKey)).toBe(
'plugin keybinding Mod+Alt+T conflicts with another plugin'
)
}
expect(writes).toHaveBeenCalledTimes(plugins.length)
})
it('preserves the last conflicting spelling for repeated owners and chord groups', () => {
const plugin = commandPlugin('repeat', {
commands: [
{ id: 'one', title: 'One', action: 'view.tasks' },
{ id: 'two', title: 'Two', action: 'view.tasks', context: 'worktree' }
]
})
const registry = new PluginCommandRegistry()
registry.reconcile(
[plugin],
() => true,
{
'plugin:orca-samples.repeat/one': ['Mod+Alt+T', 'Mod+Alt+Y'],
'plugin:orca-samples.repeat/two': ['Ctrl+Alt+T', 'Ctrl+Alt+Y']
},
'linux'
)
expect(registry.list()).toEqual([])
expect(registry.error(plugin.pluginKey)).toBe(
'plugin keybinding Ctrl+Alt+Y conflicts with another plugin'
)
})
it('retains pending previews and exposes only approved commands', () => {
const plugin = commandPlugin('aliases', {
commands: [{ id: 'tasks', title: 'Open Tasks', action: 'view.tasks' }],
+25 -34
View File
@@ -30,7 +30,6 @@ export type PluginCommandRegistration = {
type CommandOwner = {
pluginKey: string
context: PluginCommandKeybinding['when']
key: string
}
@@ -82,7 +81,6 @@ export class PluginCommandRegistry {
const owners = chordOwners.get(identity) ?? []
owners.push({
pluginKey: plugin.pluginKey,
context: keybinding.when,
key: keybinding.key
})
chordOwners.set(identity, owners)
@@ -92,24 +90,16 @@ export class PluginCommandRegistry {
const conflicted = new Set<string>()
for (const owners of chordOwners.values()) {
for (let index = 0; index < owners.length; index += 1) {
for (let compared = index + 1; compared < owners.length; compared += 1) {
const first = owners[index]!
const second = owners[compared]!
if (!contextsOverlap(first.context, second.context)) {
continue
}
conflicted.add(first.pluginKey)
conflicted.add(second.pluginKey)
this.errors.set(
first.pluginKey,
`plugin keybinding ${first.key} conflicts with another plugin`
)
this.errors.set(
second.pluginKey,
`plugin keybinding ${second.key} conflicts with another plugin`
)
}
// Global/worktree are the only contexts, so all owners of the same chord overlap.
if (owners.length < 2) {
continue
}
for (const owner of owners) {
conflicted.add(owner.pluginKey)
this.errors.set(
owner.pluginKey,
`plugin keybinding ${owner.key} conflicts with another plugin`
)
}
}
@@ -134,6 +124,16 @@ function registrationsForManifest(
pluginKey: string,
manifest: PluginManifest
): PluginCommandRegistration[] {
const bindingsByCommand = new Map<string, PluginKeybindingContribution[]>()
for (const binding of manifest.contributes.keybindings) {
const commandId = binding.command
const bindings = bindingsByCommand.get(commandId)
if (bindings) {
bindings.push(binding)
} else {
bindingsByCommand.set(commandId, [binding])
}
}
return manifest.contributes.commands.map((command) => ({
pluginKey,
id: command.id,
@@ -143,7 +143,7 @@ function registrationsForManifest(
command.action === undefined
? { type: 'worker' as const }
: { type: 'built-in' as const, action: command.action as PluginCommandAliasActionId },
keybindings: keybindingsForCommand(command, manifest.contributes.keybindings)
keybindings: keybindingsForCommand(command, bindingsByCommand.get(command.id) ?? [])
}))
}
@@ -151,17 +151,8 @@ function keybindingsForCommand(
command: PluginCommandContribution,
keybindings: readonly PluginKeybindingContribution[]
): PluginCommandKeybinding[] {
return keybindings
.filter((keybinding) => keybinding.command === command.id)
.map((keybinding) => ({
key: keybinding.key,
when: keybinding.when ?? command.context ?? 'global'
}))
}
function contextsOverlap(
first: PluginCommandKeybinding['when'],
second: PluginCommandKeybinding['when']
): boolean {
return first === 'global' || second === 'global' || first === second
return keybindings.map((keybinding) => ({
key: keybinding.key,
when: keybinding.when ?? command.context ?? 'global'
}))
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
isIgnoredNestedRepoDirectory,
readNestedRepoGitignoreRules
} from './nested-repo-scan-rules'
async function readRules(content: string, baseSegments: string[] = []) {
return readNestedRepoGitignoreRules({
folderPath: '/workspace',
entries: [{ name: '.gitignore', isDirectory: false }],
baseSegments,
filesystem: {
readDirectory: async () => [],
readTextFile: async () => content,
joinPath: (parent, child) => `${parent}/${child}`,
basename: (path) => path.split('/').at(-1) ?? '',
hasGitMarker: () => false,
isSelectedPathGitRepo: () => false
}
})
}
describe('nested repository ignore rules', () => {
it.each([
['cache*', ['parent', 'cache-data', 'child'], true],
['cache*\n!cache-keep', ['parent', 'cache-keep'], false],
['/cache*', ['parent', 'cache-data'], false],
['/cache*', ['cache-data'], true],
['packages/*/output?', ['packages', 'app', 'output1'], true],
['packages/*/output?', ['packages', 'app', 'nested', 'output1'], false],
['packages/**/output?', ['packages', 'output1'], true],
['packages/**/output?', ['packages', 'app', 'nested', 'output1'], true],
['**', ['anything', 'child'], true],
['/**', ['anything', 'child'], true],
['**\n!**', ['anything', 'child'], false],
['[literal]+.*', ['[literal]+.suffix'], true],
['[literal]+.*', ['literal-suffix'], false]
])('matches %s against %j', async (content, segments, expected) => {
const rules = await readRules(content)
for (let repeat = 0; repeat < 3; repeat++) {
expect(isIgnoredNestedRepoDirectory(segments.at(-1)!, segments, rules)).toBe(expected)
}
})
it('scopes inherited anchored patterns to the directory that declared them', async () => {
const rules = await readRules('/cache*', ['parent'])
expect(isIgnoredNestedRepoDirectory('cache-data', ['parent', 'cache-data'], rules)).toBe(true)
expect(
isIgnoredNestedRepoDirectory('cache-data', ['parent', 'child', 'cache-data'], rules)
).toBe(false)
expect(isIgnoredNestedRepoDirectory('parent', ['parent'], rules)).toBe(false)
})
})
@@ -17,6 +17,7 @@ export type NestedRepoScanFilesystem = {
type IgnoreRule = {
pattern: string
segmentPatterns: (string | RegExp)[]
negate: boolean
basenameOnly: boolean
baseSegments: string[]
@@ -82,16 +83,22 @@ function shouldSkipDirectory(name: string, depth: number): boolean {
return depth > 0 && name.startsWith('.')
}
function globSegmentMatches(pattern: string, value: string): boolean {
function compileGlobSegment(pattern: string): string | RegExp {
if (!pattern.includes('*') && !pattern.includes('?')) {
return pattern === value
return pattern
}
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`)
return regex.test(value)
return new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`)
}
function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean {
function globSegmentMatches(pattern: string | RegExp, value: string): boolean {
return typeof pattern === 'string' ? pattern === value : pattern.test(value)
}
function pathSegmentsMatch(
patternSegments: (string | RegExp)[],
candidateSegments: string[]
): boolean {
const matchFrom = (patternIndex: number, candidateIndex: number): boolean => {
if (patternIndex >= patternSegments.length) {
return candidateIndex >= candidateSegments.length
@@ -122,10 +129,16 @@ function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRul
const unprefixed = negate ? line.slice(1) : line
const anchored = unprefixed.startsWith('/')
const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '')
const basenameOnly = !anchored && !pattern.includes('/')
return {
pattern,
segmentPatterns: basenameOnly
? [compileGlobSegment(pattern)]
: pattern
.split('/')
.map((segment) => (segment === '**' ? segment : compileGlobSegment(segment))),
negate,
basenameOnly: !anchored && !pattern.includes('/'),
basenameOnly,
baseSegments
}
})
@@ -143,10 +156,9 @@ export function isIgnoredNestedRepoDirectory(
continue
}
const relativeSegments = segments.slice(rule.baseSegments.length)
const patternSegments = rule.pattern.split('/')
const matches = rule.basenameOnly
? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment))
: pathSegmentsMatch(patternSegments, relativeSegments)
? relativeSegments.some((segment) => globSegmentMatches(rule.segmentPatterns[0], segment))
: pathSegmentsMatch(rule.segmentPatterns, relativeSegments)
if (matches) {
ignored = !rule.negate
}
+34 -28
View File
@@ -15,34 +15,6 @@ export function buildWallClockTimestamp(
return isMatchingDateParts(localDate, parts) ? localDate.getTime() : null
}
const timestamp = buildTimeZoneTimestamp(parts, timeZone)
if (timestamp === null) {
return null
}
const resolvedParts = getTimeZoneDateParts(timestamp, timeZone)
return resolvedParts && areMatchingWallClockParts(resolvedParts, parts) ? timestamp : null
}
function buildTimeZoneTimestamp(parts: WallClockDateParts, timeZone: string): number | null {
const utcGuess = Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute)
const firstOffset = getTimeZoneOffsetMs(utcGuess, timeZone)
if (firstOffset === null) {
return null
}
const firstTimestamp = utcGuess - firstOffset
const secondOffset = getTimeZoneOffsetMs(firstTimestamp, timeZone)
return secondOffset === null ? null : utcGuess - secondOffset
}
function getTimeZoneOffsetMs(timestamp: number, timeZone: string): number | null {
const parts = getTimeZoneDateParts(timestamp, timeZone)
if (!parts) {
return null
}
return Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute) - timestamp
}
function getTimeZoneDateParts(timestamp: number, timeZone: string): WallClockDateParts | null {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
@@ -52,6 +24,40 @@ function getTimeZoneDateParts(timestamp: number, timeZone: string): WallClockDat
hour: '2-digit',
minute: '2-digit'
})
const timestamp = buildTimeZoneTimestamp(parts, formatter)
if (timestamp === null) {
return null
}
const resolvedParts = getTimeZoneDateParts(timestamp, formatter)
return resolvedParts && areMatchingWallClockParts(resolvedParts, parts) ? timestamp : null
}
function buildTimeZoneTimestamp(
parts: WallClockDateParts,
formatter: Intl.DateTimeFormat
): number | null {
const utcGuess = Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute)
const firstOffset = getTimeZoneOffsetMs(utcGuess, formatter)
if (firstOffset === null) {
return null
}
const firstTimestamp = utcGuess - firstOffset
const secondOffset = getTimeZoneOffsetMs(firstTimestamp, formatter)
return secondOffset === null ? null : utcGuess - secondOffset
}
function getTimeZoneOffsetMs(timestamp: number, formatter: Intl.DateTimeFormat): number | null {
const parts = getTimeZoneDateParts(timestamp, formatter)
if (!parts) {
return null
}
return Date.UTC(parts.year, parts.monthIndex, parts.day, parts.hour, parts.minute) - timestamp
}
function getTimeZoneDateParts(
timestamp: number,
formatter: Intl.DateTimeFormat
): WallClockDateParts | null {
const parts = Object.fromEntries(
formatter.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value])
)
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { PROVEN_ABSENT_LEAF_PTY_TTL_MS as TTL_MS } from './orca-runtime-core'
type VerdictInternals = {
provenAbsentLeafPtyVerdicts: Map<string, number>
isLeafPtyProvenAbsent: (ptyId: string) => Promise<boolean>
}
function createRuntime(
probePtyLiveness = vi.fn<(ptyId: string) => Promise<boolean | null>>(async () => false)
) {
const runtime = new OrcaRuntimeService()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
hasPty: (id) => id === 'live',
probePtyLiveness
})
const internals = runtime as unknown as VerdictInternals
return {
runtime,
probe: probePtyLiveness,
verdicts: internals.provenAbsentLeafPtyVerdicts,
isAbsent: (id: string) => internals.isLeafPtyProvenAbsent(id)
}
}
afterEach(() => vi.restoreAllMocks())
describe('leaf PTY verdict expiry', () => {
it('retires old unique IDs on a live-PTY consult without probing that live PTY', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
const { verdicts, isAbsent, probe } = createRuntime()
for (let index = 0; index < 1_000; index++) {
await expect(isAbsent(`retired-${index}`)).resolves.toBe(true)
}
expect(verdicts.size).toBe(1_000)
now.mockReturnValue(100_000 + TTL_MS)
await expect(isAbsent('live')).resolves.toBe(false)
expect(verdicts.size).toBe(0)
expect(probe).toHaveBeenCalledTimes(1_000)
})
it('preserves every fresh verdict and the exact per-key TTL between bulk sweeps', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
const { verdicts, isAbsent, probe } = createRuntime()
await isAbsent('initial')
now.mockReturnValue(101_000)
for (let index = 0; index < 1_000; index++) {
await isAbsent(`fresh-${index}`)
}
now.mockReturnValue(100_000 + TTL_MS)
await isAbsent('live')
expect(verdicts.size).toBe(1_000)
now.mockReturnValue(101_000 + TTL_MS - 1)
probe.mockClear()
for (let index = 0; index < 1_000; index++) {
await expect(isAbsent(`fresh-${index}`)).resolves.toBe(true)
}
expect(probe).not.toHaveBeenCalled()
now.mockReturnValue(101_000 + TTL_MS)
probe.mockResolvedValue(null)
await expect(isAbsent('fresh-0')).resolves.toBe(false)
expect(probe).toHaveBeenCalledOnce()
expect(verdicts.has('fresh-0')).toBe(false)
})
it('sweeps at most once per TTL through a burst of probes and live sends', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
const { verdicts, isAbsent } = createRuntime()
const iterations = vi.spyOn(verdicts, Symbol.iterator)
for (let index = 0; index < 1_000; index++) {
await isAbsent(`dead-${index}`)
await isAbsent('live')
}
expect(iterations).toHaveBeenCalledOnce()
now.mockReturnValue(100_000 + TTL_MS)
for (let index = 0; index < 1_000; index++) {
await isAbsent('live')
}
expect(iterations).toHaveBeenCalledTimes(2)
expect(verdicts.size).toBe(0)
})
it('cleans old entries when a delayed probe completes after the next sweep is due', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
const { verdicts, isAbsent, probe } = createRuntime()
await isAbsent('old')
let finish!: (value: boolean | null) => void
probe.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve)))
const pending = isAbsent('new')
now.mockReturnValue(100_000 + 2 * TTL_MS)
finish(false)
await expect(pending).resolves.toBe(true)
expect([...verdicts]).toEqual([['new', 100_000 + 2 * TTL_MS]])
})
it('resumes pruning after a backward clock adjustment without expiring future-dated evidence', async () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(100_000)
const { verdicts, isAbsent, probe } = createRuntime()
await isAbsent('future-dated')
now.mockReturnValue(1_000)
await isAbsent('after-clock-change')
now.mockReturnValue(1_000 + TTL_MS)
await isAbsent('live')
expect([...verdicts]).toEqual([['future-dated', 100_000]])
await expect(isAbsent('future-dated')).resolves.toBe(true)
expect(probe).toHaveBeenCalledTimes(2)
})
it('leaves unverifiable probes uncached and preserves concurrent probe coalescing', async () => {
vi.spyOn(Date, 'now').mockReturnValue(100_000)
let finish!: (value: boolean | null) => void
const probe = vi.fn(() => new Promise<boolean | null>((resolve) => (finish = resolve)))
const { verdicts, isAbsent } = createRuntime(probe)
const first = isAbsent('ssh-id')
const second = isAbsent('ssh-id')
expect(first).toBe(second)
finish(null)
await expect(first).resolves.toBe(false)
expect(verdicts.size).toBe(0)
probe.mockRejectedValueOnce(new Error('host unavailable'))
await expect(isAbsent('ssh-id')).resolves.toBe(false)
expect(verdicts.size).toBe(0)
})
})
@@ -1,6 +1,7 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { OrcaRuntimeWithResolveTerminalPane } from './orca-runtime-resolve-terminal-pane'
import { PROVEN_ABSENT_LEAF_PTY_TTL_MS } from './orca-runtime-core'
import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts'
import type { RuntimeTerminalSend } from '../../shared/runtime-types'
import type { RuntimeAgentPromptWriteOptions } from './runtime-terminal-contracts'
import {
@@ -10,6 +11,26 @@ import {
import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection'
export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithResolveTerminalPane {
private lastProvenAbsentLeafPtyVerdictPruneAt: number | undefined
private pruneExpiredLeafPtyVerdicts(now: number): void {
const lastPruneAt = this.lastProvenAbsentLeafPtyVerdictPruneAt
// Per-key expiry stays exact; throttle whole-cache scans on the keystroke path.
if (
lastPruneAt !== undefined &&
now >= lastPruneAt &&
now - lastPruneAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS
) {
return
}
this.lastProvenAbsentLeafPtyVerdictPruneAt = now
pruneExpiredProvenAbsentLeafPtyVerdicts(
this.provenAbsentLeafPtyVerdicts,
now,
PROVEN_ABSENT_LEAF_PTY_TTL_MS
)
}
protected controllerKnowsPtyIsLive(ptyId: string): boolean {
try {
return this.ptyController?.hasPty?.(ptyId) === true
@@ -21,6 +42,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
/** True only on controller-proven absence; live, unknown, and probe errors all answer false. */
protected isLeafPtyProvenAbsent(ptyId: string): Promise<boolean> {
this.pruneExpiredLeafPtyVerdicts(Date.now())
// Why hasPty and not ptysById: graph sync mirrors a connected record for
// every leaf ptyId — including a prior process's — so runtime records can't
// distinguish live from stale. The controller's exact-id hasPty is the
@@ -50,7 +72,9 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso
if ((await probeLiveness(ptyId)) !== false) {
return false
}
this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now())
const now = Date.now()
this.pruneExpiredLeafPtyVerdicts(now)
this.provenAbsentLeafPtyVerdicts.set(ptyId, now)
return true
} catch {
// Why: a failed probe is unknown, and unknown never rejects a write.
@@ -190,6 +190,9 @@ export function boundArchiveLines(lines: string[]): { lines: string[]; truncated
let total = 0
for (const line of lines) {
total += line.length + 1
if (total > TERMINAL_ARCHIVE_MAX_CHARS) {
break
}
}
if (total <= TERMINAL_ARCHIVE_MAX_CHARS) {
return { lines, truncated: false }
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { pruneExpiredProvenAbsentLeafPtyVerdicts } from './proven-absent-leaf-pty-verdicts'
describe('pruneExpiredProvenAbsentLeafPtyVerdicts', () => {
it('removes only entries at or past the TTL without a re-probe', () => {
const map = new Map<string, number>([
['live-dead', 1_000],
['still-fresh', 1_400],
['exact-expiry', 1_000]
])
pruneExpiredProvenAbsentLeafPtyVerdicts(map, 1_000 + 15_000, 15_000)
expect([...map.keys()]).toEqual(['still-fresh'])
})
it('leaves an empty map alone', () => {
const map = new Map<string, number>()
pruneExpiredProvenAbsentLeafPtyVerdicts(map, Date.now(), 15_000)
expect(map.size).toBe(0)
})
it('clears everything when ttl is non-positive', () => {
const map = new Map<string, number>([['a', 1]])
pruneExpiredProvenAbsentLeafPtyVerdicts(map, 100, 0)
expect(map.size).toBe(0)
})
})
@@ -0,0 +1,16 @@
/** Drop cache entries whose TTL has elapsed without requiring a re-probe of that ptyId. */
export function pruneExpiredProvenAbsentLeafPtyVerdicts(
verdicts: Map<string, number>,
nowMs: number,
ttlMs: number
): void {
if (ttlMs <= 0) {
verdicts.clear()
return
}
for (const [ptyId, verdictAt] of verdicts) {
if (nowMs - verdictAt >= ttlMs) {
verdicts.delete(ptyId)
}
}
}
@@ -1,9 +1,6 @@
import type WebSocket from 'ws'
import type { E2EEKeypair } from '../e2ee-keypair'
import type {
RelayConnectionOpenMessage,
RelayDrainMessage,
} from './relay-control-protocol'
import type { RelayConnectionOpenMessage, RelayDrainMessage } from './relay-control-protocol'
export type RelayControlClientOptions = {
cellUrl: string
@@ -76,7 +76,11 @@ export const RelayConnectionOpenMessageSchema = z
export const RelayDrainMessageSchema = z
.object({
type: z.literal('drain'),
graceMs: z.number().int().nonnegative().max(60 * 60 * 1000),
graceMs: z
.number()
.int()
.nonnegative()
.max(60 * 60 * 1000),
recovery: z.literal('resolve-director')
})
.strict()
+3 -13
View File
@@ -187,19 +187,14 @@ export class RelayOriginPool {
}
this.deferredAssignment = null
if (assignment.cellUrl === origin.cellUrl) {
let rebound = false
let rebound = false
try {
await origin.rebind(this.relayJwt, assignment)
rebound = true
} catch {
// Why: a restarted cell cannot know the prior process's resume secret;
// after rebind fails, a fresh generation is the only recoverable path.
await this.activateTarget(
origin,
assignment,
this.relayJwt,
message.graceMs,
)
await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs)
}
if (rebound) {
this.assertCurrent()
@@ -208,12 +203,7 @@ export class RelayOriginPool {
this.drainingOrigins.delete(origin)
}
} else {
await this.activateTarget(
origin,
assignment,
this.relayJwt,
message.graceMs,
)
await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs)
}
this.options.onStatus('registered')
this.drainRetry.reset()
@@ -0,0 +1,47 @@
import { expect, it } from 'vitest'
import type { TerminalOutputSourceRange } from '../../../../../shared/terminal-output-source-range'
import { createTerminalOutputBatcher } from './terminal-output-batcher'
function range(start: number): TerminalOutputSourceRange {
return {
id: 'pty',
providerGeneration: 1,
clientGeneration: 1,
ownerGeneration: 1,
ptyIncarnation: 'incarnation',
deliveryToken: 'delivery',
spanId: `span-${start}`,
sourceStartSu: start,
sourceEndSu: start + 1,
displayStart: start,
displayEnd: start + 1,
splittable: true,
transform: { transformed: false, rawLengthSu: 1, scalarSafe: true }
}
}
it('keeps delivered ranges frozen and isolated from reentrant flushes and disposal', () => {
const firstRange = range(0)
const secondRange = range(1)
const sourceRanges = [firstRange]
const delivered: (readonly TerminalOutputSourceRange[])[] = []
const batcher = createTerminalOutputBatcher((_data, meta) => {
delivered.push(meta!.sourceRanges!)
if (delivered.length === 1) {
batcher.push('b', { sourceRanges: [secondRange] })
batcher.flush()
}
})
try {
batcher.push('a', { sourceRanges })
batcher.flush()
sourceRanges.push(secondRange)
batcher.dispose()
expect(delivered).toEqual([[firstRange], [secondRange]])
expect(delivered[0]).not.toBe(delivered[1])
expect(delivered.every(Object.isFrozen)).toBe(true)
expect(Object.isFrozen(sourceRanges)).toBe(false)
} finally {
batcher.dispose()
}
})
@@ -44,7 +44,7 @@ export function createTerminalOutputBatcher(
...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}),
...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}),
...(pendingSourceRanges.length > 0
? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) }
? { sourceRanges: Object.freeze(pendingSourceRanges) }
: {})
}
: undefined
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'
import { StringDecoder } from 'node:string_decoder'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Socket } from 'node:net'
import { UnixSocketTransport } from './unix-socket-transport'
@@ -11,6 +12,7 @@ class FakeSocket extends EventEmitter {
setEncoding(): void {}
setNoDelay(): void {}
setTimeout(): void {}
end(): void {}
write(data: string): boolean {
this.writes.push(data)
@@ -40,6 +42,47 @@ describe('UnixSocketTransport', () => {
vi.useRealTimers()
})
function createReceiver() {
const transport = new UnixSocketTransport({ endpoint: 'test-pipe', kind: 'named-pipe' })
const socket = new FakeSocket()
const received: string[] = []
transport.onMessage((message, reply) => {
received.push(message)
reply('ok')
})
;(transport as unknown as UnixSocketTransportInternals).handleConnection(
socket as unknown as Socket
)
return { socket, received }
}
it.each([false, true])('preserves the UTF-8 byte boundary with oversized=%s', (oversized) => {
const { socket, received } = createReceiver()
const message = `${'é'.repeat(524287)}a${oversized ? 'x' : ''}`
const wire = Buffer.from(`${message}\n`)
const decoder = new StringDecoder('utf8')
for (let offset = 0; offset < wire.length; offset += 4095) {
socket.emit('data', decoder.write(wire.subarray(offset, offset + 4095)))
}
expect(received).toEqual([oversized ? '' : message])
})
it('retains only the byte count of the partial tail between messages', () => {
const { socket, received } = createReceiver()
const large = 'a'.repeat(700000)
socket.emit('data', `${large}\npart`)
socket.emit('data', `ial\r\n\n${large}\n`)
expect(received).toEqual([large, 'partial', large])
})
it('checks the combined incoming buffer before dispatching any complete messages', () => {
const { socket, received } = createReceiver()
socket.emit('data', `${'a'.repeat(700000)}\n${'b'.repeat(700000)}\n`)
expect(received).toEqual([''])
socket.emit('data', 'later\n')
expect(received).toEqual([''])
})
it('clears request keepalive timers when the socket closes before a reply', () => {
const transport = new UnixSocketTransport({
endpoint: '/tmp/orca-runtime-rpc-test.sock',
@@ -105,6 +105,7 @@ export class UnixSocketTransport implements RpcTransport {
private handleConnection(socket: Socket): void {
this.activeSockets.add(socket)
let buffer = ''
let retainedBytes = 0
let oversized = false
// Why: each in-flight dispatch registers its own AbortController here so
// `socket.on('close')` can abort them all at once. Keeping the set scoped
@@ -134,10 +135,12 @@ export class UnixSocketTransport implements RpcTransport {
return
}
buffer += chunk
// setEncoding('utf8') keeps split codepoints intact, so chunk byte lengths add exactly.
retainedBytes += Buffer.byteLength(chunk, 'utf8')
// Why: the Orca runtime lives in Electron main, so it must reject
// oversized local RPC frames instead of letting a local client grow an
// unbounded buffer and stall the app.
if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
if (retainedBytes > MAX_RUNTIME_RPC_MESSAGE_BYTES) {
oversized = true
this.messageHandler?.('', (response) => {
socket.write(`${response}\n`)
@@ -145,6 +148,9 @@ export class UnixSocketTransport implements RpcTransport {
})
return
}
if (!chunk.includes('\n')) {
return
}
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim()
@@ -154,6 +160,7 @@ export class UnixSocketTransport implements RpcTransport {
}
newlineIndex = buffer.indexOf('\n')
}
retainedBytes = Buffer.byteLength(buffer, 'utf8')
})
}
+3 -3
View File
@@ -57,18 +57,18 @@ export function skillPackageGitTreeSha(entries: readonly SkillGitTreeFileEntry[]
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
sortKey: Buffer.from(`${name}/`),
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
sortKey: Buffer.from(file.filename),
hash: file.blobSha
}))
].sort((left, right) => {
// Git orders tree entries as raw bytes with directory names read as `name/`.
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
return Buffer.from(leftName).compare(Buffer.from(rightName))
return left.sortKey.compare(right.sortKey)
})
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
+3 -3
View File
@@ -142,14 +142,14 @@ export function describeObservedSkillFile(
normalized = null
}
}
const classification = normalized ? 'text' : 'binary'
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = normalized ? sha256(normalized) : null
const textNormalizedSha256 =
normalized && (normalized.equals(bytes) ? exactSha256 : sha256(normalized))
return {
path,
size: bytes.length,
executable,
classification,
classification: normalized ? 'text' : 'binary',
exactSha256,
textNormalizedSha256,
identitySha256:

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