mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
Merge remote-tracking branch 'origin/main' into fix-20324
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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, '', '')
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user