diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 7e0009b3a24..6b36938a3d1 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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 diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 5a93ec12c25..45145572b80 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -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 () => { diff --git a/config/scripts/session-search-retention-benchmark.ts b/config/scripts/session-search-retention-benchmark.ts new file mode 100644 index 00000000000..095eb5f29b0 --- /dev/null +++ b/config/scripts/session-search-retention-benchmark.ts @@ -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 { + 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 }) +} diff --git a/config/scripts/session-search-write-benchmark.ts b/config/scripts/session-search-write-benchmark.ts new file mode 100644 index 00000000000..db09275cd98 --- /dev/null +++ b/config/scripts/session-search-write-benchmark.ts @@ -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 { + let previous = performance.now() + while (running()) { + await yieldToEventLoop() + const now = performance.now() + stalls.push(now - previous) + previous = now + } +} + +function tableBytes(db: SyncDatabase): Record { + 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 { + // 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 }) +} diff --git a/config/scripts/telemetry-bundle-constant-patterns.mjs b/config/scripts/telemetry-bundle-constant-patterns.mjs index 04944b7b156..87c2ae43cf7 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.mjs @@ -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_-]+)["'`]/ diff --git a/config/scripts/telemetry-bundle-constant-patterns.test.mjs b/config/scripts/telemetry-bundle-constant-patterns.test.mjs index b8df5ec3d0f..ca87e3ee971 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.test.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.test.mjs @@ -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) + }) }) diff --git a/config/scripts/verify-telemetry-constants.mjs b/config/scripts/verify-telemetry-constants.mjs index 3bdcd8b3ec6..836a3e4546f 100644 --- a/config/scripts/verify-telemetry-constants.mjs +++ b/config/scripts/verify-telemetry-constants.mjs @@ -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 diff --git a/mobile/src/components/codex-reset-credit-capability.ts b/mobile/src/components/codex-reset-credit-capability.ts index 1a32ef37873..8e88f74f2f9 100644 --- a/mobile/src/components/codex-reset-credit-capability.ts +++ b/mobile/src/components/codex-reset-credit-capability.ts @@ -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 { 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) ) diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts index 198efbaf3ea..6840184c3a0 100644 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -18,7 +18,7 @@ type SshTargetSummaryRow = { id: string; label: string } async function requestResult(client: RpcClient, method: string): Promise { 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 diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index c16b69ced89..20c43aa6c8b 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -130,7 +130,7 @@ export function MobileNativeChatComposer({ if (trigger.kind === 'slash') { const commands = structuredCommands !== undefined - ? structuredSlashCommands(structuredCommands) + ? structuredSlashCommands(structuredCommands, agent) : agent ? getVerifiedNativeChatCommands(agent) : [] diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts index 8a020d2eea9..2deb3042ca2 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts @@ -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) => { diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts index 9e26eaab91e..bd15e595736 100644 --- a/mobile/src/session/mobile-structured-agent-session-launch.ts +++ b/mobile/src/session/mobile-structured-agent-session-launch.ts @@ -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 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))) } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index d3d68b85032..5b941367451 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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, diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts new file mode 100644 index 00000000000..3cf432381c2 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -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 + 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() + }) +}) diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts index 70261e9df9b..d1cfe3d42f4 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts @@ -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, diff --git a/mobile/src/terminal/terminal-viewport-refit-state.ts b/mobile/src/terminal/terminal-viewport-refit-state.ts index dfa4daadf4c..766345d8c73 100644 --- a/mobile/src/terminal/terminal-viewport-refit-state.ts +++ b/mobile/src/terminal/terminal-viewport-refit-state.ts @@ -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 diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.ts b/mobile/src/transport/mobile-relay-direct-upgrade.ts index b804d6fb610..ed019262bca 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade.ts @@ -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 { 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' -} diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 26dc08620de..b37a18a06c2 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -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, installed: DeviceCredentialInstalled diff --git a/mobile/src/transport/mobile-relay-rpc-streams.ts b/mobile/src/transport/mobile-relay-rpc-streams.ts index abb2c12564b..3af5b2b3f5e 100644 --- a/mobile/src/transport/mobile-relay-rpc-streams.ts +++ b/mobile/src/transport/mobile-relay-rpc-streams.ts @@ -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 diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index a9b04320471..eb1f524334e 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -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' } diff --git a/mobile/src/transport/rpc-acceptance-policies.test.ts b/mobile/src/transport/rpc-acceptance-policies.test.ts new file mode 100644 index 00000000000..2f2abc90e16 --- /dev/null +++ b/mobile/src/transport/rpc-acceptance-policies.test.ts @@ -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) + }) +}) diff --git a/mobile/src/transport/rpc-acceptance-policies.ts b/mobile/src/transport/rpc-acceptance-policies.ts new file mode 100644 index 00000000000..742d94aafa7 --- /dev/null +++ b/mobile/src/transport/rpc-acceptance-policies.ts @@ -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 | null { + if (!response.ok || typeof response.result !== 'object' || response.result === null) { + return null + } + return response.result as Record +} + +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 +} diff --git a/mobile/src/transport/rpc-client-stream-registry.ts b/mobile/src/transport/rpc-client-stream-registry.ts index ac20acdecc0..68e50a6e6dd 100644 --- a/mobile/src/transport/rpc-client-stream-registry.ts +++ b/mobile/src/transport/rpc-client-stream-registry.ts @@ -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 } diff --git a/src/main/ai-vault-search/session-search-content-hash.test.ts b/src/main/ai-vault-search/session-search-content-hash.test.ts new file mode 100644 index 00000000000..1e7232fc855 --- /dev/null +++ b/src/main/ai-vault-search/session-search-content-hash.test.ts @@ -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) +}) diff --git a/src/main/ai-vault-search/session-search-content-hash.ts b/src/main/ai-vault-search/session-search-content-hash.ts new file mode 100644 index 00000000000..a9d2ab229da --- /dev/null +++ b/src/main/ai-vault-search/session-search-content-hash.ts @@ -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 +} diff --git a/src/main/ai-vault-search/session-search-cwd-key.test.ts b/src/main/ai-vault-search/session-search-cwd-key.test.ts new file mode 100644 index 00000000000..24d915eedc9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-cwd-key.test.ts @@ -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') + ) +}) diff --git a/src/main/ai-vault-search/session-search-file-cursor.ts b/src/main/ai-vault-search/session-search-file-cursor.ts new file mode 100644 index 00000000000..2ba978b00ae --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-cursor.ts @@ -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 +} diff --git a/src/main/ai-vault-search/session-search-file-records.ts b/src/main/ai-vault-search/session-search-file-records.ts new file mode 100644 index 00000000000..2ee79cbdc13 --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-records.ts @@ -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 + ) + } +} diff --git a/src/main/ai-vault-search/session-search-file-write.test.ts b/src/main/ai-vault-search/session-search-file-write.test.ts new file mode 100644 index 00000000000..942565e18d6 --- /dev/null +++ b/src/main/ai-vault-search/session-search-file-write.test.ts @@ -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 { + 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)) +}) diff --git a/src/main/ai-vault-search/session-search-identifier-split.test.ts b/src/main/ai-vault-search/session-search-identifier-split.test.ts new file mode 100644 index 00000000000..24f8a67d5c3 --- /dev/null +++ b/src/main/ai-vault-search/session-search-identifier-split.test.ts @@ -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) +}) diff --git a/src/main/ai-vault-search/session-search-identifier-split.ts b/src/main/ai-vault-search/session-search-identifier-split.ts new file mode 100644 index 00000000000..e2df1822cfa --- /dev/null +++ b/src/main/ai-vault-search/session-search-identifier-split.ts @@ -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() + 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(' ') +} diff --git a/src/main/ai-vault-search/session-search-index-consumer.test.ts b/src/main/ai-vault-search/session-search-index-consumer.test.ts new file mode 100644 index 00000000000..ca02333cf0b --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-consumer.test.ts @@ -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) +}) diff --git a/src/main/ai-vault-search/session-search-index-consumer.ts b/src/main/ai-vault-search/session-search-index-consumer.ts new file mode 100644 index 00000000000..e4fa6da4a8e --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-consumer.ts @@ -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)) +} diff --git a/src/main/ai-vault-search/session-search-index-test-fixture.ts b/src/main/ai-vault-search/session-search-index-test-fixture.ts new file mode 100644 index 00000000000..baa3e2976fe --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-test-fixture.ts @@ -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 { + 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 { + 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 +}): 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 +} + +/** An on-disk index: `:memory:` is per-connection, so a second reader needs a real file. */ +export async function openSessionSearchIndexFile(name: string): Promise { + 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) + } + } +} diff --git a/src/main/ai-vault-search/session-search-index-writer.test.ts b/src/main/ai-vault-search/session-search-index-writer.test.ts new file mode 100644 index 00000000000..46d147dcce0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-writer.test.ts @@ -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 + ) + } +}) diff --git a/src/main/ai-vault-search/session-search-index-writer.ts b/src/main/ai-vault-search/session-search-index-writer.ts new file mode 100644 index 00000000000..a5c981b5bb2 --- /dev/null +++ b/src/main/ai-vault-search/session-search-index-writer.ts @@ -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 + +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() + + 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) + } +} diff --git a/src/main/ai-vault-search/session-search-live-transcript.test.ts b/src/main/ai-vault-search/session-search-live-transcript.test.ts new file mode 100644 index 00000000000..7f37ca662cb --- /dev/null +++ b/src/main/ai-vault-search/session-search-live-transcript.test.ts @@ -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 { + 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([]) +}) diff --git a/src/main/ai-vault-search/session-search-message-rows.test.ts b/src/main/ai-vault-search/session-search-message-rows.test.ts new file mode 100644 index 00000000000..d2cf2fbca61 --- /dev/null +++ b/src/main/ai-vault-search/session-search-message-rows.test.ts @@ -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 { + 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[] + 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() + } +}) diff --git a/src/main/ai-vault-search/session-search-message-rows.ts b/src/main/ai-vault-search/session-search-message-rows.ts new file mode 100644 index 00000000000..21254c183aa --- /dev/null +++ b/src/main/ai-vault-search/session-search-message-rows.ts @@ -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 { + 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 +): Generator { + 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 +} diff --git a/src/main/ai-vault-search/session-search-retention-delete.test.ts b/src/main/ai-vault-search/session-search-retention-delete.test.ts new file mode 100644 index 00000000000..c21c8d7fe2b --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-delete.test.ts @@ -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() + } +}) diff --git a/src/main/ai-vault-search/session-search-retention-delete.ts b/src/main/ai-vault-search/session-search-retention-delete.ts new file mode 100644 index 00000000000..e8d407f8be9 --- /dev/null +++ b/src/main/ai-vault-search/session-search-retention-delete.ts @@ -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 = yieldToEventLoop +): Promise { + 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 = yieldToEventLoop +): Promise { + // 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})`) +} diff --git a/src/main/ai-vault-search/session-search-row-identity.test.ts b/src/main/ai-vault-search/session-search-row-identity.test.ts new file mode 100644 index 00000000000..4adc655b584 --- /dev/null +++ b/src/main/ai-vault-search/session-search-row-identity.test.ts @@ -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 + ) +}) diff --git a/src/main/ai-vault-search/session-search-schema.test.ts b/src/main/ai-vault-search/session-search-schema.test.ts new file mode 100644 index 00000000000..b23f36cde55 --- /dev/null +++ b/src/main/ai-vault-search/session-search-schema.test.ts @@ -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('node:fs') + return { + ...actual, + rmSync: (...args: Parameters) => { + recordedRmSync(...args) + return actual.rmSync(...args) + } + } +}) + +let roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.map((root) => removeTree(root))) + roots = [] +}) + +async function tempDatabasePath(): Promise { + 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: `/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() + } +}) diff --git a/src/main/ai-vault-search/session-search-schema.ts b/src/main/ai-vault-search/session-search-schema.ts new file mode 100644 index 00000000000..2da1e64bc11 --- /dev/null +++ b/src/main/ai-vault-search/session-search-schema.ts @@ -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 +} diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts new file mode 100644 index 00000000000..da9f4d6f731 --- /dev/null +++ b/src/main/ai-vault-search/session-search-store.ts @@ -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() + 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 { + 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 { + 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() + } +} diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts new file mode 100644 index 00000000000..9b6a48beb4e --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-corpus.test.ts @@ -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 }) + } + } +) diff --git a/src/main/ai-vault-search/session-search-synthetic-corpus.ts b/src/main/ai-vault-search/session-search-synthetic-corpus.ts new file mode 100644 index 00000000000..14e8a27fe5f --- /dev/null +++ b/src/main/ai-vault-search/session-search-synthetic-corpus.ts @@ -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 { + 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 } +} diff --git a/src/main/ai-vault-search/session-search-transcript-fixtures.ts b/src/main/ai-vault-search/session-search-transcript-fixtures.ts new file mode 100644 index 00000000000..bc7eda9a8ff --- /dev/null +++ b/src/main/ai-vault-search/session-search-transcript-fixtures.ts @@ -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 { + 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 { + 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 } + }) + ] +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 88e09627eb7..18f273d6be3 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -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) => { diff --git a/src/main/ai-vault/session-scanner-codex-message-records.ts b/src/main/ai-vault/session-scanner-codex-message-records.ts index 5aa739275ff..a8a5c3c9d13 100644 --- a/src/main/ai-vault/session-scanner-codex-message-records.ts +++ b/src/main/ai-vault/session-scanner-codex-message-records.ts @@ -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, 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, timestamp: unknown ): boolean { + publishCodexCompletedTool(accumulator, payload, timestamp) const item = asRecord(payload.item) if (!item) { return false diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 02a385400de..b3571d4a337 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -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) => { diff --git a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts index 1c4322f89f6..852ec174e95 100644 --- a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts +++ b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts @@ -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 } diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.test.ts b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts new file mode 100644 index 00000000000..a5aa909bfa1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-tool-records.test.ts @@ -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): 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) => + 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']) +}) diff --git a/src/main/ai-vault/session-scanner-codex-tool-records.ts b/src/main/ai-vault/session-scanner-codex-tool-records.ts new file mode 100644 index 00000000000..976d5eff36d --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-tool-records.ts @@ -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, + 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, + 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 + ) + } + } +} diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts index 57cadebeee0..07f3646b537 100644 --- a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -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) => { diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 9783f8a0520..62149920b5a 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -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 diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index b1d480aa944..f4b60270544 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -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 diff --git a/src/main/ai-vault/session-transcript-consumers.ts b/src/main/ai-vault/session-transcript-consumers.ts index 6707b298586..7aff8dcf87b 100644 --- a/src/main/ai-vault/session-transcript-consumers.ts +++ b/src/main/ai-vault/session-transcript-consumers.ts @@ -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 = { diff --git a/src/main/ai-vault/session-transcript-reader.ts b/src/main/ai-vault/session-transcript-reader.ts index 228b0c832a3..3fc0ddcc146 100644 --- a/src/main/ai-vault/session-transcript-reader.ts +++ b/src/main/ai-vault/session-transcript-reader.ts @@ -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({ diff --git a/src/main/orca-profiles/profile-cloud-auth-status.test.ts b/src/main/orca-profiles/profile-cloud-auth-status.test.ts new file mode 100644 index 00000000000..7a28783e9a9 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-status.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 943d0081fdf..32e62e105d7 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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(), diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 39048611155..3a26b1c615d 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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 }], diff --git a/src/main/skills/skill-root-file-walk.test.ts b/src/main/skills/skill-root-file-walk.test.ts index ad84eced6b6..81e9b167fcc 100644 --- a/src/main/skills/skill-root-file-walk.test.ts +++ b/src/main/skills/skill-root-file-walk.test.ts @@ -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) }) diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index 202c99df4d2..31de9b1ce8d 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -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: { ) : null} - {shouldMountUpdateCard ? ( + + {shouldMountUpdateCard ? ( + + + + + + ) : null} - - + + - ) : null} - - - + + + + diff --git a/src/renderer/src/components/NotificationCardStack.tsx b/src/renderer/src/components/NotificationCardStack.tsx new file mode 100644 index 00000000000..9d3d5cddf3c --- /dev/null +++ b/src/renderer/src/components/NotificationCardStack.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export function NotificationCardStack({ children }: { children: ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx index f0e184143fc..0bf3312463e 100644 --- a/src/renderer/src/components/StarNagCard.tsx +++ b/src/renderer/src/components/StarNagCard.tsx @@ -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('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 ( -
+
diff --git a/src/renderer/src/components/UnexpectedSignoutCard.tsx b/src/renderer/src/components/UnexpectedSignoutCard.tsx new file mode 100644 index 00000000000..ee0e8949ec3 --- /dev/null +++ b/src/renderer/src/components/UnexpectedSignoutCard.tsx @@ -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 ( +
+ +
+

{title}

+

{description}

+
+
+ ) +} + +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(null) + const [authRefreshReady, setAuthRefreshReady] = useState(false) + const [expanded, setExpanded] = useState(false) + const [preview] = useState(readPreviewFlag) + const [previewDismissed, setPreviewDismissed] = useState(false) + const reconnectingProfile = useRef(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 ( +
+ +
+
+
+ +

+ {translate( + 'auto.components.UnexpectedSignoutCard.9f2c1a4b7d', + "You've been signed out" + )} +

+
+ +
+ +

+ {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.' + )} +

+ + + + + + + + + + + + +
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 1e6da6879f7..186a285b26d 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -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() + return render( + + + + ) } function renderAfterAvailableStatus(): RenderResult { diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 4ccf95ff252..e2023ae21e1 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -239,10 +239,7 @@ export function UpdateCard(): React.JSX.Element | null { !reassuranceSeen && ((status.state === 'available' && !status.externallyManaged) || status.state === 'downloading') return ( -
+
{showReassurance && (
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 45d95140f9e..055230c1a4e 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -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( { 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) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index 9e7b3abc5b2..427a56c739f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -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)), diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts index 273e9fbfa60..d2e0b93ac40 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.ts @@ -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( diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx new file mode 100644 index 00000000000..82cd0f0cb03 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -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.' + ) + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx index 69f85ee8d00..29d78fa55a0 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx @@ -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() + }) + + expect(container.textContent).toBe(detail) + expect(markedText()).toBe('src') + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx index 471fd9a42cc..d9568dd5526 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx @@ -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 ( @@ -80,9 +81,14 @@ export function ProjectOptionDetail({ } return ( - {/* Head collapses first; the tail only truncates once the head is gone. */} {split.head} - /{split.tail} + + {split.tailRanges.length > 0 ? ( + range.start)} /> + ) : ( + split.tail + )} + ) } diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts index 6e16dae386d..28c422e6c2b 100644 --- a/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts @@ -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() - }) -}) diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.ts index 2e3459ac745..6ee21dd2194 100644 --- a/src/renderer/src/components/new-workspace/project-combobox-matching.ts +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.ts @@ -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('/') } -} diff --git a/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx new file mode 100644 index 00000000000..e14faedbc18 --- /dev/null +++ b/src/renderer/src/components/terminal-parked-watcher-sync-entries.test.tsx @@ -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) => 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 { + 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()) +} + +function lastSyncEntries(): Map { + return mocks.sync.mock.calls.at(-1)?.[0] as Map +} + +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) + }) +}) diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx index 770b1cfc47b..59984d7fe06 100644 --- a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -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) => diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx new file mode 100644 index 00000000000..f220d803a01 --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-card.test.tsx @@ -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 { + render() + 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() + 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() + 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() + 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() + await act(async () => {}) + act(() => useAppStore.getState().hydratePersistedUI(getDefaultUIState())) + expect(screen.queryByRole('complementary')).toBeNull() + cleanup() + render() + 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() + 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() + 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() + }) +}) diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts new file mode 100644 index 00000000000..c1b8459204f --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts new file mode 100644 index 00000000000..a9a47b2d98b --- /dev/null +++ b/src/renderer/src/components/unexpected-signout/unexpected-signout-visibility.ts @@ -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 + ) +} diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 3c6a89fb323..fc44352c942 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -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 = 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() + let parkedTabIds: ReadonlySet = NO_PARKED_TAB_IDS let deferredTabIds: ReadonlySet | null = null if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspaceId)) { + const mountedParkedTabIds = new Set() + 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) } } } diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 61726fa9643..d68c5104da2 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -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 diff --git a/src/renderer/src/components/workspace-surface-projection.test.ts b/src/renderer/src/components/workspace-surface-projection.test.ts index d5226bc5662..7f7c1709ca9 100644 --- a/src/renderer/src/components/workspace-surface-projection.test.ts +++ b/src/renderer/src/components/workspace-surface-projection.test.ts @@ -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] diff --git a/src/renderer/src/components/workspace-surface-projection.ts b/src/renderer/src/components/workspace-surface-projection.ts index 4a3d28ea17f..9b0a5731be2 100644 --- a/src/renderer/src/components/workspace-surface-projection.ts +++ b/src/renderer/src/components/workspace-surface-projection.ts @@ -49,6 +49,7 @@ export function projectWorkspaceSurfaces({ }: { worktreesById: ReadonlyMap> 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 diff --git a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx index 95220a25f2e..4a3acb10f11 100644 --- a/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx +++ b/src/renderer/src/components/worktree-jump-palette-browser-simulator-rows.tsx @@ -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({ ) : null}
-
+
- {simulatorRepoName && ( - - - - - - - )} + @@ -187,16 +182,15 @@ export function WorktreeJumpPaletteBrowserRow({ } />
-
+
- {browserRepoName && ( - - - - - - - )} + { 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: '' })] diff --git a/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx b/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx index a54a48329be..8834c240086 100644 --- a/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-primitives.test.tsx @@ -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={[]} /> ) @@ -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( + + + + ) + + 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( + + + + ) + + 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( + + + + ) + 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( + + + + ) + 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( + + + + ) + + 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') +}) diff --git a/src/renderer/src/components/worktree-jump-palette-primitives.tsx b/src/renderer/src/components/worktree-jump-palette-primitives.tsx index 4162a56cb6e..aadad46748d 100644 --- a/src/renderer/src/components/worktree-jump-palette-primitives.tsx +++ b/src/renderer/src/components/worktree-jump-palette-primitives.tsx @@ -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 ? ( + + {split.head} + + + + + ) : ( + + + + ) + return ( + + {content} + + {text} + + + ) +} + 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({
@@ -116,12 +155,11 @@ export function PaletteOpenTabPrimaryLine({ ) : null} {leadingBadges} {showSecondary ? ( - <> - · - - - - + ) : null} {additionalSecondaryMatches.length ? ( <> @@ -154,85 +192,76 @@ export function PaletteOpenTabPrimaryLine({ ) : null} - {showWorktree ? ( - <> - · - - - - - ) : null}
) } -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 | 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(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 = ( + + {showRepo ? ( + <> + + + + + + ) : null} + {showRepo && showWorktree ? ( + + · + + ) : null} + {showWorktree ? ( + + + + ) : null} + + ) return ( - - - - - - - {tooltip} + {chip} + + {label} ) diff --git a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx index 787cbff449c..0763300eca7 100644 --- a/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx +++ b/src/renderer/src/components/worktree-jump-palette-workspace-tab-row.tsx @@ -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({ ) : null}
-
+
- {workspaceTabRepoName && ( - - - - - - - )} + { + 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() + }) +}) diff --git a/src/renderer/src/lib/path-head-elision.ts b/src/renderer/src/lib/path-head-elision.ts new file mode 100644 index 00000000000..e21bac240d6 --- /dev/null +++ b/src/renderer/src/lib/path-head-elision.ts @@ -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 + })) + } +} diff --git a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts index f17c77c78e3..180f96e427a 100644 --- a/src/renderer/src/store/slices/ui-notice-dismissals.test.ts +++ b/src/renderer/src/store/slices/ui-notice-dismissals.test.ts @@ -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') + } + ) +}) diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index dff5f50c7e3..d913f998d2c 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -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 diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index a5daf8a500d..6ca8d702bd3 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -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. diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts index bfed25a75cc..9737a575f06 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-sanitizers.ts @@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta } return out as StatusBarItem[] } + +export function hydrateUnexpectedSignoutDismissal( + state: Pick, + version: string | null | undefined +): Pick { + 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 + } +} diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts index e4beadf4c6c..942ff9610a5 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -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 }) }, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7a06e11dba3..6840d92adc9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -296,6 +296,7 @@ export function getDefaultUIState(): PersistedUIState { usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY, statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE, dismissedUpdateVersion: null, + dismissedUnexpectedSignoutVersion: null, lastUpdateCheckAt: null, trustedOrcaHooks: {}, setupScriptPromptDismissedRepoIds: [], diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts index 4d64e283c01..546a4bf9db0 100644 --- a/src/shared/native-chat-agent-profiles.test.ts +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { getNativeChatAgentProfile } from './native-chat-agent-profiles' +import { + getHostClaimedNativeChatCommands, + getNativeChatAgentProfile, + getVerifiedNativeChatCommands +} from './native-chat-agent-profiles' describe('native chat agent picker profiles', () => { // The composer types the same `/` for every agent; skillPrefix is only the @@ -27,3 +31,28 @@ describe('native chat agent picker profiles', () => { expect(getNativeChatAgentProfile('custom-agent')).toBeNull() }) }) + +describe('host-claimed native chat commands', () => { + function names(agent: string): string[] { + return getHostClaimedNativeChatCommands(agent).map((command) => command.name) + } + + // Claude's harness expands a slash command out of the message body, so claiming + // its catalog only answered "/init is not available" for commands that do run. + it('claims nothing from the Claude-family catalog', () => { + expect(names('claude')).toEqual([]) + expect(names('openclaude')).toEqual([]) + }) + + it('keeps the Codex catalog claimed except the model-driven /goal', () => { + expect(names('codex')).toContain('permissions') + expect(names('codex')).toContain('vim') + expect(names('codex')).not.toContain('goal') + expect(getVerifiedNativeChatCommands('codex').map((command) => command.name)).toContain('goal') + }) + + it('claims the whole catalog for agents with no pass-through policy', () => { + expect(names('custom-agent')).toEqual(['clear', 'help']) + expect(names('grok')).toEqual([]) + }) +}) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index 6f86313d17d..82fe88e9ac3 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -5,20 +5,31 @@ export type NativeChatAgentProfile = { skillPrefix: '$' | '/' /** OpenClaude reads Claude-owned roots, so this can differ from the agent. */ skillSourceOwner: AgentType + /** The agent's own harness expands a slash command out of the message text, so + * the chat host claims only the commands it implements itself. */ + expandsSlashCommandsFromText?: true + /** Catalog commands the model acts on when they arrive as prose, even though + * the runtime has no slash parser of its own. */ + textDrivenCommands?: readonly string[] } const NATIVE_CHAT_AGENT_PROFILES: Partial> = { codex: { skillPrefix: '$', - skillSourceOwner: 'codex' + skillSourceOwner: 'codex', + // The app-server has no slash parser, but the model owns goal tools and + // calls create_goal itself when `/goal ` reaches it as prose. + textDrivenCommands: ['goal'] }, claude: { skillPrefix: '/', - skillSourceOwner: 'claude' + skillSourceOwner: 'claude', + expandsSlashCommandsFromText: true }, openclaude: { skillPrefix: '/', - skillSourceOwner: 'claude' + skillSourceOwner: 'claude', + expandsSlashCommandsFromText: true }, grok: { skillPrefix: '/', @@ -38,3 +49,34 @@ export function getNativeChatAgentProfile( export function getVerifiedNativeChatCommands(agent: AgentType): readonly SlashCommandSuggestion[] { return agent === 'grok' ? [] : getAgentSlashCommands(agent) } + +/** The mirror of the claimed set: catalog commands this agent acts on when they + * arrive as message text. The picker offers these too, so a command the agent + * implements is discoverable and not merely typable. */ +export function getTextDrivenNativeChatCommands( + agent: AgentType | null | undefined +): readonly SlashCommandSuggestion[] { + if (!agent) { + return [] + } + const names = new Set(getNativeChatAgentProfile(agent)?.textDrivenCommands ?? []) + return names.size === 0 + ? [] + : getVerifiedNativeChatCommands(agent).filter((command) => names.has(command.name)) +} + +/** Catalog commands the chat host answers itself. Whatever is left over reaches + * the agent as ordinary text, which is only correct where the agent implements + * the command — so an agent unclaims a command only via the profile above. + * Claiming stays the default: it is what stops a hand-typed `/clear` from being + * sent to the model as literal prompt text. */ +export function getHostClaimedNativeChatCommands( + agent: AgentType +): readonly SlashCommandSuggestion[] { + const profile = getNativeChatAgentProfile(agent) + if (profile?.expandsSlashCommandsFromText) { + return [] + } + const passedThrough = new Set(profile?.textDrivenCommands ?? []) + return getVerifiedNativeChatCommands(agent).filter((command) => !passedThrough.has(command.name)) +} diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 943813d7255..2a3eb411a0e 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -125,6 +125,8 @@ export type PersistedUIState = { /** Client-side footer presentation; verbose preserves the pre-roster all-window default. */ statusBarUsageMode?: StatusBarUsageMode dismissedUpdateVersion: string | null + /** App version that last dismissed the unexpected-sign-out card; null = never. Re-arms on each new version while still signed out. */ + dismissedUnexpectedSignoutVersion?: string | null lastUpdateCheckAt: number | null /** Dev-only update channel override; absent means the build's own channel. */ releaseChannelOverride?: ReleaseChannel | null diff --git a/src/shared/structured-agent-session-composer.test.ts b/src/shared/structured-agent-session-composer.test.ts index ec3b301a8e2..b51de0396be 100644 --- a/src/shared/structured-agent-session-composer.test.ts +++ b/src/shared/structured-agent-session-composer.test.ts @@ -6,15 +6,59 @@ import { } from './structured-agent-session-composer' describe('structuredSlashCommands', () => { - // The composer menu and the dispatcher read this one list. When they disagreed, + const hostController = { + snapshot: [], + invokeAction: async () => true, + setOption: async () => true, + conversationCommands: ['clear', 'compact'] as const, + runConversationCommand: async () => ({ accepted: true, error: null }) + } + const REFUSAL = /is not available in chat sessions/ + + // The composer menu and the dispatcher read the same policy. When they disagreed, // a Claude session was offered Codex-only tokens that missed the command guard - // and reached the model as literal prompt text instead of erroring. - it.each(['codex', 'claude'] as const)('offers %s only commands it also accepts', (agent) => { - const offered = structuredSlashCommands() - expect(offered.length).toBeGreaterThan(0) - for (const command of offered) { - expect(isStructuredAgentSessionComposerCommand(`/${command.name}`, agent)).toBe(true) + // and reached the model as literal prompt text instead of erroring. A row is + // honored either way now: the host answers it, or it passes through to the agent. + it.each(['codex', 'claude'] as const)( + 'offers %s only commands the host answers or the agent runs', + async (agent) => { + const offered = structuredSlashCommands(['clear', 'compact'], agent) + expect(offered.length).toBeGreaterThan(0) + for (const command of offered) { + const outcome = await dispatchStructuredAgentSessionComposerCommand(`/${command.name}`, { + ...hostController, + agent + }) + expect(outcome.error ?? '').not.toMatch(REFUSAL) + } } + ) + + // Codex reports no catalog of its own, so this fallback is its whole `/` menu — + // without the row, a command that now works is impossible to discover. + it('offers Codex the /goal the model acts on, described from the catalog', () => { + const offered = structuredSlashCommands(['clear', 'compact'], 'codex') + expect(offered.map((command) => command.name)).toEqual([ + 'model', + 'effort', + 'clear', + 'compact', + 'goal' + ]) + expect(offered.find((command) => command.name === 'goal')?.description).toBe( + 'Set or view the goal' + ) + // Picking it must reach the model, not the host's refusal. + expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false) + }) + + it('adds nothing for an agent whose own harness expands its commands', () => { + expect(structuredSlashCommands(['clear', 'compact'], 'claude').map((c) => c.name)).toEqual([ + 'model', + 'effort', + 'clear', + 'compact' + ]) }) it('offers only the commands a chat session can carry out', () => { @@ -98,3 +142,68 @@ describe('dispatchStructuredAgentSessionComposerCommand', () => { expect(runConversationCommand).not.toHaveBeenCalled() }) }) + +describe('agent-implemented commands pass through to the agent', () => { + const controller = { + snapshot: [], + invokeAction: async () => true, + setOption: async () => true + } + const PASSED_THROUGH = { handled: false, accepted: false, error: null } + + // Claude's harness runs a slash command it finds in the message text, so + // claiming these answered "not available" for commands that do work. + it.each(['init', 'review', 'help'] as const)( + 'sends /%s on to the Claude harness instead of refusing it', + async (name) => { + expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(false) + expect( + await dispatchStructuredAgentSessionComposerCommand(`/${name}`, { + ...controller, + agent: 'claude' + }) + ).toEqual(PASSED_THROUGH) + } + ) + + it.each(['clear', 'compact', 'model', 'effort'] as const)( + 'still claims the host-owned /%s on Claude', + async (name) => { + expect(isStructuredAgentSessionComposerCommand(`/${name}`, 'claude')).toBe(true) + expect( + ( + await dispatchStructuredAgentSessionComposerCommand(`/${name}`, { + ...controller, + agent: 'claude' + }) + ).handled + ).toBe(true) + } + ) + + // Codex's app-server has no slash parser, but the model owns goal tools and + // creates a real goal from `/goal ` arriving as prose. + it('passes /goal through on Codex, arguments and all', async () => { + expect(isStructuredAgentSessionComposerCommand('/goal', 'codex')).toBe(false) + expect( + await dispatchStructuredAgentSessionComposerCommand('/goal ship the fix', { + ...controller, + agent: 'codex' + }) + ).toEqual(PASSED_THROUGH) + }) + + it('keeps refusing a Codex command the model cannot carry out', async () => { + expect(isStructuredAgentSessionComposerCommand('/permissions', 'codex')).toBe(true) + expect( + await dispatchStructuredAgentSessionComposerCommand('/permissions', { + ...controller, + agent: 'codex' + }) + ).toMatchObject({ + handled: true, + error: + '/permissions is not available in chat sessions. Use the slash menu to see available commands.' + }) + }) +}) diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts index 70d10c34cfa..093d588effb 100644 --- a/src/shared/structured-agent-session-composer.ts +++ b/src/shared/structured-agent-session-composer.ts @@ -1,4 +1,7 @@ -import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles' +import { + getHostClaimedNativeChatCommands, + getTextDrivenNativeChatCommands +} from './native-chat-agent-profiles' import type { AgentType } from './agent-status-types' import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' import type { SlashCommandSuggestion } from './native-chat-slash-commands' @@ -50,30 +53,45 @@ function commandParts(text: string): { name: string; argument: string } | null { return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null } -/** The commands the composer menu offers. Strictly what the dispatcher honors, - * so a menu pick is never answered with "not available". */ +/** The commands the composer menu offers when the host reports no catalog of its + * own: the host's own commands, plus the ones this agent acts on from message + * text. Both are honored — the first here, the second by the agent — so a menu + * pick is never answered with "not available". */ export function structuredSlashCommands( - commands: readonly AgentSessionConversationCommand[] = [] + commands: readonly AgentSessionConversationCommand[] = [], + agent?: AgentType | null ): readonly SlashCommandSuggestion[] { - return [ + const hostOwned = [ ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS.filter((entry) => commands.includes(entry.name as AgentSessionConversationCommand) ) ] + // Why: a host with no catalog to report would otherwise hide the commands the + // agent itself implements, e.g. Codex's `/goal`. + return [ + ...hostOwned, + ...getTextDrivenNativeChatCommands(agent).filter( + (entry) => !hostOwned.some((offered) => offered.name === entry.name) + ) + ] } /** Wider than the offered menu on purpose: a TUI-only command still has to be * claimed here and answered, or a hand-typed `/clear` reaches the model as - * literal prompt text. */ + * literal prompt text. Commands the agent itself implements are deliberately + * absent — the profile unclaims those so they pass through as text. */ function structuredRecognizedCommands(agent: AgentType): readonly SlashCommandSuggestion[] { return [ ...STRUCTURED_AGENT_SESSION_SLASH_COMMANDS, ...CONVERSATION_COMMANDS, - ...getVerifiedNativeChatCommands(agent) + ...getHostClaimedNativeChatCommands(agent) ] } +/** Whether the chat host, rather than the agent, owns this command. Callers also + * use it to refuse attachments: a host command sends no message, so attachments + * would be silently dropped, whereas a pass-through command is a real send. */ export function isStructuredAgentSessionComposerCommand( text: string, agent: AgentType = 'codex'