Merge origin/main into session-record-read-repair

This commit is contained in:
Merge Sim
2026-09-10 21:17:25 -07:00
106 changed files with 7388 additions and 332 deletions
+13 -10
View File
@@ -279,16 +279,6 @@ module.exports = {
}
},
afterPack: async (context) => {
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
// Fail packaging if any bundled native binary exceeds the supported floor.
if (context.electronPlatformName === 'linux') {
// Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary,
// so a cross-built slice could ship the host's pty.node and only fail at runtime.
verifyLinuxGlibcFloor(context.appOutDir, {
targetArch: { 1: 'x64', 3: 'arm64' }[context.arch]
})
}
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(
@@ -326,6 +316,19 @@ module.exports = {
}
stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version)
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
// Fail packaging if any bundled native binary exceeds the supported floor.
// Why after the prune: cross-builds intentionally install every optional
// native variant, so an arm64 slice still carries the x64 @parcel/watcher
// until prunePackagedRuntimeNodeModules drops it.
if (context.electronPlatformName === 'linux') {
// Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary,
// so a cross-built slice could ship the host's pty.node and only fail at runtime.
verifyLinuxGlibcFloor(context.appOutDir, {
targetArch: { 1: 'x64', 3: 'arm64' }[context.arch]
})
}
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
// slice matching the packaging host's arch — daemon-entry.js is JS, yet it
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync } from 'node:fs'
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path'
import { delimiter, dirname, join, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
@@ -387,6 +387,72 @@ describe('packaged runtime resources', () => {
}
})
it.skipIf(process.platform === 'win32')(
'prunes non-target native packages before the Linux glibc gate',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-after-pack-prune-order-'))
const previousPath = process.env.PATH
try {
const appOutDir = join(root, 'linux-unpacked')
const resourcesDir = join(appOutDir, 'resources')
await cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ recursive: true }
)
const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
await mkdir(unpackedMainDir, { recursive: true })
await writeFile(join(unpackedMainDir, 'daemon-entry.js'), '', 'utf8')
await writeFile(
join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'),
`${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`,
'utf8'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
await writeFile(join(unpackedCliDir, 'index.js'), '', 'utf8')
const target =
process.arch === 'x64'
? { electronArch: 3, machine: 0xb7, nonTarget: 'x64' }
: { electronArch: 1, machine: 0x3e, nonTarget: 'arm64' }
const wrongArchPackage = join(
resourcesDir,
'node_modules',
'@parcel',
`watcher-linux-${target.nonTarget}-glibc`
)
await mkdir(wrongArchPackage, { recursive: true })
const wrongArchElf = Buffer.alloc(20)
wrongArchElf.set([0x7f, 0x45, 0x4c, 0x46])
wrongArchElf[5] = 1
wrongArchElf.writeUInt16LE(target.machine, 18)
await writeFile(join(wrongArchPackage, 'watcher.node'), wrongArchElf)
const stubBinDir = join(root, 'bin')
await mkdir(stubBinDir)
await writeFile(join(stubBinDir, 'objdump'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
process.env.PATH = `${stubBinDir}${delimiter}${previousPath ?? ''}`
await expect(
electronBuilderConfig.afterPack({
appOutDir,
electronPlatformName: 'linux',
arch: target.electronArch,
packager: { appInfo: { version: '9.9.9' } }
})
).resolves.toBeUndefined()
await expect(stat(wrongArchPackage)).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
process.env.PATH = previousPath
await rm(root, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import {
syntheticCandidate,
syntheticSession,
userMessages
} from '../../src/main/ai-vault-search/session-search-index-test-fixture'
import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
import SyncDatabase from '../../src/main/sqlite/sync-database'
// Bundle with esbuild --bundle --platform=node, then run on the host under test.
// Every mode seeds through SessionSearchStore so the three arms are comparable;
// only `whole-file` leaves the shipped path, because it is the baseline the
// batched purge exists to replace.
const ROWS = 60_000
/** The purge yields with `setImmediate` between chunks, so a peer chain samples each gap. */
async function sampleLoopStalls(running: () => boolean, intervals: number[]): Promise<void> {
let previous = performance.now()
while (running()) {
await yieldToEventLoop()
const now = performance.now()
intervals.push(now - previous)
previous = now
}
}
/** What a search would still return: rows whose session row is still there. */
function visibleRows(db: SyncDatabase): number {
return (
db
.prepare(`SELECT count(*) AS n FROM messages m JOIN sessions s ON s.id = m.session_row_id`)
.get() as { n: number }
).n
}
const root = await mkdtemp(join(tmpdir(), 'orca-search-retention-bench-'))
try {
for (const mode of ['whole-file', 'batched', 'batched-pinned-reader']) {
const path = join(root, `${mode}.sqlite`)
const errors: unknown[] = []
const store = new SessionSearchStore(path, (error) => errors.push(error))
let reader: SyncDatabase | null = null
try {
const write = store.beginWrite(syntheticCandidate(), 'replace', 0)!
for (const message of userMessages(
'synthetic benchmark needle repeated context for a representative coding conversation with commands and paths src/example.ts',
ROWS
)) {
write.add(message)
}
assert.equal(
write.commit({
session: syntheticSession(),
byteOffset: 4096,
incomplete: false
}),
true
)
assert.deepEqual(errors, [])
// Truncating first is what makes walBytes below the purge's own growth.
const checkpoint = new SyncDatabase(path)
checkpoint.pragma('wal_checkpoint(TRUNCATE)')
checkpoint.close()
if (mode === 'batched-pinned-reader') {
reader = new SyncDatabase(path, { readonly: true })
reader.exec('BEGIN')
reader.prepare('SELECT count(*) FROM messages').get()
}
const probe = new SyncDatabase(path, { readonly: true })
const intervals: number[] = []
const started = performance.now()
if (mode === 'whole-file') {
const raw = new SyncDatabase(path)
try {
raw.exec('BEGIN IMMEDIATE')
const ids = raw.prepare('SELECT id FROM messages').all() as {
id: number
}[]
for (const { id } of ids) {
raw.prepare('DELETE FROM messages_fts WHERE rowid=?').run(id)
}
raw.exec('DELETE FROM messages; DELETE FROM sessions; DELETE FROM files; COMMIT')
} finally {
raw.close()
}
intervals.push(performance.now() - started)
} else {
let purging = true
const purge = store.purgeOlderThan(Date.now() + 60_000)
// Hiding is immediate: cutting the session loose from its file is the
// first transaction, so a read one turn in already sees nothing, long
// before the rows are gone.
const hiddenEarly = yieldToEventLoop().then(() => visibleRows(probe))
const sampler = sampleLoopStalls(() => purging, intervals)
await purge
purging = false
await sampler
assert.equal(await hiddenEarly, 0)
assert.deepEqual(errors, [])
}
const wallMs = performance.now() - started
probe.close()
reader?.exec('COMMIT')
reader?.close()
reader = null
const after = new SyncDatabase(path, { readonly: true })
try {
for (const table of ['messages_fts']) {
assert.equal(
(
after.prepare(`SELECT count(*) AS n FROM ${table}`).get() as {
n: number
}
).n,
0
)
}
} finally {
after.close()
}
const walBytes = (await stat(`${path}-wal`)).size
intervals.sort((a, b) => a - b)
console.log(
JSON.stringify({
mode,
platform: process.platform,
node: process.version,
rows: ROWS,
wallMs: Math.round(wallMs),
samples: intervals.length,
maxStepMs: Math.round(intervals.at(-1) ?? 0),
p95StepMs: Math.round(intervals[Math.floor(intervals.length * 0.95)] ?? 0),
walBytes
})
)
} finally {
reader?.close()
store.close()
}
}
} finally {
await rm(root, { recursive: true, force: true })
}
@@ -0,0 +1,266 @@
import assert from 'node:assert/strict'
import { rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from '../../src/main/ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers'
import { requestWholeTranscriptRead } from '../../src/main/ai-vault/session-transcript-reader'
import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer'
import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
import { writeSyntheticTranscriptCorpus } from '../../src/main/ai-vault-search/session-search-synthetic-corpus'
import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures'
import SyncDatabase from '../../src/main/sqlite/sync-database'
// Measures the real transcript reader and search store over a synthetic corpus.
// Never point this at a real transcript tree.
/**
* How long the longest single transaction held the process.
*
* With one transaction per file that is the whole stall a file costs, so it is
* the number the commit ceiling exists to bound. Measured by wrapping `exec`,
* because the writer's transactions are the only ones this benchmark runs.
*/
function recordTransactionDurations(durations: number[]): () => void {
const exec = SyncDatabase.prototype.exec
let started = 0
SyncDatabase.prototype.exec = function (this: SyncDatabase, sql: string): void {
if (sql === 'BEGIN IMMEDIATE') {
started = performance.now()
}
exec.call(this, sql)
if (sql === 'COMMIT' && started > 0) {
durations.push(performance.now() - started)
started = 0
}
}
return () => {
SyncDatabase.prototype.exec = exec
}
}
/** The writer commits synchronously, so a peer chain samples the gap each read leaves. */
async function sampleLoopStalls(running: () => boolean, stalls: number[]): Promise<void> {
let previous = performance.now()
while (running()) {
await yieldToEventLoop()
const now = performance.now()
stalls.push(now - previous)
previous = now
}
}
function tableBytes(db: SyncDatabase): Record<string, number> {
const rows = db.prepare('SELECT name, sum(pgsize) AS bytes FROM dbstat GROUP BY name').all() as {
name: string
bytes: number
}[]
const group = (prefix: string): number =>
rows
.filter((row) => row.name === prefix || row.name.startsWith(`${prefix}_`))
.reduce((sum, row) => sum + row.bytes, 0)
return {
messagesFts: group('messages_fts'),
messages: group('messages') - group('messages_fts'),
sessions: group('sessions'),
total: rows.reduce((sum, row) => sum + row.bytes, 0)
}
}
function assertIndexedMessages(db: SyncDatabase, expected: number): number {
const { n } = db
.prepare('SELECT count(*) AS n FROM messages m JOIN sessions s ON s.id = m.session_row_id')
.get() as { n: number }
assert.equal(n, expected, 'indexed message count')
return n
}
async function checkpointedFileBytes(db: SyncDatabase, path: string): Promise<number> {
// Flush committed WAL pages before reporting the final database footprint.
const [checkpoint] = db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[]
assert.equal(checkpoint?.busy, 0, 'storage measurement requires a completed checkpoint')
return (await stat(path)).size
}
// The default corpus puts tool output at about half the message text; set this
// far higher to price the tool-row cap against the real 80-97 % band.
const toolResultWords = Number(process.env.ORCA_SEARCH_BENCH_TOOL_WORDS ?? 200)
const corpus = await writeSyntheticTranscriptCorpus({ toolResultWords })
const indexPath = join(corpus.root, 'index.sqlite')
try {
const errors: unknown[] = []
const store = new SessionSearchStore(indexPath, (error) => errors.push(error))
const unregister = registerSessionSearchIndexConsumer(store)
const stalls: number[] = []
const transactions: number[] = []
const restoreExec = recordTransactionDurations(transactions)
let indexing = true
try {
const stats = createSessionParseStats()
const started = performance.now()
const sampler = sampleLoopStalls(() => indexing, stalls)
for (const path of corpus.files) {
await parseAgentSessionFileCached(
await sessionCandidate('claude', path),
process.platform,
stats
)
}
indexing = false
await sampler
restoreExec()
const rebuildMs = performance.now() - started
assert.deepEqual(errors, [])
const reader = new SyncDatabase(indexPath, { readonly: true })
try {
const rows = assertIndexedMessages(reader, corpus.messageCount)
const sessions = (
reader.prepare('SELECT count(*) AS n FROM sessions').get() as {
n: number
}
).n
assert.equal(sessions, corpus.files.length)
const bytes = tableBytes(reader)
const perMb = (value: number): number =>
Math.round((value / (corpus.transcriptBytes / (1024 * 1024))) * 10) / 10
const fileBytes = await checkpointedFileBytes(store.connection, indexPath)
stalls.sort((a, b) => a - b)
transactions.sort((a, b) => a - b)
console.log(
JSON.stringify(
{
platform: process.platform,
node: process.version,
transcriptMb: Math.round((corpus.transcriptBytes / (1024 * 1024)) * 100) / 100,
toolResultWords,
sessions,
rows,
rebuildMs: Math.round(rebuildMs),
rowsPerSecond: Math.round(rows / (rebuildMs / 1000)),
transcriptMbPerSecond:
Math.round((corpus.transcriptBytes / (1024 * 1024) / (rebuildMs / 1000)) * 100) / 100,
bytesPerTranscriptMb: {
messagesFts: perMb(bytes.messagesFts),
messages: perMb(bytes.messages),
sessions: perMb(bytes.sessions),
total: perMb(bytes.total)
},
writeAmplification: Math.round((bytes.total / corpus.transcriptBytes) * 100) / 100,
fileWriteAmplification: Math.round((fileBytes / corpus.transcriptBytes) * 100) / 100,
transactions: transactions.length,
maxTransactionMs: Math.round((transactions.at(-1) ?? 0) * 100) / 100,
maxLoopStallMs: Math.round(stalls.at(-1) ?? 0),
p95LoopStallMs: Math.round(stalls[Math.floor(stalls.length * 0.95)] ?? 0),
loopStallSamples: stalls.length,
parseStats: stats
},
null,
2
)
)
} finally {
reader.close()
}
} finally {
indexing = false
restoreExec()
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
}
} finally {
await rm(corpus.root, { recursive: true, force: true })
}
// Phase two: one transcript far larger than any real one, to price the ceiling
// that decides whether a file commits once or in chunks.
const largeTurns = Number(process.env.ORCA_SEARCH_BENCH_LARGE_TURNS ?? 23_000)
const large = await writeSyntheticTranscriptCorpus({
sessions: 1,
turnsPerSession: largeTurns,
seed: 2
})
const largeIndexPath = join(large.root, 'index.sqlite')
try {
const errors: unknown[] = []
const store = new SessionSearchStore(largeIndexPath, (error) => errors.push(error))
const unregister = registerSessionSearchIndexConsumer(store)
const transactions: number[] = []
const restoreExec = recordTransactionDurations(transactions)
try {
const stats = createSessionParseStats()
const started = performance.now()
await parseAgentSessionFileCached(
await sessionCandidate('claude', large.files[0]!),
process.platform,
stats
)
const indexMs = performance.now() - started
restoreExec()
assert.deepEqual(errors, [])
assertIndexedMessages(store.connection, large.messageCount)
transactions.sort((a, b) => a - b)
// The same file again, over a generation the index already holds. That is
// the pass a growing transcript really costs, and the one whose transaction
// used to be sized by the old session rather than by the chunk being
// written. The drain that reclaims the cut-loose generation runs after the
// commit, so its bounded batches are in `replaceTransactions` too.
const replaceTransactions: number[] = []
const restoreReplaceExec = recordTransactionDurations(replaceTransactions)
requestWholeTranscriptRead(large.files[0]!)
const replaceStarted = performance.now()
await parseAgentSessionFileCached(
await sessionCandidate('claude', large.files[0]!),
process.platform,
stats
)
const replaceMs = performance.now() - replaceStarted
// Finishes whatever the scheduled drain has not reached, so the reclaim is
// priced rather than left half done under the next measurement.
const reclaimStarted = performance.now()
await store.purgeOlderThan(null)
const reclaimMs = performance.now() - reclaimStarted
restoreReplaceExec()
assert.deepEqual(errors, [])
assertIndexedMessages(store.connection, large.messageCount)
replaceTransactions.sort((a, b) => a - b)
console.log(
JSON.stringify(
{
phase: 'single-large-file',
transcriptMb: Math.round((large.transcriptBytes / (1024 * 1024)) * 100) / 100,
indexMs: Math.round(indexMs),
transactions: transactions.length,
maxTransactionMs: Math.round(transactions.at(-1) ?? 0),
replaceMs: Math.round(replaceMs),
replaceTransactions: replaceTransactions.length,
maxReplaceTransactionMs: Math.round(replaceTransactions.at(-1) ?? 0),
reclaimMs: Math.round(reclaimMs),
indexMb:
Math.round(
((await checkpointedFileBytes(store.connection, largeIndexPath)) / (1024 * 1024)) *
100
) / 100
},
null,
2
)
)
} finally {
restoreExec()
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
}
} finally {
await rm(large.root, { recursive: true, force: true })
}
@@ -1,2 +1,6 @@
export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
// The unminified bundle keeps these names. Production minification may rename
// them, but the injected identity and key remain adjacent in the declaration.
export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*["`](rc|stable)["`]/
export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*["`](phc_[A-Za-z0-9_-]+)["`]/
export const MINIFIED_TELEMETRY_RE =
/\b(?:const|let|var)\s+[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
import {
BUILD_IDENTITY_RE,
MINIFIED_TELEMETRY_RE,
WRITE_KEY_RE
} from './telemetry-bundle-constant-patterns.mjs'
describe('telemetry bundle constant patterns', () => {
it.each(['const', 'let', 'var'])('accepts %s declarations', (declaration) => {
@@ -13,4 +17,9 @@ describe('telemetry bundle constant patterns', () => {
expect('const WRITE_KEY = null').not.toMatch(WRITE_KEY_RE)
expect('const WRITE_KEY = "example-key"').not.toMatch(WRITE_KEY_RE)
})
it('accepts minified adjacent declarations', () => {
const bundle = 'var dde=`stable`,fde=`phc_example-key_123`,pde=(dde===`stable`)'
expect(bundle).toMatch(MINIFIED_TELEMETRY_RE)
})
})
+14 -4
View File
@@ -38,7 +38,11 @@ import { join, resolve } from 'node:path'
// `node_modules`). If electron-builder ever drops it, promote this to a
// direct devDependency in package.json.
import { extractFile, listPackage } from '@electron/asar'
import { BUILD_IDENTITY_RE, WRITE_KEY_RE } from './telemetry-bundle-constant-patterns.mjs'
import {
BUILD_IDENTITY_RE,
MINIFIED_TELEMETRY_RE,
WRITE_KEY_RE
} from './telemetry-bundle-constant-patterns.mjs'
// Why resolve from import.meta.url instead of cwd: a release runner (or a
// developer debugging locally) may invoke this script from a non-root cwd.
@@ -156,8 +160,14 @@ function verifyAsar(asarPath) {
const buildIdentityMatch = BUILD_IDENTITY_RE.exec(indexJs)
const writeKeyMatch = WRITE_KEY_RE.exec(indexJs)
const minifiedTelemetryMatch = MINIFIED_TELEMETRY_RE.exec(indexJs)
if (!buildIdentityMatch) {
// Rolldown renames module-local constants in production output. In that
// form, verify the adjacent injected identity/key declaration instead.
const verifiedIdentity = buildIdentityMatch?.[1] ?? minifiedTelemetryMatch?.[1]
const verifiedWriteKey = writeKeyMatch?.[1] ?? minifiedTelemetryMatch?.[2]
if (!verifiedIdentity) {
console.error(`::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}`)
const sample = indexJs.match(/.{0,80}BUILD_IDENTITY.{0,80}/g)?.slice(0, 5) ?? []
for (const line of sample) {
@@ -165,7 +175,7 @@ function verifyAsar(asarPath) {
}
return null
}
if (!writeKeyMatch) {
if (!verifiedWriteKey) {
console.error(`::error::PostHog WRITE_KEY missing from ${asarPath}`)
const sample = indexJs.match(/.{0,80}WRITE_KEY.{0,80}/g)?.slice(0, 5) ?? []
for (const line of sample) {
@@ -174,7 +184,7 @@ function verifyAsar(asarPath) {
return null
}
return { asarPath, buildIdentity: buildIdentityMatch[1], writeKey: writeKeyMatch[1] }
return { asarPath, buildIdentity: verifiedIdentity, writeKey: verifiedWriteKey }
}
// Why verify every match (not just the first): macOS dual-arch produces one
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { CODEX_RESET_CREDIT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
import type { RpcClient } from '../transport/rpc-client'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
import { rpcObjectResultOrNull } from '../transport/rpc-acceptance-policies'
// Why: source the capability string from the shared contract so a host bump can never
// silently drift from the mobile probe.
@@ -12,10 +13,7 @@ export async function readCodexResetCreditCapability(
): Promise<boolean> {
try {
const response = await client.sendRequest('status.get')
if (!response.ok || !response.result || typeof response.result !== 'object') {
return false
}
const capabilities = (response.result as { capabilities?: unknown }).capabilities
const capabilities = rpcObjectResultOrNull(response)?.capabilities
return (
Array.isArray(capabilities) && capabilities.includes(MOBILE_CODEX_RESET_CREDIT_CAPABILITY)
)
@@ -18,7 +18,7 @@ type SshTargetSummaryRow = { id: string; label: string }
async function requestResult(client: RpcClient, method: string): Promise<unknown> {
try {
const response = await client.sendRequest(method)
return response.ok ? (response as RpcSuccess).result : null
return response.ok ? response.result : null
} catch {
// Best-effort: hosts that predate a method still list repos; labels degrade to host ids.
return null
@@ -130,7 +130,7 @@ export function MobileNativeChatComposer({
if (trigger.kind === 'slash') {
const commands =
structuredCommands !== undefined
? structuredSlashCommands(structuredCommands)
? structuredSlashCommands(structuredCommands, agent)
: agent
? getVerifiedNativeChatCommands(agent)
: []
@@ -228,6 +228,34 @@ describe('mobile structured agent-session launch', () => {
})
})
describe.each(['top-level', 'nested'])('%s refusal messages', (location) => {
function refusalClient(message: unknown) {
const refusal = { code: 'method_not_found', ...(message === undefined ? {} : { message }) }
return clientReturning(
{ ok: true, result: { supported: true } },
location === 'top-level'
? { ok: false, error: refusal }
: { ok: true, result: { ok: false, refusal } }
)
}
it.each(
[undefined, null, 42, false, { text: 'unavailable' }, ['unavailable']].map((message) => ({
message
}))
)('keeps a malformed message $message unknown', async ({ message }) => {
await expect(
createMobileStructuredAgentSession(refusalClient(message), 'workspace-1', 'codex')
).resolves.toMatchObject({ kind: 'unknown' })
})
it('preserves the fallback for an empty string message', async () => {
await expect(
createMobileStructuredAgentSession(refusalClient(''), 'workspace-1', 'codex')
).resolves.toEqual({ kind: 'failed', message: 'Could not open Codex chat.' })
})
})
it.each(['structured_agent_session_unsupported', 'method_not_found'])(
'treats a top-level %s as a definitive refusal',
async (code) => {
@@ -137,18 +137,22 @@ export async function createMobileStructuredAgentSession(
}
}
// Why: this path distrusts the declared RpcResponse type — a malformed reply must read as
// unconfirmed, not as a refusal we can classify.
if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
}
if (!response.ok) {
const error = response.error as { code?: unknown; message?: unknown } | null | undefined
if (
!response.error ||
typeof response.error !== 'object' ||
typeof response.error.code !== 'string'
!error ||
typeof error !== 'object' ||
typeof error.code !== 'string' ||
typeof error.message !== 'string'
) {
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
}
return classifyCreateRefusal(agent, response.error.code, response.error.message)
return classifyCreateRefusal(agent, error.code, error.message)
}
const result = response.result as AgentSessionMutationResult<AgentSessionAttachResult>
if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
@@ -158,7 +162,8 @@ export async function createMobileStructuredAgentSession(
if (
!result.refusal ||
typeof result.refusal !== 'object' ||
typeof result.refusal.code !== 'string'
typeof result.refusal.code !== 'string' ||
typeof result.refusal.message !== 'string'
) {
return unknownCreateResult(agent, new Error(unconfirmedMessage(agent)))
}
@@ -140,7 +140,7 @@ export function useMobileNativeChatController(args: {
)
const {
permission: legacyNativeChatPermission,
question: legacyNativeChatQuestion,
question: legacyQuestion,
detectedAsk: nativeChatDetectedAsk,
ask: nativeChatAskPrompt
} = useMobileNativeChatPrompts({
@@ -242,6 +242,7 @@ export function useMobileNativeChatController(args: {
})
const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({
agent: activeChatResolution?.agent === 'claude' ? 'claude' : 'codex',
sendStructured: structuredNativeChat.sendWithOutcome,
captureSendOrigin,
clearDraftForSend,
@@ -309,9 +310,7 @@ export function useMobileNativeChatController(args: {
nativeChatPermission: activeChatStructured
? structuredNativeChat.permission
: legacyNativeChatPermission,
nativeChatQuestion: activeChatStructured
? structuredNativeChat.question
: legacyNativeChatQuestion,
nativeChatQuestion: activeChatStructured ? structuredNativeChat.question : legacyQuestion,
nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null,
nativeChatAskKey,
dismissNativeChatAsk,
@@ -0,0 +1,88 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
const ORIGIN: MobileNativeChatSendOrigin = {
draftKey: 'draft',
draftEditGeneration: 0,
pendingKey: 'pending',
normalizedText: 'command',
baselineOccurrences: 0,
baselineTailMessageId: null,
baselineResolved: true
}
describe('useMobileStructuredNativeChatSendBridge', () => {
let renderer: ReactTestRenderer | null = null
let sendWithOutcome: (text: string) => Promise<MobileNativeChatSendOutcome>
const acceptSend = vi.fn()
const captureSendOrigin = vi.fn(() => ORIGIN)
const clearDraftForSend = vi.fn()
const holdUnconfirmedSend = vi.fn()
const onSendError = vi.fn()
const restoreRejectedDraft = vi.fn()
const sendStructured = vi.fn()
function Harness({ agent }: { agent: AgentSessionHandleProvider }): null {
sendWithOutcome = useMobileStructuredNativeChatSendBridge({
agent,
acceptSend,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
onSendError,
restoreRejectedDraft,
sendStructured
}).sendWithOutcome
return null
}
function mount(agent: AgentSessionHandleProvider): void {
act(() => {
renderer = create(createElement(Harness, { agent }))
})
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('optimistically echoes accepted commands owned by the active agent', async () => {
sendStructured.mockResolvedValue('accepted')
mount('claude')
await expect(sendWithOutcome('/init')).resolves.toBe('accepted')
expect(acceptSend).toHaveBeenCalledWith(ORIGIN, '/init', undefined)
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('holds unknown delivery for commands owned by the active agent', async () => {
sendStructured.mockResolvedValue('unknown')
mount('claude')
await expect(sendWithOutcome('/review')).resolves.toBe('unknown')
expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, '/review', expect.any(Function))
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('keeps host-command reconciliation for Codex', async () => {
sendStructured.mockResolvedValue('unknown')
mount('codex')
await expect(sendWithOutcome('/review')).resolves.toBe('unknown')
expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, '/review')
expect(holdUnconfirmedSend).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,5 @@
import { useCallback } from 'react'
import type { AgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
import { isStructuredAgentSessionComposerCommand } from '../../../src/shared/structured-agent-session-composer'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
@@ -10,6 +11,7 @@ type StructuredNativeChatAttachment = {
}
export function useMobileStructuredNativeChatSendBridge(args: {
agent: AgentSessionHandleProvider
sendStructured: (
text: string,
images?: string[],
@@ -37,6 +39,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
} {
const {
acceptSend,
agent,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
@@ -56,6 +59,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
onSendError('Message not sent (disconnected)')
return 'rejected'
}
const isHostCommand = isStructuredAgentSessionComposerCommand(text, agent)
clearDraftForSend(origin, text)
const outcome =
attachments !== undefined
@@ -66,19 +70,13 @@ export function useMobileStructuredNativeChatSendBridge(args: {
? await sendStructured(text, images)
: await sendStructured(text)
if (outcome === 'accepted') {
if (
!isStructuredAgentSessionComposerCommand(text, 'codex') &&
!isStructuredAgentSessionComposerCommand(text, 'claude')
) {
if (!isHostCommand) {
acceptSend(origin, text.trimEnd(), images)
}
return 'accepted'
}
if (outcome === 'unknown') {
if (
isStructuredAgentSessionComposerCommand(text, 'codex') ||
isStructuredAgentSessionComposerCommand(text, 'claude')
) {
if (isHostCommand) {
restoreRejectedDraft(origin, text)
return 'unknown'
}
@@ -92,6 +90,7 @@ export function useMobileStructuredNativeChatSendBridge(args: {
},
[
acceptSend,
agent,
captureSendOrigin,
clearDraftForSend,
holdUnconfirmedSend,
@@ -1,4 +1,8 @@
import type { RpcResponse } from '../transport/types'
import {
isMethodNotFoundRefusal,
rpcObjectResultOrNull
} from '../transport/rpc-acceptance-policies'
export type TerminalUpdateViewportCapability = 'unknown' | 'supported' | 'unsupported'
@@ -14,17 +18,11 @@ export type TerminalViewportRefitTargetState = {
}
export function isTerminalUpdateViewportUpdated(response: RpcResponse): boolean {
if (!response.ok || typeof response.result !== 'object' || response.result == null) {
return false
}
return (response.result as { updated?: unknown }).updated === true
return rpcObjectResultOrNull(response)?.updated === true
}
export function isTerminalUpdateViewportApplied(response: RpcResponse): boolean {
if (!response.ok || typeof response.result !== 'object' || response.result == null) {
return false
}
return (response.result as { applied?: unknown }).applied === true
return rpcObjectResultOrNull(response)?.applied === true
}
export function resolveTerminalUpdateViewportCapability(
@@ -33,7 +31,7 @@ export function resolveTerminalUpdateViewportCapability(
if (response.ok) {
return 'supported'
}
return response.error.code === 'method_not_found' ? 'unsupported' : 'unknown'
return isMethodNotFoundRefusal(response) ? 'unsupported' : 'unknown'
}
// Why: defer height refits while typing, then coalesce every skipped layout
@@ -21,7 +21,11 @@ import {
type MobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import type { RpcClient } from './rpc-client'
import type { HostProfile, RpcResponse } from './types'
import type { HostProfile } from './types'
import {
isMethodNotFoundRefusal,
requireRpcResultOrThrowCodedError
} from './rpc-acceptance-policies'
export type MobileRelayDirectUpgradeResult = {
host: HostProfile
@@ -79,11 +83,13 @@ export async function upgradeDirectMobileRelay(args: {
reqId: journal.reqId,
newResumeTokenHash: journal.pendingResumeTokenHash
})
if (isMethodNotFound(provisionResponse)) {
if (isMethodNotFoundRefusal(provisionResponse)) {
await dependencies.clearJournal(args.host.id)
return null
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse))
const installed = DeviceCredentialInstalledSchema.parse(
requireRpcResultOrThrowCodedError(provisionResponse)
)
assertDirectInstall(journal, installed)
const reconciled = await getEndpoints(args.client, journal.reqId)
if (reconciled === 'method-not-found') {
@@ -136,10 +142,10 @@ async function getEndpoints(
installReqId: string
): Promise<PairingGetEndpointsResult | 'method-not-found'> {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (isMethodNotFound(response)) {
if (isMethodNotFoundRefusal(response)) {
return 'method-not-found'
}
return PairingGetEndpointsResultSchema.parse(requireSuccess(response))
return PairingGetEndpointsResultSchema.parse(requireRpcResultOrThrowCodedError(response))
}
function assertDirectInstall(
@@ -162,14 +168,3 @@ function assertCommitted(
throw new Error('relay credential install was not authoritatively reconciled')
}
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function isMethodNotFound(response: RpcResponse): boolean {
return !response.ok && response.error.code === 'method_not_found'
}
@@ -25,7 +25,8 @@ import {
type PairingCandidateClient
} from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { HostProfile, RpcResponse } from './types'
import type { HostProfile } from './types'
import { requireRpcResultOrThrowCodedError } from './rpc-acceptance-policies'
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned'
@@ -128,7 +129,7 @@ async function runRecovery(
if (credential.kind === 'invite' && endpoints.installStatus?.state === 'not-found') {
journal = await transitionToInviteAuthorization(journal, dependencies)
const installed = DeviceCredentialInstalledSchema.parse(
requireSuccess(
requireRpcResultOrThrowCodedError(
await client.sendRequest('pairing.provisionRelay', {
reqId: journal.metadata.installReqId,
newResumeTokenHash: journal.metadata.pendingResumeTokenHash
@@ -220,7 +221,7 @@ async function getRecoveryStatus(
kind: 'resume' | 'invite'
) {
return PairingGetEndpointsResultSchema.parse(
requireSuccess(
requireRpcResultOrThrowCodedError(
await client.sendRequest('pairing.getEndpoints', {
installReqId: journal.metadata.installReqId,
...(kind === 'resume' ? { resumeConfirmReqId: journal.metadata.resumeConfirmReqId } : {})
@@ -294,13 +295,6 @@ function pairingRelay(journal: MobileRelayPairingJournal): PairingRelay {
return { ...journal.metadata.relay, inviteToken: journal.secrets.inviteToken }
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function assertCommitted(
endpoints: ReturnType<typeof PairingGetEndpointsResultSchema.parse>,
installed: DeviceCredentialInstalled
@@ -9,6 +9,7 @@ import {
updateTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription'
import { isStreamingOpenerReply } from './rpc-acceptance-policies'
import type { RpcClient } from './rpc-client'
import type { RpcResponse, RpcSuccess } from './types'
@@ -119,7 +120,7 @@ export class MobileRelayRpcStreams {
}
}
}
if (response.ok && response.streaming !== true) {
if (response.ok && !isStreamingOpenerReply(response)) {
this.cancelledSubscriptions.delete(response.id)
}
return true
@@ -7,7 +7,11 @@ import {
} from '../../../src/shared/mobile-relay-credential-contract'
import { connect, type ConnectOptions } from './rpc-client'
import { resolvePairingHostIdentity, saveHost } from './host-store'
import type { HostProfile, PairingOffer, RpcResponse } from './types'
import type { HostProfile, PairingOffer } from './types'
import {
isMethodNotFoundRefusal,
requireRpcResultOrThrowCodedError
} from './rpc-acceptance-policies'
import {
createMobileRelayPairingJournal,
type MobileRelayPairingJournal
@@ -219,7 +223,7 @@ async function runPairing(
reqId: journal.metadata.installReqId,
newResumeTokenHash: journal.metadata.pendingResumeTokenHash
})
if (isMethodNotFound(provision)) {
if (isMethodNotFoundRefusal(provision)) {
if (winner.path !== 'direct') {
throw new Error('relay pairing RPC unavailable after relay path authentication')
}
@@ -227,9 +231,11 @@ async function runPairing(
await dependencies.clearJournal(journal.metadata.journalId)
return { hostId }
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provision))
const installed = DeviceCredentialInstalledSchema.parse(
requireRpcResultOrThrowCodedError(provision)
)
const endpoints = PairingGetEndpointsResultSchema.parse(
requireSuccess(
requireRpcResultOrThrowCodedError(
await winner.client.sendRequest('pairing.getEndpoints', {
installReqId: journal.metadata.installReqId
})
@@ -283,17 +289,6 @@ function relayWebSocketUrl(relay: MobileRelayEndpoint): string {
return url.toString()
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function isMethodNotFound(response: RpcResponse): boolean {
return !response.ok && response.error.code === 'method_not_found'
}
function assertCommittedInstall(
status:
| { state: 'not-found' }
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from './types'
import {
isMethodNotFoundRefusal,
isStreamingOpenerReply,
requireRpcResultOrThrowCodedError,
rpcObjectResultOrNull
} from './rpc-acceptance-policies'
const meta = { runtimeId: 'runtime-1' }
function success(result: unknown, streaming?: true): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: meta, ...(streaming ? { streaming } : {}) }
}
function refusal(code: string, message = 'Nope'): RpcResponse {
return { id: 'rpc-1', ok: false, error: { code, message }, _meta: meta }
}
/** Every result partition a policy has to survive. */
const resultPartitions: [string, unknown][] = [
['object result', { value: 1 }],
['undefined result', undefined],
['null result', null],
['empty object result', {}],
['numeric result', 7],
['zero result', 0],
['string result', 'done'],
['empty string result', ''],
['boolean result', false],
['array result', [1, 2]],
['empty array result', []]
]
describe('requireRpcResultOrThrowCodedError', () => {
it.each(resultPartitions)('returns the %s untouched', (_label, result) => {
expect(requireRpcResultOrThrowCodedError(success(result))).toEqual(result)
})
it('returns an absent result field as undefined', () => {
const response = { id: 'rpc-1', ok: true, _meta: meta } as unknown as RpcResponse
expect(requireRpcResultOrThrowCodedError(response)).toBeUndefined()
})
it('throws a code-prefixed message on refusal', () => {
expect(() => requireRpcResultOrThrowCodedError(refusal('method_not_found', 'no such'))).toThrow(
'method_not_found: no such'
)
})
it('throws even when the refusal carries an empty message', () => {
expect(() => requireRpcResultOrThrowCodedError(refusal('runtime_error', ''))).toThrow(
'runtime_error: '
)
})
})
describe('rpcObjectResultOrNull', () => {
it('accepts a plain object result', () => {
expect(rpcObjectResultOrNull(success({ value: 1 }))).toEqual({ value: 1 })
})
it('accepts an empty object result', () => {
expect(rpcObjectResultOrNull(success({}))).toEqual({})
})
it('accepts an array result, because arrays are objects', () => {
expect(rpcObjectResultOrNull(success([1, 2]))).toEqual([1, 2])
})
it.each([
['null', null],
['undefined', undefined],
['numeric', 7],
['zero', 0],
['string', 'done'],
['empty string', ''],
['boolean', false],
['true', true]
])('refuses a %s result', (_label, result) => {
expect(rpcObjectResultOrNull(success(result))).toBeNull()
})
it('refuses a refusal regardless of its code', () => {
expect(rpcObjectResultOrNull(refusal('method_not_found'))).toBeNull()
expect(rpcObjectResultOrNull(refusal('runtime_error'))).toBeNull()
})
})
// A refusal that illegally carries success-shaped fields: without the `ok` check each of these
// would read the stray field and answer as if the call had succeeded.
describe('a refusal carrying stray success fields', () => {
const strayRefusal = {
id: 'rpc-1',
ok: false,
error: { code: 'method_not_found', message: 'Nope' },
result: { value: 1 },
streaming: true,
_meta: meta
} as unknown as RpcResponse
it('yields null rather than the stray result', () => {
expect(rpcObjectResultOrNull(strayRefusal)).toBeNull()
})
it('is still recognised as method-not-found', () => {
expect(isMethodNotFoundRefusal(strayRefusal)).toBe(true)
})
// The mirror case: a success carrying a stray error must not read as a refusal.
it('does not read a success carrying a stray error as a refusal', () => {
const straySuccess = {
id: 'rpc-1',
ok: true,
result: { value: 1 },
error: { code: 'method_not_found', message: 'Nope' },
_meta: meta
} as unknown as RpcResponse
expect(isMethodNotFoundRefusal(straySuccess)).toBe(false)
})
})
describe('isMethodNotFoundRefusal', () => {
it('matches only the method_not_found code', () => {
expect(isMethodNotFoundRefusal(refusal('method_not_found'))).toBe(true)
expect(isMethodNotFoundRefusal(refusal('runtime_error'))).toBe(false)
expect(isMethodNotFoundRefusal(refusal('METHOD_NOT_FOUND'))).toBe(false)
})
it('never matches a success, including one with a null result', () => {
expect(isMethodNotFoundRefusal(success(null))).toBe(false)
expect(isMethodNotFoundRefusal(success({ code: 'method_not_found' }))).toBe(false)
})
})
describe('isStreamingOpenerReply', () => {
it('accepts a success flagged streaming', () => {
expect(isStreamingOpenerReply(success({ subscriptionId: 's1' }, true))).toBe(true)
})
it('refuses a success with no streaming flag', () => {
expect(isStreamingOpenerReply(success({ subscriptionId: 's1' }))).toBe(false)
})
// A truthy non-boolean off the wire must not open a stream: the registry would route it to
// handleStreamingResponse and wait for frames that never come.
it.each([['yes'], [1], [{}]])('refuses a truthy non-boolean streaming flag %j', (flag) => {
const response = {
id: 'rpc-1',
ok: true,
result: { subscriptionId: 's1' },
streaming: flag,
_meta: meta
} as unknown as RpcResponse
expect(isStreamingOpenerReply(response)).toBe(false)
})
it('refuses a refusal even when it carries a streaming flag', () => {
const response = {
id: 'rpc-1',
ok: false,
error: { code: 'runtime_error', message: 'Nope' },
streaming: true,
_meta: meta
} as unknown as RpcResponse
expect(isStreamingOpenerReply(response)).toBe(false)
})
})
@@ -0,0 +1,33 @@
import type { RpcResponse, RpcSuccess } from './types'
// Named acceptance policies for RPC replies. Call sites used to hand-roll these
// predicates and did not agree with each other; each policy here preserves one
// call site's existing acceptance exactly. Do not merge two policies without
// proving every caller of both tolerates the wider or narrower set.
/** Throws `code: message` on refusal. Diagnostic text; not user-facing. */
export function requireRpcResultOrThrowCodedError(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
/** Accepts only a success whose result is a non-null object. Arrays qualify. */
export function rpcObjectResultOrNull(response: RpcResponse): Record<string, unknown> | null {
if (!response.ok || typeof response.result !== 'object' || response.result === null) {
return null
}
return response.result as Record<string, unknown>
}
export function isMethodNotFoundRefusal(response: RpcResponse): boolean {
return !response.ok && response.error.code === 'method_not_found'
}
/** A success that opened a stream rather than delivering a terminal result. */
export function isStreamingOpenerReply(
response: RpcResponse
): response is RpcSuccess & { streaming: true } {
return response.ok && response.streaming === true
}
@@ -8,6 +8,7 @@ import {
updateTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription'
import { isStreamingOpenerReply } from './rpc-acceptance-policies'
import {
isStreamingSubscriptionReadyResult,
isTerminalSubscribedResult
@@ -112,7 +113,7 @@ export class RpcClientStreamRegistry {
}
handleResponse(response: RpcResponse): boolean {
if (response.ok && response.streaming === true) {
if (isStreamingOpenerReply(response)) {
this.handleStreamingResponse(response)
return true
}
@@ -0,0 +1,48 @@
import { expect, it } from 'vitest'
import {
EMPTY_CONTENT_HASH,
foldContentHash,
isCollapsibleContentHash
} from './session-search-content-hash'
import { userMessages } from './session-search-index-test-fixture'
it('reaches the same digest whether the prefix arrives whole or in two appends', () => {
const messages = userMessages('turn', 5)
const whole = foldContentHash(EMPTY_CONTENT_HASH, messages)
const resumed = foldContentHash(
foldContentHash(EMPTY_CONTENT_HASH, messages.slice(0, 2)),
messages.slice(2)
)
expect(resumed).toEqual(whole)
expect(whole.count).toBe(5)
})
it('freezes once the prefix limit is reached so later appends cannot move it', () => {
// Found rather than imported: the limit is the module's business, and a test
// that reads it off the export cannot notice the fold ignoring it.
const capped = foldContentHash(EMPTY_CONTENT_HASH, userMessages('turn', 64))
expect(capped.count).toBeLessThan(64)
expect(foldContentHash(capped, userMessages('later', 20))).toEqual(capped)
})
it('separates two conversations that share an opening prompt', () => {
const shared = userMessages('same opening', 1)
const first = foldContentHash(EMPTY_CONTENT_HASH, [
...shared,
{ role: 'user', text: 'left', timestamp: null }
])
const second = foldContentHash(EMPTY_CONTENT_HASH, [
...shared,
{ role: 'user', text: 'right', timestamp: null }
])
expect(first.hash).not.toBe(second.hash)
})
it('refuses to collapse on a prefix too short to mean anything', () => {
const one = foldContentHash(EMPTY_CONTENT_HASH, userMessages('only turn', 1))
expect(isCollapsibleContentHash(one.hash, one.count)).toBe(false)
const two = foldContentHash(EMPTY_CONTENT_HASH, userMessages('two turns', 2))
expect(isCollapsibleContentHash(two.hash, two.count)).toBe(true)
expect(isCollapsibleContentHash(null, 9)).toBe(false)
})
@@ -0,0 +1,45 @@
import { createHash } from 'node:crypto'
import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers'
// Why: Claude `--resume` and Codex fork copy the parent transcript into a new
// file under a new session id, so one conversation lands N times in results.
// The shared opening prefix is what identifies the copy; the tail diverges.
const CONTENT_HASH_MESSAGE_LIMIT = 8
// One shared opening prompt is not evidence of a fork; two turns is.
const CONTENT_HASH_MIN_MESSAGES = 2
export type SessionContentHash = { hash: string | null; count: number }
export const EMPTY_CONTENT_HASH: SessionContentHash = { hash: null, count: 0 }
/**
* Chained digest over the first `CONTENT_HASH_MESSAGE_LIMIT` messages. Chaining
* (rather than hashing one joined string) makes it resumable, so an `append`
* can finish a prefix a short `replace` started; once the limit is reached the
* value is frozen and later appends leave it untouched.
*/
export function foldContentHash(
previous: SessionContentHash,
messages: readonly TranscriptMessage[]
): SessionContentHash {
let { hash, count } = previous
for (const message of messages) {
if (count >= CONTENT_HASH_MESSAGE_LIMIT) {
break
}
hash = createHash('sha256')
.update(hash ?? '')
.update('\0')
.update(message.role)
.update('\0')
.update(message.text)
.digest('hex')
count += 1
}
return { hash, count }
}
/** Sessions collapse only on a hash that covers enough turns to mean anything. */
export function isCollapsibleContentHash(hash: string | null, count: number): hash is string {
return hash !== null && count >= CONTENT_HASH_MIN_MESSAGES
}
@@ -0,0 +1,37 @@
import { expect, it } from 'vitest'
import { folderGroupKey } from '../../shared/ai-vault-session-filters'
import { cwdKey } from './session-search-file-records'
// The sidebar groups sessions by `folderGroupKey`, which is the shared
// normalizer under a `folder:` prefix. A hit's `cwd_key` has to be the same
// string, or joining an indexed hit to a sidebar group returns nothing.
const CASES: [name: string, cwd: string][] = [
['a POSIX path', '/repo/app'],
['a trailing slash', '/repo/app/'],
['a Windows drive', 'C:\\Users\\me\\repo'],
['a WSL interop mount', '/mnt/c/Users/me/repo'],
['a WSL UNC path', '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'],
['the wsl$ alias for the same path', '//wsl$/Ubuntu/home/me/repo'],
['a Linux path from inside WSL', '/home/me/repo'],
['the filesystem root', '/']
]
it.each(CASES)('keys %s exactly as the sidebar does', (_name, cwd) => {
expect(`folder:${cwdKey(cwd)}`).toBe(folderGroupKey(cwd))
})
it('keeps the root as a path rather than collapsing it to nothing', () => {
// An empty key is indistinguishable from "no cwd", and the scope filter builds
// its child prefix as `key + '/'`, which would be `//` for an empty key.
expect(cwdKey('/')).toBe('/')
})
it('has no key for a session whose cwd the transcript never recorded', () => {
expect(cwdKey(null)).toBeNull()
})
it('folds the two WSL UNC aliases onto one key', () => {
expect(cwdKey('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).toBe(
cwdKey('//wsl$/ubuntu/home/me/repo')
)
})
@@ -0,0 +1,41 @@
import type { FileWithMtime } from '../ai-vault/session-scanner-types'
// Why the index keeps its own cursor: the parse cache's cursor answers "what
// does the session list already show", which is a different question from "what
// bytes of this file are already rows". They diverge the moment either side
// declines a read, so neither may consult the other.
/** Filesystem identity, when discovery could prove it. */
export type SessionSearchFileIdentity = { dev: number; ino: number } | null
/**
* What the index holds for one transcript.
*
* A null `byteOffset` is a file the index holds rows for and cannot continue:
* a chunked read committed a prefix, and the reader only hands out an offset
* when a read finishes. Null rather than a flag because every caller that does
* arithmetic on the offset then has to say what it means here, at compile time,
* instead of ignoring a boolean it did not know to read.
*/
export type SessionSearchIndexedFile = {
byteOffset: number | null
mtimeMs: number
sizeBytes: number | null
}
/**
* Whether this file has to be read from the start, whatever its stat says.
*
* The mtime and size are the real ones, so a freshness check that compares only
* those would call a half-written file current and never re-read it. Every such
* check must start here.
*/
export function requiresWholeRead(indexed: SessionSearchIndexedFile | null): boolean {
return indexed !== null && indexed.byteOffset === null
}
export function fileIdentity(file: FileWithMtime): SessionSearchFileIdentity {
return typeof file.dev === 'number' && typeof file.ino === 'number'
? { dev: file.dev, ino: file.ino }
: null
}
@@ -0,0 +1,134 @@
import { fileIdentity } from './session-search-file-cursor'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import type SyncDatabase from '../sqlite/sync-database'
import { EMPTY_CONTENT_HASH, type SessionContentHash } from './session-search-content-hash'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
/**
* The stored comparison key for a session's working directory.
*
* Why the shared normalizer verbatim: the sidebar already groups sessions by
* `folderGroupKey`, which is this function under a prefix. A second spelling
* here means any later join between an indexed hit and a sidebar group returns
* nothing. An earlier version qualified a WSL cwd with its distro so two
* distros could not collide at `/home/me/repo`; that is a real collision, but it
* is one every SSH host has too, neither key qualifies for SSH, and the fix for
* it is a column that names the execution host, not a path key that only some
* hosts spell differently.
*/
export function cwdKey(cwd: string | null): string | null {
return cwd ? normalizeRuntimePathForComparison(cwd) : null
}
export class SessionSearchFileRecords {
constructor(private readonly db: SyncDatabase) {}
/**
* The row a read hangs its messages off, before the parser has said what the
* session is. The same transaction fills it in: from the decoded session when
* the read finished, and from `updateProvisionalSession` when this is a chunk
* of one that has not.
*/
createSessionRow(candidate: SessionFileCandidate): number {
return Number(
this.db
.prepare(
`INSERT INTO sessions(agent,session_id,file_path,title,resume_command)
VALUES (?,'',?,'','')`
)
.run(candidate.agent, candidate.file.path).lastInsertRowid
)
}
/**
* Writes what the parser knows so far onto a session a chunk is committing.
*
* Rows a chunk commits answer searches the moment they land, so the session
* they hang off has to be nameable before the read producing it ends — and it
* may never end, because a crash between chunks leaves exactly this row. That
* is why the identity is required rather than optional: a read that has none
* does not chunk at all. The final commit overwrites all of it from the
* decoded session; until then the title in particular is provisional.
*/
updateProvisionalSession(rowId: number, identity: TranscriptSessionIdentity): void {
this.db
.prepare(
`UPDATE sessions SET session_id = ?, title = ?, cwd = ?, cwd_key = ?,
created_at = ?, updated_at = ? WHERE id = ?`
)
.run(
identity.sessionId,
identity.title ?? '',
identity.cwd,
cwdKey(identity.cwd),
identity.createdAt,
identity.updatedAt,
rowId
)
}
contentHash(rowId: number): SessionContentHash {
const row = this.db
.prepare('SELECT content_hash, content_hash_count FROM sessions WHERE id = ?')
.get(rowId) as { content_hash: string | null; content_hash_count: number } | undefined
return row ? { hash: row.content_hash, count: row.content_hash_count } : EMPTY_CONTENT_HASH
}
updateSession(session: AiVaultSession, rowId: number, contentHash: SessionContentHash): void {
const values = [
session.agent,
session.sessionId,
session.filePath,
session.codexHome,
session.title,
session.cwd,
cwdKey(session.cwd),
session.branch,
session.createdAt,
session.updatedAt,
session.messageCount,
session.resumeCommand,
contentHash.hash,
contentHash.count
]
this.db
.prepare(
`UPDATE sessions SET agent = ?, session_id = ?, file_path = ?, codex_home = ?, title = ?,
cwd = ?, cwd_key = ?, branch = ?, created_at = ?, updated_at = ?, message_count = ?, resume_command = ?,
content_hash = ?, content_hash_count = ? WHERE id = ?`
)
.run(...values, rowId)
}
upsertFile(
candidate: SessionFileCandidate,
byteOffset: number,
sessionRowId: number | null
): void {
const { file } = candidate
const identity = fileIdentity(file)
this.db
.prepare(
`INSERT INTO files(path, dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
-- Partial observations must never create a pair that no stat proved.
dev = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL
THEN excluded.dev ELSE files.dev END,
ino = CASE WHEN excluded.dev IS NOT NULL AND excluded.ino IS NOT NULL
THEN excluded.ino ELSE files.ino END,
byte_offset = excluded.byte_offset, mtime_ms = excluded.mtime_ms,
size_bytes = excluded.size_bytes, session_row_id = excluded.session_row_id`
)
.run(
file.path,
identity?.dev ?? null,
identity?.ino ?? null,
byteOffset,
file.mtimeMs,
file.sizeBytes ?? null,
sessionRowId
)
}
}
@@ -0,0 +1,614 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import SyncDatabase from '../sqlite/sync-database'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { cwdKey } from './session-search-file-records'
import { requiresWholeRead } from './session-search-file-cursor'
import { SessionSearchIndexWriter } from './session-search-index-writer'
import { deleteExpiredSearchFiles } from './session-search-retention-delete'
import {
openSessionSearchIndexFile,
replayTranscriptRead,
syntheticCandidate,
syntheticSession,
SYNTHETIC_TRANSCRIPT,
userMessages,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
import { SessionSearchStore } from './session-search-store'
// Every assertion here reads through `index.db`, a second connection to the same
// file. That is the whole consistency model: one transaction per file in WAL
// mode, so another handle sees the last committed state and never a session part
// way through being rewritten.
let index: SessionSearchIndexFile
let store: SessionSearchStore
let errors: unknown[]
beforeEach(async () => {
index = await openSessionSearchIndexFile('ss-file-write')
errors = []
store = new SessionSearchStore(index.path, (error) => errors.push(error))
registerSessionSearchIndexConsumer(store)
})
afterEach(async () => {
vi.restoreAllMocks()
resetTranscriptConsumersForTests()
store.close()
await index.close()
})
function matches(db: SyncDatabase, table: string, term: string): number {
return (
db
.prepare(
`SELECT count(*) AS n FROM ${table} JOIN messages m ON m.id = ${table}.rowid
JOIN sessions s ON s.id = m.session_row_id WHERE ${table} MATCH ?`
)
.get(term) as { n: number }
).n
}
/** Fails the nth statement matching `pick`, wherever the writer prepares it. */
function failOnStatement(pick: (sql: string) => boolean, nth: number): void {
const prepare = SyncDatabase.prototype.prepare
let seen = 0
vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function (
this: SyncDatabase,
sql: string
) {
if (pick(sql) && ++seen === nth) {
throw new Error('index write crashed mid transaction')
}
return prepare.call(this, sql)
})
}
function counts(db: SyncDatabase): Record<string, number> {
const one = (sql: string): number => (db.prepare(sql).get() as { n: number }).n
return {
sessions: one('SELECT count(*) AS n FROM sessions'),
messages: one('SELECT count(*) AS n FROM messages'),
files: one('SELECT count(*) AS n FROM files'),
full: one('SELECT count(*) AS n FROM messages_fts')
}
}
it('writes a whole read in one transaction', () => {
replayTranscriptRead({ messages: userMessages('needle text', 300) })
const after = counts(index.db)
expect(after.sessions).toBe(1)
expect(after.messages).toBe(300)
expect(after.full).toBe(300)
expect(errors).toEqual([])
})
it('files every row in one FTS table, under the column its role owns', () => {
replayTranscriptRead({
messages: [
{ role: 'user', text: 'alpha question', timestamp: null },
{ role: 'assistant', text: 'beta answer', timestamp: null },
{ role: 'tool', text: 'gamma tool output', timestamp: null }
]
})
// One table carries all three; the conversation scope is a column filter over
// it, which is what the second table used to be.
expect(counts(index.db).full).toBe(3)
expect(matches(index.db, 'messages_fts', 'gamma')).toBe(1)
expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: gamma')).toBe(0)
expect(matches(index.db, 'messages_fts', '{user_text assistant_text}: beta')).toBe(1)
})
it('leaves the index exactly as it found it when a read never finishes', () => {
const write = store.beginWrite(syntheticCandidate(), 'replace', 0)!
for (const message of userMessages('neverfinished', 200)) {
write.add(message)
}
// The process dies here: the rows only ever existed in this buffer.
expect(counts(index.db)).toMatchObject({
sessions: 0,
messages: 0,
files: 0
})
})
it('rolls a whole file back when a write throws part way through its transaction', () => {
replayTranscriptRead({
messages: userMessages('firstgeneration', 3),
outcome: { byteOffset: 40 }
})
const before = counts(index.db)
failOnStatement((sql) => sql.startsWith('INSERT INTO messages('), 50)
replayTranscriptRead({
messages: userMessages('crashedgeneration', 100),
outcome: { byteOffset: 900 }
})
vi.restoreAllMocks()
// Not one of the 49 rows that were already inserted survived, the previous
// generation is untouched, and the cursor still describes what is really here.
expect(counts(index.db)).toEqual(before)
expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0)
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40)
expect(errors).toHaveLength(1)
// The file is owed a re-read, which is the only reason anything was lost.
expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
// And the connection is usable again: a transaction left open by the failure
// would take down every write after it, not just the one that threw.
replayTranscriptRead({
messages: userMessages('afterthecrash', 2),
outcome: { byteOffset: 900 }
})
expect(matches(index.db, 'messages_fts', 'afterthecrash')).toBe(2)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(900)
})
it('takes the rows back when recording the cursor is what fails', () => {
replayTranscriptRead({
messages: userMessages('firstgeneration', 3),
outcome: { byteOffset: 40 }
})
// The cursor is written last, so this is the crash point that would leave rows
// no cursor describes: a later append would continue from an offset those rows
// already cover, and index the same span twice.
failOnStatement((sql) => sql.startsWith('INSERT INTO files('), 1)
replayTranscriptRead({
messages: userMessages('crashedgeneration', 5),
outcome: { byteOffset: 900 }
})
vi.restoreAllMocks()
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 })
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(3)
expect(matches(index.db, 'messages_fts', 'crashedgeneration')).toBe(0)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(40)
})
it('shows a reader on another handle one generation or the other, never a mixture', async () => {
replayTranscriptRead({
messages: userMessages('firstgeneration', 3),
outcome: { byteOffset: 40 }
})
expect(counts(index.db).messages).toBe(3)
const write = store.beginWrite(syntheticCandidate(), 'replace', 0)!
for (const message of userMessages('secondgeneration', 7)) {
write.add(message)
// Every point at which the other handle could issue a query mid-read.
expect(counts(index.db).messages).toBe(3)
expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(0)
}
expect(
write.commit({
session: syntheticSession(),
byteOffset: 900,
incomplete: false
})
).toBe(true)
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(7)
// The old three are cut loose, not deleted, so they are still on disk and
// already unreachable; the drain the store scheduled hands them back.
expect(counts(index.db).messages).toBe(10)
await vi.waitFor(() => {
expect(counts(index.db).messages).toBe(7)
})
})
// Four of these fill the 400-char ceiling the two tests below construct.
const CHUNKED_MESSAGE = `chunkedneedle ${'filler '.repeat(12)}nd`
const PROVISIONAL_IDENTITY = {
sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
cwd: '/repo/app',
title: 'provisional title',
createdAt: '2026-05-01T10:00:00.000Z',
updatedAt: '2026-05-01T10:05:00.000Z'
}
// Only a read that can name its session chunks at all, so every test below that
// wants a chunk has to supply one.
const named = (): typeof PROVISIONAL_IDENTITY => PROVISIONAL_IDENTITY
it('leaves the session consistent after every chunk of a file too large for one transaction', () => {
expect(CHUNKED_MESSAGE.length).toBe(100)
const writer = new SessionSearchIndexWriter(index.db, 400)
const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
for (const [position, message] of userMessages(CHUNKED_MESSAGE, 10).entries()) {
write.add(message)
const rows = counts(index.db).messages
// Four messages per chunk, and nothing else reaches the file between them.
expect(rows).toBe(Math.floor((position + 1) / 4) * 4)
// Whatever landed is a coherent prefix of this session and answers searches.
expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(rows)
if (rows > 0) {
// The cursor a chunk leaves refuses every append rather than inventing an
// offset the reader never gave it.
expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true)
expect(writer.beginWrite(syntheticCandidate(), 'append', 0)).toBeNull()
}
}
expect(counts(index.db).messages).toBe(8)
expect(
write.commit({
session: syntheticSession(),
byteOffset: 4096,
incomplete: false
})
).toBe(true)
expect(counts(index.db)).toMatchObject({
sessions: 1,
messages: 10,
full: 10
})
expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096)
})
it('holds the ceiling against a single message larger than it', () => {
const writer = new SessionSearchIndexWriter(index.db, 8000)
const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
const exec = SyncDatabase.prototype.exec
let opened = 0
vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function (
this: SyncDatabase,
sql: string
) {
if (sql === 'BEGIN IMMEDIATE') {
opened += 1
}
exec.call(this, sql)
})
// One conversation turn, three times the ceiling. Checked once per message,
// this commits all 24,000 characters in a single transaction — the ceiling
// bounds nothing that a message can exceed on its own.
write.add({ role: 'assistant', text: 'a'.repeat(24_000), timestamp: null })
vi.restoreAllMocks()
expect(opened).toBe(3)
expect(counts(index.db).messages).toBe(3)
expect(
write.commit({
session: syntheticSession(),
byteOffset: 4096,
incomplete: false
})
).toBe(true)
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3 })
})
it('names a session on its first chunk, not only when the read ends', () => {
const writer = new SessionSearchIndexWriter(index.db, 400)
const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
write.add(message)
}
// The chunks that landed already answer searches, so the session they hang
// off has to be nameable on another handle before the read ends. This is also
// the whole record a crash between chunks leaves behind.
expect(counts(index.db).messages).toBe(8)
expect(
index.db.prepare('SELECT session_id, cwd, cwd_key, title, created_at FROM sessions').get()
).toEqual({
session_id: PROVISIONAL_IDENTITY.sessionId,
cwd: '/repo/app',
cwd_key: cwdKey('/repo/app'),
title: 'provisional title',
created_at: '2026-05-01T10:00:00.000Z'
})
// And the decoded session still wins at the end: the mid-read title is
// provisional, never a value the final commit has to defer to.
expect(
write.commit({
session: syntheticSession({ title: 'the settled title' }),
byteOffset: 4096,
incomplete: false
})
).toBe(true)
expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({
title: 'the settled title'
})
})
it('commits a whole-file read over the ceiling in one transaction, never a chunk', () => {
// The whole-file readers (Grok, Cursor, Gemini, OpenCode) pass no identity:
// their formats are rewritten in place and have no resumable state to ask.
const writer = new SessionSearchIndexWriter(index.db, 400)
const write = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
const exec = SyncDatabase.prototype.exec
let opened = 0
vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function (
this: SyncDatabase,
sql: string
) {
if (sql === 'BEGIN IMMEDIATE') {
opened += 1
}
exec.call(this, sql)
})
for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
write.add(message)
// Chunking here would publish rows under a session with an empty id, an
// empty title and a null cwd, and an interrupted read would leave that
// prefix answering searches for good.
expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0 })
}
expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe(
true
)
vi.restoreAllMocks()
expect(opened).toBe(1)
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 })
// And a real cursor, not the partial sentinel a chunk would have left.
expect(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(4096)
})
it('starts chunking only once the parser has an id to name the session with', () => {
const writer = new SessionSearchIndexWriter(index.db, 400)
let decoded: typeof PROVISIONAL_IDENTITY | null = null
const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, () => decoded)!
for (const message of userMessages(CHUNKED_MESSAGE, 4)) {
write.add(message)
}
// Past the ceiling, but the parser has decoded nothing: the buffer keeps
// growing rather than naming a session it cannot name.
expect(counts(index.db).messages).toBe(0)
decoded = PROVISIONAL_IDENTITY
write.add(userMessages(CHUNKED_MESSAGE, 1)[0]!)
// Everything held goes with the first chunk that can say what it is.
expect(counts(index.db).messages).toBe(5)
expect(index.db.prepare('SELECT session_id, cwd FROM sessions').get()).toEqual({
session_id: PROVISIONAL_IDENTITY.sessionId,
cwd: '/repo/app'
})
})
it('reports a chunk-partial file as held, and as one that must be read whole', () => {
const writer = new SessionSearchIndexWriter(index.db, 400)
const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
write.add(message)
}
// Held, with no cursor to continue. Reporting nothing here reads as "never
// indexed", so a caller asks for whatever read the parse cache offers, the
// reader picks append, and only a decline heals it a cycle later.
const held = writer.indexedFile(SYNTHETIC_TRANSCRIPT, null)
expect(held).not.toBeNull()
expect(held?.byteOffset).toBeNull()
expect(requiresWholeRead(held)).toBe(true)
expect(held?.mtimeMs).toBe(syntheticCandidate().file.mtimeMs)
// A file this index has never seen is still the other answer, so the two
// states a caller has to tell apart are distinguishable.
expect(writer.indexedFile('/never-seen.jsonl', null)).toBeNull()
expect(requiresWholeRead(null)).toBe(false)
// And no offset continues it, including the one the chunk recorded.
for (const offset of [0, -1, 400, 1000]) {
expect(writer.beginWrite(syntheticCandidate(), 'append', offset)).toBeNull()
}
})
it('re-reads a chunked file whole when its writer died between chunks', async () => {
const writer = new SessionSearchIndexWriter(index.db, 400)
const abandoned = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
for (const message of userMessages(CHUNKED_MESSAGE, 10)) {
abandoned.add(message)
}
expect(counts(index.db).messages).toBe(8)
// Nothing can continue that prefix, so the only way forward is a whole re-read,
// and that replaces every row the dead writer left.
expect(requiresWholeRead(writer.indexedFile(SYNTHETIC_TRANSCRIPT, null))).toBe(true)
const replacement = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
replacement.add(userMessages('wholereread', 1)[0]!)
expect(
replacement.commit({
session: syntheticSession(),
byteOffset: 4096,
incomplete: false
})
).toBe(true)
// The eight stranded rows stop answering the moment the replace commits, and
// the drain hands them back after it rather than inside it.
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 9 })
expect(matches(index.db, 'messages_fts', 'chunkedneedle')).toBe(0)
await deleteExpiredSearchFiles(index.db, null, () => false)
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 1 })
})
it('stops a chunked read whose file was removed between its chunks', () => {
const writer = new SessionSearchIndexWriter(index.db, 400)
const write = writer.beginWrite(syntheticCandidate(), 'replace', 0, named)!
const messages = userMessages(CHUNKED_MESSAGE, 10)
for (const message of messages.slice(0, 4)) {
write.add(message)
}
expect(counts(index.db).messages).toBe(4)
writer.removeFile(SYNTHETIC_TRANSCRIPT)
const exec = SyncDatabase.prototype.exec
let opened = 0
vi.spyOn(SyncDatabase.prototype, 'exec').mockImplementation(function (
this: SyncDatabase,
sql: string
) {
if (sql === 'BEGIN IMMEDIATE') {
opened += 1
}
exec.call(this, sql)
})
for (const message of messages.slice(4)) {
write.add(message)
}
expect(write.commit({ session: syntheticSession(), byteOffset: 4096, incomplete: false })).toBe(
false
)
vi.restoreAllMocks()
// Not one row of the removed source came back. The read stopped at the first
// refusal rather than reopening a transaction it already knows will roll back,
// once for every message left in a file that may be a hundred megabytes.
expect(opened).toBe(1)
expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 })
})
it('fences a first-ever read whose file was removed before it committed', () => {
const candidate = syntheticCandidate({ path: '/never-indexed.jsonl' })
const write = store.beginWrite(candidate, 'replace', 0)!
for (const message of userMessages('removedbeforefirstcommit', 3)) {
write.add(message)
}
// The path was never indexed, so there is no cursor for the removal to move.
// PR 3's retirement sweep removes exactly these: paths the index deferred over
// budget and never wrote, while the registered consumer is fed concurrently.
store.removeFile('/never-indexed.jsonl')
expect(write.commit({ session: syntheticSession(), byteOffset: 300, incomplete: false })).toBe(
false
)
expect(counts(index.db)).toMatchObject({ sessions: 0, messages: 0, files: 0, full: 0 })
})
it('replaces the previous generation without ever showing both', async () => {
replayTranscriptRead({ messages: userMessages('firstgeneration', 10) })
replayTranscriptRead({ messages: userMessages('secondgeneration', 10) })
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(10)
await vi.waitFor(() => {
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 10, full: 10 })
})
})
it('replaces a generation by cutting the old one loose, not by deleting it inline', async () => {
const writer = new SessionSearchIndexWriter(index.db)
const first = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
for (const message of userMessages('firstgeneration', 200)) {
first.add(message)
}
expect(first.commit({ session: syntheticSession(), byteOffset: 100, incomplete: false })).toBe(
true
)
const before = index.db.prepare('SELECT id FROM sessions').get() as { id: number }
expect(counts(index.db).messages).toBe(200)
const second = writer.beginWrite(syntheticCandidate(), 'replace', 0)!
for (const message of userMessages('secondgeneration', 3)) {
second.add(message)
}
expect(second.commit({ session: syntheticSession(), byteOffset: 200, incomplete: false })).toBe(
true
)
// The transaction inserted three rows and deleted one, rather than deleting
// two hundred: all 203 are still on disk, and the old 200 already answer
// nothing, because every retrieval joins `sessions`.
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 203, full: 203 })
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(3)
// A new session row, with `files` repointed at it in that same transaction.
// AUTOINCREMENT never hands the freed id back while orphans still name it.
const after = index.db.prepare('SELECT id FROM sessions').get() as { id: number }
expect(after.id).toBeGreaterThan(before.id)
expect(index.db.prepare('SELECT session_row_id FROM files').get()).toEqual({
session_row_id: after.id
})
await deleteExpiredSearchFiles(index.db, null, () => false)
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 })
})
it('drains what a replace cut loose without being asked', async () => {
replayTranscriptRead({ messages: userMessages('firstgeneration', 200) })
replayTranscriptRead({ messages: userMessages('secondgeneration', 3) })
// The store schedules the reclaim the way it schedules retention's. Hiding a
// generation and never reclaiming it would grow the file by every re-read.
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
await vi.waitFor(() => {
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 3, full: 3 })
})
expect(errors).toEqual([])
})
it('continues a session across an append rather than replaying it', () => {
replayTranscriptRead({
messages: userMessages('openingturn', 3),
outcome: { byteOffset: 40 }
})
replayTranscriptRead({
messages: userMessages('laterturn', 2),
mode: 'append',
previousByteOffset: 40,
outcome: { byteOffset: 90 }
})
expect(counts(index.db)).toMatchObject({ sessions: 1, messages: 5 })
expect(matches(index.db, 'messages_fts', 'openingturn')).toBe(3)
expect(matches(index.db, 'messages_fts', 'laterturn')).toBe(2)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(90)
})
it('stops answering for a removed file the moment it is removed', () => {
replayTranscriptRead({ messages: userMessages('removedneedle', 3) })
store.removeFile(SYNTHETIC_TRANSCRIPT)
expect(counts(index.db)).toMatchObject({
sessions: 0,
messages: 0,
files: 0,
full: 0
})
expect(matches(index.db, 'messages_fts', 'removedneedle')).toBe(0)
})
it('writes nothing for an incomplete read and owes the file a whole re-read', () => {
replayTranscriptRead({
messages: userMessages('incompleteread', 300),
outcome: { incomplete: true }
})
expect(counts(index.db)).toMatchObject({
sessions: 0,
messages: 0,
files: 0,
full: 0
})
expect(store.pendingFileCount).toBe(1)
expect(errors).toEqual([])
})
it('exposes the handle a composed reader queries through', () => {
replayTranscriptRead({ messages: userMessages('composedreader', 3) })
// PR 4's engine reads through this rather than opening a second connection,
// so it sees a write the moment the transaction commits.
expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 3 })
})
it('closes twice without turning the second call into an error', () => {
store.close()
// node:sqlite throws ERR_INVALID_STATE on a second close of one handle, and a
// store is closed both by whoever owns it and by a teardown that cannot know.
expect(() => store.close()).not.toThrow()
store = new SessionSearchStore(index.path, (error) => errors.push(error))
})
@@ -0,0 +1,29 @@
import { expect, it } from 'vitest'
import { identifierShadowTerms, identifierShadowText } from './session-search-identifier-split'
it('splits a camel-case symbol into its pieces and keeps the whole', () => {
expect(identifierShadowTerms('call resolveTerminalPath here')).toEqual([
'resolveterminalpath',
'resolve',
'terminal',
'path'
])
})
it('splits a path into its segments and extension', () => {
// The whole path already tokenizes on its own; only the pieces need shadowing.
expect(identifierShadowText('src/main/foo-bar.ts')).toBe('src main foo bar ts')
})
it('leaves ordinary prose alone', () => {
expect(identifierShadowTerms('the quick brown fox')).toEqual([])
})
it('shadows a screaming-case constant', () => {
expect(identifierShadowTerms('MAX_RETRIES')).toEqual(['max', 'retries'])
})
it('stops at the term limit rather than growing with the message', () => {
const text = Array.from({ length: 50 }, (_unused, index) => `alpha_beta${index}`).join(' ')
expect(identifierShadowTerms(text, 10)).toHaveLength(10)
})
@@ -0,0 +1,54 @@
// Identifier shadow terms: `resolveTerminalPath` → `resolve terminal path`,
// `src/main/foo-bar.ts` → `src main foo bar ts`. Stored in a separate FTS5
// column so a partial identifier still matches; the largest single accuracy
// win measured in the retrieval shoot-out (MRR 0.50 → 0.55).
const RAW_TOKEN = /[A-Za-z0-9_./-]+/g
const CAMEL_PIECE = /[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+/g
const SEPARATOR = /[_./-]+/
// Worth shadowing: has a separator, a camel boundary, or is SCREAMING_CASE.
const INTERESTING = /[_./-]|[a-z0-9][A-Z]|^[A-Z]{2,}[0-9_]*$/
const MIN_TOKEN = 3
const MAX_TOKEN = 120
const MIN_PIECE = 2
function hasMixedCase(piece: string): boolean {
return /[a-z]/.test(piece) && /[A-Z]/.test(piece)
}
export function identifierShadowTerms(text: string, limit = 4000): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const match of text.matchAll(RAW_TOKEN)) {
const token = match[0]
if (token.length < MIN_TOKEN || token.length > MAX_TOKEN || !INTERESTING.test(token)) {
continue
}
const parts: string[] = []
for (const piece of token.split(SEPARATOR)) {
if (!piece) {
continue
}
parts.push(piece)
if (hasMixedCase(piece)) {
parts.push(...(piece.match(CAMEL_PIECE) ?? []))
}
}
for (const part of parts) {
const lowered = part.toLowerCase()
if (lowered.length < MIN_PIECE || seen.has(lowered)) {
continue
}
seen.add(lowered)
out.push(lowered)
if (out.length >= limit) {
return out
}
}
}
return out
}
export function identifierShadowText(text: string, limit?: number): string {
return identifierShadowTerms(text, limit).join(' ')
}
@@ -0,0 +1,373 @@
import { afterEach, beforeEach, expect, it } from 'vitest'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import {
openSessionSearchIndexFile,
replayTranscriptRead,
syntheticCandidate,
syntheticSession,
SYNTHETIC_TRANSCRIPT,
userMessages,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
import { SessionSearchStore, STALE_PATH_LIMIT } from './session-search-store'
let index: SessionSearchIndexFile
let store: SessionSearchStore
let errors: unknown[]
beforeEach(async () => {
index = await openSessionSearchIndexFile('ss-index-consumer')
errors = []
store = new SessionSearchStore(index.path, (error) => errors.push(error))
registerSessionSearchIndexConsumer(store)
})
afterEach(async () => {
resetTranscriptConsumersForTests()
store.close()
await index.close()
})
function indexedMessages(): number {
return (
index.db.prepare('SELECT count(*) AS n FROM messages').get() as {
n: number
}
).n
}
function cursor(): number | null | undefined {
return store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset
}
it('appends onto its own cursor and carries the content hash forward', async () => {
replayTranscriptRead({
messages: userMessages('first half', 3),
outcome: { byteOffset: 100 }
})
const first = index.db
.prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions')
.get() as { hash: string; count: number }
replayTranscriptRead({
mode: 'append',
previousByteOffset: 100,
messages: userMessages('second half', 2),
outcome: { byteOffset: 220 }
})
expect(indexedMessages()).toBe(5)
expect(cursor()).toBe(220)
const second = index.db
.prepare('SELECT content_hash AS hash, content_hash_count AS count FROM sessions')
.get() as { hash: string; count: number }
expect(second.count).toBe(first.count + 2)
expect(second.hash).not.toBe(first.hash)
expect(store.takeStale()).toEqual([])
})
it('appends onto a file it read through and decoded no session from', async () => {
// An excluded Codex worker transcript: read through, nothing to index, and
// still growing. Its cursor is sound, so a re-read of the whole file every
// pass buys nothing.
replayTranscriptRead({
messages: userMessages('excluded span', 3),
outcome: { session: null, byteOffset: 100 }
})
expect(cursor()).toBe(100)
expect(store.takeStale()).toEqual([])
replayTranscriptRead({
mode: 'append',
previousByteOffset: 100,
messages: userMessages('decoded at last', 2),
outcome: { byteOffset: 220 }
})
expect(indexedMessages()).toBe(2)
expect(cursor()).toBe(220)
expect(store.takeStale()).toEqual([])
})
it('declines an append that starts past its own cursor and records the file', async () => {
replayTranscriptRead({
messages: userMessages('indexed span', 3),
outcome: { byteOffset: 100 }
})
// The session list read further than this index did, so the appended span
// continues from bytes the index never saw.
replayTranscriptRead({
mode: 'append',
previousByteOffset: 900,
messages: userMessages('unseen span', 4),
outcome: { byteOffset: 1200 }
})
expect(indexedMessages()).toBe(3)
expect(cursor()).toBe(100)
expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
})
it('declines a file whose identity changed under the same path', async () => {
const original = syntheticCandidate({ dev: 1, ino: 10 })
replayTranscriptRead({
candidate: original,
messages: userMessages('original file', 2),
outcome: { byteOffset: 100 }
})
replayTranscriptRead({
candidate: syntheticCandidate({ dev: 1, ino: 77 }),
mode: 'append',
previousByteOffset: 100,
messages: userMessages('replacement file', 2),
outcome: { byteOffset: 200 }
})
expect(indexedMessages()).toBe(2)
expect(store.takeStale()).toHaveLength(1)
})
it('never advances the cursor for an incomplete read', async () => {
replayTranscriptRead({
messages: userMessages('complete span', 3),
outcome: { byteOffset: 100 }
})
replayTranscriptRead({
mode: 'append',
previousByteOffset: 100,
messages: userMessages('partial span', 5),
outcome: { byteOffset: 400, incomplete: true }
})
expect(indexedMessages()).toBe(3)
expect(cursor()).toBe(100)
expect(
(
index.db.prepare('SELECT count(*) AS n FROM messages').get() as {
n: number
}
).n
).toBe(3)
expect(store.takeStale()).toHaveLength(1)
})
it('indexes nothing at all from a read that was incomplete from the start', async () => {
replayTranscriptRead({
messages: userMessages('unreachable', 4),
outcome: { byteOffset: 0, incomplete: true }
})
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
n: 0
})
expect(cursor()).toBeUndefined()
})
it('drops a file whose parser returned no session', async () => {
replayTranscriptRead({
messages: userMessages('was indexed', 3),
outcome: { byteOffset: 100 }
})
replayTranscriptRead({
messages: userMessages('now rejected', 2),
outcome: { session: null, byteOffset: 300 }
})
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
n: 0
})
// The file is still read through, so a later scan does not re-read it.
expect(cursor()).toBe(300)
})
it('writes nothing for a source whose parser cannot reach the channel', async () => {
// An OpenCode SQLite candidate decodes in a worker, so every read of it is
// incomplete, and no re-read would help.
const candidate = {
...syntheticCandidate({ path: '/opencode/opencode.db#session-1' }),
agent: 'opencode' as const
}
replayTranscriptRead({
candidate,
messages: [],
outcome: { byteOffset: 0, incomplete: true }
})
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(store.takeStale()).toEqual([])
})
it('ignores a candidate older than the retention cutoff', async () => {
store.setRetentionCutoffMs(Date.now())
replayTranscriptRead({ messages: userMessages('too old', 3) })
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(store.takeStale()).toEqual([])
})
it('stops writing while the store refuses writes, but remembers what it skipped', async () => {
store.setAcceptingWrites(false)
replayTranscriptRead({ messages: userMessages('paused', 3) })
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(errors).toEqual([])
// A pause is exactly the window in which every read is declined. Forgetting
// them would leave the whole paused span unindexed with nothing to replay it.
expect(store.takeStale().map((candidate) => candidate.file.path)).toEqual([SYNTHETIC_TRANSCRIPT])
})
it('keeps the paused re-read set when the retention window is reconfigured', async () => {
store.setAcceptingWrites(false)
replayTranscriptRead({ messages: userMessages('paused', 2) })
expect(store.pendingFileCount).toBe(1)
// The set records what still has to be read, not what is worth keeping. A
// window that now excludes this file is enforced where the re-read is
// dispatched, so nothing is written and the file leaves the set there.
store.setRetentionCutoffMs(Date.now())
expect(store.pendingFileCount).toBe(1)
store.setAcceptingWrites(true)
expect(store.takeStale()).toHaveLength(1)
replayTranscriptRead({ messages: userMessages('outside the window now', 2) })
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(store.pendingFileCount).toBe(0)
})
it('drops the oldest record rather than growing without a bound, and says so', () => {
store.setAcceptingWrites(false)
for (let index = 0; index < STALE_PATH_LIMIT + 5; index++) {
store.markStale(syntheticCandidate({ path: `/transcript-${index}.jsonl` }))
}
expect(store.pendingFileCount).toBe(STALE_PATH_LIMIT)
expect(store.droppedPendingFileCount).toBe(5)
const kept = store.takeStale().map((candidate) => candidate.file.path)
expect(kept).not.toContain('/transcript-0.jsonl')
expect(kept).toContain(`/transcript-${STALE_PATH_LIMIT + 4}.jsonl`)
})
it('keeps the session list running when the index write fails', async () => {
replayTranscriptRead({
messages: userMessages('healthy', 2),
outcome: { byteOffset: 100 }
})
index.db.exec('DROP TABLE messages_fts')
expect(() =>
replayTranscriptRead({
mode: 'append',
previousByteOffset: 100,
messages: userMessages('broken', 400),
outcome: { byteOffset: 500 }
})
).not.toThrow()
expect(errors.length).toBeGreaterThan(0)
expect(store.takeStale()).toHaveLength(1)
})
it('unregisters cleanly, leaving later reads unindexed', async () => {
resetTranscriptConsumersForTests()
replayTranscriptRead({ messages: userMessages('after unregister', 3) })
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
})
it('drops a removed source and keeps its cursor gone', async () => {
replayTranscriptRead({
messages: userMessages('present', 3),
outcome: { byteOffset: 100 }
})
store.removeFile(SYNTHETIC_TRANSCRIPT)
expect(cursor()).toBeUndefined()
expect(index.db.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 0
})
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
n: 0
})
})
it('writes the session metadata the read decoded', async () => {
replayTranscriptRead({
messages: userMessages('metadata', 1),
outcome: {
session: syntheticSession({
sessionId: 'abc-123',
title: 'a titled session',
cwd: '/repo/app',
branch: 'main',
messageCount: 1,
resumeCommand: 'claude --resume abc-123'
}),
byteOffset: 42
}
})
expect(
index.db
.prepare('SELECT session_id, title, cwd, cwd_key, branch, resume_command FROM sessions')
.get()
).toEqual({
session_id: 'abc-123',
title: 'a titled session',
cwd: '/repo/app',
cwd_key: '/repo/app',
branch: 'main',
resume_command: 'claude --resume abc-123'
})
})
it('keeps a proven file identity when a later read cannot stat it', async () => {
const withIdentity = syntheticCandidate({ dev: 1, ino: 10 })
replayTranscriptRead({
candidate: withIdentity,
messages: userMessages('first', 2),
outcome: { byteOffset: 100 }
})
// A host that cannot prove identity re-reads the same file.
replayTranscriptRead({
candidate: syntheticCandidate(),
mode: 'append',
previousByteOffset: 100,
messages: userMessages('second', 2),
outcome: { byteOffset: 200 }
})
expect(indexedMessages()).toBe(4)
// The stored identity survived, so a rename-replace is still detectable.
replayTranscriptRead({
candidate: syntheticCandidate({ dev: 1, ino: 99 }),
mode: 'append',
previousByteOffset: 200,
messages: userMessages('replacement', 2),
outcome: { byteOffset: 300 }
})
expect(indexedMessages()).toBe(4)
expect(cursor()).toBe(200)
expect(store.takeStale()).toHaveLength(1)
})
@@ -0,0 +1,118 @@
import { parserPublishesMessages } from '../ai-vault/session-scanner-agent-parser'
import {
registerTranscriptConsumer,
type TranscriptConsumer,
type TranscriptMessage,
type TranscriptReadConsumer,
type TranscriptReadOutcome,
type TranscriptReadStart
} from '../ai-vault/session-transcript-consumers'
import { fileIdentity } from './session-search-file-cursor'
import type { SessionSearchFileWrite } from './session-search-index-writer'
import type { SessionSearchStore } from './session-search-store'
/**
* The search index as a consumer of the transcript reader.
*
* It keeps its own cursor in the `files` table and never consults the parse
* cache: the two answer different questions and diverge the moment either
* declines a read. Three refusals, each of which leaves the cursor where it
* was and records the file for a later whole re-read:
*
* - `beginRead` returns null when this index's cursor is behind the offset an
* `append` continues from, or when the file's identity changed.
* - a buffering failure stops the read's rows without failing the session list.
* - an `incomplete` outcome never commits; those rows are not the whole span.
*/
export class SessionSearchIndexConsumer implements TranscriptConsumer {
constructor(private readonly store: SessionSearchStore) {}
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null {
const { candidate } = start
if (!this.store.acceptsCandidate(candidate)) {
// A pause is a reason not to write now, not a reason to forget the read.
// `markStale` applies the retention rule itself, so a candidate that is
// out of scope rather than merely paused is still dropped here.
this.store.markStale(candidate)
return null
}
// A parser that decodes where the channel cannot reach it reports every read
// as incomplete. Declining here is not the same as being behind: no re-read
// would help, so the file is not recorded either.
if (!parserPublishesMessages(candidate)) {
return null
}
if (start.mode === 'append') {
const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file))
if (!cursor || cursor.byteOffset !== start.previousByteOffset) {
// This index never saw the span before `previousByteOffset`; appending
// here would leave a hole no later read can fill. A null cursor is the
// file a chunked read left half written, which no offset continues.
this.store.markStale(candidate)
return null
}
}
const write = this.store.beginWrite(
candidate,
start.mode,
start.previousByteOffset,
start.identity
)
if (!write) {
this.store.markStale(candidate)
return null
}
return new SessionSearchReadConsumer(this.store, start, write)
}
}
class SessionSearchReadConsumer implements TranscriptReadConsumer {
private failed = false
constructor(
private readonly store: SessionSearchStore,
private readonly start: TranscriptReadStart,
private readonly write: SessionSearchFileWrite
) {}
message(message: TranscriptMessage): void {
if (this.failed) {
return
}
try {
this.write.add(message)
} catch (error) {
// Never throws back into the reader: the channel would drop this consumer
// for the rest of the read and `finish` would never run. Failing here
// keeps the whole read on one path — the buffer is dropped and the file is
// re-read.
this.failed = true
this.store.reportWriteFailure(error)
}
}
finish(outcome: TranscriptReadOutcome): void {
const { candidate } = this.start
let committed = false
try {
// An incomplete read's rows are not the whole span, so the cursor must not
// move past them; the file is re-read whole instead.
committed = !this.failed && !outcome.incomplete && this.write.commit(outcome)
} catch (error) {
this.store.reportWriteFailure(error)
}
if (committed) {
this.store.writeCommitted(candidate)
return
}
this.store.markStale(candidate)
}
}
/**
* Registers the index with the reader and returns the unregister function.
* Nothing in production calls this yet: PR 3 owns when the index is live.
*/
export function registerSessionSearchIndexConsumer(store: SessionSearchStore): () => void {
return registerTranscriptConsumer(new SessionSearchIndexConsumer(store))
}
@@ -0,0 +1,124 @@
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { removeTree } from '../../shared/windows-transient-lock-removal'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import { TranscriptMessageChannel } from '../ai-vault/session-transcript-channel'
import type {
TranscriptMessage,
TranscriptReadOutcome,
TranscriptReadStart
} from '../ai-vault/session-transcript-consumers'
import type SyncDatabase from '../sqlite/sync-database'
import { openSessionSearchDatabase } from './session-search-schema'
export const SYNTHETIC_TRANSCRIPT = 'synthetic-transcript'
export function syntheticCandidate(
overrides: Partial<SessionFileCandidate['file']> = {}
): SessionFileCandidate {
const at = new Date(1740000000000)
return {
agent: 'claude',
codexHome: null,
file: {
path: SYNTHETIC_TRANSCRIPT,
mtimeMs: at.getTime(),
modifiedAt: at.toISOString(),
sizeBytes: 4096,
...overrides
}
}
}
export function syntheticSession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
const at = new Date(1740000000000).toISOString()
return {
id: 'fixture',
executionHostId: 'local',
agent: 'claude',
sessionId: 'fixture',
title: 'fixture session',
cwd: '/fixture',
branch: null,
model: null,
filePath: SYNTHETIC_TRANSCRIPT,
codexHome: null,
createdAt: at,
updatedAt: at,
modifiedAt: at,
messageCount: 0,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: '',
subagent: null,
...overrides
}
}
export function userMessages(text: string, count: number): TranscriptMessage[] {
return Array.from({ length: count }, (_unused, index) => ({
role: 'user' as const,
text,
timestamp: new Date(1740000000000 + index * 1000).toISOString()
}))
}
/**
* Drives one read through the real fan-out channel, so a test exercises the
* registration path the transcript reader uses rather than the consumer alone.
*/
export function replayTranscriptRead(args: {
candidate?: SessionFileCandidate
mode?: TranscriptReadStart['mode']
previousByteOffset?: number
messages: TranscriptMessage[]
outcome?: Partial<TranscriptReadOutcome>
}): void {
const candidate = args.candidate ?? syntheticCandidate()
const mode = args.mode ?? 'replace'
const channel = new TranscriptMessageChannel()
channel.beginRead({
candidate,
mode,
previousByteOffset: args.previousByteOffset ?? 0
})
for (const message of args.messages) {
channel.push(message)
}
channel.finishRead({
session: syntheticSession(),
byteOffset: 4096,
incomplete: false,
...args.outcome
})
}
export type SessionSearchIndexFile = {
path: string
/** The store keeps its own connection private, so row assertions need this one. */
db: SyncDatabase
close: () => Promise<void>
}
/** An on-disk index: `:memory:` is per-connection, so a second reader needs a real file. */
export async function openSessionSearchIndexFile(name: string): Promise<SessionSearchIndexFile> {
const root = await mkdtemp(join(tmpdir(), `${name}-`))
const path = join(root, 'index.sqlite')
const db = openSessionSearchDatabase(path)
let open = true
return {
path,
db,
close: async () => {
if (open) {
open = false
db.close()
}
await removeTree(root)
}
}
}
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, expect, it } from 'vitest'
import { SessionSearchIndexConsumer } from './session-search-index-consumer'
import {
openSessionSearchIndexFile,
syntheticCandidate,
syntheticSession,
SYNTHETIC_TRANSCRIPT,
userMessages,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
import { SessionSearchStore } from './session-search-store'
// The store is driven directly here. Every guard below is also shadowed by the
// consumer's own check, so a test that goes through the consumer proves nothing
// about which of the two is holding.
let index: SessionSearchIndexFile
let store: SessionSearchStore
let errors: unknown[]
beforeEach(async () => {
index = await openSessionSearchIndexFile('ss-index-writer')
errors = []
store = new SessionSearchStore(index.path, (error) => errors.push(error))
})
afterEach(async () => {
store.close()
await index.close()
})
function count(table: string): number {
return (
index.db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as {
n: number
}
).n
}
function indexRead(previousByteOffset: number, byteOffset: number, text: string): boolean {
const write = store.beginWrite(
syntheticCandidate(),
previousByteOffset === 0 ? 'replace' : 'append',
previousByteOffset
)
if (!write) {
return false
}
for (const message of userMessages(text, 2)) {
write.add(message)
}
return write.commit({
session: syntheticSession(),
byteOffset,
incomplete: false
})
}
it('refuses an append whose predecessor offset is not the committed cursor', () => {
expect(indexRead(0, 100, 'first')).toBe(true)
expect(store.beginWrite(syntheticCandidate(), 'append', 900)).toBeNull()
expect(store.beginWrite(syntheticCandidate(), 'append', 99)).toBeNull()
// The one offset that does continue the committed span is accepted.
expect(store.beginWrite(syntheticCandidate(), 'append', 100)).not.toBeNull()
})
it('refuses to commit a write whose cursor moved underneath it', () => {
const stale = store.beginWrite(syntheticCandidate(), 'replace', 0)!
for (const message of userMessages('stalegeneration', 40)) {
stale.add(message)
}
// A second read of the same path finishes first. Without the parse file lane
// this is the overlap that would otherwise resurrect the stale rows.
expect(indexRead(0, 200, 'winninggeneration')).toBe(true)
expect(
stale.commit({
session: syntheticSession(),
byteOffset: 100,
incomplete: false
})
).toBe(false)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)?.byteOffset).toBe(200)
expect(count('sessions')).toBe(1)
expect(count('messages')).toBe(2)
expect(errors).toEqual([])
})
it('refuses to commit a write whose file was removed mid-read', () => {
expect(indexRead(0, 100, 'firstgeneration')).toBe(true)
const write = store.beginWrite(syntheticCandidate(), 'append', 100)!
for (const message of userMessages('afterremoval', 10)) {
write.add(message)
}
store.removeFile(SYNTHETIC_TRANSCRIPT)
// Committing here would put a source back that its owner proved was deleted.
expect(
write.commit({
session: syntheticSession(),
byteOffset: 300,
incomplete: false
})
).toBe(false)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, null)).toBeNull()
expect(count('sessions')).toBe(0)
expect(count('messages')).toBe(0)
expect(count('files')).toBe(0)
})
it('declines a behind cursor in beginRead before it ever reaches the store', () => {
const attempted: number[] = []
const stub = {
acceptsCandidate: () => true,
indexedFile: () => ({ byteOffset: 100, mtimeMs: 1, sizeBytes: 1 }),
beginWrite: (_candidate: unknown, _mode: unknown, previousByteOffset: number) => {
attempted.push(previousByteOffset)
return { add: () => undefined, commit: () => true }
},
markStale: () => undefined
} as unknown as SessionSearchStore
const consumer = new SessionSearchIndexConsumer(stub)
expect(
consumer.beginRead({
candidate: syntheticCandidate(),
mode: 'append',
previousByteOffset: 900
})
).toBeNull()
// The store was never asked, so the writer's own guard cannot be what refused.
expect(attempted).toEqual([])
expect(
consumer.beginRead({
candidate: syntheticCandidate(),
mode: 'append',
previousByteOffset: 100
})
).not.toBeNull()
expect(attempted).toEqual([100])
})
it("hands the read's identity accessor to the store", () => {
const captured: unknown[] = []
const stub = {
acceptsCandidate: () => true,
indexedFile: () => null,
beginWrite: (
_candidate: unknown,
_mode: unknown,
_previousByteOffset: unknown,
identity: unknown
) => {
captured.push(identity)
return { add: () => undefined, commit: () => true }
},
markStale: () => undefined
} as unknown as SessionSearchStore
const identity = (): null => null
new SessionSearchIndexConsumer(stub).beginRead({
candidate: syntheticCandidate(),
mode: 'replace',
previousByteOffset: 0,
identity
})
// Dropped here, a chunked read writes rows under a session with no id and no
// cwd for as long as the read lasts, and for ever if it crashes first.
expect(captured).toEqual([identity])
})
it('treats half a recorded identity as no identity at all', () => {
// New partial observations are not stored as identities.
const partial = {
...syntheticCandidate({ dev: 7 }),
agent: 'claude' as const
}
const write = store.beginWrite(partial, 'replace', 0)!
for (const message of userMessages('halfidentity', 2)) {
write.add(message)
}
write.commit({
session: syntheticSession(),
byteOffset: 100,
incomplete: false
})
expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual({
dev: null,
ino: null
})
// Older indexes may still carry a half-pair.
index.db.exec('UPDATE files SET dev = 7')
// One matching number is not proof of sameness, and one mismatching number is
// not proof of replacement. Neither compares, so neither declines.
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 7, ino: 99 })?.byteOffset).toBe(100)
expect(store.indexedFile(SYNTHETIC_TRANSCRIPT, { dev: 8, ino: 99 })?.byteOffset).toBe(100)
expect(store.beginWrite(syntheticCandidate({ dev: 8, ino: 99 }), 'append', 100)).not.toBeNull()
})
it.each([
[null, { dev: null, ino: null }],
[
{ dev: 7, ino: 11 },
{ dev: 7, ino: 11 }
]
])('never combines partial stats with the previous identity %j', (initial, expected) => {
const observations = [initial ?? {}, { dev: 9 }, { ino: 13 }, { dev: 17, ino: 19 }]
for (const [position, identity] of observations.entries()) {
const write = store.beginWrite(
syntheticCandidate(identity),
position ? 'append' : 'replace',
position * 100
)!
expect(
write.commit({
session: syntheticSession(),
byteOffset: (position + 1) * 100,
incomplete: false
})
).toBe(true)
expect(index.db.prepare('SELECT dev, ino FROM files').get()).toEqual(
position === 3 ? { dev: 17, ino: 19 } : expected
)
}
})
@@ -0,0 +1,359 @@
import type SyncDatabase from '../sqlite/sync-database'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import type {
TranscriptMessage,
TranscriptReadOutcome,
TranscriptSessionIdentity
} from '../ai-vault/session-transcript-consumers'
import { EMPTY_CONTENT_HASH, foldContentHash } from './session-search-content-hash'
import type {
SessionSearchFileIdentity,
SessionSearchIndexedFile
} from './session-search-file-cursor'
import { SessionSearchFileRecords } from './session-search-file-records'
import {
deleteSearchMessages,
insertSearchMessage,
searchMessageRows
} from './session-search-message-rows'
/**
* How much decoded text one transaction may carry.
*
* A file's rows are buffered in memory and written in one transaction, so the
* whole read is either in the index or not. The ceiling is what keeps that
* promise affordable: at the measured 26 MB of transcript per second it caps a
* single commit near a second and the WAL it produces near 64 MB, and it is far
* above the largest real transcript (the 40-session benchmark corpus is 10.5 MB
* in total), so an ordinary file never reaches it. Above the ceiling the read is
* cut into chunks that each leave the index consistent — but only a read that
* can name its session chunks at all. See `add`.
*/
export const SESSION_SEARCH_COMMIT_CHARS = 32 * 1024 * 1024
/**
* The cursor of a file whose rows are a prefix, written by a chunk of a read
* that has not reached the end of the file.
*
* The reader hands out byte offsets only when a read finishes, so a chunk has
* no honest offset to record. This one is unusable on purpose: `indexedFile`
* reports no cursor for it, so an append is declined and the file is re-read
* whole. The rows are still a coherent prefix of that session and answer
* searches until the re-read replaces them.
*/
const PARTIAL_FILE_CURSOR = -1
type FileRow = {
dev: number | null
ino: number | null
byte_offset: number
mtime_ms: number
size_bytes: number | null
session_row_id: number | null
}
type FileCursor = Pick<FileRow, 'session_row_id' | 'byte_offset'>
export type SessionSearchFileWrite = {
/**
* Buffers one message, committing a chunk when the buffer reaches the ceiling
* — and only while this read can name the session it is writing.
*
* A chunk's rows answer searches the moment they land, so a read with no
* `identity` would publish them under a session with an empty id, an empty
* title and a null cwd, and an interrupted read would leave that prefix
* behind for good. The readers that supply no identity are the whole-file
* ones (Grok, Cursor, Gemini, OpenCode), whose formats are rewritten in place
* and have no resumable state to ask; they are also small — the largest on
* the author's machine is 5 MB — so buffering one to the end and committing
* it whole costs nothing. Chunking stays reserved for the readers that can
* say which session this is before the read ends.
*/
add(message: TranscriptMessage): void
/**
* Writes this file's rows, its session and its cursor in one transaction.
* False when the file's record changed under this read — it was removed, or
* another writer moved the cursor these rows continue from. A read that never
* calls this leaves the index exactly as it found it, unless it chunked.
*/
commit(outcome: TranscriptReadOutcome): boolean
}
export class SessionSearchIndexWriter {
private readonly records: SessionSearchFileRecords
// Removals per path, so a write can prove its source was not dropped under it
// rather than infer it from the cursor. In memory is enough: one process owns
// the index, and a removal only has to fence writes this process opened.
private readonly removals = new Map<string, number>()
constructor(
private readonly db: SyncDatabase,
private readonly commitChars: number = SESSION_SEARCH_COMMIT_CHARS,
/**
* Called after a transaction that left a session's messages with no session
* row, so the owner can start the bounded drain that reclaims them.
* Synchronous work here would put the cost back where it was taken from.
*/
private readonly onOrphanedRows: () => void = () => undefined
) {
this.records = new SessionSearchFileRecords(db)
}
/**
* What the index holds for this file, or null when it holds nothing usable:
* an unknown path, or one whose recorded identity no longer matches.
*
* A file a chunked read left half written is reported, with a null cursor.
* Reporting nothing for it would read as "never indexed", so the caller would
* ask for whatever read the parse cache offers, the reader would pick append,
* and the decline would be the only thing that ever forced the whole read.
*/
indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null {
const row = this.db
.prepare(
'SELECT dev, ino, byte_offset, mtime_ms, size_bytes, session_row_id FROM files WHERE path = ?'
)
.get(path) as FileRow | undefined
if (!row) {
return null
}
// Older indexes can carry half-pairs; only a complete identity can prove replacement.
if (identity && row.dev !== null && row.ino !== null) {
if (row.dev !== identity.dev || row.ino !== identity.ino) {
return null
}
}
return {
byteOffset: row.byte_offset === PARTIAL_FILE_CURSOR ? null : row.byte_offset,
mtimeMs: row.mtime_ms,
sizeBytes: row.size_bytes
}
}
/**
* Opens a buffered write for one read, or returns null when the read cannot
* extend what the index holds: an `append` whose predecessor byte offset is
* not this index's own cursor covers a span the index never saw.
*/
beginWrite(
candidate: SessionFileCandidate,
mode: 'replace' | 'append',
previousByteOffset: number,
identity?: () => TranscriptSessionIdentity | null
): SessionSearchFileWrite | null {
const path = candidate.file.path
const cursor = this.cursor(path)
if (mode === 'append') {
// The partial sentinel is not a byte offset, so nothing continues it —
// including a caller that reads it back off the row and passes it in.
if (cursor === undefined || cursor.byte_offset === PARTIAL_FILE_CURSOR) {
return null
}
if (cursor.byte_offset !== previousByteOffset) {
return null
}
}
// A file the index read through and decoded no session from still has a
// cursor worth continuing: it has no session row to hang new rows off, so
// this read makes one. Declining instead would force a whole re-read of
// that file on every pass for as long as it grows.
return this.buffered(candidate, cursor, mode === 'append', identity)
}
/**
* Drops a source: its session, its rows and its file record, in one
* transaction. Unbounded on purpose — the caller has proven this one file is
* gone and expects it out of results when the call returns, and a read of it
* that is still in flight is fenced by the cursor its commit re-reads.
*/
removeFile(path: string): void {
this.removals.set(path, (this.removals.get(path) ?? 0) + 1)
const cursor = this.cursor(path)
this.db.exec('BEGIN IMMEDIATE')
try {
this.dropSession(cursor?.session_row_id ?? null)
this.db.prepare('DELETE FROM files WHERE path = ?').run(path)
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
}
private cursor(path: string): FileCursor | undefined {
return this.db
.prepare('SELECT session_row_id,byte_offset FROM files WHERE path = ?')
.get(path) as FileCursor | undefined
}
private buffered(
candidate: SessionFileCandidate,
opened: FileCursor | undefined,
append: boolean,
identity?: () => TranscriptSessionIdentity | null
): SessionSearchFileWrite {
const db = this.db
const path = candidate.file.path
const buffer: TranscriptMessage[] = []
let bufferedChars = 0
// What this write believes the file record holds. Re-read inside every
// transaction: a `removeFile` or another writer between two chunks means
// these rows no longer continue anything, and committing on top of that
// would resurrect a deleted source or duplicate a span.
let expected = opened
const removalsAtStart = this.removals.get(path) ?? 0
// The session row is reused across re-reads of one file, so a `replace`
// swaps a session's rows rather than minting a second generation of it.
let session = opened?.session_row_id ?? null
let hash = append && session !== null ? this.records.contentHash(session) : EMPTY_CONTENT_HASH
// A replace owns the session's whole row set, so the old generation goes in
// the same transaction as the first of the new one. Chunk two onwards must
// not repeat it.
//
// It goes by being cut loose, not by being deleted. Deleting every old row
// inline sizes the transaction by the session being replaced rather than by
// the chunk being written: 1,286 ms against 720 ms fresh on the 100 MB
// corpus, and it grows with the history. Instead the first transaction
// mints a new session row, points `files` at it and deletes the one old
// `sessions` row. Every retrieval joins `sessions`, so the old generation
// stops answering the moment that commits, and its messages are reclaimed
// afterwards by the same bounded drain retention uses — which is where the
// old rows would have ended up had the process died here anyway.
// `sessions.id` is AUTOINCREMENT, so the freed id is never handed to
// another session while those rows still name it (round 8).
let replaced = append
// Set by the transaction that cut a generation loose; read once it commits.
let orphaned = false
// Set when the file record moved under this read. Nothing this write holds
// can land after that, so it stops buffering rather than reopening a
// transaction it already knows will roll back, once per remaining message.
let fenced = false
// Why a counter and not the cursor alone: on a path this index never wrote,
// `expected` and the absent row are both undefined, so the cursor compare
// reads a removal as no change and the write recreates the source.
const current = (): boolean => {
if ((this.removals.get(path) ?? 0) !== removalsAtStart) {
return false
}
const row = this.cursor(path)
return (
row?.session_row_id === expected?.session_row_id &&
row?.byte_offset === expected?.byte_offset
)
}
/**
* `outcome` is null for a chunk of a read that has not reached the file's
* end, and `named` is what that chunk writes onto its session row.
*/
const write = (
outcome: TranscriptReadOutcome | null,
named: TranscriptSessionIdentity | null
): boolean => {
const decoded = outcome?.session ?? null
db.exec('BEGIN IMMEDIATE')
try {
if (!current()) {
db.exec('ROLLBACK')
return false
}
if (outcome && !decoded) {
// Read through, but nothing to search: the cursor advances so the file
// is not re-read whole on every pass, and whatever generation was here
// — including this read's own committed chunks — goes with it.
this.dropSession(session)
session = null
this.records.upsertFile(candidate, outcome.byteOffset, null)
} else {
if (replaced) {
session ??= this.records.createSessionRow(candidate)
} else {
const previous = session
session = this.records.createSessionRow(candidate)
if (previous !== null) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(previous)
orphaned = true
}
replaced = true
}
for (const row of buffer) {
insertSearchMessage(db, session, row)
}
if (decoded) {
this.records.updateSession(decoded, session, hash)
} else if (named) {
// A chunk's rows answer searches as soon as they land, so the
// session they hang off is written with whatever the parser has
// decoded rather than left empty until a read that may never end.
// `add` refuses to chunk without this, so it is never absent here.
this.records.updateProvisionalSession(session, named)
}
this.records.upsertFile(
candidate,
outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR,
session
)
}
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
// After the transaction that cut them loose is durable, never before: a
// rollback leaves the old session row standing and nothing to reclaim.
if (orphaned) {
orphaned = false
this.onOrphanedRows()
}
expected = {
session_row_id: session,
byte_offset: outcome ? outcome.byteOffset : PARTIAL_FILE_CURSOR
}
buffer.length = 0
bufferedChars = 0
return true
}
return {
add: (message) => {
if (fenced) {
return
}
hash = foldContentHash(hash, [message])
// The ceiling is checked per row, not per message: one message is a whole
// conversation turn and may be megabytes, so checking it after the whole
// message had been buffered let a single one carry a transaction as far
// past the ceiling as it was large.
for (const row of searchMessageRows([message])) {
buffer.push(row)
bufferedChars += row.text.length
if (bufferedChars < this.commitChars) {
continue
}
// Publishing a chunk under a session nothing can identify is worse
// than holding the buffer: the rows answer searches at once, and an
// interrupted read leaves that prefix for good. A read with nothing
// to name it keeps buffering and commits whole at `finish`.
const named = identity?.() ?? null
if (named && !write(null, named)) {
fenced = true
buffer.length = 0
bufferedChars = 0
return
}
}
},
commit: (outcome) => !fenced && write(outcome, null)
}
}
/** Caller's transaction: drops a session and every row that hangs off it. */
private dropSession(sessionRowId: number | null): void {
if (sessionRowId === null) {
return
}
deleteSearchMessages(this.db, sessionRowId)
this.db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionRowId)
}
}
@@ -0,0 +1,208 @@
import { mkdtemp, rm, writeFile, appendFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests,
type TranscriptSessionIdentity
} from '../ai-vault/session-transcript-consumers'
import { requestWholeTranscriptRead } from '../ai-vault/session-transcript-reader'
import SyncDatabase from '../sqlite/sync-database'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { SessionSearchStore } from './session-search-store'
import {
assistantRecord,
CLAUDE_SESSION_ID as SESSION_ID,
CODEX_ROLLOUT_FILE,
CODEX_SESSION_ID,
codexRolloutLines,
parseTranscript,
userRecord
} from './session-search-transcript-fixtures'
let tempRoots: string[] = []
let store: SessionSearchStore
// The store keeps its connection private, so row assertions need a second one.
let reader: SyncDatabase
let errors: unknown[]
beforeEach(async () => {
resetSessionParseCacheForTests()
resetTranscriptConsumersForTests()
errors = []
const path = join(await makeTempDir(), 'index.sqlite')
store = new SessionSearchStore(path, (error) => errors.push(error))
registerSessionSearchIndexConsumer(store)
reader = new SyncDatabase(path, { readonly: true })
})
afterEach(async () => {
resetTranscriptConsumersForTests()
reader.close()
store.close()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
async function makeTempDir(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-session-search-live-'))
tempRoots.push(root)
return root
}
/** Sessions a query would return for one FTS term, read on a second handle. */
function sessionsMatching(term: string): string[] {
return (
reader
.prepare(
`SELECT DISTINCT s.session_id AS id FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ? ORDER BY s.session_id`
)
.all(term) as { id: string }[]
).map((row) => row.id)
}
it('indexes a Claude transcript through the reader and resumes on append', async () => {
const root = await makeTempDir()
const path = join(root, `${SESSION_ID}.jsonl`)
await writeFile(
path,
`${[
userRecord(0, 'find the flaky terminal reattach'),
assistantRecord(1, 'look at resolveTerminalPath first')
].join('\n')}\n`
)
await parseTranscript(path)
expect(errors).toEqual([])
expect(sessionsMatching('reattach')).toEqual([SESSION_ID])
// The identifier column shadows a camel-case symbol into its pieces.
expect(sessionsMatching('terminal')).toEqual([SESSION_ID])
await appendFile(path, `${assistantRecord(2, 'the zygomorphic follow-up landed')}\n`)
const resumed = await parseTranscript(path)
// The reader resumed, so the index saw an `append`, not a whole re-read.
expect(resumed.stats).toMatchObject({ incremental: 1, fullParses: 0 })
expect(errors).toEqual([])
expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID])
// An append extends one session rather than creating a second.
expect(reader.prepare('SELECT count(*) AS n FROM sessions').get()).toEqual({
n: 1
})
})
it('keeps a tool result searchable but out of the conversation half', async () => {
const root = await makeTempDir()
const codexHome = await makeTempDir()
const path = join(root, CODEX_ROLLOUT_FILE)
await writeFile(
path,
`${codexRolloutLines(
['rg', 'pericardium'],
`outputonly ${'padding '.repeat(600)}tailonly`,
'promptonly search for the module'
).join('\n')}\n`
)
await parseTranscript(path, 'codex', codexHome)
expect(errors).toEqual([])
expect(sessionsMatching('pericardium')).toHaveLength(1)
// The prompt is conversation; the command output is not, and the column
// filter is what tells them apart.
expect(sessionsMatching('outputonly')).toHaveLength(1)
expect(sessionsMatching('tailonly')).toHaveLength(0)
expect(sessionsMatching('rg')).toHaveLength(1)
expect(sessionsMatching('{user_text assistant_text}: promptonly')).toHaveLength(1)
expect(sessionsMatching('{user_text assistant_text}: outputonly')).toHaveLength(0)
expect(sessionsMatching('{user_text assistant_text}: rg')).toHaveLength(0)
})
/** What `start.identity()` returns at each message of one read. */
function recordIdentityPerMessage(): (TranscriptSessionIdentity | null)[] {
const seen: (TranscriptSessionIdentity | null)[] = []
registerTranscriptConsumer({
beginRead: (start) => ({
message: () => {
seen.push(start.identity?.() ?? null)
},
finish: () => undefined
})
})
return seen
}
it('names the session mid-read, before the reader has finished the file', async () => {
const root = await makeTempDir()
const path = join(root, `${SESSION_ID}.jsonl`)
await writeFile(
path,
`${[
userRecord(0, 'find the flaky terminal reattach'),
assistantRecord(1, 'look at resolveTerminalPath first')
].join('\n')}\n`
)
const seen = recordIdentityPerMessage()
await parseTranscript(path)
// A chunked read commits partway through a file this size or larger, so what
// it can name the session with is exactly this.
expect(seen.length).toBeGreaterThan(0)
expect(seen[0]).toMatchObject({
sessionId: SESSION_ID,
cwd: '/repo/app',
createdAt: expect.any(String)
})
})
it('names a Codex session mid-read from its own opening record', async () => {
const root = await makeTempDir()
const codexHome = await makeTempDir()
const path = join(root, CODEX_ROLLOUT_FILE)
await writeFile(
path,
`${codexRolloutLines(['rg', 'pericardium'], 'src/main/pericardium.ts:12: match', 'search for the pericardium module').join('\n')}\n`
)
const seen = recordIdentityPerMessage()
await parseTranscript(path, 'codex', codexHome)
// Codex builds its own resumable state rather than the shared accumulator
// fold, so it is the other half of the surface a chunked commit depends on.
expect(seen[0]).toMatchObject({
sessionId: CODEX_SESSION_ID,
cwd: '/repo/app'
})
})
it('indexes a file the session list already read past, once a whole read is asked for', async () => {
const root = await makeTempDir()
const path = join(root, `${SESSION_ID}.jsonl`)
await writeFile(path, `${userRecord(0, 'the opening prompt')}\n`)
// The state on first enablement inside a running app: the session list has
// read this file, so the parse cache is warm, while the index is empty.
resetTranscriptConsumersForTests()
await parseTranscript(path)
registerSessionSearchIndexConsumer(store)
await appendFile(path, `${assistantRecord(1, 'a zygomorphic reply')}\n`)
const appended = await parseTranscript(path)
expect(appended.stats).toMatchObject({ incremental: 1, fullParses: 0 })
// The append continued from a byte offset the index never saw, so it declined.
expect(sessionsMatching('zygomorphic')).toEqual([])
const behind = store.takeStale()
expect(behind.map((candidate) => candidate.file.path)).toEqual([path])
for (const candidate of behind) {
requestWholeTranscriptRead(candidate.file.path)
}
const reread = await parseTranscript(path)
expect(reread.stats).toMatchObject({ incremental: 0, fullParses: 1 })
expect(errors).toEqual([])
expect(sessionsMatching('zygomorphic')).toEqual([SESSION_ID])
expect(sessionsMatching('opening')).toEqual([SESSION_ID])
expect(store.takeStale()).toEqual([])
})
@@ -0,0 +1,249 @@
import { expect, it } from 'vitest'
import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers'
import { insertSearchMessage, searchMessageRows } from './session-search-message-rows'
import {
openSessionSearchIndexFile,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
/** Every column of the FTS table, so an assertion cannot miss the shadow terms. */
async function indexedColumns(
index: SessionSearchIndexFile,
message: TranscriptMessage
): Promise<string[]> {
for (const row of searchMessageRows([message])) {
insertSearchMessage(index.db, 1, row)
}
const full = index.db
.prepare('SELECT user_text, assistant_text, tool_text, identifiers FROM messages_fts')
.all() as Record<string, string>[]
return full.flatMap((row) => Object.values(row))
}
it('splits an oversized message on a line boundary and keeps every character', () => {
const line = `${'padding '.repeat(11)}word\n`
const text = line.repeat(400)
const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map(
(row) => row.text
)
expect(chunks.length).toBeGreaterThan(1)
expect(chunks.join('')).toBe(text)
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(8000)
expect(chunk.endsWith('\n')).toBe(true)
}
})
it('cuts at whitespace rather than through the word on the boundary', async () => {
const index = await openSessionSearchIndexFile('ss-rows-whitespace')
try {
// The 8,000th character lands inside `pericardium`. Cutting at the target
// would file `per` under one row and `icardium` under another, and the word
// the user types would match neither.
const text = `${' '.repeat(7997)}pericardium`
const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])]
expect(chunks.map((row) => row.text).join('')).toBe(text)
for (const row of chunks) {
insertSearchMessage(index.db, 1, row)
}
expect(
index.db
.prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
.get('pericardium')
).toEqual({ n: 1 })
} finally {
await index.close()
}
})
it.each(['/repo/pericardium.ts', 'PROJ-12345', 'C++', 'cafe\u0301ine'])(
'preserves the exact FTS token %s at a chunk boundary',
async (token) => {
const index = await openSessionSearchIndexFile('ss-rows-tokenchars')
try {
const text = ' '.repeat(7998) + token
const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])]
expect(chunks.map((row) => row.text).join('')).toBe(text)
for (const row of chunks) {
insertSearchMessage(index.db, 1, row)
}
expect(
index.db
.prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
.get(`"${token}"`)
).toEqual({ n: 1 })
} finally {
await index.close()
}
}
)
it.each(['\u0305', '\u030d', '\u0332'])(
'cuts at a combining mark unicode61 treats as a separator: %s',
async (mark) => {
const index = await openSessionSearchIndexFile('ss-rows-unicode-separator')
try {
const text = `${'x'.repeat(7997)}${mark}pericardium`
for (const row of searchMessageRows([{ role: 'user', text, timestamp: null }])) {
insertSearchMessage(index.db, 1, row)
}
expect(
index.db
.prepare("SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH 'pericardium'")
.get()
).toEqual({ n: 1 })
} finally {
await index.close()
}
}
)
it('backs up to any whitespace, not only a newline', () => {
// An ideographic space separates words in a CJK transcript exactly as a
// space does here, and a newline-only backoff tears the token after it.
const text = `${'\u4e00'.repeat(7000)}\u3000${'\u4e8c'.repeat(2000)}`
const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map(
(row) => row.text
)
expect(chunks[0]).toBe(`${'\u4e00'.repeat(7000)}\u3000`)
expect(chunks.join('')).toBe(text)
})
it('cuts at punctuation when the window holds no whitespace at all', async () => {
const index = await openSessionSearchIndexFile('ss-rows-minified')
try {
// Valid minified JSON, the shape a tool result carries: 8,000 characters
// without a single space. The 8,000th lands inside `pericardium`, and a
// whitespace-only backoff has nothing in the window to back up to, so it
// files `perica` under one row and `rdium` under the next.
const text = `{"pad":"${'x'.repeat(7976)}","note":"pericardium"}`
expect(JSON.parse(text)).toEqual({ pad: 'x'.repeat(7976), note: 'pericardium' })
expect(text.slice(7994, 8005)).toBe('pericardium')
expect(/\s/.test(text)).toBe(false)
const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])]
expect(chunks.map((row) => row.text).join('')).toBe(text)
for (const row of chunks) {
insertSearchMessage(index.db, 1, row)
}
expect(
index.db
.prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
.get('pericardium')
).toEqual({ n: 1 })
} finally {
await index.close()
}
})
it('keeps a 9,000-character identifier whole rather than cutting at its underscores', () => {
// `_` sits inside a token for this tokenizer, so it is not a boundary. A
// snake_case name that long holds none at all, and the target itself is the
// honest cut — backing up to every `_` would file the name in pieces.
const text = 'ab_'.repeat(3000)
expect(text.length).toBe(9000)
const chunks = [...searchMessageRows([{ role: 'user', text, timestamp: null }])].map(
(row) => row.text
)
expect(chunks.map((chunk) => chunk.length)).toEqual([8000, 1000])
expect(chunks.join('')).toBe(text)
})
it('still chunks a message that holds no whitespace at all', () => {
// A 20,000-character token is not a word, so the target itself is the cut and
// the message is still bounded.
const chunks = [
...searchMessageRows([{ role: 'user', text: 'a'.repeat(20_000), timestamp: null }])
]
expect(chunks.map((row) => row.text.length)).toEqual([8000, 8000, 4000])
})
it('leaves a message that fits as a single row', () => {
const rows = [...searchMessageRows([{ role: 'user', text: 'short enough', timestamp: null }])]
expect(rows.map((row) => row.text)).toEqual(['short enough'])
})
it('caps a tool row at its head and never caps the conversation', async () => {
const index = await openSessionSearchIndexFile('ss-rows-tool-cap')
try {
// The reader hands over untruncated text (its own bound is 256 KB per
// message and a consumer may be handed more); the cap is this module's.
const output = `pericardium ${'padding '.repeat(140_000)}`
expect(output.length).toBeGreaterThan(1024 * 1024)
const toolRows = [...searchMessageRows([{ role: 'tool', text: output, timestamp: null }])]
expect(toolRows).toHaveLength(1)
expect(toolRows[0]!.text.length).toBe(3072)
// The head is what identifies what ran, so it is what survives.
expect(toolRows[0]!.text.startsWith('pericardium ')).toBe(true)
// The same text as an assistant turn is conversation, and keeps every byte.
const assistantRows = [
...searchMessageRows([{ role: 'assistant', text: output, timestamp: null }])
]
expect(assistantRows.map((row) => row.text).join('')).toBe(output)
expect(assistantRows.length).toBeGreaterThan(100)
for (const row of toolRows) {
insertSearchMessage(index.db, 1, row)
}
expect(
index.db
.prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
.get('pericardium')
).toEqual({ n: 1 })
} finally {
await index.close()
}
})
it('files a tool row under the tool column alone', async () => {
const index = await openSessionSearchIndexFile('ss-message-rows-tool')
try {
for (const row of searchMessageRows([
{ role: 'tool', text: 'rg pericardium', timestamp: null }
])) {
insertSearchMessage(index.db, 1, row)
}
expect(index.db.prepare('SELECT count(*) AS n FROM messages_fts').get()).toEqual({ n: 1 })
// What makes a conversation-scoped search exclude it: the column filter, not
// a second table.
expect(
index.db
.prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
.get('{user_text assistant_text}: pericardium')
).toEqual({ n: 0 })
expect(
index.db
.prepare('SELECT count(*) AS n FROM messages_fts WHERE messages_fts MATCH ?')
.get('{tool_text}: pericardium')
).toEqual({ n: 1 })
} finally {
await index.close()
}
})
it('stores a chunk exactly as the transcript wrote it', async () => {
const index = await openSessionSearchIndexFile('ss-rows-verbatim')
try {
const text = 'deploy with AKIAIOSFODNN7EXAMPLE and the resolveTerminalPath fix'
const stored = await indexedColumns(index, {
role: 'assistant',
text,
timestamp: null
})
// The index is a second copy of content the user already holds in plaintext,
// so it neither rewrites nor drops any of it.
expect(stored).toContain(text)
// Identifier shadow terms come off that same raw chunk.
expect(stored.some((column) => column.includes('resolve terminal path'))).toBe(true)
} finally {
await index.close()
}
})
@@ -0,0 +1,135 @@
import type SyncDatabase from '../sqlite/sync-database'
import type { TranscriptMessage } from '../ai-vault/session-transcript-consumers'
import { sliceAtCodeUnitLimit } from '../ai-vault/session-scanner-text-normalization'
import { identifierShadowText } from './session-search-identifier-split'
const CHUNK_TARGET_CHARS = 8000
/**
* How much of one tool output is indexed. Its head: a command, its arguments and
* the first lines of what it printed are what a user searches for, while the
* tail is the padding that makes these messages large in the first place.
*
* Tool output is 80-97 % of a transcript's bytes, and a single one can be a
* quarter of a megabyte (the reader's own per-message bound). Without this the
* index, the in-memory buffer a read holds and the transaction it commits are
* all sized by how much a tool printed rather than by how much is worth
* searching. 3 KB was the accuracy/size sweet spot in the original design
* measurement. User and assistant text is never capped: it is the conversation,
* and it is small.
*/
const TOOL_ROW_CHARS = 3072
// Keep unicode61's tokenchars intact, including before an available space.
// SQLite ext/fts5/fts5_unicode2.c: sqlite3Fts5UnicodeIsdiacritic, with remove_diacritics=1.
const FOLDED_DIACRITIC =
/[\u0300-\u0304\u0306-\u030c\u030f\u0311\u031b\u0323-\u0328\u032d-\u032e\u0330-\u0331]/
const TOKEN_BOUNDARY = /[^\p{L}\p{N}\p{Co}_.\-/+\uD800-\uDFFF]/u
/**
* Index just past the last token boundary in `[floor, end)`, or -1 when the
* window holds none. Not only a newline: a wrapped paragraph, a CJK transcript
* separated by ideographic spaces and a minified log all chunk on a boundary a
* tokenizer would have picked anyway.
*/
function lastTokenBoundaryEnd(text: string, floor: number, end: number): number {
for (let at = end - 1; at >= floor; at--) {
if (TOKEN_BOUNDARY.test(text[at]!) && !FOLDED_DIACRITIC.test(text[at]!)) {
return at + 1
}
}
return -1
}
/**
* Splits an oversized message into rows of at most `CHUNK_TARGET_CHARS`, cutting
* on a token boundary so no token is torn in half and every word stays
* searchable. A phrase that straddles two chunks is not matched: chunks are
* separate FTS rows and FTS5 cannot span them.
*/
function* textChunks(text: string): Generator<string> {
if (text.length <= CHUNK_TARGET_CHARS) {
yield text
return
}
let start = 0
while (start < text.length) {
let end = Math.min(text.length, start + CHUNK_TARGET_CHARS)
if (end < text.length) {
// Only the second half of the window: backing up further would trade a
// torn token for chunks half the size. No boundary at all in 4,000
// characters is not a word, so the target itself is the honest cut.
const split = lastTokenBoundaryEnd(text, start + CHUNK_TARGET_CHARS / 2, end)
if (split > start) {
end = split
}
}
yield text.slice(start, end)
start = end
}
}
/**
* The row policy for one message: a `tool` message becomes one capped row, and
* anything else becomes N chunks, because FTS5 ranks a short row far better
* than a huge one.
*/
export function* searchMessageRows(
messages: Iterable<TranscriptMessage>
): Generator<TranscriptMessage> {
for (const message of messages) {
if (message.role === 'tool') {
yield {
...message,
text: sliceAtCodeUnitLimit(message.text, TOOL_ROW_CHARS)
}
continue
}
for (const text of textChunks(message.text)) {
yield { ...message, text }
}
}
}
/**
* Writes one row into `messages` and `messages_fts` in the caller's
* transaction, so a message is never present in one and absent from the other.
* A conversation-scoped query filters the columns rather than reading a second
* table (see the schema).
*/
export function insertSearchMessage(
db: SyncDatabase,
sessionId: number,
message: TranscriptMessage
): void {
const text = message.text
const id = db
.prepare('INSERT INTO messages(session_row_id, role, ts) VALUES (?, ?, ?)')
.run(sessionId, message.role, message.timestamp).lastInsertRowid
const user = message.role === 'user' ? text : ''
const assistant = message.role === 'assistant' ? text : ''
const tool = message.role === 'tool' ? text : ''
db.prepare(
'INSERT INTO messages_fts(rowid,user_text,assistant_text,tool_text,identifiers) VALUES (?,?,?,?,?)'
).run(id, user, assistant, tool, identifierShadowText(text))
}
/**
* Deletes up to `limit` of a session's rows from `messages` and `messages_fts`,
* in the caller's transaction, and reports how many went. Bounded
* because a retention sweep must not hold one transaction over a whole
* session; a replace passes no limit, since its rows and their replacements
* have to land together.
*/
export function deleteSearchMessages(db: SyncDatabase, sessionId: number, limit = -1): number {
const ids = db
.prepare('SELECT id FROM messages WHERE session_row_id = ? LIMIT ?')
.all(sessionId, limit) as { id: number }[]
const full = db.prepare('DELETE FROM messages_fts WHERE rowid = ?')
const message = db.prepare('DELETE FROM messages WHERE id = ?')
for (const { id } of ids) {
full.run(id)
message.run(id)
}
return ids.length
}
@@ -0,0 +1,188 @@
import { expect, it } from 'vitest'
import type SyncDatabase from '../sqlite/sync-database'
import {
deleteExpiredSearchFiles,
RETENTION_DELETE_ROWS_PER_STEP
} from './session-search-retention-delete'
import { openSessionSearchIndexFile } from './session-search-index-test-fixture'
import { SessionSearchStore } from './session-search-store'
function seed(db: SyncDatabase, id: number, rows: number, mtime: number): void {
db.prepare(
`INSERT INTO sessions(id,agent,session_id,file_path,title,cwd,cwd_key,resume_command)
VALUES (?, 'claude', ?, ?, 'synthetic retention', '/fixture', '/fixture', '')`
).run(id, String(id), String(id))
db.prepare('INSERT INTO files(path,byte_offset,mtime_ms,session_row_id) VALUES (?,1,?,?)').run(
String(id),
mtime,
id
)
db.exec('BEGIN')
for (let i = 0; i < rows; i++) {
const row = db
.prepare("INSERT INTO messages(session_row_id,role) VALUES (?,'user')")
.run(id).lastInsertRowid
db.prepare('INSERT INTO messages_fts(rowid,user_text) VALUES (?,?)').run(row, 'retentionneedle')
}
db.exec('COMMIT')
}
/**
* Sessions a search would still return. Every retrieval joins a message to its
* session, which is what makes cutting the session loose enough to hide the
* whole thing while its rows are still being reclaimed.
*/
function visibleSessionIds(db: SyncDatabase): string[] {
return (
db
.prepare(
`SELECT DISTINCT s.session_id AS id FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH 'retentionneedle' ORDER BY s.session_id`
)
.all() as { id: string }[]
).map((row) => row.id)
}
function count(db: SyncDatabase, table: string): number {
return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n
}
it('seeks the expiring end of the file list instead of scanning it', async () => {
const index = await openSessionSearchIndexFile('ss-retention-plan')
try {
seed(index.db, 1, 1, 1)
const plan = (
index.db
.prepare('EXPLAIN QUERY PLAN SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms')
.all(100) as { detail: string }[]
)
.map((row) => row.detail)
.join(' ')
// Without files_mtime this is "SCAN files" plus a "USE TEMP B-TREE FOR ORDER BY".
expect(plan).toContain('files_mtime')
expect(plan).not.toContain('TEMP B-TREE')
} finally {
await index.close()
}
})
it('hides an expiring session at once, then reclaims its rows in bounded steps', async () => {
const index = await openSessionSearchIndexFile('ss-retention-yield')
seed(index.db, 1, 1025, 1)
seed(index.db, 2, 1, 200)
let previous = 1025
const steps: number[] = []
try {
await deleteExpiredSearchFiles(
index.db,
100,
() => false,
async () => {
const left = count(index.db, 'messages WHERE session_row_id=1')
steps.push(previous - left)
previous = left
// Cut loose in the very first transaction, so no query ever sees it with
// some of its messages already gone.
expect(visibleSessionIds(index.db)).toEqual(['2'])
}
)
// The file transaction, then one bounded batch per step until the rows are gone.
expect(steps).toEqual([0, RETENTION_DELETE_ROWS_PER_STEP, 256, 256, 256, 1])
expect(count(index.db, 'messages_fts')).toBe(1)
expect(count(index.db, 'sessions')).toBe(1)
} finally {
await index.close()
}
})
it('finishes an interrupted deletion after reopening', async () => {
const index = await openSessionSearchIndexFile('ss-retention-resume')
let store = new SessionSearchStore(index.path)
let closed = false
let steps = 0
try {
seed(index.db, 1, 513, 1)
await deleteExpiredSearchFiles(
index.db,
100,
() => closed,
async () => {
if (++steps === 2) {
store.close()
closed = true
}
}
)
// Some rows went, the rest did not, and nothing recorded that anywhere.
const stranded = count(index.db, 'messages')
expect(stranded).toBeGreaterThan(0)
expect(stranded).toBeLessThan(513)
expect(visibleSessionIds(index.db)).toEqual([])
store = new SessionSearchStore(index.path)
closed = false
// Rows nothing points at are the whole record of unfinished work, so the
// rest goes even with retention now unlimited.
await store.purgeOlderThan(null)
expect(count(index.db, 'messages')).toBe(0)
expect(count(index.db, 'messages_fts')).toBe(0)
} finally {
if (!closed) {
store.close()
}
await index.close()
}
})
it('cancels retention between batches and resumes without exposing a partial session', async () => {
const index = await openSessionSearchIndexFile('ss-retention-cancel')
const store = new SessionSearchStore(index.path)
try {
seed(index.db, 1, 1025, 1)
const controller = new AbortController()
const purge = store.purgeOlderThan(100, controller.signal)
setImmediate(() => controller.abort())
await purge
const remaining = count(index.db, 'messages')
expect(remaining).toBeGreaterThan(0)
expect(remaining).toBeLessThan(1025)
expect(visibleSessionIds(index.db)).toEqual([])
await store.purgeOlderThan(null)
expect(count(index.db, 'messages')).toBe(0)
} finally {
store.close()
await index.close()
}
})
it('keeps a file a read refreshed after the expiry list was taken', async () => {
const index = await openSessionSearchIndexFile('ss-retention-refreshed')
try {
seed(index.db, 1, 2, 1)
seed(index.db, 2, 2, 2)
let refreshed = false
// The scan of `files` happens once, up front. A read of the second transcript
// lands while the first is being deleted, which makes it new enough to keep.
await deleteExpiredSearchFiles(
index.db,
100,
() => false,
async () => {
if (!refreshed) {
refreshed = true
index.db.prepare('UPDATE files SET mtime_ms = 500 WHERE path = ?').run('2')
}
}
)
// Only the per-file transaction re-reading the mtime it is about to act on
// keeps that session; the list it came from says both should go.
expect(count(index.db, 'files')).toBe(1)
expect(visibleSessionIds(index.db)).toEqual(['2'])
expect(count(index.db, 'messages')).toBe(2)
} finally {
await index.close()
}
})
@@ -0,0 +1,102 @@
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type SyncDatabase from '../sqlite/sync-database'
import { deleteSearchMessages } from './session-search-message-rows'
export const RETENTION_DELETE_ROWS_PER_STEP = 256
// Why in step with the deletes rather than one sweep at the end: `auto_vacuum =
// INCREMENTAL` holds every freed page until something asks for it back, and
// asking for a whole purge's worth at once is one long stall (40 ms per 22 MB
// freed, measured) instead of many short ones.
const RECLAIM_PAGES_PER_STEP = 2000
/**
* Drops every file older than the cutoff, then hands its rows back in bounded
* steps.
*
* The two halves are separate on purpose. Cutting a session loose from its file
* is one small transaction, and it is what makes the session stop answering
* searches — every read joins `sessions`, so a row whose session is gone is
* already unreachable. Reclaiming those rows is the expensive half, and it can
* be paused, interrupted or resumed at any point without a reader ever seeing a
* session that is half deleted. A crash in the middle leaves rows nothing
* points at, and `drainOrphanedMessages` finds them on the next pass.
*/
export async function deleteExpiredSearchFiles(
db: SyncDatabase,
cutoffMs: number | null,
closed: () => boolean,
yieldStep: () => Promise<void> = yieldToEventLoop
): Promise<void> {
if (cutoffMs !== null) {
const expired = db
.prepare('SELECT path FROM files WHERE mtime_ms < ? ORDER BY mtime_ms')
.all(cutoffMs) as { path: string }[]
for (const { path } of expired) {
if (closed()) {
return
}
db.exec('BEGIN IMMEDIATE')
try {
// Re-read under the lock: a read of this file may have landed since the
// list was taken, which makes it new enough to keep.
const file = db
.prepare('SELECT session_row_id FROM files WHERE path = ? AND mtime_ms < ?')
.get(path, cutoffMs) as { session_row_id: number | null } | undefined
if (file) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(file.session_row_id)
db.prepare('DELETE FROM files WHERE path = ?').run(path)
}
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
await yieldStep()
}
}
await drainOrphanedMessages(db, closed, yieldStep)
}
/**
* Deletes rows whose session no longer exists, a bounded batch per transaction.
*
* That set is exactly what retention, a replace that cut its old generation
* loose, a removed source and an interrupted earlier drain leave behind, so the
* index needs no record of unfinished work beyond the rows themselves.
*
* Exported for the store, which runs it after a replace commits for the same
* reason retention runs it after its own small transaction: cutting a session
* loose is what hides it, and reclaiming its rows is the half that must not
* hold one transaction.
*/
export async function drainOrphanedMessages(
db: SyncDatabase,
closed: () => boolean,
yieldStep: () => Promise<void> = yieldToEventLoop
): Promise<void> {
// Ordered by session so one call to this walks a session's rows to the end
// before paying for the scan that finds the next one.
const nextOrphan = db.prepare(
`SELECT session_row_id FROM messages
WHERE session_row_id NOT IN (SELECT id FROM sessions) LIMIT 1`
)
let orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id
while (orphan !== undefined && !closed()) {
db.exec('BEGIN IMMEDIATE')
let deleted = 0
try {
deleted = deleteSearchMessages(db, orphan, RETENTION_DELETE_ROWS_PER_STEP)
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`)
if (deleted < RETENTION_DELETE_ROWS_PER_STEP) {
orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id
}
await yieldStep()
}
// A `removeFile` frees its pages outside this loop and may leave none to drain.
db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`)
}
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, expect, it } from 'vitest'
import type SyncDatabase from '../sqlite/sync-database'
import { deleteExpiredSearchFiles } from './session-search-retention-delete'
import {
openSessionSearchIndexFile,
syntheticCandidate,
syntheticSession,
userMessages,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
import { SessionSearchStore } from './session-search-store'
// A session row id outlives the row: it names the rows in `messages` until a
// retention drain has walked all of them, which takes many transactions. These
// tests are about what may be handed that id in the meantime.
let index: SessionSearchIndexFile
let store: SessionSearchStore
let errors: unknown[]
beforeEach(async () => {
index = await openSessionSearchIndexFile('ss-row-identity')
errors = []
store = new SessionSearchStore(index.path, (error) => errors.push(error))
})
afterEach(async () => {
store.close()
await index.close()
})
const OLD_MTIME = 1_000
const LIVE_MTIME = 1_000_000
const LIVE_PATH = '/live.jsonl'
function count(db: SyncDatabase, table: string): number {
return (db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n
}
/** Rows a search would return for a term: the join every retrieval makes. */
function matches(db: SyncDatabase, term: string): number {
return (
db
.prepare(
`SELECT count(*) AS n FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id WHERE messages_fts MATCH ?`
)
.get(term) as { n: number }
).n
}
function indexFile(path: string, mtimeMs: number, text: string, rows: number): void {
const write = store.beginWrite(syntheticCandidate({ path, mtimeMs }), 'replace', 0)!
for (const message of userMessages(text, rows)) {
write.add(message)
}
expect(write.commit({ session: syntheticSession(), byteOffset: 50, incomplete: false })).toBe(
true
)
}
it('never hands a live session the rows of a purged one', async () => {
// Two expiring transcripts, each large enough that reclaiming their rows takes
// several transactions, and one live transcript the parser decoded no session
// from — so it holds a cursor and no session row of its own.
indexFile('/old-a.jsonl', OLD_MTIME, 'purgedneedle', 400)
indexFile('/old-b.jsonl', OLD_MTIME, 'purgedneedle', 400)
const live = syntheticCandidate({ path: LIVE_PATH, mtimeMs: LIVE_MTIME })
const opening = store.beginWrite(live, 'replace', 0)!
opening.add(userMessages('excluded', 1)[0]!)
expect(opening.commit({ session: null, byteOffset: 50, incomplete: false })).toBe(true)
let appended = false
await deleteExpiredSearchFiles(
index.db,
LIVE_MTIME,
() => false,
async () => {
// The window: both expiring sessions are cut loose, most of their rows are
// still on disk, and the live transcript grows. The append is legitimate —
// it continues this index's own cursor — and it needs a session row.
if (appended || count(index.db, 'sessions') > 0) {
return
}
appended = true
const write = store.beginWrite(live, 'append', 50)!
for (const message of userMessages('liveneedle', 2)) {
write.add(message)
}
expect(
write.commit({ session: syntheticSession(), byteOffset: 120, incomplete: false })
).toBe(true)
}
)
expect(appended).toBe(true)
// Reusing a freed id would adopt whatever of that session's rows the drain had
// not reached, and put them behind a live session no purge will visit again.
expect(matches(index.db, 'purgedneedle')).toBe(0)
expect(matches(index.db, 'liveneedle')).toBe(2)
expect(count(index.db, 'messages')).toBe(2)
expect(errors).toEqual([])
})
it('never reissues a session row id a delete freed', () => {
for (const path of ['/a.jsonl', '/b.jsonl', '/c.jsonl']) {
indexFile(path, OLD_MTIME, 'seeded', 1)
}
const before = (index.db.prepare('SELECT max(id) AS id FROM sessions').get() as { id: number }).id
index.db.exec('DELETE FROM sessions')
indexFile('/d.jsonl', OLD_MTIME, 'seeded', 1)
expect((index.db.prepare('SELECT id FROM sessions').get() as { id: number }).id).toBeGreaterThan(
before
)
})
@@ -0,0 +1,350 @@
import type * as NodeFs from 'node:fs'
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
removeTree,
WINDOWS_RM_MAX_RETRIES,
WINDOWS_RM_RETRY_DELAY_MS
} from '../../shared/windows-transient-lock-removal'
import SyncDatabase from '../sqlite/sync-database'
import {
SESSION_SEARCH_SCHEMA_VERSION,
openSessionSearchDatabase,
removeSessionSearchDatabase
} from './session-search-schema'
const recordedRmSync = vi.hoisted(() => vi.fn())
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
rmSync: (...args: Parameters<typeof actual.rmSync>) => {
recordedRmSync(...args)
return actual.rmSync(...args)
}
}
})
let roots: string[] = []
afterEach(async () => {
await Promise.all(roots.map((root) => removeTree(root)))
roots = []
})
async function tempDatabasePath(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-session-search-schema-'))
roots.push(root)
return join(root, 'index.sqlite')
}
function schemaVersion(db: SyncDatabase): string | undefined {
return (
db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as
| { value: string }
| undefined
)?.value
}
describe('openSessionSearchDatabase', () => {
it('keeps a current-version index and its rows', async () => {
const path = await tempDatabasePath()
const first = openSessionSearchDatabase(path)
first.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
first.close()
const second = openSessionSearchDatabase(path)
expect(schemaVersion(second)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
expect(second.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
c: 1
})
second.close()
})
it('carries one FTS table and throws away an index that carries two', async () => {
const path = await tempDatabasePath()
const fresh = openSessionSearchDatabase(path)
const tables = (): string[] =>
(
fresh
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_fts'")
.all() as { name: string }[]
).map((row) => row.name)
expect(tables()).toEqual(['messages_fts'])
// What an index written before this bump looks like: the second table, and
// rows in it. `CREATE TABLE IF NOT EXISTS` would leave both in place, so
// only the version bump makes that file go.
fresh.exec('CREATE VIRTUAL TABLE conversation_fts USING fts5(user_text, assistant_text)')
fresh.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
fresh.prepare("UPDATE meta SET value = '3' WHERE key = 'schema_version'").run()
fresh.close()
const rebuilt = openSessionSearchDatabase(path)
expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
expect(
rebuilt
.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name = 'conversation_fts'")
.get()
).toEqual({ n: 0 })
expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({ c: 0 })
rebuilt.close()
})
it('replaces the file on a version mismatch instead of dropping tables in place', async () => {
const path = await tempDatabasePath()
const stale = openSessionSearchDatabase(path)
stale.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
stale
.prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'")
.run(String(SESSION_SEARCH_SCHEMA_VERSION + 1))
stale.close()
// Why: a stale sidecar must go with the main file, or SQLite replays it into the new one.
await writeFile(`${path}-wal`, 'stale wal bytes')
const before = await stat(path)
const fresh = openSessionSearchDatabase(path)
expect(schemaVersion(fresh)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
expect(fresh.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
c: 0
})
fresh.close()
// Why not inode: ext4 hands a freed inode straight back to the next create.
// The planted sidecar is gone (a fresh WAL is checkpointed away on close).
await expect(stat(`${path}-wal`)).rejects.toMatchObject({ code: 'ENOENT' })
expect((await stat(path)).mtimeMs).toBeGreaterThanOrEqual(before.mtimeMs)
})
it('removes the database with every sidecar', async () => {
const path = await tempDatabasePath()
openSessionSearchDatabase(path).close()
await writeFile(`${path}-shm`, '')
removeSessionSearchDatabase(path)
for (const suffix of ['', '-wal', '-shm']) {
await expect(stat(`${path}${suffix}`)).rejects.toMatchObject({
code: 'ENOENT'
})
}
})
})
it('rebuilds a file too corrupt to open instead of refusing forever', async () => {
const path = await tempDatabasePath()
const healthy = openSessionSearchDatabase(path)
healthy.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
healthy.close()
// A torn page, not a truncation: SQLite opens the header and fails on the read.
const bytes = await readFile(path)
bytes.fill(0x7f, 4096, Math.min(bytes.length, 12_288))
await writeFile(path, bytes)
const rebuilt = openSessionSearchDatabase(path)
try {
expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
c: 0
})
} finally {
rebuilt.close()
}
})
it('rebuilds a file that is not a database at all', async () => {
const path = await tempDatabasePath()
await writeFile(path, 'not a SQLite database')
const rebuilt = openSessionSearchDatabase(path)
try {
expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
} finally {
rebuilt.close()
}
})
it('gives up rather than looping when a fresh file still cannot be opened', async () => {
const path = await tempDatabasePath()
await writeFile(path, 'not a SQLite database')
// Every open of this path fails, so the one permitted retry is exhausted.
const open = vi.spyOn(SyncDatabase.prototype, 'pragma').mockImplementation(() => {
throw Object.assign(new Error('database disk image is malformed'), {
code: 'SQLITE_CORRUPT'
})
})
try {
expect(() => openSessionSearchDatabase(path)).toThrow(/malformed/)
} finally {
open.mockRestore()
}
})
it('surfaces the unlink failure itself when a stale index cannot be removed', async () => {
const path = await tempDatabasePath()
const stale = openSessionSearchDatabase(path)
stale
.prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'")
.run(String(SESSION_SEARCH_SCHEMA_VERSION + 1))
stale.close()
recordedRmSync.mockReset()
recordedRmSync.mockImplementation(() => {
throw Object.assign(new Error('EPERM: operation not permitted, unlink'), {
code: 'EPERM'
})
})
try {
// The stale handle is closed before the unlink, so the failure path must not
// close it again: ERR_INVALID_STATE would bury the cause and would not be
// classified as worth a rebuild.
expect(() => openSessionSearchDatabase(path)).toThrow(/EPERM/)
expect(() => openSessionSearchDatabase(path)).not.toThrow(/not open/)
} finally {
recordedRmSync.mockReset()
}
})
it('creates the directory the index lives in', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-session-search-mkdir-'))
roots.push(root)
// The real layout: `<userData>/ai-vault-search/index.sqlite`, where nothing
// has made that folder yet. SQLite would fail with `unable to open database
// file`, which is correctly not treated as corruption, so it never retries.
const db = openSessionSearchDatabase(join(root, 'ai-vault-search', 'index.sqlite'))
try {
expect(schemaVersion(db)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
} finally {
db.close()
}
})
it('rebuilds a newer index rather than reading a schema it does not know', async () => {
const path = await tempDatabasePath()
const newer = openSessionSearchDatabase(path)
newer.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
newer
.prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'")
.run(String(SESSION_SEARCH_SCHEMA_VERSION + 1))
newer.close()
const rebuilt = openSessionSearchDatabase(path)
try {
expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
c: 0
})
} finally {
rebuilt.close()
}
})
it('rebuilds when meta exists but its version row is gone', async () => {
const path = await tempDatabasePath()
const damaged = openSessionSearchDatabase(path)
damaged.prepare("INSERT INTO files(path,byte_offset,mtime_ms) VALUES ('a',1,1)").run()
// A meta table with no version is a damaged index, never a fresh one: seeding
// the current version over it would keep whatever the old schema left behind.
damaged.prepare("DELETE FROM meta WHERE key = 'schema_version'").run()
damaged.close()
const rebuilt = openSessionSearchDatabase(path)
try {
expect(schemaVersion(rebuilt)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION))
expect(rebuilt.prepare('SELECT COUNT(*) AS c FROM files').get()).toEqual({
c: 0
})
} finally {
rebuilt.close()
}
})
it('opens with the pragmas the write path depends on', async () => {
const db = openSessionSearchDatabase(await tempDatabasePath())
try {
// auto_vacuum=2 is INCREMENTAL, and only takes on an empty file: without it
// a purge cannot hand pages back in bounded steps.
expect(Number(db.pragma('auto_vacuum', { simple: true }))).toBe(2)
expect(String(db.pragma('journal_mode', { simple: true })).toLowerCase()).toBe('wal')
expect(Number(db.pragma('synchronous', { simple: true }))).toBe(1)
// A WAL with no size limit never hands its space back after a large write.
expect(Number(db.pragma('journal_size_limit', { simple: true }))).toBe(8388608)
// Zero here turns every contended write into an immediate SQLITE_BUSY.
expect(Number(db.pragma('busy_timeout', { simple: true }))).toBe(5000)
} finally {
db.close()
}
})
it("walks a session's rows through an index rather than scanning the table", async () => {
const db = openSessionSearchDatabase(await tempDatabasePath())
try {
// The replace delete and the orphan drain both take this path, once per file.
const plan = (
db
.prepare('EXPLAIN QUERY PLAN SELECT id FROM messages WHERE session_row_id = ? LIMIT ?')
.all(1, 1) as { detail: string }[]
)
.map((row) => row.detail)
.join(' ')
expect(plan).toContain('messages_session')
} finally {
db.close()
}
})
it('keeps only the session indexes a retrieval query can seek', async () => {
const db = openSessionSearchDatabase(await tempDatabasePath())
try {
const names = (
db
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='sessions'")
.all() as { name: string }[]
)
.map((row) => row.name)
.sort()
// One per shape PR 4's retrieval seeks: the agent filter, the newest-first
// order and date window, and the folder-prefix range scan. Fork folding reads
// `content_hash` off rows it already holds, so that column is not indexed.
expect(names).toEqual(['sessions_agent', 'sessions_cwd_key', 'sessions_updated_at'])
} finally {
db.close()
}
})
it("retries a Windows lock that outlives rmSync's own retries", async () => {
const path = await tempDatabasePath()
openSessionSearchDatabase(path).close()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
recordedRmSync.mockReset()
const locked = Object.assign(new Error('EPERM: operation not permitted'), {
code: 'EPERM'
})
recordedRmSync.mockImplementationOnce(() => {
throw locked
})
try {
expect(() => removeSessionSearchDatabase(path)).not.toThrow()
expect(recordedRmSync.mock.calls.length).toBe(5)
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
recordedRmSync.mockReset()
vi.restoreAllMocks()
}
})
it('gives Windows the shared retry options for a late handle release', async () => {
const path = await tempDatabasePath()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
recordedRmSync.mockClear()
try {
removeSessionSearchDatabase(path)
expect(recordedRmSync).toHaveBeenCalled()
for (const [, options] of recordedRmSync.mock.calls) {
expect(options).toMatchObject({
maxRetries: WINDOWS_RM_MAX_RETRIES,
retryDelay: WINDOWS_RM_RETRY_DELAY_MS
})
}
} finally {
vi.restoreAllMocks()
}
})
@@ -0,0 +1,198 @@
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import SyncDatabase from '../sqlite/sync-database'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
// The index stores transcript content as written, with no redaction. A secret in
// a transcript is already plaintext under the user's home directory and is
// treated as compromised; this is a second copy of content the user already
// holds. What a snippet may carry once it leaves this machine is a transport
// policy, decided where the wire is.
// Bump to drop and rebuild: the index is a cache over the transcripts, never a source.
export const SESSION_SEARCH_SCHEMA_VERSION = 5
// unicode61 keeps `_ . - /` inside tokens so paths and identifiers match exactly;
// the `identifiers` column carries the split form (see session-search-identifier-split).
// Why: `+` keeps `C++` a token of its own instead of the letter `c`; `#` is
// left out so `#123` still answers a search for `123`.
const TOKENIZER = `tokenize="unicode61 tokenchars '_.-/+'"`
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS sessions(
-- AUTOINCREMENT, because this id names rows in the messages table for longer
-- than the row itself lives: retention cuts a session loose in one
-- transaction and reclaims its messages over many. A plain rowid is reissued
-- as max+1, so a session created inside that window would be handed a freed
-- id and adopt whatever of the purged conversation the drain had not reached,
-- behind a live session no later purge visits.
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL,
session_id TEXT NOT NULL,
-- The transcript this session was decoded from. Not unique: OpenCode's SQLite
-- sessions all report the store's own path here, while files.path holds the
-- synthetic db#sessionId candidate that really is one per session.
file_path TEXT NOT NULL,
codex_home TEXT,
title TEXT NOT NULL,
cwd TEXT,
cwd_key TEXT,
branch TEXT,
created_at TEXT,
updated_at TEXT,
message_count INTEGER NOT NULL DEFAULT 0,
resume_command TEXT NOT NULL,
-- Chained digest of the first N messages; forks of one conversation share it.
content_hash TEXT,
content_hash_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS sessions_agent ON sessions(agent);
CREATE INDEX IF NOT EXISTS sessions_updated_at ON sessions(updated_at);
CREATE INDEX IF NOT EXISTS sessions_cwd_key ON sessions(cwd_key);
CREATE TABLE IF NOT EXISTS files(
path TEXT PRIMARY KEY,
dev INTEGER,
ino INTEGER,
byte_offset INTEGER NOT NULL,
mtime_ms REAL NOT NULL,
size_bytes INTEGER,
session_row_id INTEGER
);
-- Retention walks the expiring end of this column; without it that is a full scan and a sort.
CREATE INDEX IF NOT EXISTS files_mtime ON files(mtime_ms);
CREATE TABLE IF NOT EXISTS messages(
id INTEGER PRIMARY KEY,
session_row_id INTEGER NOT NULL,
role TEXT NOT NULL,
ts TEXT
);
-- Both the replace delete and the orphan drain walk a session's rows through this.
CREATE INDEX IF NOT EXISTS messages_session ON messages(session_row_id);
-- One FTS table, not two. A conversation-scoped search is a column filter on
-- this one — 'MATCH {user_text assistant_text}: q' with bm25 weights that zero
-- the other two — and PR 4 measured that at 1.16-1.36x the p95 of a dedicated
-- second table on a 105 MB corpus, under the 2x bar the decision was set at.
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
user_text, assistant_text, tool_text, identifiers, ${TOKENIZER}, detail=full
);
`
/**
* Opens the index, rebuilding it whenever what is on disk cannot be trusted:
* a different schema version, a version SQLite cannot report, or a file torn
* badly enough that opening or recovery fails. The index is a cache over the
* transcripts, so throwing away a bad one costs a re-scan and nothing else;
* refusing to open would strand the feature until a human deleted the file.
*/
export function openSessionSearchDatabase(path: string): SyncDatabase {
// SQLite will not create the directory, and its failure is `unable to open
// database file`, which is correctly not corruption — so without this the
// feature strands on a profile that has never held an index.
if (path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true })
}
try {
return openExisting(path)
} catch (error) {
if (!isUnusableDatabaseError(error)) {
throw error
}
// One retry only: a second failure on a file we just created is not corruption.
removeSessionSearchDatabase(path)
return openExisting(path)
}
}
function openExisting(path: string): SyncDatabase {
// Nulled while no handle is open, because closing an already-closed handle
// throws ERR_INVALID_STATE, which would replace whatever really failed —
// an unlink refused by a virus scanner or a second Orca holding the file —
// with an error nothing classifies as worth rebuilding for.
let db: SyncDatabase | null = openWithPragmas(path)
try {
if (isStaleSchema(db)) {
// Why: DROP TABLE on a multi-GB FTS index takes minutes and runs inside the
// scanner service's init, past its ready timeout; unlinking is instant.
db.close()
db = null
removeSessionSearchDatabase(path)
db = openWithPragmas(path)
}
db.exec(SCHEMA_SQL)
db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run(
'schema_version',
String(SESSION_SEARCH_SCHEMA_VERSION)
)
return db
} catch (error) {
db?.close()
throw error
}
}
// SQLite reports a torn file at the first statement that has to read a page, so
// this has to match on the message as well as the code.
const UNUSABLE_DATABASE =
/SQLITE_CORRUPT|SQLITE_NOTADB|file is not a database|database disk image is malformed/i
function isUnusableDatabaseError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const code = (error as { code?: unknown }).code
return (
(typeof code === 'string' && UNUSABLE_DATABASE.test(code)) ||
UNUSABLE_DATABASE.test(error.message)
)
}
function openWithPragmas(path: string): SyncDatabase {
const db = new SyncDatabase(path)
try {
// Why: only takes effect on an empty file; it is what lets a purge hand pages
// back in bounded steps instead of a full VACUUM. Set before any table exists.
db.pragma('auto_vacuum = INCREMENTAL')
// The whole consistency model: a file's rows and its cursor land in one
// transaction, and a reader on another handle sees the last committed state
// of the index rather than a session half way through being rewritten.
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.pragma('journal_size_limit = 8388608')
db.pragma('busy_timeout = 5000')
return db
} catch (error) {
db?.close()
throw error
}
}
export function removeSessionSearchDatabase(path: string): void {
if (path === ':memory:') {
return
}
for (const suffix of ['', '-wal', '-shm', '-journal']) {
removeTreeSync(`${path}${suffix}`)
}
}
/**
* Whether what is on disk has to be thrown away. No `meta` table at all is a
* file with nothing in it to throw away, and removing it would make the first
* open of every new profile a create-remove-create. A meta table whose version
* row is missing or unparseable is a damaged index rather than a new one:
* seeding the current version over it would keep whatever rows the old schema
* left.
*/
function isStaleSchema(db: SyncDatabase): boolean {
const table = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'")
.get()
if (!table) {
return false
}
const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as
| { value: string }
| undefined
return (row ? Number(row.value) : Number.NaN) !== SESSION_SEARCH_SCHEMA_VERSION
}
@@ -0,0 +1,253 @@
import type SyncDatabase from '../sqlite/sync-database'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
import type {
SessionSearchFileIdentity,
SessionSearchIndexedFile
} from './session-search-file-cursor'
import {
SESSION_SEARCH_COMMIT_CHARS,
SessionSearchIndexWriter,
type SessionSearchFileWrite
} from './session-search-index-writer'
import { deleteExpiredSearchFiles, drainOrphanedMessages } from './session-search-retention-delete'
import { openSessionSearchDatabase } from './session-search-schema'
// A paused store keeps recording what it declined, so the set needs a ceiling.
// Above it the oldest record goes and the drop is counted, because a re-read set
// that silently forgets is worse than one that says it is incomplete.
export const STALE_PATH_LIMIT = 20_000
/**
* Owns the index database. PR 2 scope: the write half only — the transcript
* consumer writes through it and nothing reads from it yet. Lifecycle (who
* indexes, when, and how the re-read set is drained) belongs to the service.
*/
export class SessionSearchStore {
private readonly db: SyncDatabase
private readonly writer: SessionSearchIndexWriter
private closed = false
private acceptingWrites = true
private retentionCutoffMs: number | null = null
// Files this index knows it is behind on. Filled by a declined or abandoned
// read; PR 3's indexer drains it. Nothing here schedules the re-read.
private readonly stale = new Map<string, SessionFileCandidate>()
private droppedStalePaths = 0
// One drain at a time. A replace that commits while one is running asks for
// another pass rather than starting a second walk of the same rows.
private draining = false
private drainRequested = false
constructor(
path: string,
private readonly onError: (error: unknown) => void = (error) =>
console.warn(
'[ai-vault-search] index write failed:',
error instanceof Error ? error.name : 'IndexError'
)
) {
this.db = openSessionSearchDatabase(path)
this.writer = new SessionSearchIndexWriter(this.db, SESSION_SEARCH_COMMIT_CHARS, () =>
this.scheduleOrphanDrain()
)
}
/**
* Reclaims the rows a replace cut loose, once its transaction has committed.
*
* The same split retention makes, for the same reason: deleting the old
* session row is what stops it answering, because every retrieval joins
* `sessions`, and handing its messages back is the expensive half that must
* not hold one transaction. Nothing records the work: rows whose session row
* is gone are the whole record, so a crash before or during a drain is found
* by the next one.
*/
private scheduleOrphanDrain(): void {
this.drainRequested = true
if (this.draining || this.closed) {
return
}
this.draining = true
// Off the committing stack. An async function runs synchronously up to its
// first `await`, so calling the drain here would put its first batch back
// inside the call that committed the replace — the cost this took out.
void Promise.resolve().then(() => this.runOrphanDrain())
}
private async runOrphanDrain(): Promise<void> {
try {
while (this.drainRequested && !this.closed) {
this.drainRequested = false
await drainOrphanedMessages(this.db, () => this.closed)
}
} catch (error) {
if (!this.closed) {
this.onError(error)
}
} finally {
this.draining = false
}
}
/**
* The index handle, for a reader composed over this store (PR 4's engine).
*
* Two rules come with it, both measured in this PR. **Never hold a read
* transaction across an `await`**: a checkpoint cannot pass an open read
* snapshot, so a paginated read that opened `BEGIN` and yielded between pages
* takes the WAL from 10 MB to 266 MB and it does not come back. And **no
* `.iterate()` that outlives its statement**, which is the same pin by
* another name. Every retrieval a single synchronous statement is the whole
* contract.
*/
get connection(): SyncDatabase {
return this.db
}
setAcceptingWrites(accept: boolean): void {
this.acceptingWrites = accept
}
/** The oldest transcript mtime worth indexing; PR 3 derives it from the retention setting. */
setRetentionCutoffMs(cutoffMs: number | null): void {
this.retentionCutoffMs = cutoffMs
}
/** Whether this candidate is new enough to be worth holding rows for at all. */
private withinRetention(candidate: SessionFileCandidate): boolean {
return this.retentionCutoffMs === null || candidate.file.mtimeMs >= this.retentionCutoffMs
}
/** Whether a write for this candidate may start right now. */
acceptsCandidate(candidate: SessionFileCandidate): boolean {
return !this.closed && this.acceptingWrites && this.withinRetention(candidate)
}
indexedFile(path: string, identity: SessionSearchFileIdentity): SessionSearchIndexedFile | null {
try {
return this.writer.indexedFile(path, identity)
} catch (error) {
this.onError(error)
return null
}
}
/** Null when this read cannot extend the index, or when the store refuses writes. */
beginWrite(
candidate: SessionFileCandidate,
mode: 'replace' | 'append',
previousByteOffset: number,
identity?: () => TranscriptSessionIdentity | null
): SessionSearchFileWrite | null {
if (!this.acceptsCandidate(candidate)) {
return null
}
try {
return this.writer.beginWrite(candidate, mode, previousByteOffset, identity)
} catch (error) {
this.reportWriteFailure(error)
return null
}
}
writeCommitted(candidate: SessionFileCandidate): void {
// Why: a list scan queues every file the backfill has not reached yet; once
// one lands, a later pass must not re-read the whole queue.
this.stale.delete(candidate.file.path)
}
reportWriteFailure(error: unknown): void {
this.onError(error)
}
/**
* Records a file whose content the index is behind on, for a later whole
* re-read. Recorded while paused too: a pause is exactly the window in which
* reads are declined, so refusing to remember them would lose every file the
* pause covered.
*/
markStale(candidate: SessionFileCandidate): void {
if (this.closed || !this.withinRetention(candidate)) {
return
}
// Re-inserting moves the path to the end, so the oldest record is the one
// dropped when a long pause overruns the bound.
this.stale.delete(candidate.file.path)
this.stale.set(candidate.file.path, candidate)
while (this.stale.size > STALE_PATH_LIMIT) {
const oldest = this.stale.keys().next()
if (oldest.done) {
break
}
this.stale.delete(oldest.value)
this.droppedStalePaths += 1
}
}
/**
* Files the index knew it was behind on and could not keep a record of. A
* non-zero count means the re-read set is incomplete, so coverage cannot be
* reported as whole until a full pass runs.
*/
get droppedPendingFileCount(): number {
return this.droppedStalePaths
}
/**
* Hands the re-read set to its scheduler and clears it.
*
* These paths are behind, not merely dirty: the index declined their last read
* because it covered a span the index never saw. Re-dispatching a scan is not
* enough on its own, because the reader picks `append` from the session list's
* resume point and the consumer will decline again. The caller must pass each
* path to `requestWholeTranscriptRead` first.
*/
takeStale(): SessionFileCandidate[] {
const candidates = [...this.stale.values()]
this.stale.clear()
return candidates
}
get pendingFileCount(): number {
return this.stale.size
}
/**
* Drops a source's rows. Only a proven deletion may call this: an unreadable
* source is `unverifiable`, not `missing`, and keeps its rows
* (docs/reference/ssh-execution-boundary.md).
*/
removeFile(path: string): void {
this.stale.delete(path)
try {
this.writer.removeFile(path)
} catch (error) {
this.onError(error)
}
}
/** Cuts expired sessions loose at once, then reclaims their rows in resumable batches. */
async purgeOlderThan(cutoffMs: number | null, signal?: AbortSignal): Promise<void> {
try {
await deleteExpiredSearchFiles(
this.db,
cutoffMs,
() => this.closed || signal?.aborted === true
)
} catch (error) {
if (!this.closed) {
this.onError(error)
}
}
}
close(): void {
// node:sqlite throws ERR_INVALID_STATE on a second close, and a store is
// closed both by its owner and by a test's teardown.
if (this.closed) {
return
}
this.closed = true
this.db.close()
}
}
@@ -0,0 +1,44 @@
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { SessionSearchStore } from './session-search-store'
import { writeSyntheticTranscriptCorpus } from './session-search-synthetic-corpus'
import { parseTranscript } from './session-search-transcript-fixtures'
it.each([Infinity, -Infinity, Number.NaN, -1, 1.5])(
'rejects invalid corpus loop bounds: %s',
async (value) => {
for (const field of ['sessions', 'turnsPerSession', 'toolResultWords']) {
await expect(writeSyntheticTranscriptCorpus({ [field]: value })).rejects.toThrow(RangeError)
}
}
)
it.each([0, 200, 2000])(
'counts the indexed messages with %s tool words',
async (toolResultWords) => {
const corpus = await writeSyntheticTranscriptCorpus({
sessions: 1,
turnsPerSession: 1,
toolResultWords
})
const store = new SessionSearchStore(join(corpus.root, 'index.sqlite'))
const unregister = registerSessionSearchIndexConsumer(store)
try {
await parseTranscript(corpus.files[0]!)
expect(corpus.messageCount).toBe(toolResultWords === 0 ? 3 : 4)
expect(store.connection.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({
n: corpus.messageCount
})
} finally {
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
await rm(corpus.root, { recursive: true, force: true })
}
}
)
@@ -0,0 +1,154 @@
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// Why synthetic and in-repo: the cost model has to be reproducible on any host
// and must never read a real transcript. The shapes here mirror what a Claude
// JSONL transcript actually holds — prose turns, a pasted diff, tool calls and
// their output — because the index's disk cost tracks the mix, not the size.
const WORDS = [
'terminal',
'reattach',
'worktree',
'resolveTerminalPath',
'src/main/ai-vault/session-transcript-reader.ts',
'the',
'index',
'cursor',
'byteOffset',
'publish',
'staged',
'transaction',
'MAX_RETRIES',
'relay',
'daemon',
'pty',
'snapshot',
'because'
]
/** Deterministic: the same seed gives the same corpus on every host and run. */
function mulberry32(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = Math.imul(state ^ (state >>> 15), 1 | state)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function words(random: () => number, count: number): string {
const out: string[] = []
for (let index = 0; index < count; index++) {
out.push(WORDS[Math.floor(random() * WORDS.length)])
}
return out.join(' ')
}
export type SyntheticCorpus = {
root: string
files: string[]
/** Total bytes of transcript written, the denominator of write amplification. */
transcriptBytes: number
messageCount: number
}
export type SyntheticCorpusOptions = {
sessions?: number
turnsPerSession?: number
seed?: number
/**
* Words per tool result. The default keeps tool output at about half the
* message text; the real distribution is 80-97 %, which is what prices the
* tool-row cap, so the benchmark runs a second arm well above the default.
*/
toolResultWords?: number
}
/** Writes a corpus of Claude JSONL transcripts and reports what it cost on disk. */
export async function writeSyntheticTranscriptCorpus(
options: SyntheticCorpusOptions = {}
): Promise<SyntheticCorpus> {
const sessions = options.sessions ?? 40
const turns = options.turnsPerSession ?? 60
const toolWords = options.toolResultWords ?? 200
for (const [name, value] of Object.entries({
sessions,
turnsPerSession: turns,
toolResultWords: toolWords
})) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new RangeError(`${name} must be a finite non-negative safe integer`)
}
}
const random = mulberry32(options.seed ?? 1)
const root = await mkdtemp(join(tmpdir(), 'orca-search-corpus-'))
const files: string[] = []
let transcriptBytes = 0
let messageCount = 0
for (let session = 0; session < sessions; session++) {
const sessionId = `00000000-0000-4000-8000-${String(session).padStart(12, '0')}`
const lines: string[] = []
for (let turn = 0; turn < turns; turn++) {
const at = new Date(1740000000000 + turn * 60_000).toISOString()
lines.push(
JSON.stringify({
type: 'user',
sessionId,
timestamp: at,
cwd: `/repo/app-${session % 7}`,
gitBranch: 'main',
message: { role: 'user', content: words(random, 40) }
})
)
lines.push(
JSON.stringify({
type: 'assistant',
sessionId,
timestamp: at,
message: {
role: 'assistant',
model: 'claude-fable-5',
content: [
{ type: 'text', text: words(random, 120) },
{
type: 'tool_use',
name: 'Bash',
input: { command: `rg ${words(random, 3)}` }
}
]
}
})
)
lines.push(
JSON.stringify({
type: 'user',
sessionId,
timestamp: at,
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
content: words(random, toolWords)
}
]
}
})
)
// Empty tool results emit no searchable message.
messageCount += toolWords === 0 ? 3 : 4
}
const path = join(root, `${sessionId}.jsonl`)
const body = `${lines.join('\n')}\n`
await writeFile(path, body)
transcriptBytes += Buffer.byteLength(body)
files.push(path)
}
return { root, files, transcriptBytes, messageCount }
}
@@ -0,0 +1,118 @@
import { stat } from 'node:fs/promises'
import {
createSessionParseStats,
parseAgentSessionFileCached,
type SessionParseStats
} from '../ai-vault/session-scanner-parse-cache'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
// Transcript builders shared by the session-search store tests; each file owns
// its temp directories, this module only shapes records and drives the parser.
export const CLAUDE_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
export const CODEX_SESSION_ID = '019f0000-1111-7222-8333-444444444444'
export const CODEX_ROLLOUT_FILE = `rollout-2026-05-01T10-00-00-${CODEX_SESSION_ID}.jsonl`
const RECORD_EPOCH_MS = 1740000000000
export function recordTimestamp(index: number): string {
return new Date(RECORD_EPOCH_MS + index * 60_000).toISOString()
}
export function userRecord(
index: number,
content: unknown,
sessionId = CLAUDE_SESSION_ID,
cwd = '/repo/app'
): string {
return JSON.stringify({
type: 'user',
sessionId,
timestamp: recordTimestamp(index),
cwd,
gitBranch: 'main',
message: { role: 'user', content }
})
}
export function assistantRecord(
index: number,
content: unknown,
sessionId = CLAUDE_SESSION_ID
): string {
return JSON.stringify({
type: 'assistant',
sessionId,
timestamp: recordTimestamp(index),
message: { role: 'assistant', model: 'claude-fable-5', content }
})
}
export async function sessionCandidate(
agent: SessionFileCandidate['agent'],
path: string,
codexHome: string | null = null
): Promise<SessionFileCandidate> {
const fileStat = await stat(path)
return {
agent,
codexHome,
file: {
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString(),
sizeBytes: fileStat.size,
dev: fileStat.dev,
ino: fileStat.ino
}
}
}
export async function parseTranscript(
path: string,
agent: SessionFileCandidate['agent'] = 'claude',
codexHome: string | null = null
): Promise<{ stats: SessionParseStats }> {
const stats = createSessionParseStats()
await parseAgentSessionFileCached(
await sessionCandidate(agent, path, codexHome),
process.platform,
stats
)
return { stats }
}
function codexLine(record: Record<string, unknown>): string {
return JSON.stringify(record)
}
/** Minimal Codex rollout: meta, one user message, one completed shell command. */
export function codexRolloutLines(command: string[], output: string, prompt: string): string[] {
return [
codexLine({
timestamp: recordTimestamp(0),
type: 'session_meta',
payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', git: { branch: 'main' } }
}),
codexLine({
timestamp: recordTimestamp(1),
type: 'response_item',
payload: { type: 'message', role: 'user', content: prompt }
}),
codexLine({
timestamp: recordTimestamp(2),
type: 'response_item',
payload: {
type: 'function_call',
call_id: 'call-1',
name: 'shell',
arguments: JSON.stringify({ command })
}
}),
codexLine({
timestamp: recordTimestamp(3),
type: 'response_item',
payload: { type: 'function_call_output', call_id: 'call-1', output }
})
]
}
@@ -23,7 +23,11 @@ import {
normalizePreviewText,
timestampMs
} from './session-scanner-values'
import { NO_TRANSCRIPT_MESSAGES, type TranscriptMessageSink } from './session-transcript-consumers'
import {
NO_TRANSCRIPT_MESSAGES,
type TranscriptMessageSink,
type TranscriptSessionIdentity
} from './session-transcript-consumers'
import {
boundedText,
transcriptMessageRole,
@@ -64,6 +68,28 @@ export function createAccumulator(args: {
}
}
/**
* The session identity a fold holds right now. Null until it has an id, which
* every supported format writes in the opening lines of the transcript.
*/
export function accumulatorSessionIdentity(
accumulator: SessionAccumulator
): TranscriptSessionIdentity | null {
const sessionId = accumulator.sessionId.trim()
if (!sessionId) {
return null
}
return {
sessionId,
cwd: accumulator.cwd,
// The generated fallback is `finalizeSession`'s, not this one's: a title
// that is still absent mid-read is better said to be absent.
title: accumulator.title ?? accumulator.fallbackTitle,
createdAt: accumulator.createdAt,
updatedAt: accumulator.updatedAt
}
}
export function cloneSessionAccumulator(accumulator: SessionAccumulator): SessionAccumulator {
return { ...accumulator, previewMessages: [...accumulator.previewMessages] }
}
@@ -77,6 +103,7 @@ export function accumulatorFoldResumeState(
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeRecordLine(accumulator, line),
identity: () => accumulatorSessionIdentity(accumulator),
clone: () =>
accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine),
touchFile: (file) => {
@@ -1,3 +1,7 @@
import {
publishCodexResponseTool,
publishCodexCompletedTool
} from './session-scanner-codex-tool-records'
import { normalizePromptField } from '../../shared/agent-status-field-normalization'
import { addPreviewContent } from './session-scanner-accumulator'
import type { SessionAccumulator } from './session-scanner-types'
@@ -8,6 +12,10 @@ export function consumeCodexResponseMessage(
payload: Record<string, unknown>,
timestamp: unknown
): boolean {
publishCodexResponseTool(accumulator, payload, timestamp)
if (payload.type !== 'message') {
return false
}
accumulator.messageCount++
const role =
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown'
@@ -24,6 +32,7 @@ export function consumeCodexCompletedMessage(
payload: Record<string, unknown>,
timestamp: unknown
): boolean {
publishCodexCompletedTool(accumulator, payload, timestamp)
const item = asRecord(payload.item)
if (!item) {
return false
@@ -4,6 +4,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
accumulatorSessionIdentity,
cloneSessionAccumulator,
createAccumulator,
finalizeSession,
@@ -153,19 +154,13 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo
accumulator.title = metadataTitle
state.titleSource = 'meta'
}
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
return
}
if (record.type === 'turn_context' && payload) {
const cwd = extractString(payload.cwd)
if (cwd) {
accumulator.cwd = cwd
}
accumulator.cwd = extractString(payload.cwd) ?? accumulator.cwd
const model = extractModel(payload)
if (model) {
accumulator.model = model
@@ -177,7 +172,7 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo
return
}
if (record.type === 'response_item' && payload.type === 'message') {
if (record.type === 'response_item') {
if (state.historyMode === 'paginated') {
return
}
@@ -285,7 +280,10 @@ function codexResumeStateFromParseState(
return {
consumeLine: (line) => consumeCodexRecordLine(state, line),
consumeLineBytes: (line) => {
const timelineOnlyRecord = readCodexTimelineOnlyRecord(line)
const timelineOnlyRecord = readCodexTimelineOnlyRecord(
line,
state.accumulator.messages.active && state.historyMode !== 'paginated'
)
if (timelineOnlyRecord) {
updateTimeline(state.accumulator, timelineOnlyRecord.timestamp)
} else {
@@ -293,6 +291,7 @@ function codexResumeStateFromParseState(
}
},
shouldStop: () => state.rejectedWorkerSession,
identity: () => accumulatorSessionIdentity(state.accumulator),
clone: () =>
codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader),
touchFile: (file) => {
@@ -1,3 +1,5 @@
import { CODEX_TOOL_RESPONSE_TYPES } from './session-scanner-codex-tool-records'
// Records below this size are decoded and parsed exactly: JSON.parse on a
// kilobyte costs less than the risk of a prefix heuristic, and the scan cost
// this path exists to remove is entirely in megabyte-scale records.
@@ -22,7 +24,10 @@ const PARSED_EVENT_TYPES = new Set([
])
/** Returns the timestamp only when the record cannot affect other visible session fields. */
export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } | null {
export function readCodexTimelineOnlyRecord(
line: Buffer,
includeTools = false
): { timestamp: string } | null {
if (line.length <= CODEX_RECORD_PREFIX_LIMIT) {
return null
}
@@ -41,6 +46,13 @@ export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string }
if (!payloadType) {
return null
}
if (
includeTools &&
recordType === 'response_item' &&
CODEX_TOOL_RESPONSE_TYPES.has(payloadType)
) {
return null
}
const parsedPayloadTypes =
recordType === 'response_item' ? PARSED_RESPONSE_ITEM_TYPES : PARSED_EVENT_TYPES
return parsedPayloadTypes.has(payloadType) ? null : { timestamp }
@@ -0,0 +1,125 @@
import { expect, it } from 'vitest'
import { createCodexSessionResumeState } from './session-scanner-codex-parser'
import type { TranscriptMessage } from './session-transcript-consumers'
import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path'
const timestamp = '2026-05-01T10:00:00.000Z'
const file = {
path: '/fixture/rollout.jsonl',
mtimeMs: Date.parse(timestamp),
modifiedAt: timestamp
}
const record = (type: string, payload: Record<string, unknown>): Buffer =>
Buffer.from(JSON.stringify({ timestamp, type, payload }))
it.each(['function_call_output', 'custom_tool_call_output'])(
'reads large %s records only when a consumer needs them',
(type) => {
const line = record('response_item', { type, output: 'outputonly '.repeat(300) })
expect(readCodexTimelineOnlyRecord(line)).toEqual({ timestamp })
expect(readCodexTimelineOnlyRecord(line, true)).toBeNull()
const messages: TranscriptMessage[] = []
const state = createCodexSessionResumeState(file, null, {
active: true,
push: (message) => messages.push(message)
})
state.consumeLineBytes!(line)
expect(messages).toEqual([{ role: 'tool', text: 'outputonly '.repeat(300), timestamp }])
}
)
it.each([false, true])(
'uses one tool representation across append when paginated=%s',
async (paginated) => {
const messages: TranscriptMessage[] = []
let state = createCodexSessionResumeState(file, null, {
active: true,
push: (message) => messages.push(message)
})
const consume = (type: string, payload: Record<string, unknown>) =>
state.consumeLineBytes!(record(type, payload))
consume('session_meta', { id: 'session-1', history_mode: paginated ? 'paginated' : 'full' })
consume('response_item', { type: 'message', role: 'user', content: 'promptonly' })
consume('event_msg', {
type: 'item_completed',
item: { type: 'UserMessage', content: [{ type: 'text', text: 'promptonly' }] }
})
consume('response_item', {
type: 'function_call',
name: 'shell',
arguments: '{"command":"commandonly"}'
})
// The next scan resumes between the call and its output.
state = state.clone()
consume('response_item', { type: 'function_call_output', output: 'outputonly' })
consume('event_msg', {
type: 'item_completed',
item: { type: 'CommandExecution', command: ['commandonly'], aggregated_output: 'outputonly' }
})
expect(messages.filter((message) => message.text.includes('commandonly'))).toHaveLength(1)
expect(messages.filter((message) => message.text === 'outputonly')).toEqual([
{ role: 'tool', text: 'outputonly', timestamp }
])
expect(messages.filter((message) => message.role === 'user')).toHaveLength(1)
expect(await state.finalize(process.platform)).toMatchObject({ messageCount: 1 })
}
)
it.each([
{ type: 'add', content: '+ addedneedle' },
{ type: 'delete', content: '+ addedneedle' },
{ type: 'update', unified_diff: '+ addedneedle', move_path: null }
])('publishes paginated $type file changes', (change) => {
const messages: TranscriptMessage[] = []
const state = createCodexSessionResumeState(file, null, {
active: true,
push: (message) => messages.push(message)
})
state.consumeLineBytes!(record('session_meta', { id: 'session-1', history_mode: 'paginated' }))
state.consumeLineBytes!(
record('event_msg', {
type: 'item_completed',
item: { type: 'FileChange', changes: { 'src/changed.ts': change } }
})
)
expect(messages.map((message) => [message.role, message.text])).toEqual([
['tool', 'apply_patch: src/changed.ts'],
['tool', '+ addedneedle']
])
})
it('normalizes custom calls and structured results through the existing content reader', () => {
const messages: TranscriptMessage[] = []
const state = createCodexSessionResumeState(file, null, {
active: true,
push: (message) => messages.push(message)
})
state.consumeLineBytes!(
record('response_item', { type: 'custom_tool_call', name: 'apply_patch', input: 'patchneedle' })
)
state.consumeLineBytes!(
record('response_item', {
type: 'custom_tool_call_output',
output: { content: [{ type: 'text', text: 'resultneedle' }] }
})
)
expect(messages.map((message) => message.text)).toEqual([
'apply_patch: patchneedle',
'resultneedle'
])
})
it('keeps local shell argv searchable', () => {
const messages: TranscriptMessage[] = []
const state = createCodexSessionResumeState(file, null, {
active: true,
push: (message) => messages.push(message)
})
state.consumeLineBytes!(
record('response_item', {
type: 'local_shell_call',
action: { type: 'exec', command: ['rg', 'argvneedle'] }
})
)
expect(messages.map((message) => message.text)).toEqual(['tool: rg argvneedle'])
})
@@ -0,0 +1,95 @@
import { timestampIso } from './session-scanner-accumulator'
import { asRecord } from './session-scanner-record-value'
import type { SessionAccumulator } from './session-scanner-types'
import { transcriptMessagesFromContent } from './session-transcript-message-content'
export const CODEX_TOOL_RESPONSE_TYPES = new Set([
'function_call',
'local_shell_call',
'custom_tool_call',
'function_call_output',
'custom_tool_call_output'
])
function publishToolContent(
accumulator: SessionAccumulator,
content: unknown,
timestamp: unknown
): void {
for (const message of transcriptMessagesFromContent('tool', content, timestampIso(timestamp))) {
accumulator.messages.push(message)
}
}
export function publishCodexResponseTool(
accumulator: SessionAccumulator,
payload: Record<string, unknown>,
timestamp: unknown
): void {
if (!accumulator.messages.active || !CODEX_TOOL_RESPONSE_TYPES.has(String(payload.type))) {
return
}
if (payload.type === 'function_call_output' || payload.type === 'custom_tool_call_output') {
const output = asRecord(payload.output)
publishToolContent(
accumulator,
[{ type: 'tool_result', content: output?.content ?? output?.output ?? payload.output }],
timestamp
)
return
}
const input = payload.arguments ?? payload.input ?? payload.action
const action = asRecord(input)
const normalizedInput =
action && Array.isArray(action.command)
? { ...action, command: action.command.filter((part) => typeof part === 'string').join(' ') }
: input
publishToolContent(
accumulator,
[
{
type: 'tool_use',
name: payload.name ?? 'tool',
input: normalizedInput
}
],
timestamp
)
}
export function publishCodexCompletedTool(
accumulator: SessionAccumulator,
payload: Record<string, unknown>,
timestamp: unknown
): void {
if (!accumulator.messages.active) {
return
}
const item = asRecord(payload.item)
if (item?.type === 'CommandExecution' || item?.type === 'command_execution') {
const command = Array.isArray(item.command)
? item.command.filter((part) => typeof part === 'string').join(' ')
: item.command
publishToolContent(
accumulator,
[
{ type: 'tool_use', name: 'shell', input: command },
{ type: 'tool_result', content: item.aggregated_output ?? item.aggregatedOutput }
],
timestamp
)
} else if (item?.type === 'FileChange' || item?.type === 'file_change') {
const changes = asRecord(item.changes) ?? {}
for (const [path, value] of Object.entries(changes)) {
const change = asRecord(value)
publishToolContent(
accumulator,
[
{ type: 'tool_use', name: 'apply_patch', input: { path } },
{ type: 'tool_result', content: change?.unified_diff ?? change?.content }
],
timestamp
)
}
}
}
@@ -101,6 +101,7 @@ export function withOmpSubagentTranscriptCount(
): ResumableSessionParseState {
return {
consumeLine: (line) => state.consumeLine(line),
identity: () => state.identity?.() ?? null,
clone: () => withOmpSubagentTranscriptCount(state.clone(), transcriptFilePath),
touchFile: (file) => state.touchFile(file),
finalize: async (platform, options) => {
@@ -12,6 +12,7 @@ import type {
} from './session-scanner-types'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import {
accumulatorSessionIdentity,
addPreviewContent,
createAccumulator,
finalizeSession,
@@ -205,6 +206,7 @@ function claudeResumeStateFromParseState(
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeClaudeSessionLine(state, line),
identity: () => accumulatorSessionIdentity(state.accumulator),
clone: () => claudeResumeStateFromParseState(cloneClaudeSessionParseState(state)),
touchFile: (file) => {
state.accumulator.modifiedAt = file.modifiedAt
+7 -1
View File
@@ -5,7 +5,10 @@ import type {
AiVaultSessionPreviewMessage
} from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { TranscriptMessageSink } from './session-transcript-consumers'
import type {
TranscriptMessageSink,
TranscriptSessionIdentity
} from './session-transcript-consumers'
import type { SessionSidecarObservation } from './session-sidecar-stat'
export type AiVaultScanOptions = {
@@ -103,6 +106,9 @@ export type ResumableSessionParseState = {
consumeLineBytes?(line: Buffer): void
// Lets a parser terminate an excluded transcript without draining the file.
shouldStop?(): boolean
// What the fold knows about the session right now, for a consumer that has to
// commit before the read ends (see TranscriptSessionIdentity).
identity?(): TranscriptSessionIdentity | null
clone(): ResumableSessionParseState
// Refresh per-scan file metadata (mtime display string) without re-parsing.
touchFile(file: FileWithMtime): void
@@ -27,12 +27,34 @@ export const NO_TRANSCRIPT_MESSAGES: TranscriptMessageSink = {
push: () => undefined
}
/**
* What a parser has decoded about the session so far, mid-read.
*
* Provisional by construction: it is read before the file ends, so a title can
* still change and a timestamp can still move. Every field the transcript
* formats put in their opening lines, which is what a consumer that has to
* commit before the read finishes needs to name what it is holding.
*/
export type TranscriptSessionIdentity = {
sessionId: string
cwd: string | null
title: string | null
createdAt: string | null
updatedAt: string | null
}
export type TranscriptReadStart = {
candidate: SessionFileCandidate
/** `replace`: the whole file is being re-read; `append`: a resumed read. */
mode: 'replace' | 'append'
/** Byte offset the messages of this read continue from. */
previousByteOffset: number
/**
* The session identity decoded so far, or null before the parser has an id.
* Called during the read, never here: nothing is decoded yet when a read
* begins. Absent when the read has no resumable parse state to ask.
*/
identity?: () => TranscriptSessionIdentity | null
}
export type TranscriptReadOutcome = {
+26 -2
View File
@@ -3,7 +3,10 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseAgentSessionFile, parserPublishesMessages } from './session-scanner-agent-parser'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
import type { SessionParseResumePoint } from './session-parse-cache-store'
import {
invalidateSessionParseCacheEntry,
type SessionParseResumePoint
} from './session-parse-cache-store'
import { TranscriptMessageChannel } from './session-transcript-channel'
const NEWLINE_BYTE = 0x0a
@@ -29,6 +32,24 @@ export type ResumableTranscriptRead = {
resume: SessionParseResumePoint
}
/**
* Ask for the next read of `path` to be a whole-file `replace`.
*
* Why this lives here: a consumer never chooses its own mode. The reader picks
* `append` or `replace` from the resume point the session list left behind, so a
* consumer that declined an append has no way to get the span it missed — with
* an empty index and a warm parse cache, every read arrives as `append`, every
* one is declined, and nothing is ever indexed. Dropping the resume point is the
* one lever that changes the next read's mode, and only the reader's own cache
* owns it.
*
* The cost is a re-parse for the session list too. That is the honest price of a
* second consumer being behind, and it is paid once per file rather than per scan.
*/
export function requestWholeTranscriptRead(path: string): void {
invalidateSessionParseCacheEntry(path)
}
/**
* Read an append-only transcript, resuming from `resume` when the file only
* grew and the recorded offset still sits on a line boundary. Anything else
@@ -70,7 +91,10 @@ export async function readResumableTranscript(args: {
channel.beginRead({
candidate: args.candidate,
mode: canResume ? 'append' : 'replace',
previousByteOffset: startOffset
previousByteOffset: startOffset,
// Read by a consumer during the read, not here: the fold has decoded
// nothing yet at this point of a whole-file read.
identity: () => state.identity?.() ?? null
})
try {
const readResult = await consumeCompleteJsonlLines({
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActiveOrcaProfileState } from './profile-index-store'
import type { OrcaCloudSessionReadResult } from './profile-cloud-session-store'
import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
const { readSession, configuration } = vi.hoisted(() => ({
readSession: vi.fn<() => OrcaCloudSessionReadResult>(),
configuration: { configured: true }
}))
vi.mock('./profile-cloud-session-store', () => ({ readOrcaCloudSession: readSession }))
vi.mock('./profile-cloud-auth-config', () => ({
getOrcaCloudAuthConfig: () => configuration,
isOrcaCloudDevAuthEnabled: () => false
}))
function activeProfile(linked: boolean): ActiveOrcaProfileState {
const profile: ActiveOrcaProfileState['profile'] = {
id: 'profile-1',
name: 'Personal',
avatar: { kind: 'initials', initials: 'P', color: 'neutral' },
kind: linked ? 'cloud-linked' : 'local',
createdAt: 0,
updatedAt: 0,
lastOpenedAt: 0,
...(linked
? {
cloud: {
cloudProfileId: 'cloud-1',
userId: 'user-1',
email: 'a@example.com',
linkedAt: 0
}
}
: {})
}
return {
profile,
index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] },
dataFile: '',
profileDirectory: ''
}
}
const absentSessions: OrcaCloudSessionReadResult[] = [
{ status: 'missing', persistence: 'none' },
{ status: 'decrypt-failed', persistence: 'none', error: 'Cannot decrypt' },
{ status: 'unreadable', persistence: 'none', error: 'Permission denied' }
]
describe('unexpected sign-out auth evidence', () => {
beforeEach(() => {
readSession.mockReset()
configuration.configured = true
})
it.each(absentSessions)('requires a preserved cloud link for $status credentials', (session) => {
readSession.mockReturnValue(session)
const linked = activeProfile(true)
expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({
state: 'reconnect-required',
cloud: linked.profile.cloud,
persistence: 'none',
credentialError: 'error' in session ? session.error : undefined
})
readSession.mockClear()
const signedOut = getOrcaProfileAuthStatusFromProfile(activeProfile(false), '')
expect(signedOut.state).toBe('local')
expect(signedOut.cloud).toBeUndefined()
expect(readSession).not.toHaveBeenCalled()
})
it.each(absentSessions)(
'keeps unconfigured linked profiles out of reconnect for $status',
(session) => {
configuration.configured = false
readSession.mockReturnValue(session)
expect(getOrcaProfileAuthStatusFromProfile(activeProfile(true), '').state).toBe(
'unconfigured'
)
expect(getOrcaProfileAuthStatusFromProfile(activeProfile(false), '').state).toBe(
'unconfigured'
)
}
)
it('treats a live memory-only session as connected, then reconnects after its loss', () => {
readSession.mockReturnValue({
status: 'found',
persistence: 'memory-only',
session: {
accessToken: 'access',
refreshToken: 'refresh',
expiresAt: Date.now() + 60_000,
capabilities: { flags: {}, refreshedAt: 0 }
}
})
const linked = activeProfile(true)
expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({
state: 'connected',
persistence: 'memory-only'
})
readSession.mockReturnValue({ status: 'missing', persistence: 'none' })
expect(getOrcaProfileAuthStatusFromProfile(linked, '').state).toBe('reconnect-required')
})
})
@@ -171,6 +171,7 @@ const UiUpdateFields = z
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
dismissedUpdateVersion: NullableString.optional(),
dismissedUnexpectedSignoutVersion: NullableString.optional(),
lastUpdateCheckAt: z.number().finite().nullable().optional(),
pendingUpdateNudgeId: NullableString.optional(),
dismissedUpdateNudgeId: NullableString.optional(),
@@ -607,6 +607,8 @@ describe('client UI RPC methods', () => {
],
['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }],
['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }],
['dismissedUnexpectedSignoutVersion', { dismissedUnexpectedSignoutVersion: '1.2.3' }],
['dismissedUnexpectedSignoutVersion null', { dismissedUnexpectedSignoutVersion: null }],
['activeView', { activeView: 'tasks' }],
['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }],
['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }],
+9 -4
View File
@@ -66,10 +66,15 @@ describe('findSkillFiles', () => {
expect(await findSkillFiles(root, 4)).toEqual([join(edge, 'SKILL.md')])
expect(statPaths).toEqual([])
expect(await findSkillFiles(root, 5)).toEqual([
join(edge, 'SKILL.md'),
join(edge, 'link00', 'SKILL.md')
])
// Why not a fixed array: `readdir` order is filesystem-dependent, and both
// the result order and which link survives dedup follow it. NTFS enumerates
// its name index alphabetically, so `link00` precedes `SKILL.md` on Windows
// and follows it on APFS/ext4. All 32 links share one realpath, so the
// visited set collapses them to a single entry beside the real file.
const withinDepth = await findSkillFiles(root, 5)
expect(withinDepth).toContain(join(edge, 'SKILL.md'))
expect(withinDepth.filter((path) => /[\\/]link\d{2}[\\/]SKILL\.md$/.test(path))).toHaveLength(1)
expect(withinDepth).toHaveLength(2)
expect(statPaths).toHaveLength(32)
})
+20 -7
View File
@@ -1,3 +1,4 @@
import { NotificationCardStack } from '../components/NotificationCardStack'
import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
import { translate } from '@/i18n/i18n'
@@ -58,6 +59,11 @@ const SshPassphraseDialog = lazy(() =>
const UpdateCard = lazy(() =>
import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard }))
)
const UnexpectedSignoutCard = lazy(() =>
import('../components/UnexpectedSignoutCard').then((module) => ({
default: module.UnexpectedSignoutCard
}))
)
const RemoteServerUpdateDialog = lazy(
() => import('../components/settings/RemoteServerUpdateDialog')
)
@@ -273,16 +279,23 @@ export function AppRootSurfaces(props: {
</OverlayBoundary>
</Suspense>
) : null}
{shouldMountUpdateCard ? (
<NotificationCardStack>
{shouldMountUpdateCard ? (
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.update-card" resetKey={activeView}>
<UpdateCard />
</OverlayBoundary>
</Suspense>
) : null}
<Suspense fallback={null}>
<OverlayBoundary boundaryId="overlay.update-card" resetKey={activeView}>
<UpdateCard />
<OverlayBoundary boundaryId="overlay.unexpected-signout" resetKey={activeView}>
<UnexpectedSignoutCard />
</OverlayBoundary>
</Suspense>
) : null}
<OverlayBoundary boundaryId="overlay.star-nag" resetKey={activeView}>
<StarNagCard />
</OverlayBoundary>
<OverlayBoundary boundaryId="overlay.star-nag" resetKey={activeView}>
<StarNagCard />
</OverlayBoundary>
</NotificationCardStack>
<OverlayBoundary boundaryId="overlay.star-nag-toast" resetKey={activeView}>
<StarNagToastHost />
</OverlayBoundary>
@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
export function NotificationCardStack({ children }: { children: ReactNode }): React.JSX.Element {
return (
<div className="pointer-events-none fixed bottom-10 right-4 z-40 flex max-h-[calc(100vh-80px)] w-[360px] max-w-[calc(100vw-32px)] flex-col-reverse gap-2 overflow-y-auto scrollbar-sleek [&>*]:pointer-events-auto [&>*]:shrink-0 max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto">
{children}
</div>
)
}
+1 -16
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, Star, X } from 'lucide-react'
import { Card } from './ui/card'
import { Button } from './ui/button'
import { useAppStore } from '../store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
@@ -25,12 +24,6 @@ export function StarNagCard(): React.JSX.Element | null {
const [busy, setBusy] = useState(false)
const [mode, setMode] = useState<StarNagMode>('gh')
const mountedRef = useMountedRef()
// Why: UpdateCard lives at the same bottom-right slot. When it is visible
// (any non-idle / non-not-available state), stack the star-nag card above
// it instead of overlapping — we must not cover a pending update prompt
// because that's a higher-priority action.
const updateStatus = useAppStore((s) => s.updateStatus)
const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
useEffect(() => {
const unsubscribeShow = window.api.starNag.onShow((payload) => {
@@ -147,15 +140,7 @@ export function StarNagCard(): React.JSX.Element | null {
}
return (
<div
// Why: when UpdateCard is up, it occupies bottom-10. Raise the star-nag
// card above it so both are visible — the update action stays on top
// visually (it's the higher-priority one) and the star-nag sits above.
className={`fixed right-4 z-40 w-[360px] max-w-[calc(100vw-32px)]
max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto ${
updateCardVisible ? 'bottom-[220px]' : 'bottom-10'
}`}
>
<div>
<Card className="py-0 gap-0" role="complementary" aria-labelledby="star-nag-heading">
<div className="flex flex-col gap-2.5 p-3.5">
<div className="flex items-start justify-between gap-2">
@@ -0,0 +1,269 @@
import { useEffect, useRef, useState } from 'react'
import { BookOpen, ChevronDown, CircleUserRound, Files, Smartphone, X } from 'lucide-react'
import { useAppStore } from '../store'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from './ui/button'
import { Card } from './ui/card'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'
import { shouldShowUnexpectedSignoutCard } from './unexpected-signout/unexpected-signout-visibility'
function readPreviewFlag(): boolean {
if (!import.meta.env.DEV) {
return false
}
try {
if (new URLSearchParams(window.location.search).get('showSignoutCard') === '1') {
return true
}
return window.localStorage.getItem('orca-debug-show-signout-card') === '1'
} catch {
return false
}
}
function FeatureRow({
icon: Icon,
title,
description
}: {
icon: typeof Files
title: string
description: string
}): React.JSX.Element {
return (
<div className="flex items-start gap-2.5">
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-xs font-medium">{title}</p>
<p className="text-xs leading-5 text-muted-foreground">{description}</p>
</div>
</div>
)
}
export function UnexpectedSignoutCard(): React.JSX.Element | null {
const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
const persistedDismissedVersion = useAppStore((s) => s.dismissedUnexpectedSignoutVersion)
const dismissedVersions = useAppStore((s) => s.unexpectedSignoutDismissedVersions)
const dismissForVersion = useAppStore((s) => s.dismissUnexpectedSignoutCard)
const connecting = useAppStore((s) => s.orcaProfileConnecting)
const connect = useAppStore((s) => s.connectCurrentOrcaProfile)
const [appVersion, setAppVersion] = useState<string | null>(null)
const [authRefreshReady, setAuthRefreshReady] = useState(false)
const [expanded, setExpanded] = useState(false)
const [preview] = useState(readPreviewFlag)
const [previewDismissed, setPreviewDismissed] = useState(false)
const reconnectingProfile = useRef<string | null>(null)
useEffect(() => {
let cancelled = false
let attempts = 0
const refresh = (): void => {
attempts += 1
void useAppStore
.getState()
.fetchOrcaProfileAuthStatus()
.then((status) => {
if (cancelled) {
return
}
if (status != null) {
setAuthRefreshReady(true)
} else if (attempts < 3) {
window.setTimeout(refresh, 500)
}
})
}
refresh()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
void window.api.updater
.getVersion()
.then((version) => {
if (!cancelled) {
setAppVersion(version)
}
})
.catch(() => {
if (!cancelled) {
setAppVersion(null)
}
})
return () => {
cancelled = true
}
}, [])
const dismissedVersion =
appVersion && dismissedVersions.includes(appVersion) ? appVersion : persistedDismissedVersion
const eligible = shouldShowUnexpectedSignoutCard({
authStatus,
persistedUIReady,
appVersion,
dismissedVersion
})
const visible = preview ? persistedUIReady && !previewDismissed : authRefreshReady && eligible
// Observe recovery independently of visibility and asynchronous version/hydration reads.
useEffect(() => {
if (preview || !authRefreshReady) {
return
}
if (authStatus?.state === 'reconnect-required' && authStatus.configured && authStatus.cloud) {
reconnectingProfile.current = authStatus.activeProfileId
} else if (authStatus?.state === 'connected') {
if (
reconnectingProfile.current === authStatus.activeProfileId &&
persistedUIReady &&
appVersion
) {
reconnectingProfile.current = null
if (dismissedVersion !== appVersion) {
dismissForVersion(appVersion)
}
}
} else {
reconnectingProfile.current = null
}
}, [
preview,
authRefreshReady,
authStatus,
persistedUIReady,
appVersion,
dismissedVersion,
dismissForVersion
])
if (!visible) {
return null
}
const email = authStatus?.cloud?.email?.trim() || null
const canConnect = authStatus?.configured === true
const handleDismiss = (): void => {
if (preview) {
setPreviewDismissed(true)
} else if (appVersion) {
dismissForVersion(appVersion)
}
}
return (
<div>
<Card
className="py-0 gap-0 shadow-floating"
role="complementary"
aria-live="polite"
aria-labelledby="unexpected-signout-heading"
>
<div className="flex flex-col gap-2.5 p-3.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<CircleUserRound className="size-4 text-muted-foreground" />
<h3 id="unexpected-signout-heading" className="text-sm font-semibold">
{translate(
'auto.components.UnexpectedSignoutCard.9f2c1a4b7d',
"You've been signed out"
)}
</h3>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={handleDismiss}
aria-label={translate('auto.components.UnexpectedSignoutCard.3e8f5c2a91', 'Dismiss')}
>
<X className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{email
? translate(
'auto.components.UnexpectedSignoutCard.7b4d9e1f2a',
'Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.',
{ value0: email }
)
: translate(
'auto.components.UnexpectedSignoutCard.5a1c8d3e6f',
'Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.'
)}
</p>
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-fit gap-1 px-1 text-xs text-muted-foreground"
aria-expanded={expanded}
>
{translate('auto.components.UnexpectedSignoutCard.1f6b2c9d4e', 'What you get back')}
<ChevronDown
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2.5">
<FeatureRow
icon={Files}
title={translate(
'auto.components.UnexpectedSignoutCard.8d2e4f7a1b',
'Artifact sharing'
)}
description={translate(
'auto.components.UnexpectedSignoutCard.2c9a5b6e8d',
'Publish HTML and Markdown files and manage every shared link from Orca.'
)}
/>
<FeatureRow
icon={Smartphone}
title={translate('auto.components.UnexpectedSignoutCard.6e3f1a9c5b', 'Orca Relay')}
description={translate(
'auto.components.UnexpectedSignoutCard.4b7d2e8f1a',
'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.'
)}
/>
<FeatureRow
icon={BookOpen}
title={translate(
'auto.components.UnexpectedSignoutCard.9a4c6b2d7e',
'Skill sharing'
)}
description={translate(
'auto.components.UnexpectedSignoutCard.3d8e5f1b9c',
'Share skills behind an unlisted link and install them on any machine you use.'
)}
/>
</CollapsibleContent>
</Collapsible>
<div className="mt-0.5 flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1"
disabled={!canConnect || connecting}
onClick={() => void connect()}
>
{connecting
? translate('auto.components.UnexpectedSignoutCard.7e1a9c4d2f', 'Signing in…')
: translate('auto.components.UnexpectedSignoutCard.c5b3e8a17d', 'Sign in to Orca')}
</Button>
</div>
</div>
</Card>
</div>
)
}
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../../shared/update-status-types'
import { useAppStore } from '../store'
import { UpdateCard } from './UpdateCard'
import { NotificationCardStack } from './NotificationCardStack'
const openUrl = vi.fn()
const download = vi.fn()
@@ -31,7 +32,11 @@ function renderWithInitialStatus(updateStatus: UpdateStatus): RenderResult {
updateCardCollapsed: false,
updateReassuranceSeen: true
})
return render(<UpdateCard />)
return render(
<NotificationCardStack>
<UpdateCard />
</NotificationCardStack>
)
}
function renderAfterAvailableStatus(): RenderResult {
+1 -4
View File
@@ -239,10 +239,7 @@ export function UpdateCard(): React.JSX.Element | null {
!reassuranceSeen &&
((status.state === 'available' && !status.externallyManaged) || status.state === 'downloading')
return (
<div
ref={cardRootRef}
className="fixed bottom-10 right-4 z-40 w-[360px] max-w-[calc(100vw-32px)] flex flex-col gap-2 max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto"
>
<div ref={cardRootRef} className="flex flex-col gap-2">
{showReassurance && (
<Card className={`py-0 gap-0 ${animationClass}`}>
<div className="flex items-center gap-3 p-3">
@@ -306,12 +306,14 @@ describe('NativeChatComposer', () => {
expect(mocks.setDraft).toHaveBeenCalledWith('')
})
// The structured menu offers only what the dispatcher can carry out. Listing the
// agent's TUI catalog here answered every pick with "not available in chat sessions".
// The structured menu offers only what a pick can carry out: the host's own
// commands, plus the ones the agent itself runs from message text (Codex `/goal`).
// Listing the agent's whole TUI catalog here answered every pick with
// "not available in chat sessions".
it.each([
['claude', 'compact'],
['codex', 'vim']
] as const)('offers %s only actionable structured slash commands', (agent, withheld) => {
['claude', 'compact', ['model', 'effort']],
['codex', 'vim', ['model', 'effort', 'goal']]
] as const)('offers %s only actionable structured slash commands', (agent, withheld, offered) => {
mocks.draft = '/'
render(
<NativeChatComposer
@@ -340,7 +342,7 @@ describe('NativeChatComposer', () => {
const names = (mocks.fieldProps?.autocomplete?.items ?? [])
.filter((item) => item.kind === 'command')
.map((item) => item.name)
expect(names).toEqual(['model', 'effort'])
expect(names).toEqual([...offered])
expect(names).not.toContain(withheld)
})
@@ -7,6 +7,7 @@ import { EMPTY_HISTORY } from './native-chat-composer-state'
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import { sessionSlashCommandSuggestions } from '../../../../shared/native-chat-slash-commands'
import { structuredSlashCommands } from '../../../../shared/structured-agent-session-composer'
function transport(sessionCommands?: NativeChatStructuredComposerTransport['sessionCommands']) {
@@ -47,6 +48,25 @@ describe('composer catalog authority', () => {
'clear'
])
})
// Codex reports no catalog, so the hook's fallback is its entire `/` menu; the
// agent has to reach structuredSlashCommands or `/goal` is invisible there.
it('offers Codex the commands its model runs from message text', () => {
const { result } = renderHook(() => useNativeChatComposerCatalog('codex', transport()))
expect(result.current.agentCommands).toEqual(structuredSlashCommands([], 'codex'))
expect(result.current.agentCommands.map(({ name }) => name)).toContain('goal')
})
it('leaves the Claude route on its own commands, reported or not', () => {
const reported = [{ name: 'init', kind: 'command' as const }]
const withReport = renderHook(() => useNativeChatComposerCatalog('claude', transport(reported)))
expect(withReport.result.current.agentCommands).toEqual(
sessionSlashCommandSuggestions('claude', reported)
)
const withoutReport = renderHook(() => useNativeChatComposerCatalog('claude', transport()))
expect(withoutReport.result.current.agentCommands.map(({ name }) => name)).toEqual([
'model',
'effort'
])
})
it('respects empty catalogs and command-only catalogs without reviving disk skills', () => {
const { result, rerender } = renderHook(
({ reported }) => useNativeChatComposerCatalog('claude', transport(reported)),
@@ -34,7 +34,7 @@ export function useNativeChatComposerCatalog(
? getVerifiedNativeChatCommands(agent)
: reported !== undefined
? sessionSlashCommandSuggestions(agent, reported)
: structuredSlashCommands(conversationCommands),
: structuredSlashCommands(conversationCommands, agent),
[agent, conversationCommands, reported, structured]
)
const sessionSkillNames = useMemo(
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
import { useNativeChatStructuredComposerSend } from './use-native-chat-structured-composer-send'
vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() }))
vi.mock('@/lib/worker-terminal-takeover-report', () => ({
reportStructuredSessionUserInput: vi.fn()
}))
const ATTACHMENT = { id: 'a1', path: '/tmp/shot.png' } as NativeChatComposerImageAttachment
function harness(agent: AgentType) {
const structuredTransport = {
send: vi.fn(() => true),
dispatchCommand: (text: string) =>
dispatchStructuredAgentSessionComposerCommand(text, {
agent,
snapshot: [],
invokeAction: async () => true,
setOption: async () => true,
conversationCommands: ['clear', 'compact'],
runConversationCommand: async () => ({ accepted: true, error: null })
}),
optionSnapshot: [],
onError: vi.fn(),
runtime: 'local',
sessionId: 'session-test',
runtimeEnvironmentId: null
} as unknown as NativeChatStructuredComposerTransport
const { result } = renderHook(() =>
useNativeChatStructuredComposerSend({
agent,
draft: '',
imageAttachments: [ATTACHMENT],
structuredTransport,
clearImageAttachments: vi.fn(),
clearSkillOrigin: vi.fn(),
setHistory: vi.fn(),
setDraft: vi.fn(),
setCaret: vi.fn()
})
)
return { send: result.current, structuredTransport }
}
// The guard exists because a host command sends no message, so its attachments
// would be dropped without a word. A pass-through command IS the message, so the
// attachments ride along with it.
describe('attachment guard follows what the host claims', () => {
it.each([
['claude', '/clear'],
['claude', '/model'],
['codex', '/permissions']
] as const)('refuses attachments on the host-claimed %s command %s', (agent, text) => {
const { send, structuredTransport } = harness(agent)
send(text)
expect(structuredTransport.onError).toHaveBeenCalledWith(
'Remove attachments before using a chat-session command.'
)
expect(structuredTransport.send).not.toHaveBeenCalled()
})
it.each([
['claude', '/init'],
['claude', '/review'],
['codex', '/goal ship the fix']
] as const)('sends %s attachments along with the passed-through %s', async (agent, text) => {
const { send, structuredTransport } = harness(agent)
send(text)
await vi.waitFor(() =>
expect(structuredTransport.send).toHaveBeenCalledWith(text, [ATTACHMENT])
)
expect(structuredTransport.onError).not.toHaveBeenCalledWith(
'Remove attachments before using a chat-session command.'
)
})
})
@@ -3,7 +3,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { MatchedText } from './ProjectComboboxRow'
import { MatchedText, ProjectOptionDetail } from './ProjectComboboxRow'
import { rankProjectOptions } from './project-combobox-matching'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
@@ -69,3 +69,17 @@ describe('MatchedText', () => {
expect(container.textContent).toBe('🚀 orca')
})
})
describe('ProjectOptionDetail', () => {
it('keeps Windows path matches highlighted in the preserved tail', () => {
const detail = 'C:\\Users\\ada\\projects\\orca\\src\\renderer\\app.ts'
const start = detail.indexOf('src')
act(() => {
root.render(<ProjectOptionDetail detail={detail} hits={[start, start + 1, start + 2]} />)
})
expect(container.textContent).toBe(detail)
expect(markedText()).toBe('src')
})
})
@@ -3,7 +3,7 @@ import { FolderOpen } from 'lucide-react'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import { splitDetailForElision } from './project-combobox-matching'
import { splitPathHeadForElision } from '@/lib/path-head-elision'
/** Identity mark shared by the field and every row, so a project reads the same in both. */
export function ProjectOptionMark({
@@ -70,7 +70,8 @@ export function ProjectOptionDetail({
hits?: readonly number[]
className?: string
}): React.JSX.Element {
const split = splitDetailForElision(detail)
const ranges = hits?.map((index) => ({ start: index, end: index + 1 }))
const split = splitPathHeadForElision(detail, ranges)
if (!split) {
return (
<span className={cn('min-w-0 truncate', className)} title={detail}>
@@ -80,9 +81,14 @@ export function ProjectOptionDetail({
}
return (
<span className={cn('flex min-w-0 items-baseline overflow-hidden', className)} title={detail}>
{/* Head collapses first; the tail only truncates once the head is gone. */}
<span className="min-w-0 shrink-[999] truncate">{split.head}</span>
<span className="min-w-0 shrink truncate">/{split.tail}</span>
<span className="min-w-0 shrink truncate">
{split.tailRanges.length > 0 ? (
<MatchedText text={split.tail} hits={split.tailRanges.map((range) => range.start)} />
) : (
split.tail
)}
</span>
</span>
)
}
@@ -3,8 +3,7 @@ import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-opti
import {
getAmbiguousProjectOptionIds,
rankProjectOptions,
sectionProjectOptions,
splitDetailForElision
sectionProjectOptions
} from './project-combobox-matching'
function project(id: string, displayName: string, detail: string): NewWorkspaceProjectOption {
@@ -76,15 +75,3 @@ describe('getAmbiguousProjectOptionIds', () => {
expect(ids).toEqual(new Set(['a', 'b']))
})
})
describe('splitDetailForElision', () => {
it('keeps the last two segments so sibling paths stay distinguishable', () => {
const split = splitDetailForElision('~/Developer/work/acme/monorepo/services/checkout-api')
expect(split?.tail).toBe('services/checkout-api')
})
it('leaves short or shallow details alone', () => {
expect(splitDetailForElision('stablyai/orca')).toBeNull()
expect(splitDetailForElision('3 hosts configured')).toBeNull()
})
})
@@ -143,16 +143,3 @@ export function getAmbiguousProjectOptionIds(
options.filter((o) => (counts.get(o.displayName) ?? 0) > 1).map((option) => option.id)
)
}
/**
* A deep path's identity lives in its tail (`…/services/checkout-api`), which is
* exactly what a plain truncate throws away — two sibling paths then render
* identically. Split so the head can elide while the tail keeps its width.
*/
export function splitDetailForElision(detail: string): { head: string; tail: string } | null {
const segments = detail.split('/')
if (segments.length <= 3 || detail.length <= 28) {
return null
}
return { head: segments.slice(0, -2).join('/'), tail: segments.slice(-2).join('/') }
}
@@ -0,0 +1,110 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTerminalWatcherEffects } from './use-terminal-watcher-effects'
import type { ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/terminal-parked-tab-watchers'
import type { TerminalColdActivationController } from './terminal-cold-activation'
const mocks = vi.hoisted(() => ({
sync: vi.fn(),
prune: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: Object.assign(() => 'unverifiable', {
getState: () => ({ activeWorktreeId: null })
})
}))
vi.mock('@/lib/workspace-terminal-host-authority', () => ({
createWorkspaceTerminalHostAuthoritySelector: () => () => 'unverifiable'
}))
vi.mock('./terminal-pane/terminal-parked-tab-watchers', () => ({
canWatcherCoverParkedTerminalTab: () => true,
disposeAllParkedTerminalWatchers: vi.fn(),
pruneParkedTerminalWatchers: mocks.prune,
syncParkedTerminalTabWatchersForWorkspaces: mocks.sync,
terminalWatcherLiveWorkspaceIds: (ids: Iterable<string>) => new Set(ids)
}))
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const SURFACE_COUNT = 423
const PARKED_WORKTREE_ID = 'repo-1::/worktree-0'
const surfaceIds = Array.from({ length: SURFACE_COUNT }, (_, index) => `repo-1::/worktree-${index}`)
let root: Root | undefined
afterEach(async () => {
await act(async () => root?.unmount())
vi.clearAllMocks()
})
function renderWatcherEffects(): Promise<void> {
function Watcher(): null {
useTerminalWatcherEffects({
activationDeferredMountTabIdsByWorktreeRef: { current: new Map() },
activeTabId: null,
activeTabIdByWorktree: {},
activeView: 'terminal',
activeWorktreeId: null,
activityTerminalPortals: [],
anyMountedWorktreeHasLayout: false,
backgroundMountRevision: 0,
effectiveParkedTerminalWorktreeIds: new Set([PARKED_WORKTREE_ID]),
evictionExemptTerminalTabIds: new Set(['tab-exempt']),
getEffectiveLayoutForWorktree: () => null,
groupsByWorktree: {},
hydrationSucceeded: false,
measurableBackgroundWorktreeIdsRef: { current: new Set() },
mountedWorktreeIdsRef: { current: new Set([PARKED_WORKTREE_ID]) },
pendingStartupByTabId: {},
// Another workspace is on screen, so the mounted one is hidden and parks.
renderedActiveWorktreeId: 'repo-1::/worktree-9',
tabsByWorktree: {
[PARKED_WORKTREE_ID]: [{ id: 'tab-parked' }, { id: 'tab-exempt' }]
},
terminalParkingEnabled: true,
terminalStartupRestorationReady: false,
terminalTitleSnapshotAuthorityEnabled: true,
workspaceSessionReady: false,
workspaceSurfaceIds: surfaceIds
} as unknown as TerminalColdActivationController)
return null
}
root = createRoot(document.createElement('div'))
return act(async () => root?.render(<Watcher />))
}
function lastSyncEntries(): Map<string, ParkedTerminalTabWatcherSyncEntry> {
return mocks.sync.mock.calls.at(-1)?.[0] as Map<string, ParkedTerminalTabWatcherSyncEntry>
}
describe('parked terminal watcher sync entries', () => {
it('publishes an entry for every surface so closed-tab disposal still sees it', async () => {
await renderWatcherEffects()
const entries = lastSyncEntries()
expect(entries.size).toBe(SURFACE_COUNT)
expect([...entries.keys()]).toEqual(surfaceIds)
expect(mocks.prune).toHaveBeenCalledWith(new Set(surfaceIds))
})
it('parks the hidden mounted workspace tabs and exempts the eviction-exempt tab', async () => {
await renderWatcherEffects()
const parkedEntry = lastSyncEntries().get(PARKED_WORKTREE_ID)
expect([...(parkedEntry?.parkedTabIds ?? [])]).toEqual(['tab-parked'])
})
it('does not allocate a parked-tab-id set per unmounted surface', async () => {
await renderWatcherEffects()
const entries = lastSyncEntries()
const unmountedSets = new Set(
[...entries]
.filter(([workspaceId]) => workspaceId !== PARKED_WORKTREE_ID)
.map(([, entry]) => entry.parkedTabIds)
)
// Pre-fix this was one empty Set per surface (422 of them) on every fire.
expect(unmountedSets.size).toBe(1)
expect([...unmountedSets][0]?.size).toBe(0)
})
})
@@ -8,6 +8,7 @@ import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures'
import { useTerminalWorkspaceFoundation } from './use-terminal-workspace-foundation'
import { applyTerminalColdActivation } from './terminal-cold-activation'
import { collectTerminalParkingPassCandidates } from './terminal-parking-pass-candidates'
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import type { WorkspaceSurface } from './workspace-surface-projection'
import type { TerminalParkingFoundation } from './use-terminal-parking-foundation'
@@ -147,6 +148,84 @@ describe('workspace surface ids', () => {
])
expect(hiddenSince.has('repo::/stale')).toBe(false)
})
it('keeps the surface projection stable across a git-worktree switch', () => {
const repo = makeRepo()
const worktrees = Array.from({ length: 423 }, (_, index) =>
makeWorktree(`repo-1::/worktree-${index}`, `Workspace ${index}`)
)
useAppStore.setState({
worktreesByRepo: { [repo.id]: worktrees },
activeWorktreeId: worktrees[0].id
})
const { result } = renderHook(() => useTerminalWorkspaceFoundation())
const surfaces = result.current.workspaceSurfaces
const ids = result.current.workspaceSurfaceIds
const idSet = result.current.workspaceSurfaceIdSet
expect(surfaces).toHaveLength(423)
// Pre-fix every switch re-ran the projection (423 surface objects) and re-derived
// the id array, because the active id was a dep even with no folder host to tie-break.
for (let index = 1; index < 10; index += 1) {
act(() => {
useAppStore.setState({ activeWorktreeId: worktrees[index].id })
})
expect(result.current.renderedActiveWorktreeId).toBe(worktrees[index].id)
expect(result.current.workspaceSurfaces).toBe(surfaces)
expect(result.current.workspaceSurfaceIds).toBe(ids)
expect(result.current.workspaceSurfaceIdSet).toBe(idSet)
}
})
it('still re-projects when the active folder workspace owns the collision tie-break', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const localFolder: FolderWorkspace = {
id: 'folder-shared',
projectGroupId: 'group-shared',
name: 'orca',
folderPath: '/work/orca-local',
connectionId: null,
executionHostId: 'local',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const runtimeFolder: FolderWorkspace = {
...localFolder,
folderPath: '/remote/orca',
executionHostId: 'runtime:env-1'
}
useAppStore.setState({
folderWorkspaces: [localFolder, runtimeFolder],
activeWorktreeId: 'folder:folder-shared',
activeWorkspaceExecutionHostId: 'runtime:env-1'
})
const { result } = renderHook(() => useTerminalWorkspaceFoundation())
expect(result.current.workspaceSurfaces).toEqual([
{ id: 'folder:folder-shared', path: '/remote/orca' }
])
// Leaving the folder workspace drops the tie-break, so the projection must re-run
// and fall back to first-wins rather than mount the previous host's path.
act(() => {
useAppStore.setState({
activeWorktreeId: 'repo-1::/worktree-0',
activeWorkspaceExecutionHostId: null
})
})
expect(result.current.workspaceSurfaces).toEqual([
{ id: 'folder:folder-shared', path: '/work/orca-local' }
])
warn.mockRestore()
})
it('stops idle worktree writes from re-firing surface-keyed terminal effects', () => {
const repo = makeRepo()
const worktrees = Array.from({ length: 20 }, (_, index) =>
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '../../store'
import { getDefaultUIState } from '../../../../shared/constants'
import { UnexpectedSignoutCard } from '../UnexpectedSignoutCard'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
const status: OrcaProfileAuthStatus = {
activeProfileId: 'profile-1',
configured: true,
state: 'reconnect-required',
persistence: 'none',
cloud: {
cloudProfileId: 'cloud-1',
userId: 'user-1',
email: 'user@example.com',
displayName: 'User',
linkedAt: 0
}
}
const persist = vi.fn().mockResolvedValue(undefined)
beforeEach(() => {
vi.stubEnv('DEV', false)
persist.mockClear()
window.localStorage.clear()
window.history.replaceState({}, '', '/')
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: { set: persist },
updater: { getVersion: vi.fn().mockResolvedValue('1.4.197') }
}
})
useAppStore.setState(useAppStore.getInitialState(), true)
useAppStore.setState({
orcaProfileAuthStatus: status,
persistedUIReady: true,
fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(status)
})
})
afterEach(() => {
cleanup()
vi.unstubAllEnvs()
})
async function showCard(): Promise<void> {
render(<UnexpectedSignoutCard />)
await screen.findByRole('complementary')
}
describe('unexpected signout lifecycle', () => {
it('stamps successful sign-in and never re-arms in the same version', async () => {
await showCard()
act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
await waitFor(() =>
expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' })
)
act(() => useAppStore.setState({ orcaProfileAuthStatus: status }))
expect(screen.queryByRole('complementary')).toBeNull()
cleanup()
render(<UnexpectedSignoutCard />)
await act(async () => {})
expect(screen.queryByRole('complementary')).toBeNull()
expect(persist).toHaveBeenCalledTimes(1)
})
it('does not treat a cached connected status as a successful re-sign-in', async () => {
useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })
render(<UnexpectedSignoutCard />)
await act(async () => {})
act(() => useAppStore.setState({ orcaProfileAuthStatus: status }))
expect(screen.queryByRole('complementary')).not.toBeNull()
expect(persist).not.toHaveBeenCalled()
})
it('waits for hydration before stamping an already recovered session', async () => {
useAppStore.setState({ persistedUIReady: false })
render(<UnexpectedSignoutCard />)
await act(async () => {})
act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
expect(persist).not.toHaveBeenCalled()
act(() => useAppStore.setState({ persistedUIReady: true }))
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
expect(persist).toHaveBeenCalledTimes(1)
})
it('keeps a failed sign-in available without stamping dismissal', async () => {
useAppStore.setState({ connectCurrentOrcaProfile: vi.fn().mockResolvedValue(undefined) })
await showCard()
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
await act(async () => {})
expect(screen.queryByRole('complementary')).not.toBeNull()
expect(persist).not.toHaveBeenCalled()
})
it('retains a reopened dismissal through stale UI sync and a renderer remount', async () => {
useAppStore.getState().hydratePersistedUI(
{
...getDefaultUIState(),
dismissedUnexpectedSignoutVersion: '1.4.197'
},
'startup'
)
render(<UnexpectedSignoutCard />)
await act(async () => {})
act(() => useAppStore.getState().hydratePersistedUI(getDefaultUIState()))
expect(screen.queryByRole('complementary')).toBeNull()
cleanup()
render(<UnexpectedSignoutCard />)
await act(async () => {})
expect(screen.queryByRole('complementary')).toBeNull()
expect(persist).not.toHaveBeenCalledWith(
expect.objectContaining({ dismissedUnexpectedSignoutVersion: expect.anything() })
)
})
it('persists X dismissal and stays hidden after remount', async () => {
await showCard()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' })
cleanup()
render(<UnexpectedSignoutCard />)
await act(async () => {})
expect(screen.queryByRole('complementary')).toBeNull()
})
it.each(['storage', 'query'])('ignores the %s preview flag in production', async (source) => {
if (source === 'storage') {
window.localStorage.setItem('orca-debug-show-signout-card', '1')
} else {
window.history.replaceState({}, '', '/?showSignoutCard=1')
}
useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } })
render(<UnexpectedSignoutCard />)
await act(async () => {})
expect(screen.queryByRole('complementary')).toBeNull()
expect(persist).not.toHaveBeenCalled()
})
it('dismisses a development preview without writing real dismissal state', async () => {
vi.stubEnv('DEV', true)
window.localStorage.setItem('orca-debug-show-signout-card', '1')
useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } })
await showCard()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('complementary')).toBeNull()
expect(persist).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
import { shouldShowUnexpectedSignoutCard } from './unexpected-signout-visibility'
function reconnectRequired(): OrcaProfileAuthStatus {
return {
activeProfileId: 'profile-1',
configured: true,
state: 'reconnect-required',
persistence: 'none',
cloud: {
cloudProfileId: 'cloud-1',
userId: 'user-1',
email: 'user@example.com',
displayName: 'User',
linkedAt: 0
}
}
}
describe('shouldShowUnexpectedSignoutCard', () => {
it.each([
{ name: 'unknown auth', auth: null, visible: false },
{
name: 'missing cloud link',
auth: { ...reconnectRequired(), cloud: undefined },
visible: false
},
{
name: 'unconfigured linked profile',
auth: { ...reconnectRequired(), configured: false, state: 'unconfigured' },
visible: false
},
{
name: 'unconfigured reconnect status',
auth: { ...reconnectRequired(), configured: false },
visible: false
},
{
name: 'local with stale cloud metadata',
auth: { ...reconnectRequired(), state: 'local' },
visible: false
},
{
name: 'live memory-only session',
auth: { ...reconnectRequired(), state: 'connected', persistence: 'memory-only' },
visible: false
},
{
name: 'decrypt failure with retained link',
auth: { ...reconnectRequired(), credentialError: 'Cannot decrypt' },
visible: true
},
{
name: 'unreadable session with retained link',
auth: { ...reconnectRequired(), credentialError: 'Permission denied' },
visible: true
}
] satisfies { name: string; auth: OrcaProfileAuthStatus | null; visible: boolean }[])(
'$name',
({ auth, visible }) => {
expect(
shouldShowUnexpectedSignoutCard({
authStatus: auth,
persistedUIReady: true,
appVersion: '1.4.197',
dismissedVersion: null
})
).toBe(visible)
}
)
it('shows when linked but the session is gone', () => {
expect(
shouldShowUnexpectedSignoutCard({
authStatus: reconnectRequired(),
persistedUIReady: true,
appVersion: '1.4.197',
dismissedVersion: null
})
).toBe(true)
})
it('hides after an explicit sign-out (link removed)', () => {
expect(
shouldShowUnexpectedSignoutCard({
authStatus: {
activeProfileId: 'profile-1',
configured: true,
state: 'local',
persistence: 'none'
},
persistedUIReady: true,
appVersion: '1.4.197',
dismissedVersion: null
})
).toBe(false)
})
it('hides when connected', () => {
const status = reconnectRequired()
status.state = 'connected'
expect(
shouldShowUnexpectedSignoutCard({
authStatus: status,
persistedUIReady: true,
appVersion: '1.4.197',
dismissedVersion: null
})
).toBe(false)
})
it('shows only once per app version', () => {
expect(
shouldShowUnexpectedSignoutCard({
authStatus: reconnectRequired(),
persistedUIReady: true,
appVersion: '1.4.197',
dismissedVersion: '1.4.197'
})
).toBe(false)
expect(
shouldShowUnexpectedSignoutCard({
authStatus: reconnectRequired(),
persistedUIReady: true,
appVersion: '1.4.198',
dismissedVersion: '1.4.197'
})
).toBe(true)
})
it('waits for hydration and version', () => {
expect(
shouldShowUnexpectedSignoutCard({
authStatus: reconnectRequired(),
persistedUIReady: false,
appVersion: '1.4.197',
dismissedVersion: null
})
).toBe(false)
expect(
shouldShowUnexpectedSignoutCard({
authStatus: reconnectRequired(),
persistedUIReady: true,
appVersion: null,
dismissedVersion: null
})
).toBe(false)
})
})
@@ -0,0 +1,22 @@
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
export type UnexpectedSignoutGate = {
authStatus: OrcaProfileAuthStatus | null
persistedUIReady: boolean
appVersion: string | null
dismissedVersion: string | null
}
export function shouldShowUnexpectedSignoutCard(gate: UnexpectedSignoutGate): boolean {
if (!gate.persistedUIReady || gate.appVersion === null) {
return false
}
if (gate.dismissedVersion === gate.appVersion) {
return false
}
return (
gate.authStatus?.configured === true &&
gate.authStatus.state === 'reconnect-required' &&
gate.authStatus.cloud != null
)
}
@@ -17,6 +17,10 @@ import { getStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-l
import { AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS } from '../../../shared/agent-session-provider-handle'
import type { TerminalColdActivationController } from './terminal-cold-activation'
// Why shared: only a mounted workspace can park a tab, and the watcher sync only reads
// this set, so every other surface would otherwise allocate its own empty one per fire.
const NO_PARKED_TAB_IDS: ReadonlySet<string> = new Set()
export function useTerminalWatcherEffects(controller: TerminalColdActivationController): void {
const {
activationDeferredMountTabIdsByWorktreeRef,
@@ -58,9 +62,11 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont
continue
}
const tabs = tabsByWorktree[workspaceId] ?? []
const parkedTabIds = new Set<string>()
let parkedTabIds: ReadonlySet<string> = NO_PARKED_TAB_IDS
let deferredTabIds: ReadonlySet<string> | null = null
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspaceId)) {
const mountedParkedTabIds = new Set<string>()
parkedTabIds = mountedParkedTabIds
const isVisible = activeView === 'terminal' && workspaceId === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspaceId)
@@ -75,7 +81,7 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont
tabId: tab.id
})
if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) {
parkedTabIds.add(tab.id)
mountedParkedTabIds.add(tab.id)
}
}
}
@@ -83,14 +89,14 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont
for (const tab of tabs) {
if (
deferredTabIds?.has(tab.id) &&
!parkedTabIds.has(tab.id) &&
!mountedParkedTabIds.has(tab.id) &&
canWatcherCoverParkedTerminalTab(workspaceId, tab) &&
!findActivityTerminalPortal(activityTerminalPortals, {
worktreeId: workspaceId,
tabId: tab.id
})
) {
parkedTabIds.add(tab.id)
mountedParkedTabIds.add(tab.id)
}
}
}
@@ -37,15 +37,19 @@ export function useTerminalWorkspaceFoundation() {
parseWorkspaceKey(renderedActiveWorktreeId ?? '')?.type === 'folder'
? activeWorktreeDeferralHostId
: null
// Why gate the id on the host: the projection reads `activeWorkspaceId` only behind a
// truthy resolved host, so without one every active id yields the same surfaces — and a
// worktree switch must not re-project (and re-identify) every surface to rediscover that.
const activeFolderSurfaceId = activeFolderSurfaceHostId ? renderedActiveWorktreeId : null
const workspaceSurfaces = useMemo(
() =>
projectWorkspaceSurfaces({
worktreesById,
folderWorkspaces,
activeWorkspaceId: renderedActiveWorktreeId,
activeWorkspaceId: activeFolderSurfaceId,
activeWorkspaceResolvedHostId: activeFolderSurfaceHostId
}),
[worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId]
[worktreesById, folderWorkspaces, activeFolderSurfaceId, activeFolderSurfaceHostId]
)
// Why split the ids out: every mount/park/activation pass reads only `.id`, but
// the surface array is re-identified on any worktree write. Reusing the previous
@@ -118,6 +118,24 @@ describe('projectWorkspaceSurfaces', () => {
expect(surfaces).toEqual([{ id: 'folder:folder-shared', path: '/remote/orca' }])
})
it('ignores the active workspace id entirely when no resolved host disambiguates', () => {
// The terminal foundation memo relies on this: with no resolved folder host it passes
// `null` instead of the active id, so a worktree switch cannot change the projection.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const folderWorkspaces = [localFolder, runtimeFolder]
const worktrees = [localWorktree]
const withoutActiveId = project({ worktrees, folderWorkspaces, activeWorkspaceId: null })
for (const activeWorkspaceId of [
'folder:folder-shared',
'folder:other-workspace',
SHARED_WORKTREE_ID
]) {
expect(project({ worktrees, folderWorkspaces, activeWorkspaceId })).toEqual(withoutActiveId)
}
warn.mockRestore()
})
it('keeps the first row when no resolved host disambiguates the folder collision', () => {
const surfaces = project({
folderWorkspaces: [runtimeFolder, localFolder]
@@ -49,6 +49,7 @@ export function projectWorkspaceSurfaces({
}: {
worktreesById: ReadonlyMap<string, Pick<Worktree, 'path'>>
folderWorkspaces: readonly FolderWorkspaceSurfaceRow[]
/** Read only when `activeWorkspaceResolvedHostId` is set; inert otherwise. */
activeWorkspaceId: string | null
/** Resolved (not user-selected) host of the active workspace; the folder tie-break. */
activeWorkspaceResolvedHostId: ExecutionHostId | null
@@ -1,15 +1,14 @@
import type React from 'react'
import { Smartphone } from 'lucide-react'
import { CommandItem } from '@/components/ui/command'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { BrowserPaletteItem, SimulatorPaletteItem } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller'
import {
HighlightedText,
PaletteHostBadgeChip,
PaletteLocationChip,
PaletteOpenTabPrimaryLine,
PaletteRowShortcutBadge
} from './worktree-jump-palette-primitives'
@@ -67,8 +66,6 @@ export function WorktreeJumpPaletteSimulatorRow({
secondaryText={result.secondaryText}
secondaryRanges={result.secondaryRanges}
secondaryMatches={result.secondaryMatches}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
sessionAge={simulatorSessionAge}
leadingBadges={
<>
@@ -94,16 +91,15 @@ export function WorktreeJumpPaletteSimulatorRow({
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex min-w-0 max-w-[40%] items-center justify-end gap-1.5">
<PaletteHostBadgeChip badge={simulatorHostBadge} />
{simulatorRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={simulatorRepo?.badgeColor} />
<span className="truncate">
<HighlightedText text={simulatorRepoName} matchRanges={result.repoRanges} />
</span>
</span>
)}
<PaletteLocationChip
repoName={simulatorRepoName}
repoRanges={result.repoRanges}
repoColor={simulatorRepo?.badgeColor}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
/>
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
@@ -165,8 +161,7 @@ export function WorktreeJumpPaletteBrowserRow({
secondaryText={result.secondaryText}
secondaryRanges={result.secondaryRanges}
secondaryMatches={result.secondaryMatches}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
elideSecondaryPathHead
sessionAge={browserSessionAge}
leadingBadges={
<>
@@ -187,16 +182,15 @@ export function WorktreeJumpPaletteBrowserRow({
}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex min-w-0 max-w-[40%] items-center justify-end gap-1.5">
<PaletteHostBadgeChip badge={browserHostBadge} />
{browserRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={browserRepo?.badgeColor} />
<span className="truncate">
<HighlightedText text={browserRepoName} matchRanges={result.repoRanges} />
</span>
</span>
)}
<PaletteLocationChip
repoName={browserRepoName}
repoRanges={result.repoRanges}
repoColor={browserRepo?.badgeColor}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
/>
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
@@ -448,14 +448,19 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
const title = row?.querySelector('[data-slot="palette-open-tab-title"]')
const worktree = row?.querySelector('[data-slot="palette-open-tab-worktree"]')
expect(title?.textContent).toBe(longTitle)
expect(title?.classList.contains('flex-auto')).toBe(true)
expect(title?.classList.contains('min-w-0')).toBe(true)
expect(title?.classList.contains('shrink-0')).toBe(false)
expect(worktree?.textContent).toBe('user-support')
const locationChip = worktree?.closest('[data-slot="palette-location-chip"]')
expect(locationChip).not.toBeNull()
expect(locationChip?.parentElement?.classList.contains('max-w-[40%]')).toBe(true)
expect(locationChip?.parentElement?.classList.contains('min-w-0')).toBe(true)
expect(worktree?.compareDocumentPosition(title ?? document.createElement('span'))).toBe(
Node.DOCUMENT_POSITION_PRECEDING
)
})
it('tags the worktree rail label as a branch when the visible name is the branch', async () => {
it('shows the branch in the location chip when the workspace display name is empty', async () => {
await renderPalette({
worktreesByRepo: {
'repo-1': [makeWorktree('wt-tabs', '', { displayName: '' })]
@@ -3,7 +3,7 @@
import { cleanup, render, type RenderResult, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { PaletteOpenTabPrimaryLine } from './worktree-jump-palette-primitives'
import { PaletteLocationChip, PaletteOpenTabPrimaryLine } from './worktree-jump-palette-primitives'
afterEach(() => cleanup())
@@ -18,8 +18,6 @@ function renderPrimaryLine(
secondaryText="src/app.ts"
secondaryRanges={[]}
secondaryMatches={secondaryMatches}
worktreeName="Workspace"
worktreeRanges={[]}
/>
</TooltipProvider>
)
@@ -45,3 +43,114 @@ it('renders no badge when every secondary match is already shown', () => {
expect(screen.queryByText(/^\+\d+$/)).toBeNull()
})
it('elides a deep path from the head so the matched tail stays visible', () => {
const path = '/Users/me/projects/orca/new-create-button-design/proposals/create-button.html'
const start = path.indexOf('create-butt')
const { container } = render(
<TooltipProvider>
<PaletteOpenTabPrimaryLine
title="Design"
titleRanges={[]}
secondaryText={path}
secondaryRanges={[{ start, end: start + 'create-butt'.length }]}
elideSecondaryPathHead
/>
</TooltipProvider>
)
const secondary = container.querySelector('[data-slot="palette-open-tab-secondary"]')
expect(secondary?.textContent).toBe(path)
const [head, tail] = Array.from(secondary?.children ?? [])
expect(head?.textContent).toBe('/Users/me/projects/orca')
expect(tail?.textContent).toBe('/new-create-button-design/proposals/create-button.html')
expect(tail?.querySelector('.font-semibold')?.textContent).toBe('create-butt')
})
it('keeps slash-separated agent snippets intact', () => {
const snippet =
'Ran pnpm test src/renderer/src/components/worktree-jump-palette-primitives.test.tsx'
const start = snippet.indexOf('worktree-jump')
const { container } = render(
<TooltipProvider>
<PaletteOpenTabPrimaryLine
title="Agent session"
titleRanges={[]}
secondaryText={snippet}
secondaryRanges={[{ start, end: start + 'worktree-jump'.length }]}
/>
</TooltipProvider>
)
const secondary = container.querySelector('[data-slot="palette-open-tab-secondary"]')
expect(secondary?.textContent).toBe(snippet)
expect(secondary?.children).toHaveLength(1)
expect(secondary?.querySelector('.font-semibold')?.textContent).toBe('worktree-jump')
})
it('folds the worktree into the repo chip and drops it when it repeats the repo name', () => {
const { container, rerender } = render(
<TooltipProvider>
<PaletteLocationChip
repoName="orca"
repoRanges={[]}
worktreeName="new-create-button-design"
worktreeRanges={[]}
/>
</TooltipProvider>
)
expect(container.querySelector('[data-slot="palette-location-chip"]')?.textContent).toBe(
'orca·new-create-button-design'
)
const chip = container.querySelector('[data-slot="palette-location-chip"]')
const repo = container.querySelector('[data-slot="palette-location-repo"]')
const worktree = container.querySelector('[data-slot="palette-open-tab-worktree"]')
expect(chip?.className).toContain('overflow-hidden')
expect(chip?.className).toContain('min-w-0')
expect(repo?.className).toContain('max-w-[55%]')
expect(repo?.className).toContain('min-w-[3ch]')
expect(worktree?.className).toContain('min-w-[3ch]')
expect(worktree?.getAttribute('data-state')).toBeNull()
expect(worktree?.getAttribute('tabindex')).toBeNull()
rerender(
<TooltipProvider>
<PaletteLocationChip
repoName="orca"
repoRanges={[]}
worktreeName="orca"
worktreeRanges={[{ start: 0, end: 4 }]}
/>
</TooltipProvider>
)
expect(container.querySelector('[data-slot="palette-location-chip"]')?.textContent).toBe('orca')
expect(
container.querySelector('[data-slot="palette-location-repo"] .font-semibold')?.textContent
).toBe('orca')
})
it('keeps a short title at its natural width so the session age stays beside it', () => {
const { container } = render(
<TooltipProvider>
<PaletteOpenTabPrimaryLine
title="Terminal 1"
titleRanges={[]}
secondaryText=""
secondaryRanges={[]}
sessionAge="2d"
/>
</TooltipProvider>
)
const title = container.querySelector('[data-slot="palette-open-tab-title"]')
expect(title?.className).toContain('min-w-0')
expect(title?.className).not.toContain('shrink-0')
})
it('caps the title only when it shares the line with secondary text', () => {
const { container } = renderPrimaryLine([])
const title = container.querySelector('[data-slot="palette-open-tab-title"]')
expect(title?.className).toContain('max-w-[62%]')
expect(title?.className).toContain('shrink-0')
})
@@ -1,11 +1,12 @@
import React, { useLayoutEffect, useRef, useState } from 'react'
import React from 'react'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { translate } from '@/i18n/i18n'
import type { PaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import type { MatchRange, PaletteSearchResult } from '@/lib/worktree-palette-search'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { Worktree } from '../../../shared/worktree/types'
import { resolveWorktreeBranchLabel } from '@/lib/worktree-default-display-name'
import { splitPathHeadForElision } from '@/lib/path-head-elision'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
const NO_SECONDARY_MATCHES: readonly { text: string; ranges: readonly MatchRange[] }[] = []
@@ -67,14 +68,51 @@ export function HighlightedText({
return <>{parts}</>
}
function PaletteOpenTabSecondaryText({
text,
ranges,
elidePathHead
}: {
text: string
ranges: readonly MatchRange[]
elidePathHead: boolean
}): React.JSX.Element {
const split = elidePathHead ? splitPathHeadForElision(text, ranges) : null
const content = split ? (
<span
data-slot="palette-open-tab-secondary"
className="flex min-w-0 items-baseline overflow-hidden font-mono text-[11px] text-muted-foreground/80"
>
<span className="min-w-0 shrink-[999] truncate">{split.head}</span>
<span className="min-w-0 shrink truncate">
<HighlightedText text={split.tail} matchRanges={split.tailRanges} />
</span>
</span>
) : (
<span
data-slot="palette-open-tab-secondary"
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/80"
>
<HighlightedText text={text} matchRanges={ranges} />
</span>
)
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
{text}
</TooltipContent>
</Tooltip>
)
}
export function PaletteOpenTabPrimaryLine({
title,
titleRanges,
secondaryText,
secondaryRanges,
secondaryMatches = NO_SECONDARY_MATCHES,
worktreeName,
worktreeRanges,
elideSecondaryPathHead = false,
sessionAge,
leadingBadges
}: {
@@ -83,13 +121,11 @@ export function PaletteOpenTabPrimaryLine({
secondaryText: string
secondaryRanges: readonly MatchRange[]
secondaryMatches?: readonly { text: string; ranges: readonly MatchRange[] }[]
worktreeName: string
worktreeRanges: readonly MatchRange[]
elideSecondaryPathHead?: boolean
sessionAge?: string
leadingBadges?: React.ReactNode
}): React.JSX.Element {
const showSecondary = secondaryText.trim().length > 0
const showWorktree = worktreeName.trim().length > 0
const additionalSecondaryMatches = secondaryMatches.filter(
(match) => match.text && match.text !== secondaryText
)
@@ -98,7 +134,10 @@ export function PaletteOpenTabPrimaryLine({
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span
data-slot="palette-open-tab-title"
className="min-w-0 flex-auto truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
className={cn(
'truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground',
showSecondary ? 'max-w-[62%] shrink-0' : 'min-w-0'
)}
>
<HighlightedText text={title} matchRanges={titleRanges} />
</span>
@@ -116,12 +155,11 @@ export function PaletteOpenTabPrimaryLine({
) : null}
{leadingBadges}
{showSecondary ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText text={secondaryText} matchRanges={secondaryRanges} />
</span>
</>
<PaletteOpenTabSecondaryText
text={secondaryText}
ranges={secondaryRanges}
elidePathHead={elideSecondaryPathHead}
/>
) : null}
{additionalSecondaryMatches.length ? (
<>
@@ -154,85 +192,76 @@ export function PaletteOpenTabPrimaryLine({
</Tooltip>
</>
) : null}
{showWorktree ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span
data-slot="palette-open-tab-worktree"
className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92"
>
<HighlightedText text={worktreeName} matchRanges={worktreeRanges} />
</span>
</>
) : null}
</div>
)
}
function resolveOpenTabWorktreeRailTooltip({
isBranch,
truncated,
name
export function PaletteLocationChip({
repoName,
repoRanges,
repoColor,
worktreeName,
worktreeRanges,
className
}: {
isBranch: boolean
truncated: boolean
name: string
}): string {
if (truncated) {
return name
}
return isBranch
? translate('auto.components.WorktreeJumpPalette.paletteOpenTabBranch', 'Branch name')
: translate('auto.components.WorktreeJumpPalette.paletteOpenTabWorkspace', 'Workspace name')
}
export function PaletteOpenTabWorktreeRailLabel({
name,
matchRanges,
worktree,
className,
slot = 'palette-open-tab-worktree'
}: {
name: string
matchRanges: readonly MatchRange[]
worktree?: Pick<Worktree, 'branch'> | null
repoName: string
repoRanges: readonly MatchRange[]
repoColor?: string
worktreeName: string
worktreeRanges: readonly MatchRange[]
className?: string
slot?: string
}): React.JSX.Element | null {
const [truncated, setTruncated] = useState(false)
const labelRef = useRef<HTMLSpanElement | null>(null)
useLayoutEffect(() => {
const node = labelRef.current
if (!node) {
setTruncated(false)
return
}
const updateTruncated = (): void => {
const next = node.scrollWidth > node.clientWidth
setTruncated((current) => (current === next ? current : next))
}
updateTruncated()
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(updateTruncated)
observer.observe(node)
return () => observer.disconnect()
}, [name])
if (name.trim().length === 0) {
const showRepo = repoName.trim().length > 0
const repeatedName = showRepo && worktreeName === repoName
const showWorktree = worktreeName.trim().length > 0 && !repeatedName
if (!showRepo && !showWorktree) {
return null
}
const isBranch = worktree != null && name === resolveWorktreeBranchLabel(worktree)
const tooltip = resolveOpenTabWorktreeRailTooltip({ isBranch, truncated, name })
const repoMatchRanges = repeatedName
? [...repoRanges, ...worktreeRanges].sort((left, right) => left.start - right.start)
: repoRanges
const label = [showRepo ? repoName : '', showWorktree ? worktreeName : '']
.filter(Boolean)
.join(' · ')
const chip = (
<span
data-slot="palette-location-chip"
className={cn(
'inline-flex min-w-0 max-w-[260px] items-center gap-1.5 overflow-hidden rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground',
className
)}
>
{showRepo ? (
<>
<RepoBadgeMark color={repoColor} />
<span
className={cn('min-w-0 truncate', showWorktree && 'min-w-[3ch] max-w-[55%]')}
data-slot="palette-location-repo"
>
<HighlightedText text={repoName} matchRanges={repoMatchRanges} />
</span>
</>
) : null}
{showRepo && showWorktree ? (
<span aria-hidden className="shrink-0 text-muted-foreground/50">
·
</span>
) : null}
{showWorktree ? (
<span
data-slot="palette-open-tab-worktree"
className="min-w-[3ch] truncate font-medium text-muted-foreground"
>
<HighlightedText text={worktreeName} matchRanges={worktreeRanges} />
</span>
) : null}
</span>
)
return (
<Tooltip>
<TooltipTrigger asChild>
<span ref={labelRef} data-slot={slot} tabIndex={-1} className={className}>
<HighlightedText text={name} matchRanges={matchRanges} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-80 break-all">
{tooltip}
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
{label}
</TooltipContent>
</Tooltip>
)
@@ -3,20 +3,20 @@ import { FileText, SquareTerminal } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { CommandItem } from '@/components/ui/command'
import { PaletteRecentTabStatusDot } from '@/components/cmd-j/palette-live-status'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { WorkspaceTabPaletteItem } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller'
import {
HighlightedText,
PaletteHostBadgeChip,
PaletteLocationChip,
PaletteOpenTabPrimaryLine,
PaletteRowShortcutBadge
} from './worktree-jump-palette-primitives'
import { formatPaletteSessionAge } from '@/components/cmd-j/palette-session-age'
import { resolvePaletteRepoForWorktree } from '@/lib/palette-repo-resolution'
import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type'
export function WorktreeJumpPaletteWorkspaceTabRow({
entry,
@@ -76,8 +76,7 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
secondaryText={result.secondaryText}
secondaryRanges={result.secondaryRanges}
secondaryMatches={result.secondaryMatches}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
elideSecondaryPathHead={isEditorTabContentType(result.contentType)}
sessionAge={sessionAge}
leadingBadges={
<>
@@ -103,16 +102,15 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex min-w-0 max-w-[40%] items-center justify-end gap-1.5">
<PaletteHostBadgeChip badge={workspaceTabHostBadge} />
{workspaceTabRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={workspaceTabRepo?.badgeColor} />
<span className="truncate">
<HighlightedText text={workspaceTabRepoName} matchRanges={result.repoRanges} />
</span>
</span>
)}
<PaletteLocationChip
repoName={workspaceTabRepoName}
repoRanges={result.repoRanges}
repoColor={workspaceTabRepo?.badgeColor}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
/>
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
+15 -2
View File
@@ -2378,8 +2378,6 @@
"recentChatsTerminalsHeader": "Recent Chats & Terminals",
"27f10cca63": "Search chats, terminals, worktrees, settings, and actions...",
"2770f02910": "Search chats, terminals, worktrees, settings, and actions",
"paletteOpenTabBranch": "Branch name",
"paletteOpenTabWorkspace": "Workspace name",
"lastActiveTime": "Last active {{value0}} ago"
},
"github": {
@@ -16885,6 +16883,21 @@
"HostedReviewUnlinkMenuItem": {
"label": "Unlink {{value0}} from workspace",
"description": "Orca will hide {{value0}} {{value1}} details for this workspace. The {{value0}} and branch on {{value2}} won’t be changed."
},
"UnexpectedSignoutCard": {
"9f2c1a4b7d": "You've been signed out",
"3e8f5c2a91": "Dismiss",
"7b4d9e1f2a": "Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.",
"5a1c8d3e6f": "Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.",
"1f6b2c9d4e": "What you get back",
"8d2e4f7a1b": "Artifact sharing",
"2c9a5b6e8d": "Publish HTML and Markdown files and manage every shared link from Orca.",
"6e3f1a9c5b": "Orca Relay",
"4b7d2e8f1a": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi.",
"9a4c6b2d7e": "Skill sharing",
"3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.",
"7e1a9c4d2f": "Signing in…",
"c5b3e8a17d": "Sign in to Orca"
}
},
"i18n": {
-2
View File
@@ -2201,8 +2201,6 @@
"recentChatsTerminalsHeader": "Chats et terminaux récents",
"27f10cca63": "Rechercher chats, terminaux, worktrees, paramètres et actions...",
"2770f02910": "Rechercher chats, terminaux, worktrees, paramètres et actions",
"paletteOpenTabBranch": "Nom de branche",
"paletteOpenTabWorkspace": "Nom de l'espace de travail",
"lastActiveTime": "Dernière activité il y a {{value0}}"
},
"github": {
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { splitPathHeadForElision } from './path-head-elision'
describe('splitPathHeadForElision', () => {
it('keeps the last two segments as the tail', () => {
expect(splitPathHeadForElision('/Users/me/projects/orca/proposals/create-button.html')).toEqual(
{
head: '/Users/me/projects/orca',
tail: '/proposals/create-button.html',
tailRanges: []
}
)
})
it('supports Windows paths without changing their separators', () => {
const path = 'C:\\Users\\me\\projects\\orca\\src\\renderer\\app.ts'
const start = path.indexOf('src')
expect(splitPathHeadForElision(path, [{ start, end: start + 3 }])).toEqual({
head: 'C:\\Users\\me\\projects\\orca',
tail: '\\src\\renderer\\app.ts',
tailRanges: [{ start: 1, end: 4 }]
})
})
it('keeps backslashes inside POSIX segment names', () => {
expect(splitPathHeadForElision('/tmp/project/src/name\\with\\slashes.ts')).toEqual({
head: '/tmp/project',
tail: '/src/name\\with\\slashes.ts',
tailRanges: []
})
})
it('leaves short or shallow paths whole', () => {
expect(splitPathHeadForElision('src/app.ts')).toBeNull()
expect(splitPathHeadForElision('a/b/c')).toBeNull()
expect(splitPathHeadForElision('/tmp/orca-create-button/create-button.html')).toEqual({
head: '/tmp',
tail: '/orca-create-button/create-button.html',
tailRanges: []
})
})
it('extends the tail back to the first matched segment and re-bases ranges', () => {
const path = '/Users/me/projects/orca/new-create-button-design/proposals/create-button.html'
const start = path.indexOf('create-butt')
const split = splitPathHeadForElision(path, [{ start, end: start + 'create-butt'.length }])
expect(split).toEqual({
head: '/Users/me/projects/orca',
tail: '/new-create-button-design/proposals/create-button.html',
tailRanges: [{ start: 5, end: 16 }]
})
})
it('pulls a segment the match starts in fully into the tail', () => {
const path = '/Users/me/projects/orca/deep/nested/file.ts'
const split = splitPathHeadForElision(path, [{ start: 20, end: 30 }])
expect(split?.head).toBe('/Users/me/projects')
expect(split?.tail).toBe('/orca/deep/nested/file.ts')
expect(split?.tailRanges).toEqual([{ start: 2, end: 12 }])
})
it('returns null when the match sits in the first segment', () => {
const path = '/Users-long-prefix/me/projects/orca/deep/file.ts'
expect(splitPathHeadForElision(path, [{ start: 1, end: 6 }])).toBeNull()
})
})
+52
View File
@@ -0,0 +1,52 @@
import type { MatchRange } from './palette-match/normalized-text'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
export type PathHeadElisionSplit = {
head: string
tail: string
tailRanges: readonly MatchRange[]
}
const MIN_ELISION_LENGTH = 28
const MIN_SEGMENTS = 4
const TAIL_SEGMENTS = 2
export function splitPathHeadForElision(
path: string,
ranges: readonly MatchRange[] = []
): PathHeadElisionSplit | null {
if (path.length <= MIN_ELISION_LENGTH) {
return null
}
const windowsPath = isWindowsAbsolutePathLike(path)
const separators: number[] = []
for (let index = 0; index < path.length; index += 1) {
if (path[index] === '/' || (windowsPath && path[index] === '\\')) {
separators.push(index)
}
}
if (separators.length < MIN_SEGMENTS - 1) {
return null
}
let tailStart = separators[separators.length - TAIL_SEGMENTS]!
const firstMatchStart = ranges.reduce(
(earliest, range) => (range.start < range.end ? Math.min(earliest, range.start) : earliest),
Number.POSITIVE_INFINITY
)
if (firstMatchStart < tailStart) {
const segmentSeparator = separators.findLast((separator) => separator < firstMatchStart)
tailStart = segmentSeparator ?? 0
}
const head = path.slice(0, tailStart)
if (!(windowsPath ? /[^/\\]/ : /[^/]/).test(head)) {
return null
}
return {
head,
tail: path.slice(tailStart),
tailRanges: ranges.map((range) => ({
start: range.start - tailStart,
end: range.end - tailStart
}))
}
}
@@ -280,3 +280,81 @@ describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => {
expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false })
})
})
describe('unexpected sign-out dismissal persistence', () => {
it('persists a dismissal once even when effects repeat', () => {
const setUI = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
const store = createUIStore()
store.getState().dismissUnexpectedSignoutCard('1.2.3')
store.getState().dismissUnexpectedSignoutCard('1.2.3')
expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
expect(setUI).toHaveBeenCalledExactlyOnceWith({ dismissedUnexpectedSignoutVersion: '1.2.3' })
})
it.each([undefined, null, '1.2.2'])(
'does not re-arm a local dismissal from stale hydration (%s)',
(dismissedUnexpectedSignoutVersion) => {
vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } })
const store = createUIStore()
store.getState().dismissUnexpectedSignoutCard('1.2.3')
store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion }))
expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3')
}
)
it('defaults legacy profiles and restores dismissal on reopen', () => {
const store = createUIStore()
expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull()
store
.getState()
.hydratePersistedUI(
makePersistedUI({ dismissedUnexpectedSignoutVersion: undefined }),
'startup'
)
expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull()
const reopened = createUIStore()
reopened
.getState()
.hydratePersistedUI(
makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }),
'startup'
)
expect(reopened.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
})
it('accepts another window dismissal after hydrating an older version', () => {
const store = createUIStore()
store
.getState()
.hydratePersistedUI(
makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.2' }),
'startup'
)
store
.getState()
.hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }))
expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
})
})
describe('unexpected sign-out hydrated dismissal history', () => {
it.each([undefined, null, '1.2.2', '1.2.4'])(
'retains an observed dismissal after a different version sync (%s)',
(dismissedUnexpectedSignoutVersion) => {
const store = createUIStore()
store
.getState()
.hydratePersistedUI(
makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }),
'startup'
)
store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion }))
expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3')
}
)
})
@@ -175,6 +175,10 @@ export type UISlicePersistence = {
dismissedUpdateVersion: string | null
dismissUpdate: (versionOverride?: string) => void
clearDismissedUpdateVersion: () => void
/** App version that dismissed the unexpected-sign-out card; null = never dismissed. */
dismissedUnexpectedSignoutVersion: string | null
unexpectedSignoutDismissedVersions: string[]
dismissUnexpectedSignoutCard: (version: string) => void
/** Dev-only channel override; null follows the running build's own channel. */
releaseChannelOverride: ReleaseChannel | null
setReleaseChannelOverride: (channel: ReleaseChannel | null) => void
@@ -52,6 +52,7 @@ import {
} from '../persisted-ui-write-baseline'
import {
hydrateTrustedOrcaHooks,
hydrateUnexpectedSignoutDismissal,
normalizeHydratedVisibleWorkspaceHostIds,
preserveStringArrayIdentity,
sanitizeHydratedActiveView,
@@ -226,6 +227,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par
return DEFAULT_PET_ID
})(),
dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null,
...hydrateUnexpectedSignoutDismissal(s, ui.dismissedUnexpectedSignoutVersion),
// Why: a persisted value from a build that knew a different channel set
// would otherwise survive as-is; activeChannel only falls back on null,
// so an unknown string reaches listBuilds and the segmented control.
@@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta
}
return out as StatusBarItem[]
}
export function hydrateUnexpectedSignoutDismissal(
state: Pick<UISlice, 'unexpectedSignoutDismissedVersions'>,
version: string | null | undefined
): Pick<UISlice, 'dismissedUnexpectedSignoutVersion' | 'unexpectedSignoutDismissedVersions'> {
const observed = state.unexpectedSignoutDismissedVersions
return {
dismissedUnexpectedSignoutVersion: version ?? null,
// A later sync must never undo any dismissal observed in this session.
unexpectedSignoutDismissedVersions:
typeof version === 'string' && !observed.includes(version) ? [...observed, version] : observed
}
}
@@ -43,6 +43,18 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial
updateChangelog: null,
updateUserInitiatedCycle: false,
dismissedUpdateVersion: null,
dismissedUnexpectedSignoutVersion: null,
unexpectedSignoutDismissedVersions: [],
dismissUnexpectedSignoutCard: (version) => {
if (get().unexpectedSignoutDismissedVersions.includes(version)) {
return
}
set({
dismissedUnexpectedSignoutVersion: version,
unexpectedSignoutDismissedVersions: [...get().unexpectedSignoutDismissedVersions, version]
})
void window.api.ui.set({ dismissedUnexpectedSignoutVersion: version }).catch(console.error)
},
clearDismissedUpdateVersion: () => {
set({ dismissedUpdateVersion: null })
},

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