diff --git a/server/coding-cli/claude-transcript-locator.ts b/server/coding-cli/claude-transcript-locator.ts index 0b98108e0..97f061928 100644 --- a/server/coding-cli/claude-transcript-locator.ts +++ b/server/coding-cli/claude-transcript-locator.ts @@ -7,56 +7,136 @@ export interface ClaudeTranscriptHit { cwd?: string } +/** + * HEALTH SEAM: typed wrapper for locator provider failures. + * + * The locator distinguishes "transcript absent" (null) from "the claude store + * could not be searched" (this error). Callers today (resolve-session.ts) do + * not catch it, so a provider failure propagates instead of silently reading + * as "not found". The later provider-health lane catches THIS class at the + * resolve seam to record providerErrors and report 'degraded'. + */ +export class ClaudeTranscriptLocatorError extends Error { + /** errno code from the underlying fs failure (e.g. 'EACCES', 'EIO'). */ + readonly code?: string + + constructor(message: string, cause: unknown) { + super(message, { cause }) + this.name = 'ClaudeTranscriptLocatorError' + this.code = (cause as NodeJS.ErrnoException | null)?.code + } +} + const UUID_ONLY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const CWD_SCAN_BYTES = 64 * 1024 + +/** Expected-absence errors are misses; EVERYTHING else is a provider failure. */ +function isAbsenceError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} /** * Exact-id fallback for claude sessions the index cannot see (e.g. cold-start - * skipped cwd-less transcripts). Scans //.jsonl. + * skipped cwd-less transcripts, subagent child sessions). Claude stores + * transcripts in TWO layouts: + * 1. direct: //.jsonl + * 2. subagent: ///subagents/.jsonl + * An exact pasted id must resolve for BOTH — child sessions included — so the + * locator probes the cheap direct layout first (one readdir per root + one + * stat per project dir) and only on a total miss falls back to the subagent + * layout (one readdir per project dir + one stat per session subdirectory). + * + * Accepts one root or several (claude can have secondary project roots). + * + * ERROR CONTRACT: expected absence (ENOENT/ENOTDIR — missing root, missing + * transcript, non-directory entries probed as directories) is a miss (null). + * Any OTHER failure (EACCES, EMFILE, EIO, …) PROPAGATES as + * ClaudeTranscriptLocatorError — a provider failure must never read as + * "not found". */ export async function locateClaudeTranscript( sessionId: string, - projectsDir: string, + projectsDir: string | readonly string[], ): Promise { + // Claude writes lowercase-UUID transcript filenames; the input contract + // accepts UUIDs in ANY case and Linux filesystems are case-sensitive, so + // normalize before building paths and return the canonical lowercase id. const normalized = sessionId.toLowerCase() if (!UUID_ONLY_RE.test(normalized)) return null + const roots = typeof projectsDir === 'string' ? [projectsDir] : projectsDir - let entries: string[] - try { - entries = await fsp.readdir(projectsDir) - } catch { - return null - } - - for (const entry of entries) { - const candidate = path.join(projectsDir, entry, `${normalized}.jsonl`) - try { - const stat = await fsp.stat(candidate) - if (!stat.isFile()) continue - } catch { - continue + // PASS 1 — direct layout. + for (const root of roots) { + for (const dir of await readdirOrEmpty(root)) { + const hit = await probeTranscript(path.join(root, dir, `${normalized}.jsonl`), normalized) + if (hit) return hit } - return { - sessionId: normalized, - sourceFile: candidate, - cwd: await readCwdFromTranscript(candidate), + } + // PASS 2 — subagent layout (only when the direct layout missed everywhere). + for (const root of roots) { + for (const dir of await readdirOrEmpty(root)) { + const projectDir = path.join(root, dir) + for (const entry of await readdirOrEmpty(projectDir)) { + const hit = await probeTranscript( + path.join(projectDir, entry, 'subagents', `${normalized}.jsonl`), + normalized, + ) + if (hit) return hit + } } } return null } +/** readdir treating absence as empty; anything else PROPAGATES (provider failure). */ +async function readdirOrEmpty(dir: string): Promise { + try { + return await fsp.readdir(dir) + } catch (err) { + if (isAbsenceError(err)) return [] + throw new ClaudeTranscriptLocatorError(`failed to list claude projects dir: ${dir}`, err) + } +} + +/** stat + cwd-read for one candidate; absence = null, anything else PROPAGATES. */ +async function probeTranscript( + candidate: string, + id: string, +): Promise { + try { + const stat = await fsp.stat(candidate) + if (!stat.isFile()) return null + } catch (err) { + if (isAbsenceError(err)) return null + throw new ClaudeTranscriptLocatorError(`failed to probe claude transcript: ${candidate}`, err) + } + return { + sessionId: id, + sourceFile: candidate, + cwd: await readCwdFromTranscript(candidate), + } +} + async function readCwdFromTranscript(filePath: string): Promise { + let handle + try { + handle = await fsp.open(filePath, 'r') + } catch (err) { + // The file existed a moment ago (stat succeeded): absence = raced + // deletion (miss the cwd only); anything else is a provider failure. + if (isAbsenceError(err)) return undefined + throw new ClaudeTranscriptLocatorError(`failed to open claude transcript: ${filePath}`, err) + } let head: string try { - const handle = await fsp.open(filePath, 'r') - try { - const buffer = Buffer.alloc(64 * 1024) - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) - head = buffer.subarray(0, bytesRead).toString('utf8') - } finally { - await handle.close() - } - } catch { - return undefined + const buffer = Buffer.alloc(CWD_SCAN_BYTES) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + head = buffer.subarray(0, bytesRead).toString('utf8') + } catch (err) { + throw new ClaudeTranscriptLocatorError(`failed to read claude transcript: ${filePath}`, err) + } finally { + await handle.close() } for (const line of head.split('\n')) { const trimmed = line.trim() diff --git a/server/coding-cli/providers/amplifier.ts b/server/coding-cli/providers/amplifier.ts index 75ce73954..b5fe5d232 100644 --- a/server/coding-cli/providers/amplifier.ts +++ b/server/coding-cli/providers/amplifier.ts @@ -218,6 +218,18 @@ export const amplifierProvider: CodingCliProvider = { // lands at the first prompt:complete. async listSessionFiles() { const projectsDir = path.join(this.homeDir, 'projects') + // Root-level guard: ABSENCE of the root is a legitimate empty result; any + // OTHER root error (EACCES/EIO/EMFILE, ...) must REJECT so the indexer + // records a scan failure — a provider outage must never read as "no + // sessions". ACCEPTED LIMITATION: deeper per-subdirectory errors inside + // walkMetadataFiles remain best-effort skips (partial results beat none). + try { + await fsp.readdir(projectsDir) + } catch (err) { + const code = (err as NodeJS.ErrnoException | null)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') return [] + throw err + } const files = await walkMetadataFiles(projectsDir) // Restrict to files under a `sessions` directory (projects//sessions//metadata.json). return files.filter((file) => diff --git a/server/coding-cli/providers/claude.ts b/server/coding-cli/providers/claude.ts index aa6f11957..dcefd64da 100644 --- a/server/coding-cli/providers/claude.ts +++ b/server/coding-cli/providers/claude.ts @@ -543,9 +543,18 @@ export const claudeProvider: CodingCliProvider = { let projectDirs: string[] = [] try { projectDirs = (await fsp.readdir(projectsDir)).map((name) => path.join(projectsDir, name)) - } catch { - return [] - } + } catch (err) { + // Root-level ABSENCE is a legitimate empty result; any OTHER root error + // (EACCES/EIO/EMFILE, ...) must REJECT so the indexer records a scan + // failure — a provider outage must never read as "no sessions". + const code = (err as NodeJS.ErrnoException | null)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') return [] + throw err + } + + // ACCEPTED LIMITATION: per-subdirectory/per-file errors deeper in the tree + // are best-effort skips (partial results beat none); only the root-level + // enumeration above distinguishes failure from absence. const files: string[] = [] for (const projectDir of projectDirs) { diff --git a/server/coding-cli/providers/codex.ts b/server/coding-cli/providers/codex.ts index 14063d618..e013be963 100644 --- a/server/coding-cli/providers/codex.ts +++ b/server/coding-cli/providers/codex.ts @@ -469,6 +469,18 @@ export const codexProvider: CodingCliProvider = { async listSessionFiles() { const sessionsDir = path.join(this.homeDir, 'sessions') + // Root-level guard: ABSENCE of the root is a legitimate empty result; any + // OTHER root error (EACCES/EIO/EMFILE, ...) must REJECT so the indexer + // records a scan failure — a provider outage must never read as "no + // sessions". ACCEPTED LIMITATION: deeper per-subdirectory errors inside + // walkJsonlFiles remain best-effort skips (partial results beat none). + try { + await fsp.readdir(sessionsDir) + } catch (err) { + const code = (err as NodeJS.ErrnoException | null)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') return [] + throw err + } return walkJsonlFiles(sessionsDir) }, diff --git a/server/coding-cli/providers/opencode-by-id-query.ts b/server/coding-cli/providers/opencode-by-id-query.ts new file mode 100644 index 000000000..32ce7400c --- /dev/null +++ b/server/coding-cli/providers/opencode-by-id-query.ts @@ -0,0 +1,56 @@ +import type { OpencodeSessionRow } from './opencode-listing-query.js' + +/** + * SHORT busy timeout, deliberately much smaller than the listing query's 5 s: + * a locked DB must fail fast (the failure is surfaced as provider-unavailable, + * NOT "not found"). This synchronous function runs INSIDE THE WORKER THREAD + * (opencode-by-id.worker.ts) — same rule as the listing query, which was moved + * off the event loop even at ~180 ms. Never call it on the main thread in + * production: `DatabaseSync` blocks whatever thread runs it for up to this + * timeout when the DB is locked. + */ +const OPENCODE_BYID_BUSY_TIMEOUT_MS = 500 + +/** + * Exact-id opencode lookup for the resolve endpoint's fallback path — the + * Node-server sibling of the Rust by-id existence probe (#579). Unlike the + * listing query it deliberately includes ARCHIVED and CHILD sessions: an + * exact id pasted by the user must resolve even when the listing hides it. + * Lazy `node:sqlite` import for the same vi.mock/TDZ reason documented in + * opencode-listing-query.ts. Errors PROPAGATE to the caller (provider + * unavailable ≠ not found). + */ +export async function runOpencodeSessionByIdQuery( + dbPath: string, + sessionId: string, +): Promise { + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath, { readOnly: true }) + try { + db.exec(`PRAGMA busy_timeout = ${OPENCODE_BYID_BUSY_TIMEOUT_MS}`) + const tableNames = new Set( + (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name?: unknown }>) + .map((row) => row.name), + ) + if (!tableNames.has('session')) return null + const hasProject = tableNames.has('project') + const projectSelect = hasProject ? 'p.worktree' : 'NULL' + const projectJoin = hasProject ? 'LEFT JOIN project p ON p.id = s.project_id' : '' + const row = db.prepare(` + SELECT + s.id AS sessionId, + s.directory AS cwd, + s.title AS title, + s.time_created AS createdAt, + s.time_updated AS lastActivityAt, + ${projectSelect} AS projectPath + FROM session s + ${projectJoin} + WHERE s.id = ? + LIMIT 1 + `).get(sessionId) as OpencodeSessionRow | undefined + return row ?? null + } finally { + db.close() + } +} diff --git a/server/coding-cli/providers/opencode-by-id-runner.ts b/server/coding-cli/providers/opencode-by-id-runner.ts new file mode 100644 index 000000000..91b882416 --- /dev/null +++ b/server/coding-cli/providers/opencode-by-id-runner.ts @@ -0,0 +1,126 @@ +import { Worker } from 'node:worker_threads' +import type { OpencodeSessionRow } from './opencode-listing-query.js' +// Importing the worker module on the MAIN thread (or a Vitest worker) is safe: +// its auto-run is sentinel-guarded, so this import never spawns/posts anything. +import { OPENCODE_BYID_WORKER_KIND } from './opencode-by-id.worker.js' + +export type OpencodeByIdQueryInput = { dbPath: string; sessionId: string } +export type OpencodeByIdQueryRunner = (input: OpencodeByIdQueryInput) => Promise + +type WorkerLike = { + on(event: 'message', listener: (value: unknown) => void): unknown + on(event: 'error', listener: (err: Error) => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + terminate(): Promise | void +} + +export type WorkerSpawnOptions = { workerData: unknown; execArgv: string[] } + +export type CreateWorkerByIdRunnerOptions = { + /** Injectable for unit tests; default spawns a real worker_threads Worker. */ + spawn?: (workerUrl: URL, options: WorkerSpawnOptions) => WorkerLike + /** Override the query-module URL (used by off-thread integration fixtures). */ + queryModuleUrl?: string + /** Hard timeout for a single by-id query. Default 15 s (same as the listing runner). */ + timeoutMs?: number +} + +const DEFAULT_TIMEOUT_MS = 15_000 +// import.meta.url ends with `.ts` in dev/test (tsx / native strip-types) and +// `.js` in prod (compiled dist). Resolve siblings with the matching extension. +const SELF_EXT = import.meta.url.endsWith('.ts') ? '.ts' : '.js' +// Append to process.execArgv (do NOT replace) so tsx's `--import .../loader.mjs` +// is inherited in dev; the flag silences node:sqlite's per-spawn ExperimentalWarning. +const WORKER_EXECARGV = [...process.execArgv, '--disable-warning=ExperimentalWarning'] + +function defaultWorkerUrl(): URL { + return new URL(`./opencode-by-id.worker${SELF_EXT}`, import.meta.url) +} +function defaultQueryModuleUrl(): string { + return new URL(`./opencode-by-id-query${SELF_EXT}`, import.meta.url).href +} +function defaultSpawn(workerUrl: URL, options: WorkerSpawnOptions): WorkerLike { + return new Worker(workerUrl, options) +} + +type OkMessage = { ok: true; row: OpencodeSessionRow | null } +type ErrMessage = { ok: false; error: { name: string; message: string } } + +// Validate the FULL shape, not just the presence of `ok` — a truncated/garbled +// message like `{ ok: true }` must NOT resolve garbage as a hit or a miss. +function isOkMessage(value: unknown): value is OkMessage { + if (typeof value !== 'object' || value === null) return false + if ((value as { ok?: unknown }).ok !== true) return false + if (!('row' in (value as object))) return false + const row = (value as { row?: unknown }).row + return row === null || (typeof row === 'object' && row !== null) +} +function isErrMessage(value: unknown): value is ErrMessage { + if (typeof value !== 'object' || value === null) return false + if ((value as { ok?: unknown }).ok !== false) return false + const error = (value as { error?: unknown }).error + return typeof error === 'object' && error !== null + && typeof (error as { message?: unknown }).message === 'string' +} + +export function createWorkerByIdRunner( + options: CreateWorkerByIdRunnerOptions = {}, +): OpencodeByIdQueryRunner { + const spawn = options.spawn ?? defaultSpawn + const queryModuleUrl = options.queryModuleUrl ?? defaultQueryModuleUrl() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const workerUrl = defaultWorkerUrl() + + return (input: OpencodeByIdQueryInput): Promise => { + return new Promise((resolve, reject) => { + const worker = spawn(workerUrl, { workerData: { ...input, queryModuleUrl, kind: OPENCODE_BYID_WORKER_KIND }, execArgv: WORKER_EXECARGV }) + let settled = false + let timer: NodeJS.Timeout | undefined + + const cleanup = () => { + if (timer) clearTimeout(timer) + try { void worker.terminate() } catch { /* ignore */ } + } + const settleResolve = (row: OpencodeSessionRow | null) => { + if (settled) return + settled = true + cleanup() + resolve(row) + } + const settleReject = (err: Error) => { + if (settled) return + settled = true + cleanup() + reject(err) + } + + timer = setTimeout(() => settleReject(new Error(`OpenCode by-id worker timed out after ${timeoutMs}ms`)), timeoutMs) + if (typeof (timer as NodeJS.Timeout).unref === 'function') (timer as NodeJS.Timeout).unref() + + worker.on('message', (value: unknown) => { + if (isOkMessage(value)) { + settleResolve(value.row) + } else if (isErrMessage(value)) { + const err = new Error(value.error.message || 'OpenCode by-id worker failed') + err.name = value.error.name ?? 'Error' + settleReject(err) + } else { + settleReject(new Error('OpenCode by-id worker sent a malformed message')) + } + }) + worker.on('error', (err: Error) => settleReject(err)) + worker.on('exit', (code: number) => settleReject(new Error(`OpenCode by-id worker exited (code ${code}) before responding`))) + }) + } +} + +/** + * Default production runner: one short-lived worker per lookup, hard timeout. + * Worker/spawn/timeout failures REJECT (provider unavailable ≠ not found). + * Per-request cost is bounded upstream (shape gate + FALLBACK_BUDGET_PER_REQUEST), + * and the EVENT LOOP stays free even when the DB is locked for the full + * 500 ms busy timeout — DatabaseSync blocks only the worker thread. + */ +export async function runOpencodeSessionByIdOffThread(dbPath: string, sessionId: string): Promise { + return createWorkerByIdRunner()({ dbPath, sessionId }) +} diff --git a/server/coding-cli/providers/opencode-by-id.worker.ts b/server/coding-cli/providers/opencode-by-id.worker.ts new file mode 100644 index 000000000..8a983adcd --- /dev/null +++ b/server/coding-cli/providers/opencode-by-id.worker.ts @@ -0,0 +1,44 @@ +import { parentPort, workerData } from 'node:worker_threads' +import type { OpencodeSessionRow } from './opencode-listing-query.js' + +/** + * Sentinel proving this thread was spawned by OUR runner. REQUIRED because the + * server Vitest config runs test files in worker threads (`pool: 'threads'`), so + * `parentPort` is non-null when a test imports this module. Without the sentinel, + * the auto-run block below would fire on import using Vitest's OWN workerData and + * post a message to Vitest's parent port — corrupting/hanging the test worker. + * The runner injects this exact value in workerData; Vitest's workerData never has it. + */ +export const OPENCODE_BYID_WORKER_KIND = 'opencode-by-id-worker' + +export type WorkerByIdInput = { + kind: typeof OPENCODE_BYID_WORKER_KIND + queryModuleUrl: string + dbPath: string + sessionId: string +} + +/** + * Run the by-id query by dynamically importing the EXACT resolved query-module + * URL (.ts in dev/test, .js in prod) provided by the spawning code. We pass the + * exact URL rather than a static relative import because NodeNext `.js`→`.ts` + * remapping fails inside a worker thread (same constraint as the listing worker). + */ +export async function executeById( + input: { queryModuleUrl: string; dbPath: string; sessionId: string }, +): Promise { + const mod = await import(input.queryModuleUrl) as typeof import('./opencode-by-id-query.js') + return mod.runOpencodeSessionByIdQuery(input.dbPath, input.sessionId) +} + +// Auto-run ONLY when we are a real worker spawned by our runner (parentPort present +// AND our sentinel in workerData). This is import-safe under Vitest's thread pool. +if (parentPort && (workerData as Partial | undefined)?.kind === OPENCODE_BYID_WORKER_KIND) { + const port = parentPort + executeById(workerData as WorkerByIdInput) + .then((row) => port.postMessage({ ok: true, row })) + .catch((err: unknown) => { + const error = err instanceof Error ? { name: err.name, message: err.message } : { name: 'Error', message: String(err) } + port.postMessage({ ok: false, error }) + }) +} diff --git a/server/coding-cli/providers/opencode.ts b/server/coding-cli/providers/opencode.ts index 5c9e10399..bb5321bff 100644 --- a/server/coding-cli/providers/opencode.ts +++ b/server/coding-cli/providers/opencode.ts @@ -77,7 +77,8 @@ export class OpencodeProvider implements CodingCliProvider { this.queryRunner = options.queryRunner ?? createWorkerListingRunner() } - private getDatabasePath(): string { + /** Public: resolve-fallbacks builds the off-thread by-id lookup from this path. */ + getDatabasePath(): string { return path.join(this.homeDir, 'opencode.db') } diff --git a/server/coding-cli/resolve-fallbacks.ts b/server/coding-cli/resolve-fallbacks.ts new file mode 100644 index 000000000..d686a8651 --- /dev/null +++ b/server/coding-cli/resolve-fallbacks.ts @@ -0,0 +1,137 @@ +import type { CodingCliProvider } from './provider.js' +import type { ResumeResolveMatch } from '../../shared/resume-resolve-contract.js' +import type { SessionMetadataEntry } from '../session-metadata-store.js' +import type { ClaudeTranscriptHit } from './claude-transcript-locator.js' +import { runOpencodeSessionByIdOffThread } from './providers/opencode-by-id-runner.js' +import type { OpencodeSessionRow } from './providers/opencode-listing-query.js' + +/** Exact-id resolve fallback: full id in, contract match (or miss) out. */ +export type ExactIdFallback = (id: string) => Promise + +export type ResolveFallbacks = { + claudeTranscriptById?: ExactIdFallback + opencodeSessionById?: ExactIdFallback +} + +/** + * FULL-id shape gates, enforced in withRequestBudget BEFORE the budget check: + * a wrong-shape token is a free no-op miss that must neither do work nor + * consume budget (otherwise earlier ses_ tokens could exhaust the claude + * budget before a valid later claude UUID — false negative). + */ +export const FALLBACK_ID_SHAPES: Record = { + claudeTranscriptById: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, + opencodeSessionById: /^ses_[0-9a-zA-Z]{26}$/, +} + +/** + * Per-request work budget: each fallback may do REAL work at most this many + * times per request; beyond that it reports a miss without doing work. + * Combined with the shape gates this bounds the fallback work (FS probes, + * worker spawns) one request can trigger, no matter how many id-shaped + * tokens a pasted blob contains. + */ +export const FALLBACK_BUDGET_PER_REQUEST = 2 + +export function withRequestBudget( + fallbacks: ResolveFallbacks, + max = FALLBACK_BUDGET_PER_REQUEST, +): ResolveFallbacks { + const budgeted = (key: keyof ResolveFallbacks): ExactIdFallback | undefined => { + const fallback = fallbacks[key] + if (!fallback) return undefined + const shape = FALLBACK_ID_SHAPES[key] + let used = 0 + return async (id) => { + // Shape FIRST, budget SECOND — order is load-bearing (see FALLBACK_ID_SHAPES). + if (!shape.test(id)) return null + if (used >= max) return null + used += 1 + return fallback(id) + } + } + return { + claudeTranscriptById: budgeted('claudeTranscriptById'), + opencodeSessionById: budgeted('opencodeSessionById'), + } +} + +export type BuildResolveFallbacksOptions = { + /** + * Metadata store (already a SessionsRouterDeps member): sessions opened via + * freshclaude/freshopencode/kilroy record their real runtime here, and a + * resume MUST reopen through that runtime, not the bare provider default. + */ + sessionMetadataStore?: { getAll(): Promise> } + /** Injectable for unit tests; production default runs the worker-thread runner. */ + runOpencodeById?: (dbPath: string, sessionId: string) => Promise + /** + * Claude exact-id transcript locate. Injected (rather than defaulted) because + * the locator needs the claude projects dir, which index.ts owns wiring. + */ + locateClaudeTranscript?: (sessionId: string) => Promise +} + +/** Build the production exact-id fallbacks from the live provider set. */ +export function buildResolveFallbacks( + providers: CodingCliProvider[], + opts: BuildResolveFallbacksOptions = {}, +): ResolveFallbacks { + const claude = providers.find((p) => p.name === 'claude') + const opencode = providers.find((p) => p.name === 'opencode') as + (CodingCliProvider & { getDatabasePath?: () => string }) | undefined + const runById = opts.runOpencodeById ?? runOpencodeSessionByIdOffThread + + // Resume tuple correctness: prefer the runtime recorded in session metadata + // (freshclaude/freshopencode/kilroy), fall back to the provider name. + // Metadata-store failures degrade to the default — they must not turn a + // located session into a provider error. + const sessionTypeFor = async (provider: 'claude' | 'opencode', id: string): Promise => { + const all = await opts.sessionMetadataStore?.getAll().catch(() => undefined) + return all?.[`${provider}:${id}`]?.sessionType ?? provider + } + + const claudeTranscriptById: ExactIdFallback | undefined = claude && opts.locateClaudeTranscript + ? async (id): Promise => { + const hit = await opts.locateClaudeTranscript!(id) + if (!hit) return null + return { + provider: 'claude', + sessionId: hit.sessionId, + // cwd may legitimately be missing here — the CLIENT must then ask + // for a working directory instead of auto-opening. + cwd: hit.cwd, + sessionType: await sessionTypeFor('claude', hit.sessionId), + matchKind: 'exact', + } + } + : undefined + + const opencodeSessionById: ExactIdFallback | undefined = opencode?.getDatabasePath + ? async (id): Promise => { + // NO catch here: a locked/corrupt DB or worker failure must PROPAGATE + // (provider unavailable ≠ not found). Runs OFF the event loop — the + // worker runner blocks only its own thread. + const row = await runById(opencode.getDatabasePath!(), id) + if (!row) return null + return { + provider: 'opencode', + sessionId: row.sessionId, + // opencode resumes in the SPAWN cwd; the sqlite row's NOT NULL + // `directory` column supplies it. + cwd: row.cwd || undefined, + sessionType: await sessionTypeFor('opencode', row.sessionId), + title: row.title || undefined, + // SQLite columns are dynamically typed (REAL possible); the contract + // requires integer epoch-ms, so floor. + lastActivityAt: + typeof row.lastActivityAt === 'number' && Number.isFinite(row.lastActivityAt) + ? Math.floor(row.lastActivityAt) + : undefined, + matchKind: 'exact', + } + } + : undefined + + return { claudeTranscriptById, opencodeSessionById } +} diff --git a/server/coding-cli/resolve-session.ts b/server/coding-cli/resolve-session.ts index ec91feb9c..d348ca967 100644 --- a/server/coding-cli/resolve-session.ts +++ b/server/coding-cli/resolve-session.ts @@ -1,109 +1,172 @@ import { parseResumeInput } from '../../shared/resume-input-parser.js' import type { + ResumeResolveHint, ResumeResolveMatch, - ResumeResolveResponse, + ResumeResolveProviderError, } from '../../shared/resume-resolve-contract.js' import type { CodingCliSession, ProjectGroup } from './types.js' -import type { ClaudeTranscriptHit } from './claude-transcript-locator.js' +import { withRequestBudget, type ExactIdFallback, type ResolveFallbacks } from './resolve-fallbacks.js' +import { ClaudeTranscriptLocatorError } from './claude-transcript-locator.js' +import { logger } from '../logger.js' export const RESOLVE_MATCH_CAP = 20 +const log = logger.child({ component: 'resolve-session' }) + export interface ResolveResumeDeps { getProjects: () => ProjectGroup[] isIndexReady: () => boolean - resolveOpencodeSessionIds?: ( - ids: readonly string[], - ) => Promise<{ - rootsBySessionId: Map - directoriesBySessionId?: Map - unresolvedSessionIds: Set - }> - locateClaudeTranscript?: (sessionId: string) => Promise + /** + * Exact-id fallbacks (buildResolveFallbacks). Shape-gated and budget-capped + * PER REQUEST here; the opencode lookup runs off the event loop. + */ + fallbacks?: ResolveFallbacks +} + +/** + * Resolve pipeline result. providerErrors carries fallback failures only + * (provider unavailable ≠ not found); the route merges in indexer scan + * failures and adds unsearchedProviders/homeDir before responding. + */ +export interface ResolveResumeResult { + status: 'ready' | 'warming' | 'degraded' + matches: ResumeResolveMatch[] + hint: ResumeResolveHint | null + providerErrors: ResumeResolveProviderError[] +} + +/** Errno code for a provider-error summary: typed locator errors carry it in .code. */ +function errnoCodeOf(err: unknown): string | undefined { + if (err instanceof ClaudeTranscriptLocatorError) return err.code + const code = (err as NodeJS.ErrnoException | null)?.code + return typeof code === 'string' ? code : undefined } +/** + * UUID/hex-family tokens (hex digits + dashes only) match case-insensitively. + * Everything else — notably ses_ + base62 ids — matches case-SENSITIVELY: + * base62 upper/lower case are distinct values, so case-folding could resolve + * the WRONG session. + */ +function isCaseInsensitiveToken(token: string): boolean { + return /^[0-9a-fA-F-]+$/.test(token) +} + +/** + * One scan answers all providers at once (spec: "evidence decides"). Candidate + * tokens are tried in priority order; PER TOKEN the resolution order is: + * + * 1. exact index hits (ALL sessions, including subagent children — an exact + * pasted id must resolve even for hidden child sessions), + * 2. exact-id fallbacks for sessions the index cannot see (opencode child + * sessions; cwd-less claude transcripts skipped on cold start), + * 3. and only then prefix matches (top-level sessions only — surfacing + * hidden subagent children for partial ids would flood disambiguation + * with noise). + * + * A prefix match must NEVER outrank any exact resolution of the same or a + * higher-priority token: an unindexed session whose id EQUALS the token beats + * any indexed session whose id merely begins with it, or the wrong session + * gets resumed. + */ export async function resolveResumeInput( input: string, deps: ResolveResumeDeps, -): Promise { +): Promise { const { candidates, hint } = parseResumeInput(input) if (!deps.isIndexReady()) { - return { status: 'warming', matches: [], hint } + return { status: 'warming', matches: [], hint, providerErrors: [] } } if (candidates.length === 0) { - return { status: 'ready', matches: [], hint } + return { status: 'ready', matches: [], hint, providerErrors: [] } } const sessions = deps.getProjects().flatMap((group) => group.sessions) - // Evidence pass: one scan answers all providers at once. Candidates are - // tried in priority order until one resolves. - for (const candidate of candidates) { - const needle = candidate.token.toLowerCase() - const exact: ResumeResolveMatch[] = [] - const prefix: ResumeResolveMatch[] = [] - for (const session of sessions) { - const id = session.sessionId.toLowerCase() - if (id === needle) exact.push(toMatch(session, 'exact')) - else if (id.startsWith(needle)) prefix.push(toMatch(session, 'prefix')) - } - const matches = exact.length > 0 ? exact : prefix - if (matches.length > 0) { - matches.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)) - return { status: 'ready', matches: dedupe(matches).slice(0, RESOLVE_MATCH_CAP), hint } + // The fallback budget is PER REQUEST (not per server): wrap once, before + // the token loop. Full-id shape gates make wrong-shape tokens free no-ops, + // the budget bounds the real work a pasted blob can trigger, and the + // opencode lookup runs OFF the event loop (worker thread) — a locked DB + // can never stall the server. + const fallbacks = deps.fallbacks ? withRequestBudget(deps.fallbacks) : undefined + + // Provider failure ≠ not found: a throwing fallback records a per-provider + // error summary while resolution CONTINUES (prefix/later tokens). Any entry + // here makes the result 'degraded' — even with matches, because a failed + // HIGHER-priority exact search may have hidden the right session. + const errorsByProvider = new Map() + + const finish = (matches: ResumeResolveMatch[]): ResolveResumeResult => { + matches.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0)) + const providerErrors = [...errorsByProvider.values()] + return { + status: providerErrors.length > 0 ? 'degraded' : 'ready', + matches: dedupe(matches).slice(0, RESOLVE_MATCH_CAP), + hint, + providerErrors, } } - // Exact-id fallbacks for sessions the index cannot see (opencode child - // sessions; cwd-less claude transcripts skipped on cold start). for (const candidate of candidates) { - if ( - candidate.kind === 'prefixed-id' && - candidate.token.startsWith('ses_') && - deps.resolveOpencodeSessionIds - ) { - const resolution = await deps.resolveOpencodeSessionIds([candidate.token]) - if (!resolution.unresolvedSessionIds.has(candidate.token)) { - return { - status: 'ready', - matches: [ - { - provider: 'opencode', - sessionId: candidate.token, - // opencode resumes in the SPAWN cwd, not the session's stored - // project dir — a cwd-less match would run the agent in the - // wrong directory. The sqlite row's NOT NULL `directory` - // column always supplies it. - cwd: resolution.directoriesBySessionId?.get(candidate.token), - sessionType: 'opencode', - matchKind: 'exact', - }, - ], - hint, - } - } + const ci = isCaseInsensitiveToken(candidate.token) + const norm = (value: string) => (ci ? value.toLowerCase() : value) + const target = norm(candidate.token) + + // 1. Exact index hits — scan ALL sessions, subagent children included. + const exact = sessions.filter((session) => norm(session.sessionId) === target) + if (exact.length > 0) { + return finish(exact.map((session) => toMatch(session, 'exact'))) } - if (candidate.kind === 'uuid' && deps.locateClaudeTranscript) { - const hit = await deps.locateClaudeTranscript(candidate.token) - if (hit) { - return { - status: 'ready', - matches: [ - { - provider: 'claude', - sessionId: hit.sessionId, - cwd: hit.cwd, - sessionType: 'claude', - matchKind: 'exact', - }, - ], - hint, + + // 2. Exact-id fallbacks run BEFORE prefix matching. Cheap: the shape + // gates inside withRequestBudget mean prefix-length tokens do no + // fallback work at all. Iterated as [provider, fallback] PAIRS so a + // failure is attributed to the RIGHT provider (identity travels with + // the entry, never its position). + if (fallbacks) { + const hits: ResumeResolveMatch[] = [] + const entries: Array<[string, ExactIdFallback | undefined]> = [ + ['claude', fallbacks.claudeTranscriptById], + ['opencode', fallbacks.opencodeSessionById], + ] + for (const [provider, fallback] of entries) { + if (!fallback) continue + try { + const match = await fallback(candidate.token) + if (match) hits.push(match) + } catch (err) { + // Never reject: an async express 4 handler would surface that as + // an unhandled rejection, not a response. Typed locator errors + // (ClaudeTranscriptLocatorError) arrive here intact — errno in .code. + const code = errnoCodeOf(err) + if (!errorsByProvider.has(provider)) { + errorsByProvider.set(provider, { + provider, + ...(code ? { code } : {}), + message: err instanceof Error ? err.message : String(err), + }) + } + log.warn( + { provider, candidateKind: candidate.kind, error: err instanceof Error ? err.message : String(err) }, + 'Resume resolve exact-id fallback failed', + ) } } + if (hits.length > 0) return finish(hits) + } + + // 3. Prefix DISCOVERY — top-level sessions only; exact ids above still + // reach subagent children. + const prefix = sessions.filter( + (session) => !session.isSubagent && norm(session.sessionId).startsWith(target), + ) + if (prefix.length > 0) { + return finish(prefix.map((session) => toMatch(session, 'prefix'))) } } - return { status: 'ready', matches: [], hint } + return finish([]) } function toMatch(session: CodingCliSession, matchKind: 'exact' | 'prefix'): ResumeResolveMatch { @@ -111,7 +174,7 @@ function toMatch(session: CodingCliSession, matchKind: 'exact' | 'prefix'): Resu provider: session.provider, sessionId: session.sessionId, cwd: session.cwd ?? session.projectPath, - sessionType: session.sessionType, + sessionType: session.sessionType ?? session.provider, title: session.title, firstUserMessage: session.firstUserMessage, lastActivityAt: session.lastActivityAt, diff --git a/server/coding-cli/session-indexer.ts b/server/coding-cli/session-indexer.ts index db843b3f7..5e712c5da 100644 --- a/server/coding-cli/session-indexer.ts +++ b/server/coding-cli/session-indexer.ts @@ -461,6 +461,7 @@ export class CodingCliSessionIndexer { private seenSessionIds = new Map() private onNewSessionHandlers = new Set<(session: CodingCliSession) => void>() private initialized = false + private scanFailures = new Set() private sessionKeyToFilePath = new Map() private urgentRefreshNeeded = false private dirtyProviders = new Set() @@ -684,6 +685,32 @@ export class CodingCliSessionIndexer { this.rootWatcher.on('error', (err) => logger.warn({ err }, 'Root watcher error')) } + /** True once at least one refresh has completed (resolve endpoint: 'warming' until then). */ + isReady(): boolean { + return this.initialized + } + + /** + * Providers whose MOST RECENT listing attempt failed. The scan path swallows + * these failures into empty lists and still reports the index initialized, + * so without this the resolve endpoint would report 'ready' + zero matches + * (i.e. "not found") during a provider outage — spec violation. + */ + getScanFailures(): CodingCliProviderName[] { + return [...this.scanFailures] + } + + /** + * Public fire-and-forget refresh request (debounced scheduleRefresh). The + * resolve route calls this on a degraded response so the user's "Retry" + * converges after a failed provider recovers — otherwise a recorded scan + * failure could outlive the outage until the next watcher event/periodic + * full scan. + */ + requestRefresh(): void { + this.scheduleRefresh() + } + onUpdate(handler: (projects: ProjectGroup[]) => void): () => void { this.onUpdateHandlers.add(handler) return () => this.onUpdateHandlers.delete(handler) @@ -1047,7 +1074,11 @@ export class CodingCliSessionIndexer { let sessions: CodingCliSession[] = [] try { sessions = await provider.listSessionsDirect() + this.scanFailures.delete(provider.name) } catch { + // Record per attempt (never bulk-cleared): the resolve endpoint must + // report this provider as unsearchable, not silently "no sessions". + this.scanFailures.add(provider.name) // A direct-listing failure is transient (e.g. the off-thread worker failed), // NOT "no sessions". Preserve this provider's existing direct-cache entries so // neither the local prune (below) nor the full-scan global prune deletes them. @@ -1222,8 +1253,12 @@ export class CodingCliSessionIndexer { try { const files = await provider.listSessionFiles() filesByProvider.set(provider, files) + this.scanFailures.delete(provider.name) } catch (err) { logger.warn({ err, provider: provider.name }, 'Could not list session files') + // Record per attempt (never bulk-cleared): the resolve endpoint must + // report this provider as unsearchable, not silently "no sessions". + this.scanFailures.add(provider.name) } }), ) @@ -1429,8 +1464,14 @@ export class CodingCliSessionIndexer { // On warm rescan this processes all files normally. for (const provider of this.providers) { if (!enabledSet.has(provider.name) || provider.listSessionsDirect) continue - const files = filesByProvider?.get(provider) ?? await provider.listSessionFiles().catch((err) => { + const files = filesByProvider?.get(provider) ?? await provider.listSessionFiles().then((listed) => { + this.scanFailures.delete(provider.name) + return listed + }).catch((err) => { logger.warn({ err, provider: provider.name }, 'Could not list session files') + // Record per attempt (never bulk-cleared): the resolve endpoint must + // report this provider as unsearchable, not silently "no sessions". + this.scanFailures.add(provider.name) return [] as string[] }) fileCount += files.length @@ -1527,6 +1568,16 @@ export class CodingCliSessionIndexer { if (!changed) { logger.debug({ sessionCount, fileCount }, 'Skipping no-op refresh (no project changes)') } + // A DISABLED provider is UNSEARCHED, not failed — prune its stale failure + // so a failed-then-disabled provider cannot keep resolve responses + // 'degraded' forever (no successful scan could ever clear it). + for (const name of [...this.scanFailures]) { + if (!enabledSet.has(name)) this.scanFailures.delete(name) + } + // Readiness = at least one refresh completed. start() also sets this after + // its initial refresh — setting it here is idempotent and makes isReady() + // observable without start() (unit tests drive refresh() directly). + this.initialized = true endRefreshTimer({ projectCount: groups.length, sessionCount, fileCount, skipped: !changed }) } diff --git a/server/index.ts b/server/index.ts index a56cd48b4..1b4722d10 100644 --- a/server/index.ts +++ b/server/index.ts @@ -31,8 +31,9 @@ import { AmplifierSessionController } from './coding-cli/amplifier-session-contr import { createOpencodeActivityIntegration } from './coding-cli/opencode-activity-integration.js' import { claudeProvider } from './coding-cli/providers/claude.js' import { codexProvider } from './coding-cli/providers/codex.js' -import { opencodeProvider, OpencodeProvider } from './coding-cli/providers/opencode.js' +import { opencodeProvider } from './coding-cli/providers/opencode.js' import { locateClaudeTranscript } from './coding-cli/claude-transcript-locator.js' +import { buildResolveFallbacks } from './coding-cli/resolve-fallbacks.js' import { getClaudeProjectsDir } from './claude-home.js' import { amplifierProvider } from './coding-cli/providers/amplifier.js' import { overrideKeysToClear } from './coding-cli/provider-title-cleanup.js' @@ -759,20 +760,18 @@ async function main() { serverInstanceId, validCliProviders: allCliNames, getIndexReadiness: () => startupState.snapshot().tasks.codingCliIndexer === true, - resolveOpencodeSessionIds: (ids) => { - const opencode = codingCliProviders.find( - (provider): provider is OpencodeProvider => provider instanceof OpencodeProvider, - ) - if (!opencode) { - return Promise.resolve({ - rootsBySessionId: new Map(), - unresolvedSessionIds: new Set(ids), - }) - } - return opencode.resolveOpencodeSessionRoots(ids) - }, - locateClaudeTranscript: (sessionId) => - locateClaudeTranscript(sessionId, getClaudeProjectsDir()), + // Exact-id resolve fallbacks: shape-gated, budget-capped per request, and + // the opencode by-id lookup runs OFF the event loop (worker thread) — a + // locked opencode DB must never stall the server. + resolveFallbacks: buildResolveFallbacks(codingCliProviders, { + sessionMetadataStore, + // Multi-root: the claude provider's getSessionRoots() covers the primary + // projects dir AND any secondary roots (evaluated per lookup so runtime + // root changes are picked up) — an exact pasted id must resolve from + // every root the indexer scans. + locateClaudeTranscript: (sessionId) => + locateClaudeTranscript(sessionId, claudeProvider.getSessionRoots()), + }), })) app.use('/api', createProjectColorsRouter({ configStore, codingCliIndexer })) diff --git a/server/sessions-router.ts b/server/sessions-router.ts index c32c3caac..24248c8a8 100644 --- a/server/sessions-router.ts +++ b/server/sessions-router.ts @@ -1,9 +1,11 @@ +import os from 'os' import { Router } from 'express' import { z } from 'zod' import { cleanString } from './utils.js' import { makeSessionKey, type CodingCliProviderName } from './coding-cli/types.js' import type { CodingCliProvider } from './coding-cli/provider.js' import { CodingCliProviderSchema } from '../shared/ws-protocol.js' +import { DEFAULT_ENABLED_CLI_PROVIDERS } from '../shared/coding-cli-defaults.js' import { logger } from './logger.js' import { setResponsePerfContext } from './request-logger.js' import { cascadeSessionRenameToTerminal } from './rename-cascade.js' @@ -19,9 +21,13 @@ import { SessionTypeMetadataSourceSchema, } from '../shared/session-flavor.js' import { querySessionDirectory } from './session-directory/service.js' -import { ResumeResolveRequestSchema } from '../shared/resume-resolve-contract.js' +import { + ResumeResolveRequestSchema, + type ResumeResolveProviderError, + type ResumeResolveResponse, +} from '../shared/resume-resolve-contract.js' import { resolveResumeInput } from './coding-cli/resolve-session.js' -import type { ClaudeTranscriptHit } from './coding-cli/claude-transcript-locator.js' +import type { ResolveFallbacks } from './coding-cli/resolve-fallbacks.js' import { createRequestAbortSignal } from './read-models/request-abort.js' import { defaultReadModelScheduler, @@ -48,6 +54,12 @@ export interface SessionsRouterDeps { codingCliIndexer: { getProjects: () => any[] refresh: () => Promise + /** True once at least one index refresh completed (resolve readiness signal). */ + isReady?: () => boolean + /** Providers whose MOST RECENT listing attempt failed (unsearchable, not empty). */ + getScanFailures?: () => string[] + /** Fire-and-forget refresh so a degraded response's Retry can converge. */ + requestRefresh?: () => void } codingCliProviders: CodingCliProvider[] perfConfig: { slowSessionRefreshMs: number } @@ -60,12 +72,10 @@ export interface SessionsRouterDeps { readModelScheduler?: ReadModelWorkScheduler /** Global index readiness (startup-state codingCliIndexer task). Defaults to ready. */ getIndexReadiness?: () => boolean - /** Opencode by-id sqlite fallback (OpencodeProvider.resolveOpencodeSessionRoots). */ - resolveOpencodeSessionIds?: ( - ids: readonly string[], - ) => Promise<{ rootsBySessionId: Map; unresolvedSessionIds: Set }> - /** Claude transcript exact-id fallback. */ - locateClaudeTranscript?: (sessionId: string) => Promise + /** Exact-id resolve fallbacks (buildResolveFallbacks); budget applied per request. */ + resolveFallbacks?: ResolveFallbacks + /** Server home directory returned to the resolve client for cwd prefill. Defaults to os.homedir(). */ + homeDir?: string } export function createSessionsRouter(deps: SessionsRouterDeps): Router { @@ -240,6 +250,11 @@ export function createSessionsRouter(deps: SessionsRouterDeps): Router { res.json({ ok: true }) }) + // The indexer scans ONLY settings-enabled providers, so a disabled + // provider's sessions can never be found. Report those as UNSEARCHED so + // "not found" never overclaims. Order matches the canonical provider list. + const KNOWN_RESUME_PROVIDERS = DEFAULT_ENABLED_CLI_PROVIDERS + router.post('/sessions/resolve', async (req, res) => { const parsed = ResumeResolveRequestSchema.safeParse(req.body ?? {}) if (!parsed.success) { @@ -247,12 +262,56 @@ export function createSessionsRouter(deps: SessionsRouterDeps): Router { .status(400) .json({ error: 'Invalid resolve request', details: parsed.error.issues }) } - const response = await resolveResumeInput(parsed.data.input, { + // Readiness = startupState (getIndexReadiness) OR'd with the indexer's own + // isReady() signal: startup readiness can stick false forever (its + // markReady only runs in the start chain's success path), so once the + // indexer has completed a refresh the endpoint must stop reporting + // warming. When NEITHER signal is wired, default to ready. + const readinessSignals: Array<() => boolean> = [] + if (deps.getIndexReadiness) readinessSignals.push(deps.getIndexReadiness) + const indexerIsReady = deps.codingCliIndexer.isReady + if (indexerIsReady) readinessSignals.push(() => indexerIsReady.call(deps.codingCliIndexer)) + const result = await resolveResumeInput(parsed.data.input, { getProjects: () => deps.codingCliIndexer.getProjects(), - isIndexReady: deps.getIndexReadiness ?? (() => true), - resolveOpencodeSessionIds: deps.resolveOpencodeSessionIds, - locateClaudeTranscript: deps.locateClaudeTranscript, + isIndexReady: () => readinessSignals.length === 0 || readinessSignals.some((fn) => fn()), + fallbacks: deps.resolveFallbacks, }) + const settings = await configStore.getSettings().catch(() => ({})) + const enabled = new Set( + settings?.codingCli?.enabledProviders ?? KNOWN_RESUME_PROVIDERS, + ) + const unsearchedProviders = KNOWN_RESUME_PROVIDERS.filter((name) => !enabled.has(name)) + // A provider whose last index SCAN failed was not searched either — the + // indexer swallows listing failures into empty lists. A DISABLED provider + // is unsearched (reported above), never a provider error: otherwise a + // failed-then-disabled provider would keep responses degraded forever (no + // successful scan could ever clear it). Fallback errors win the dedupe — + // they carry the more specific message/code. + const errorsByProvider = new Map( + result.providerErrors.map((entry) => [entry.provider, entry]), + ) + for (const name of deps.codingCliIndexer.getScanFailures?.() ?? []) { + if (!enabled.has(name) || errorsByProvider.has(name)) continue + errorsByProvider.set(name, { provider: name, message: 'session scan failed' }) + } + const providerErrors = [...errorsByProvider.values()] + // degraded = something FAILED — even when matches exist: a failed provider + // means a HIGHER-priority exact match may have been missed, so the client + // must never auto-resume a surviving lower-priority match. + const status: 'ready' | 'warming' | 'degraded' = + result.status === 'warming' ? 'warming' : providerErrors.length > 0 ? 'degraded' : 'ready' + // Fire-and-forget: give the user's Retry a chance to converge once a + // failed provider recovers (scan failures only clear on a new scan). + if (status === 'degraded') deps.codingCliIndexer.requestRefresh?.() + const response: ResumeResolveResponse = { + status, + matches: result.matches, + hint: result.hint, + providerErrors, + unsearchedProviders, + // Lets the client prefill a CONCRETE cwd instead of the '~' sentinel. + homeDir: deps.homeDir ?? os.homedir(), + } res.json(response) }) diff --git a/shared/resume-input-parser.ts b/shared/resume-input-parser.ts index cb0db7adf..5b6a25ca0 100644 --- a/shared/resume-input-parser.ts +++ b/shared/resume-input-parser.ts @@ -17,17 +17,24 @@ export interface ResumeHint { } export interface ResumeInputParse { - /** Candidate tokens in resolution-priority order. */ + /** Candidate tokens in resolution-priority order, capped at MAX_RESUME_CANDIDATES. */ candidates: ResumeCandidate[] hint: ResumeHint | null } +/** + * Work budget: candidates are capped so one pasted blob can never trigger + * unbounded server-side scans/DB lookups in the resolve endpoint. + */ +export const MAX_RESUME_CANDIDATES = 8 + const ANSI_ESCAPE_RE = /\u001b\[[0-9;?]*[0-9A-Za-z]/g const UUID_RE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g -// ses_ + 26 base62 is the first-class shape; the generic form also accepts -// other known xxx_-prefixed id families. -const PREFIXED_ID_RE = /\b[a-z]{2,10}_[0-9A-Za-z]{8,40}\b/g +// Known xxx_-prefixed id families only (ses_ + 26 base62 is opencode's, +// first-class). Arbitrary snake_case identifiers must NOT match: they would +// rank FIRST and waste resolver passes on non-ids. +const PREFIXED_ID_RE = /\b(?:ses|sess|session|thread|thr|run|msg|task|amp)_[0-9A-Za-z]{8,64}\b/g // >=8 hex chars, <=32; must contain a digit (filters decade/facade/deadbeef). const HEX_PREFIX_RE = /\b[0-9a-fA-F]{8,32}\b/g @@ -118,5 +125,7 @@ export function parseResumeInput(text: string): ResumeInputParse { for (const token of uuids) push(token, 'uuid') for (const token of hexTokens) push(token, 'hex-prefix') - return { candidates, hint: deriveHint(sanitized, candidates) } + // Cap = work budget: bounds resolver scans + exact-id fallback lookups per request. + const capped = candidates.slice(0, MAX_RESUME_CANDIDATES) + return { candidates: capped, hint: deriveHint(sanitized, capped) } } diff --git a/shared/resume-resolve-contract.ts b/shared/resume-resolve-contract.ts index a942a962e..8e45cc9f0 100644 --- a/shared/resume-resolve-contract.ts +++ b/shared/resume-resolve-contract.ts @@ -22,12 +22,42 @@ export const ResumeResolveHintSchema = z.object({ source: z.enum(['command', 'word', 'id-shape']), }) +/** + * Per-provider error summary. A provider that could not be searched (locked + * DB, unreadable store, failed index scan) is 'degraded' — NEVER "not found". + */ +export const ResumeResolveProviderErrorSchema = z.object({ + provider: z.string().min(1), + /** errno code from the underlying failure when known (e.g. 'EACCES'). */ + code: z.string().optional(), + message: z.string().optional(), +}) + +/** + * Provider-health extension is ADDITIVE and backward-tolerant: legacy + * responses without the new fields still parse (defaults apply), and legacy + * clients ignore the extra fields. + * + * status semantics: + * - 'ready': every enabled provider was searched successfully. + * - 'warming': the index has not completed its first scan — retry, not "not found". + * - 'degraded': at least one provider FAILED. Even with matches present the + * client must never auto-resume: a failed higher-priority exact search may + * have hidden the right session. + */ export const ResumeResolveResponseSchema = z.object({ - status: z.enum(['ready', 'warming']), + status: z.enum(['ready', 'warming', 'degraded']), matches: z.array(ResumeResolveMatchSchema), hint: ResumeResolveHintSchema.nullable(), + providerErrors: z.array(ResumeResolveProviderErrorSchema).default([]), + /** Settings-disabled providers — reported so "not found" never overclaims. */ + unsearchedProviders: z.array(z.string()).default([]), + /** Server home directory, so the client can prefill a concrete cwd. */ + homeDir: z.string().optional(), }) export type ResumeResolveRequest = z.infer export type ResumeResolveMatch = z.infer +export type ResumeResolveHint = z.infer +export type ResumeResolveProviderError = z.infer export type ResumeResolveResponse = z.infer diff --git a/src/components/ResumeSessionDialog.tsx b/src/components/ResumeSessionDialog.tsx index 7b8558f65..d2cdea074 100644 --- a/src/components/ResumeSessionDialog.tsx +++ b/src/components/ResumeSessionDialog.tsx @@ -11,6 +11,7 @@ import { parseResumeInput } from '@shared/resume-input-parser' import { ResumeResolveResponseSchema, type ResumeResolveMatch, + type ResumeResolveProviderError, } from '@shared/resume-resolve-contract' const WARMING_RETRY_MS = 2000 @@ -28,8 +29,14 @@ type Phase = | { kind: 'warming' } | { kind: 'index-unavailable' } | { kind: 'no-token' } - | { kind: 'no-match' } + | { kind: 'no-match'; unsearchedProviders: string[] } | { kind: 'disambiguate'; matches: ResumeResolveMatch[] } + // Provider unavailable ≠ not found: some agent stores could not be searched. + // MANUAL retry only (the server fire-and-forgets an index refresh on every + // degraded response, so Retry converges once the provider recovers) — never + // the warming auto-retry budget, and NEVER auto-resume (a failed higher- + // priority exact search may have hidden the right session). + | { kind: 'degraded'; matches: ResumeResolveMatch[]; providerErrors: ResumeResolveProviderError[] } | { kind: 'resumed'; note: string } | { kind: 'request-failed' } @@ -41,6 +48,21 @@ export interface ResumeSessionDialogProps { const providers = DEFAULT_ENABLED_CLI_PROVIDERS as readonly string[] +// Focus-trap helper — same pattern as src/components/ui/confirm-modal.tsx +// (repo modal a11y convention: trap Tab, restore focus, lock scroll). +function getFocusable(container: HTMLElement): HTMLElement[] { + const selectors = [ + 'button', + '[href]', + 'input', + 'select', + 'textarea', + '[tabindex]:not([tabindex="-1"])', + ] + return Array.from(container.querySelectorAll(selectors.join(','))) + .filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')) +} + export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSessionDialogProps) { const dispatch = useAppDispatch() const store = useStore() @@ -49,9 +71,16 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession const [agentTouched, setAgentTouched] = useState(false) const [anywayCwd, setAnywayCwd] = useState('~') const [phase, setPhase] = useState({ kind: 'idle' }) + // Inline error for confirming a cwd-less match with a blank cwd field. + const [matchCwdError, setMatchCwdError] = useState(false) const inputRef = useRef(null) + const dialogRef = useRef(null) const closeTimerRef = useRef(undefined) const warmingRetriesRef = useRef(0) + // Stale-response guard: only the LATEST resolve request may mutate state. + const resolveSeqRef = useRef(0) + // homeDir prefill never overwrites a USER-edited working directory. + const cwdTouchedRef = useRef(false) // Advisory hint pre-fills the picker; never overrides a manual choice. useEffect(() => { @@ -77,6 +106,11 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession setPhase({ kind: 'no-token' }) return } + // Stale-response guard: bump the sequence; only the LATEST request may + // mutate state. A stale single-match response must NEVER auto-resume — + // it could open the WRONG session. + const seq = ++resolveSeqRef.current + setMatchCwdError(false) setPhase({ kind: 'resolving' }) let response try { @@ -84,10 +118,31 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession await api.post('/api/sessions/resolve', { input: trimmed }), ) } catch { + if (seq !== resolveSeqRef.current) return // stale — ignore setPhase({ kind: 'request-failed' }) return } - if (response.status === 'warming') { + if (seq !== resolveSeqRef.current) return // stale — ignore + // Prefill the working directory with the server's CONCRETE home instead + // of the '~' sentinel — but never clobber a user-edited value. + if (response.homeDir && !cwdTouchedRef.current) setAnywayCwd(response.homeDir) + if (response.status === 'degraded') { + // Provider unavailable ≠ not found — and it must NEVER reach the + // auto-resume below: a failed provider means a higher-priority exact + // match may have been missed, so auto-opening a surviving match could + // open the WRONG session. Surviving matches render for MANUAL + // confirmation; retry is MANUAL only (the server already schedules an + // index refresh on every degraded response, so Retry converges). + setPhase({ + kind: 'degraded', + matches: response.matches, + providerErrors: response.providerErrors, + }) + return + } + if (response.status !== 'ready') { + // Warming is a retry state, never "not found" — and it must NEVER + // reach the auto-resume below. if (warmingRetriesRef.current >= WARMING_RETRY_LIMIT) { setPhase({ kind: 'index-unavailable' }) return @@ -96,16 +151,21 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession setPhase({ kind: 'warming' }) return } - if (response.matches.length === 1) { + // Auto-resume needs a healthy response AND a concrete recorded cwd: a + // lone match without one renders in the match list below alongside an + // editable working-directory field (spec: never open without a cwd). + if (response.matches.length === 1 && response.matches[0].cwd) { const found = response.matches[0] finishResume(found, `Found in ${found.provider}`) return } - if (response.matches.length > 1) { + if (response.matches.length >= 1) { setPhase({ kind: 'disambiguate', matches: response.matches }) return } - setPhase({ kind: 'no-match' }) + // Absence claims must name what was NOT searched (disabled providers) — + // otherwise "not found" implies the id does not exist anywhere. + setPhase({ kind: 'no-match', unsearchedProviders: response.unsearchedProviders }) }, [finishResume], ) @@ -137,8 +197,23 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession [], ) + // Closing invalidates any in-flight resolve. + useEffect(() => { + if (!open) resolveSeqRef.current += 1 + }, [open]) + + // Modal a11y (mirrors src/components/ui/confirm-modal.tsx): capture + restore + // the previously focused element, lock background scroll, focus the paste field. useEffect(() => { - if (open) inputRef.current?.focus() + if (!open) return + const previousFocus = document.activeElement as HTMLElement | null + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + inputRef.current?.focus() + return () => { + document.body.style.overflow = previousOverflow || '' + previousFocus?.focus() + } }, [open]) if (!open) return null @@ -149,18 +224,47 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession setPhase({ kind: 'no-token' }) return } + // cwd-required gating: the button is disabled while the field is blank; + // this is the backstop. '~' stays the documented server-home default. const cwd = anywayCwd.trim() + if (cwd === '') return finishResume( { provider: agent, sessionId: token, sessionType: agent, - cwd: cwd === '' || cwd === '~' ? undefined : cwd, + cwd: cwd === '~' ? undefined : cwd, }, `Resuming with ${agent}`, ) } + // Confirming a listed match: a match with a recorded cwd resumes directly; a + // cwd-less match (exact-id fallback hit) requires the editable + // working-directory field — a session must NEVER open from a blank field. + const confirmMatch = (candidate: ResumeResolveMatch) => { + if (candidate.cwd) { + finishResume(candidate, `Found in ${candidate.provider}`) + return + } + const cwd = anywayCwd.trim() + if (cwd === '') { + setMatchCwdError(true) + return + } + finishResume( + { + provider: candidate.provider, + sessionId: candidate.sessionId, + sessionType: candidate.sessionType, + cwd: cwd === '~' ? undefined : cwd, + title: candidate.title, + firstUserMessage: candidate.firstUserMessage, + }, + `Found in ${candidate.provider}`, + ) + } + const controlClass = 'min-w-0 flex-1 h-7 px-2 text-xs bg-muted/50 border-0 rounded-md focus:outline-none focus:ring-1 focus:ring-border' @@ -171,6 +275,7 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession > {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- same convention as App.tsx's update-instructions dialog: the container's onClick is a stopPropagation shield and onKeyDown handles Escape; the dialog's real controls are native buttons/inputs. */}
event.stopPropagation()} onKeyDown={(event) => { - if (event.key === 'Escape') onClose() + if (event.key === 'Escape') { + onClose() + return + } + if (event.key !== 'Tab') return + // Focus trap — repo modal pattern (see src/components/ui/confirm-modal.tsx). + const dialog = dialogRef.current + if (!dialog) return + const focusables = getFocusable(dialog) + if (focusables.length === 0) { + event.preventDefault() + return + } + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement as HTMLElement | null + if (event.shiftKey) { + if (active === first || !dialog.contains(active)) { + event.preventDefault() + last.focus() + } + } else if (active === last) { + event.preventDefault() + first.focus() + } }} >

Resume a session

@@ -192,7 +321,16 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession value={input} rows={3} className="w-full text-xs bg-muted/50 border-0 rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-border resize-none" - onChange={(event) => setInput(event.target.value)} + onChange={(event) => { + setInput(event.target.value) + // EDITING invalidates everything derived from the previous text: + // bump the sequence so in-flight responses go stale, and reset the + // phase so stale "Resume anyway"/disambiguation actions can never + // act on old tokens. + resolveSeqRef.current += 1 + setMatchCwdError(false) + setPhase({ kind: 'idle' }) + }} onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() @@ -269,6 +407,29 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession
)} + {phase.kind === 'degraded' && ( +
+ Some agents could not be searched:{' '} + {phase.providerErrors + .map( + (entry) => + `${entry.provider}${entry.code ? ` (${entry.code})` : ''}${entry.message ? ` — ${entry.message}` : ''}`, + ) + .join('; ')} + . + {phase.matches.length > 0 + ? ' The matches below may be incomplete — confirm one manually or retry.' + : ' This is not a "not found".'} + +
+ )} {phase.kind === 'no-token' && (
No session id found in the pasted text. @@ -284,7 +445,7 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession {phase.note}
)} - {phase.kind === 'disambiguate' && ( + {(phase.kind === 'disambiguate' || phase.kind === 'degraded') && phase.matches.length > 0 && (
    {phase.matches.map((candidate) => (
  • @@ -292,7 +453,7 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession type="button" data-testid="resume-match" className="w-full text-left text-xs p-2 rounded-md bg-muted/50 hover:bg-muted focus:outline-none focus:ring-1 focus:ring-border" - onClick={() => finishResume(candidate, `Found in ${candidate.provider}`)} + onClick={() => confirmMatch(candidate)} > {candidate.title ?? candidate.firstUserMessage ?? candidate.sessionId} @@ -309,10 +470,42 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession ))}
)} + {(phase.kind === 'disambiguate' || phase.kind === 'degraded') && + phase.matches.some((candidate) => !candidate.cwd) && ( +
+
+ + { + cwdTouchedRef.current = true + setAnywayCwd(event.target.value) + setMatchCwdError(false) + }} + className={controlClass} + /> +
+

+ Required for sessions without a recorded working directory. ~ resolves to the + server's home directory. +

+ {matchCwdError && ( +
+ Enter a working directory to open this session. +
+ )} +
+ )} {phase.kind === 'no-match' && (
- No matching session found in any agent's store. + {phase.unsearchedProviders.length > 0 + ? `No matching session found. Not searched (disabled): ${phase.unsearchedProviders.join(', ')}.` + : "No matching session found in any agent's store."}
@@ -333,7 +529,8 @@ export function ResumeSessionDialog({ open, onClose, onNavigate }: ResumeSession type="button" data-testid="resume-anyway-button" onClick={resumeAnyway} - className="h-8 px-3 text-xs rounded-md bg-muted/50 hover:bg-muted focus:outline-none focus:ring-1 focus:ring-border" + disabled={anywayCwd.trim() === ''} + className="h-8 px-3 text-xs rounded-md bg-muted/50 hover:bg-muted focus:outline-none focus:ring-1 focus:ring-border disabled:opacity-50 disabled:cursor-not-allowed" > Resume anyway with {agent} diff --git a/test/integration/server/claude-transcript-locator.test.ts b/test/integration/server/claude-transcript-locator.test.ts index 62014c2de..94b52f29d 100644 --- a/test/integration/server/claude-transcript-locator.test.ts +++ b/test/integration/server/claude-transcript-locator.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { locateClaudeTranscript } from '../../../server/coding-cli/claude-transcript-locator.js' +import { + ClaudeTranscriptLocatorError, + locateClaudeTranscript, +} from '../../../server/coding-cli/claude-transcript-locator.js' const SESSION_ID = 'ed2afda6-a340-443e-ba60-024a1b3554b4' @@ -66,4 +69,76 @@ describe('locateClaudeTranscript', () => { locateClaudeTranscript(SESSION_ID, path.join(projectsDir, 'missing')), ).resolves.toBeNull() }) + + it('tolerates a plain file entry inside the projects dir (ENOTDIR is a miss)', async () => { + await fsp.writeFile(path.join(projectsDir, 'stray.txt'), 'not a project dir', 'utf8') + await expect(locateClaudeTranscript(SESSION_ID, projectsDir)).resolves.toBeNull() + }) + + it('searches SECONDARY roots when given multiple project roots', async () => { + // Claude can have more than one projects root; the locator must not be + // single-root only. + const secondRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-projects-2-')) + try { + const dir = path.join(secondRoot, '-repo-delta') + await fsp.mkdir(dir, { recursive: true }) + const file = path.join(dir, `${SESSION_ID}.jsonl`) + await fsp.writeFile(file, JSON.stringify({ cwd: '/repo/delta' }), 'utf8') + await expect(locateClaudeTranscript(SESSION_ID, [projectsDir, secondRoot])).resolves.toEqual({ + sessionId: SESSION_ID, + sourceFile: file, + cwd: '/repo/delta', + }) + } finally { + await fsp.rm(secondRoot, { recursive: true, force: true }) + } + }) + + it('finds a SUBAGENT transcript at //subagents/.jsonl (index-missed child session)', async () => { + // Claude also stores child-session transcripts one level deeper (see + // claude.ts listSessionFiles): the exact-id contract covers them too. + const PARENT_ID = 'aaaaaaaa-a340-443e-ba60-024a1b3554b4' + const dir = path.join(projectsDir, '-repo-alpha', PARENT_ID, 'subagents') + await fsp.mkdir(dir, { recursive: true }) + const file = path.join(dir, `${SESSION_ID}.jsonl`) + await fsp.writeFile(file, JSON.stringify({ cwd: '/repo/alpha' }) + '\n', 'utf8') + const hit = await locateClaudeTranscript(SESSION_ID, projectsDir) + expect(hit).not.toBeNull() + expect(hit?.cwd).toBe('/repo/alpha') + expect(hit?.sourceFile).toBe(file) + }) + + it('prefers the direct layout when both layouts contain the id (pass ordering)', async () => { + await writeTranscript('-repo-alpha', SESSION_ID, [JSON.stringify({ cwd: '/direct' })]) + const dir = path.join(projectsDir, '-repo-alpha', 'aaaaaaaa-a340-443e-ba60-024a1b3554b4', 'subagents') + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(path.join(dir, `${SESSION_ID}.jsonl`), JSON.stringify({ cwd: '/sub' }) + '\n', 'utf8') + const hit = await locateClaudeTranscript(SESSION_ID, projectsDir) + expect(hit?.cwd).toBe('/direct') + }) + + it('PROPAGATES non-absence failures on the projects root (EACCES) instead of swallowing them as a miss', async () => { + // Provider failure ≠ not found: an unreadable root must reject so the + // provider-health lane can report 'degraded', never "no matching session". + // chmod-based: CI/dev runs unprivileged (root would bypass the mode bits). + const lockedRoot = path.join(projectsDir, 'locked') + await fsp.mkdir(lockedRoot, { recursive: true }) + await fsp.chmod(lockedRoot, 0o000) + try { + await expect(locateClaudeTranscript(SESSION_ID, lockedRoot)).rejects.toThrow() + } finally { + await fsp.chmod(lockedRoot, 0o700) + } + }) + + it('PROPAGATES non-absence failures on a project dir probe (EACCES on stat) instead of swallowing', async () => { + const dir = path.join(projectsDir, '-repo-locked') + await fsp.mkdir(dir, { recursive: true }) + await fsp.chmod(dir, 0o000) + try { + await expect(locateClaudeTranscript(SESSION_ID, projectsDir)).rejects.toThrow() + } finally { + await fsp.chmod(dir, 0o700) + } + }) }) diff --git a/test/integration/server/sessions-resolve-router.test.ts b/test/integration/server/sessions-resolve-router.test.ts index ee94d7432..a50469a96 100644 --- a/test/integration/server/sessions-resolve-router.test.ts +++ b/test/integration/server/sessions-resolve-router.test.ts @@ -3,6 +3,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import express, { type Express } from 'express' import request from 'supertest' import { createSessionsRouter } from '../../../server/sessions-router.js' +import { + buildResolveFallbacks, + FALLBACK_BUDGET_PER_REQUEST, + type ResolveFallbacks, +} from '../../../server/coding-cli/resolve-fallbacks.js' +import type { CodingCliProvider } from '../../../server/coding-cli/provider.js' import type { ProjectGroup } from '../../../server/coding-cli/types.js' const CLAUDE_ID = 'ed2afda6-a340-443e-ba60-024a1b3554b4' @@ -67,18 +73,20 @@ function fixtureProjects(): ProjectGroup[] { interface HarnessOptions { projects?: ProjectGroup[] ready?: boolean - resolveOpencodeSessionIds?: ( - ids: readonly string[], - ) => Promise<{ - rootsBySessionId: Map - directoriesBySessionId?: Map - unresolvedSessionIds: Set - }> - locateClaudeTranscript?: ( - id: string, - ) => Promise<{ sessionId: string; sourceFile: string; cwd?: string } | null> + /** Omit getIndexReadiness entirely (readiness comes from the indexer signal alone). */ + omitStartupReadiness?: boolean + resolveFallbacks?: ResolveFallbacks + settings?: Record + indexerIsReady?: () => boolean + getScanFailures?: () => string[] + requestRefresh?: () => void + homeDir?: string } +const opencodeStub = () => + ({ name: 'opencode', getDatabasePath: () => '/tmp/x.db' }) as unknown as CodingCliProvider +const claudeStub = () => ({ name: 'claude' }) as unknown as CodingCliProvider + function buildApp(options: HarnessOptions = {}): Express { const app = express() app.use(express.json()) @@ -86,20 +94,23 @@ function buildApp(options: HarnessOptions = {}): Express { '/api', createSessionsRouter({ configStore: { - getSettings: vi.fn().mockResolvedValue({}), + getSettings: vi.fn().mockResolvedValue(options.settings ?? {}), patchSessionOverride: vi.fn(), deleteSession: vi.fn(), }, codingCliIndexer: { getProjects: () => options.projects ?? fixtureProjects(), refresh: vi.fn().mockResolvedValue(undefined), + isReady: options.indexerIsReady, + getScanFailures: options.getScanFailures, + requestRefresh: options.requestRefresh, }, codingCliProviders: [], perfConfig: { slowSessionRefreshMs: 500 }, terminalMetadata: { list: () => [] }, - getIndexReadiness: () => options.ready ?? true, - resolveOpencodeSessionIds: options.resolveOpencodeSessionIds, - locateClaudeTranscript: options.locateClaudeTranscript, + getIndexReadiness: options.omitStartupReadiness ? undefined : () => options.ready ?? true, + resolveFallbacks: options.resolveFallbacks, + homeDir: options.homeDir, }), ) return app @@ -212,24 +223,31 @@ describe('POST /api/sessions/resolve', () => { expect(res.body).toMatchObject({ status: 'warming', matches: [] }) }) - it('falls back to the opencode by-id query on exact-id index miss (with the row directory as cwd)', async () => { + it('falls back to the off-thread opencode by-id lookup on exact-id index miss (row directory as cwd)', async () => { const unknown = 'ses_child000000000000000000000' + const runOpencodeById = vi.fn().mockResolvedValue({ + sessionId: unknown, + cwd: '/repo/beta', + title: 'child session', + createdAt: 1, + lastActivityAt: 2, + projectPath: '/repo/beta', + }) const res = await post( buildApp({ - resolveOpencodeSessionIds: vi.fn().mockResolvedValue({ - rootsBySessionId: new Map([[unknown, OPENCODE_ID]]), - directoriesBySessionId: new Map([[unknown, '/repo/beta']]), - unresolvedSessionIds: new Set(), - }), + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), }), { input: unknown }, ) + expect(runOpencodeById).toHaveBeenCalledWith('/tmp/x.db', unknown) expect(res.body.matches).toEqual([ { provider: 'opencode', sessionId: unknown, cwd: '/repo/beta', sessionType: 'opencode', + title: 'child session', + lastActivityAt: 2, matchKind: 'exact', }, ]) @@ -239,10 +257,12 @@ describe('POST /api/sessions/resolve', () => { const unknown = 'aaaaaaaa-1111-4222-8333-444444444444' const res = await post( buildApp({ - locateClaudeTranscript: vi.fn().mockResolvedValue({ - sessionId: unknown, - sourceFile: `/home/u/.claude/projects/x/${unknown}.jsonl`, - cwd: '/repo/gamma', + resolveFallbacks: buildResolveFallbacks([claudeStub()], { + locateClaudeTranscript: vi.fn().mockResolvedValue({ + sessionId: unknown, + sourceFile: `/home/u/.claude/projects/x/${unknown}.jsonl`, + cwd: '/repo/gamma', + }), }), }), { input: unknown }, @@ -258,6 +278,136 @@ describe('POST /api/sessions/resolve', () => { ]) }) + it('shape-gates the opencode fallback: a short ses_ token never touches the DB path', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(null) + const res = await post( + buildApp({ + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), + }), + { input: 'ses_short123' }, + ) + expect(res.body).toMatchObject({ status: 'ready', matches: [] }) + expect(runOpencodeById).not.toHaveBeenCalled() + }) + + it('caps opencode by-id fallback work per request, with a FRESH budget on the next request', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(null) + const app2 = buildApp({ + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), + }) + const ids = ['a', 'b', 'c'].map((c) => `ses_${c.repeat(26)}`) + const input = ids.join(' ') + const first = await post(app2, { input }) + expect(first.body.matches).toEqual([]) + expect(runOpencodeById).toHaveBeenCalledTimes(FALLBACK_BUDGET_PER_REQUEST) + // The budget is per-request, not per-server: a second request gets its own. + await post(app2, { input }) + expect(runOpencodeById).toHaveBeenCalledTimes(FALLBACK_BUDGET_PER_REQUEST * 2) + }) + + it('a failing opencode by-id lookup (locked DB) never fails the request — it degrades with a provider error', async () => { + const runOpencodeById = vi.fn().mockRejectedValue(new Error('database is locked')) + const res = await post( + buildApp({ + resolveFallbacks: buildResolveFallbacks([opencodeStub()], { runOpencodeById }), + }), + { input: 'ses_child000000000000000000000' }, + ) + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ status: 'degraded', matches: [] }) + expect(res.body.providerErrors).toEqual([ + { provider: 'opencode', message: 'database is locked' }, + ]) + }) + + it('reports degraded (NOT "not found") when a provider SCAN failed, even with no fallback error', async () => { + const res = await post( + buildApp({ projects: [], getScanFailures: () => ['codex'] }), + { input: CODEX_ID }, + ) + expect(res.status).toBe(200) + expect(res.body.status).toBe('degraded') + expect(res.body.providerErrors).toEqual([ + { provider: 'codex', message: 'session scan failed' }, + ]) + expect(res.body.matches).toEqual([]) + }) + + it('reports degraded EVEN WITH matches when a higher-priority exact-id fallback failed (client must not auto-resume)', async () => { + // Unindexed full claude UUID (fallback throws) + a second token that + // prefix-matches an indexed amplifier session: the match survives, but + // the response must be degraded so the client refuses to auto-resume. + const MISSING_V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b9' + const res = await post( + buildApp({ + resolveFallbacks: buildResolveFallbacks([claudeStub()], { + locateClaudeTranscript: vi.fn().mockRejectedValue(new Error('EACCES: permission denied')), + }), + }), + { input: `${MISSING_V4} 417e8345` }, + ) + expect(res.status).toBe(200) + expect(res.body.status).toBe('degraded') + expect(res.body.providerErrors.map((e: { provider: string }) => e.provider)).toEqual(['claude']) + expect(res.body.matches.length).toBeGreaterThan(0) + expect(res.body.matches[0].provider).toBe('amplifier') + }) + + it('a degraded response fire-and-forgets indexer.requestRefresh() so Retry can converge', async () => { + const requestRefresh = vi.fn() + await post( + buildApp({ projects: [], getScanFailures: () => ['codex'], requestRefresh }), + { input: CODEX_ID }, + ) + expect(requestRefresh).toHaveBeenCalled() + }) + + it('a scan failure for a DISABLED provider is excluded from providerErrors (unsearched, not degraded)', async () => { + const res = await post( + buildApp({ + settings: { codingCli: { enabledProviders: ['claude'] } }, + getScanFailures: () => ['codex'], + }), + { input: CLAUDE_ID }, + ) + expect(res.body.providerErrors).toEqual([]) + expect(res.body.unsearchedProviders).toEqual(['codex', 'opencode', 'amplifier']) + expect(res.body.status).toBe('ready') + }) + + it('reports DISABLED providers as unsearched, never silently as absence', async () => { + const res = await post( + buildApp({ settings: { codingCli: { enabledProviders: ['claude', 'opencode'] } } }), + { input: CLAUDE_ID }, + ) + expect(res.body.unsearchedProviders).toEqual(['codex', 'amplifier']) + }) + + it('returns the server home directory so the client can prefill a concrete cwd', async () => { + const res = await post(buildApp({ homeDir: '/home/testuser' }), { input: CLAUDE_ID }) + expect(res.body.homeDir).toBe('/home/testuser') + }) + + it("index readiness is startupState OR'd with the indexer signal: indexer-ready wins over a stuck-false startup task", async () => { + // startupState readiness can stick false forever (its markReady only runs + // in the start chain's success path); once the indexer has completed a + // refresh the endpoint must stop reporting warming. + const res = await post( + buildApp({ ready: false, indexerIsReady: () => true }), + { input: CLAUDE_ID }, + ) + expect(res.body.status).toBe('ready') + expect(res.body.matches).toHaveLength(1) + }) + + it('reports warming from the indexer signal alone when no startup readiness is wired', async () => { + const res = await post( + buildApp({ omitStartupReadiness: true, indexerIsReady: () => false }), + { input: CLAUDE_ID }, + ) + expect(res.body).toMatchObject({ status: 'warming', matches: [] }) + }) + it('returns ready + empty matches for garbage input with no id-like token', async () => { const res = await post(app, { input: 'hello decade facade!!' }) expect(res.body).toMatchObject({ status: 'ready', matches: [], hint: null }) diff --git a/test/unit/client/components/ResumeSessionDialog.test.tsx b/test/unit/client/components/ResumeSessionDialog.test.tsx index 9ebbcd7b0..0625e4edb 100644 --- a/test/unit/client/components/ResumeSessionDialog.test.tsx +++ b/test/unit/client/components/ResumeSessionDialog.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' @@ -31,6 +31,11 @@ const match = (overrides: Record = {}) => ({ const ok = (matches: unknown[], hint: unknown = null) => Promise.resolve({ status: 'ready', matches, hint }) +const degraded = ( + matches: unknown[], + providerErrors: unknown[] = [{ provider: 'opencode', message: 'database is locked' }], +) => Promise.resolve({ status: 'degraded', matches, hint: null, providerErrors }) + function renderDialog() { const store = configureStore({ reducer: { connection: () => ({ serverInstanceId: 'srv-1' }) }, @@ -182,4 +187,210 @@ describe('ResumeSessionDialog', () => { fireEvent.keyDown(screen.getByTestId('resume-dialog'), { key: 'Escape' }) expect(onClose).toHaveBeenCalled() }) + + it('ignores STALE responses: a late first response cannot override or auto-resume', async () => { + // Overlapping resolves (edit-then-Enter) can deliver out of order; a stale + // single-match response must NEVER auto-resume — it could open the WRONG session. + let resolveFirst!: (value: unknown) => void + apiPost + .mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve })) + .mockReturnValueOnce(ok([])) + renderDialog() + typeAndResolve('ed2afda6') + typeAndResolve(V4) + await screen.findByTestId('resume-error') + expect(screen.getByTestId('resume-error').textContent).toMatch(/no matching session/i) + // The stale FIRST response now arrives with a single match: ignore it. + await act(async () => { + resolveFirst({ status: 'ready', matches: [match()], hint: null }) + await Promise.resolve() + await Promise.resolve() + }) + expect(resumeSessionInTab).not.toHaveBeenCalled() + expect(screen.getByTestId('resume-error').textContent).toMatch(/no matching session/i) + }) + + it('EDITING the input invalidates the previous result: stale resume-anyway cannot act', async () => { + // Without this, resolve(A) -> "not found" -> replace text with B -> + // "Resume anyway" would still be actionable against STALE id A. + apiPost.mockReturnValue(ok([])) + renderDialog() + typeAndResolve(V4) + await screen.findByTestId('resume-anyway-button') + fireEvent.change(screen.getByTestId('resume-input'), { target: { value: SES } }) + expect(screen.queryByTestId('resume-anyway-button')).toBeNull() + expect(screen.queryByTestId('resume-error')).toBeNull() + }) + + it('single match WITHOUT a cwd does NOT auto-resume: asks for a working directory instead', async () => { + // Exact-id fallback hits can lack a recorded cwd; the spec requires a + // concrete working directory before opening — never auto-open without one. + apiPost.mockReturnValue(ok([match({ cwd: undefined })])) + renderDialog() + typeAndResolve(V7) + const row = await screen.findByTestId('resume-match') + expect(resumeSessionInTab).not.toHaveBeenCalled() + fireEvent.change(screen.getByTestId('resume-anyway-cwd'), { target: { value: '/repo/beta' } }) + fireEvent.click(row) + expect(resumeSessionInTab).toHaveBeenCalledTimes(1) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ + provider: 'codex', + sessionId: V7, + cwd: '/repo/beta', + }) + }) + + it('cwd-less match with a BLANK working-directory field: confirm is blocked with an inline error', async () => { + apiPost.mockReturnValue(ok([match({ cwd: undefined })])) + renderDialog() + typeAndResolve(V7) + const row = await screen.findByTestId('resume-match') + fireEvent.change(screen.getByTestId('resume-anyway-cwd'), { target: { value: ' ' } }) + fireEvent.click(row) + expect(resumeSessionInTab).not.toHaveBeenCalled() + expect(screen.getByTestId('resume-error').textContent).toMatch(/working directory/i) + }) + + it('"Resume anyway" is DISABLED while the working-directory field is blank', async () => { + apiPost.mockReturnValue(ok([])) + renderDialog() + typeAndResolve(V4) + const anyway = await screen.findByTestId('resume-anyway-button') + fireEvent.change(screen.getByTestId('resume-anyway-cwd'), { target: { value: ' ' } }) + expect(anyway).toBeDisabled() + fireEvent.click(anyway) + expect(resumeSessionInTab).not.toHaveBeenCalled() + }) + + it('a NON-ready response never auto-resumes, even with a single cwd match (degraded seam)', async () => { + // Pins the ordering the future 'degraded' status depends on: any non-ready + // response must be handled as a retry state BEFORE match handling. + apiPost.mockReturnValue(Promise.resolve({ status: 'warming', matches: [match()], hint: null })) + renderDialog() + typeAndResolve(V7) + await screen.findByTestId('resume-warming') + expect(resumeSessionInTab).not.toHaveBeenCalled() + }) + + it('degraded (provider unavailable): explicit "could not be searched" state with details, NOT "no matching session"', async () => { + apiPost.mockReturnValue(degraded([], [ + { provider: 'opencode', code: 'EACCES', message: 'database is locked' }, + ])) + renderDialog() + typeAndResolve(SES) + const notice = await screen.findByTestId('resume-degraded') + expect(notice.textContent).toMatch(/could not be searched/i) + expect(notice.textContent).toContain('opencode') + expect(notice.textContent).toContain('EACCES') + expect(screen.queryByTestId('resume-error')).toBeNull() + expect(resumeSessionInTab).not.toHaveBeenCalled() + }) + + it('degraded MANUAL retry re-resolves and can succeed', async () => { + apiPost + .mockReturnValueOnce(degraded([])) + .mockReturnValueOnce(ok([match()])) + renderDialog() + typeAndResolve(V7) + await screen.findByTestId('resume-degraded') + fireEvent.click(screen.getByTestId('resume-degraded-retry')) + await waitFor(() => expect(resumeSessionInTab).toHaveBeenCalled()) + }) + + it('degraded does NOT auto-retry: no warming-style interval polling', async () => { + apiPost.mockReturnValue(degraded([])) + renderDialog() + typeAndResolve(V7) + await screen.findByTestId('resume-degraded') + await vi.advanceTimersByTimeAsync(10_000) + expect(apiPost).toHaveBeenCalledTimes(1) + }) + + it('degraded single match WITH cwd: NEVER auto-resumes — listed for manual confirmation instead', async () => { + // A failed provider means a higher-priority exact match may have been + // missed: auto-opening the surviving match could open the WRONG session. + apiPost.mockReturnValue(degraded([match()], [{ provider: 'claude', message: 'EACCES' }])) + renderDialog() + typeAndResolve(V7) + await screen.findByTestId('resume-degraded') + expect(resumeSessionInTab).not.toHaveBeenCalled() + // The surviving match is still offered for MANUAL confirmation. + const row = await screen.findByTestId('resume-match') + fireEvent.click(row) + expect(resumeSessionInTab).toHaveBeenCalledTimes(1) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ provider: 'codex', sessionId: V7 }) + }) + + it('prefills the working directory from the server homeDir instead of the "~" sentinel', async () => { + apiPost.mockReturnValue(Promise.resolve({ + status: 'ready', matches: [], hint: null, homeDir: '/home/serveruser', + })) + renderDialog() + typeAndResolve(V4) + await screen.findByTestId('resume-anyway-button') + expect((screen.getByTestId('resume-anyway-cwd') as HTMLInputElement).value).toBe('/home/serveruser') + fireEvent.click(screen.getByTestId('resume-anyway-button')) + expect(resumeSessionInTab.mock.calls[0][2]).toMatchObject({ cwd: '/home/serveruser' }) + }) + + it('homeDir prefill never overwrites a user-edited working directory', async () => { + apiPost.mockReturnValue(Promise.resolve({ + status: 'ready', matches: [], hint: null, homeDir: '/home/serveruser', + })) + renderDialog() + typeAndResolve(V4) + await screen.findByTestId('resume-anyway-cwd') + fireEvent.change(screen.getByTestId('resume-anyway-cwd'), { target: { value: '/repo/mine' } }) + typeAndResolve(V4) + await screen.findByTestId('resume-anyway-cwd') + expect((screen.getByTestId('resume-anyway-cwd') as HTMLInputElement).value).toBe('/repo/mine') + }) + + it('names DISABLED (unsearched) providers in the no-match message', async () => { + apiPost.mockReturnValue(Promise.resolve({ + status: 'ready', matches: [], hint: null, unsearchedProviders: ['codex', 'amplifier'], + })) + renderDialog() + typeAndResolve(V4) + const error = await screen.findByTestId('resume-error') + expect(error.textContent).toMatch(/not searched \(disabled\): codex, amplifier/i) + // Resume-anyway stays available. + expect(screen.getByTestId('resume-anyway-button')).toBeInTheDocument() + }) + + it('traps Tab focus inside the dialog: wraps last→first and first→last (Shift+Tab)', () => { + renderDialog() + const dialog = screen.getByTestId('resume-dialog') + const input = screen.getByTestId('resume-input') + const resolveBtn = screen.getByTestId('resume-resolve-button') + resolveBtn.focus() + fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(document.activeElement).toBe(input) + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(resolveBtn) + }) + + it('locks background scroll while open; restores scroll and focus on close', () => { + const outside = document.createElement('button') + document.body.appendChild(outside) + outside.focus() + const store = configureStore({ + reducer: { connection: () => ({ serverInstanceId: 'srv-1' }) }, + }) + const onClose = vi.fn() + const { rerender } = render( + + + , + ) + expect(document.body.style.overflow).toBe('hidden') + rerender( + + + , + ) + expect(document.body.style.overflow).toBe('') + expect(document.activeElement).toBe(outside) + outside.remove() + }) }) diff --git a/test/unit/server/coding-cli/opencode-by-id-query.test.ts b/test/unit/server/coding-cli/opencode-by-id-query.test.ts new file mode 100644 index 000000000..2d3700bcd --- /dev/null +++ b/test/unit/server/coding-cli/opencode-by-id-query.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fsp from 'fs/promises' +import os from 'os' +import path from 'path' +import { runOpencodeSessionByIdQuery } from '../../../../server/coding-cli/providers/opencode-by-id-query' + +const SES_ROOT = 'ses_root0000000000000000000000' +const SES_CHILD = 'ses_child000000000000000000000' + +let dir: string +let dbPath: string + +beforeEach(async () => { + // Throwaway tmp DB — never the user's real opencode data dir (session safety rule). + dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'opencode-byid-')) + dbPath = path.join(dir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER, + time_archived INTEGER + ); + INSERT INTO project (id, worktree) VALUES ('p1', '/home/u/oc-proj'); + INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated, time_archived) + VALUES ('${SES_ROOT}', 'p1', NULL, '/home/u/oc-proj', 'root session', 100, 200, NULL); + INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated, time_archived) + VALUES ('${SES_CHILD}', 'p1', '${SES_ROOT}', '/home/u/oc-proj', 'child session', 110, 210, 999); + `) + db.close() +}) + +afterEach(async () => { + await fsp.rm(dir, { recursive: true, force: true }) +}) + +describe('runOpencodeSessionByIdQuery', () => { + it('finds a root session by exact id with metadata', async () => { + const row = await runOpencodeSessionByIdQuery(dbPath, SES_ROOT) + expect(row).toMatchObject({ + sessionId: SES_ROOT, + cwd: '/home/u/oc-proj', + title: 'root session', + lastActivityAt: 200, + projectPath: '/home/u/oc-proj', + }) + }) + + it('finds CHILD and ARCHIVED sessions too (unlike the listing query)', async () => { + const row = await runOpencodeSessionByIdQuery(dbPath, SES_CHILD) + expect(row?.sessionId).toBe(SES_CHILD) + }) + + it('returns null for an unknown id', async () => { + expect(await runOpencodeSessionByIdQuery(dbPath, 'ses_missing0000000000000000000')).toBeNull() + }) + + it('works when the project table is absent (degraded schema)', async () => { + const bare = path.join(dir, 'bare.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(bare) + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session VALUES ('${SES_ROOT}', '/d', 't', 1, 2); + `) + db.close() + const row = await runOpencodeSessionByIdQuery(bare, SES_ROOT) + expect(row?.sessionId).toBe(SES_ROOT) + expect(row?.projectPath ?? null).toBeNull() + }) +}) diff --git a/test/unit/server/coding-cli/opencode-by-id-runner.test.ts b/test/unit/server/coding-cli/opencode-by-id-runner.test.ts new file mode 100644 index 000000000..1a9782fd9 --- /dev/null +++ b/test/unit/server/coding-cli/opencode-by-id-runner.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment node +import { EventEmitter } from 'events' +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import fsp from 'fs/promises' +import os from 'os' +import path from 'path' +import { + createWorkerByIdRunner, + runOpencodeSessionByIdOffThread, +} from '../../../../server/coding-cli/providers/opencode-by-id-runner' + +class FakeWorker extends EventEmitter { + terminated = 0 + postedData: unknown + execArgv: string[] + constructor(public url: URL, public options: { workerData: unknown; execArgv: string[] }) { + super() + this.postedData = options.workerData + this.execArgv = options.execArgv + } + terminate() { this.terminated += 1; return Promise.resolve(0) } + // helpers + emitMessage(msg: unknown) { this.emit('message', msg) } + emitError(err: Error) { this.emit('error', err) } + emitExit(code: number) { this.emit('exit', code) } +} + +function makeRunner(overrides: Partial[0]> = {}) { + const workers: FakeWorker[] = [] + const spawn = vi.fn((url: URL, options: { workerData: unknown; execArgv: string[] }) => { + const w = new FakeWorker(url, options) + workers.push(w) + return w + }) + const runner = createWorkerByIdRunner({ spawn: spawn as any, timeoutMs: 50, ...overrides }) + return { runner, workers, spawn } +} + +const SES_ROOT = 'ses_root0000000000000000000000' +const input = { dbPath: '/tmp/opencode.db', sessionId: SES_ROOT } +const row = { sessionId: SES_ROOT, cwd: '/home/u/oc-proj', title: 'root session', createdAt: 100, lastActivityAt: 200, projectPath: '/home/u/oc-proj' } + +describe('createWorkerByIdRunner', () => { + it('resolves a row from an ok message and terminates the worker', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, row }) + await expect(promise).resolves.toEqual(row) + expect(workers[0].terminated).toBe(1) + }) + + it('resolves null from an ok-null message (id not in the DB)', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, row: null }) + await expect(promise).resolves.toBeNull() + expect(workers[0].terminated).toBe(1) + }) + + it('passes dbPath, sessionId, queryModuleUrl and the sentinel in workerData, and suppresses the experimental warning via execArgv', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + const data = workers[0].postedData as any + expect(data.dbPath).toBe(input.dbPath) + expect(data.sessionId).toBe(SES_ROOT) + expect(String(data.queryModuleUrl)).toContain('opencode-by-id-query') + expect(data.kind).toBe('opencode-by-id-worker') // sentinel that gates the worker auto-run + // Appended to process.execArgv so the tsx loader (dev) survives AND the + // per-spawn node:sqlite ExperimentalWarning is silenced. + expect(workers[0].execArgv).toEqual([...process.execArgv, '--disable-warning=ExperimentalWarning']) + workers[0].emitMessage({ ok: true, row: null }) + await promise + }) + + it('ignores a late exit event after a successful message (no double-settle)', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: true, row }) + workers[0].emitExit(0) + await expect(promise).resolves.toEqual(row) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects on an error message and terminates', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage({ ok: false, error: { name: 'SqliteError', message: 'database is locked' } }) + await expect(promise).rejects.toThrow(/database is locked/) + expect(workers[0].terminated).toBe(1) + }) + + it.each([ + ['truncated ok without row key', { ok: true }], + ['ok:true with a non-object row', { ok: true, row: 'nope' }], + ['ok:false without error', { ok: false }], + ['no ok key', { row: null }], + ])('rejects a malformed message (%s) instead of resolving garbage', async (_label, msg) => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitMessage(msg) + await expect(promise).rejects.toThrow(/malformed|failed/i) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects on a worker error event and terminates', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitError(new Error('worker crashed')) + await expect(promise).rejects.toThrow(/worker crashed/) + expect(workers[0].terminated).toBe(1) + }) + + it('rejects when the worker exits before sending a message', async () => { + const { runner, workers } = makeRunner() + const promise = runner(input) + await Promise.resolve() + workers[0].emitExit(1) + await expect(promise).rejects.toThrow(/exit/i) + }) + + it('rejects and terminates on timeout', async () => { + vi.useFakeTimers() + try { + const { runner, workers } = makeRunner({ timeoutMs: 25 }) + const promise = runner(input) + await Promise.resolve() + const expectation = expect(promise).rejects.toThrow(/timed out/i) + await vi.advanceTimersByTimeAsync(30) + await expectation + expect(workers[0].terminated).toBe(1) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('runOpencodeSessionByIdOffThread (real worker integration)', () => { + // Throwaway tmp DB — never the user's real opencode data dir (session safety rule). + let dir: string + let dbPath: string + + beforeEach(async () => { + dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'opencode-byid-runner-')) + dbPath = path.join(dir, 'opencode.db') + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(dbPath) + db.exec(` + CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT, + parent_id TEXT, + directory TEXT, + title TEXT, + time_created INTEGER, + time_updated INTEGER, + time_archived INTEGER + ); + INSERT INTO project (id, worktree) VALUES ('p1', '/home/u/oc-proj'); + INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated, time_archived) + VALUES ('${SES_ROOT}', 'p1', NULL, '/home/u/oc-proj', 'root session', 100, 200, NULL); + `) + db.close() + }) + + afterEach(async () => { + await fsp.rm(dir, { recursive: true, force: true }) + }) + + it('resolves the root session through a REAL worker (off-thread wiring end to end)', async () => { + const found = await runOpencodeSessionByIdOffThread(dbPath, SES_ROOT) + expect(found?.sessionId).toBe(SES_ROOT) + expect(found?.projectPath).toBe('/home/u/oc-proj') + }) +}) diff --git a/test/unit/server/coding-cli/provider-root-failures.test.ts b/test/unit/server/coding-cli/provider-root-failures.test.ts new file mode 100644 index 000000000..387d5a585 --- /dev/null +++ b/test/unit/server/coding-cli/provider-root-failures.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +// Ported from the reference provider-root-failures suite (feat/resume-button). +// Root-level scan failures must REJECT (provider unavailable ≠ not found) while +// root ABSENCE stays a legitimate empty result. Real fs, throwaway tmp dirs +// only — never a real HOME (session safety rule). +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fsp from 'fs/promises' +import os from 'os' +import path from 'path' +import { claudeProvider } from '../../../../server/coding-cli/providers/claude' +import { codexProvider } from '../../../../server/coding-cli/providers/codex' +import { amplifierProvider } from '../../../../server/coding-cli/providers/amplifier' + +const CASES = [ + { name: 'claude', provider: claudeProvider, rootSubdir: 'projects' }, + { name: 'codex', provider: codexProvider, rootSubdir: 'sessions' }, + { name: 'amplifier', provider: amplifierProvider, rootSubdir: 'projects' }, +] as const + +let homeDir: string + +beforeEach(async () => { + homeDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'provider-root-')) +}) + +afterEach(async () => { + // Restore modes so cleanup can traverse the tree. + for (const c of CASES) { + await fsp.chmod(path.join(homeDir, c.rootSubdir), 0o700).catch(() => {}) + } + await fsp.rm(homeDir, { recursive: true, force: true }) +}) + +describe.each(CASES)('$name provider root failures', ({ provider, rootSubdir }) => { + it('missing root resolves to an empty list (absence is not a failure)', async () => { + const p = { ...provider, homeDir } + await expect(p.listSessionFiles()).resolves.toEqual([]) + }) + + it.skipIf(process.getuid?.() === 0)( + 'unreadable root (EACCES) REJECTS instead of reading as an empty scan', + async () => { + const root = path.join(homeDir, rootSubdir) + await fsp.mkdir(root, { recursive: true }) + await fsp.chmod(root, 0o000) + const p = { ...provider, homeDir } + await expect(p.listSessionFiles()).rejects.toThrow() + }, + ) +}) diff --git a/test/unit/server/coding-cli/resolve-fallbacks.test.ts b/test/unit/server/coding-cli/resolve-fallbacks.test.ts new file mode 100644 index 000000000..a24dbfa2b --- /dev/null +++ b/test/unit/server/coding-cli/resolve-fallbacks.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import fsp from 'fs/promises' +import os from 'os' +import path from 'path' +import { + buildResolveFallbacks, + withRequestBudget, + FALLBACK_BUDGET_PER_REQUEST, +} from '../../../../server/coding-cli/resolve-fallbacks' +import { locateClaudeTranscript } from '../../../../server/coding-cli/claude-transcript-locator' +import type { CodingCliProvider } from '../../../../server/coding-cli/provider' + +const CLAUDE_V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' +const OPENCODE_ID = 'ses_root0000000000000000000000' + +describe('withRequestBudget', () => { + it('shape gate runs BEFORE the budget: wrong-shape tokens do no work and consume no budget', async () => { + const inner = vi.fn().mockResolvedValue(null) + const budgeted = withRequestBudget({ claudeTranscriptById: inner }) + // Prefix hex and ses_ tokens are not full claude UUIDs — free no-op misses. + expect(await budgeted.claudeTranscriptById!('417e8345')).toBeNull() + expect(await budgeted.claudeTranscriptById!(OPENCODE_ID)).toBeNull() + expect(inner).not.toHaveBeenCalled() + // Budget still fully available for later valid-shape tokens. + const ids = [ + 'ed2afda6-a340-443e-ba60-024a1b3554b1', + 'ed2afda6-a340-443e-ba60-024a1b3554b2', + 'ed2afda6-a340-443e-ba60-024a1b3554b3', + ] + for (const id of ids) await budgeted.claudeTranscriptById!(id) + expect(inner.mock.calls.map((c) => c[0])).toEqual(ids.slice(0, FALLBACK_BUDGET_PER_REQUEST)) + }) + + it('the (budget+1)-th valid-shape token returns null WITHOUT calling the inner fallback', async () => { + const inner = vi.fn().mockResolvedValue(null) + const budgeted = withRequestBudget({ opencodeSessionById: inner }, 1) + expect(await budgeted.opencodeSessionById!(OPENCODE_ID)).toBeNull() + expect(inner).toHaveBeenCalledTimes(1) + expect(await budgeted.opencodeSessionById!('ses_next0000000000000000000000')).toBeNull() + expect(inner).toHaveBeenCalledTimes(1) + }) + + it('leaves absent fallbacks undefined', () => { + const budgeted = withRequestBudget({}) + expect(budgeted.claudeTranscriptById).toBeUndefined() + expect(budgeted.opencodeSessionById).toBeUndefined() + }) +}) + +describe('buildResolveFallbacks — claude wiring', () => { + // Throwaway fixture root — never the real HOME (session safety rule). + let root: string + + beforeEach(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), 'resolve-fallbacks-')) + }) + + afterEach(async () => { + await fsp.rm(root, { recursive: true, force: true }) + }) + + function claudeStub(): CodingCliProvider { + return { name: 'claude' } as unknown as CodingCliProvider + } + + // The locator itself stays injected (the merged locator takes a projects + // dir); wire it to the REAL locator over the fixture root, as index.ts does. + const locateInRoot = (id: string) => locateClaudeTranscript(id, root) + + async function writeTranscript(projectDir: string, id: string, lines: string[]) { + const dir = path.join(root, projectDir) + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(path.join(dir, `${id}.jsonl`), lines.join('\n') + '\n') + } + + it('locates a real transcript and returns the full match tuple (no metadata → sessionType claude)', async () => { + await writeTranscript('-home-u-proj', CLAUDE_V4, [ + JSON.stringify({ type: 'user', cwd: '/home/u/proj', message: { content: 'hello' } }), + ]) + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + locateClaudeTranscript: locateInRoot, + }) + const match = await claudeTranscriptById!(CLAUDE_V4) + expect(match).toEqual({ + provider: 'claude', + sessionId: CLAUDE_V4, + cwd: '/home/u/proj', + sessionType: 'claude', + matchKind: 'exact', + }) + }) + + it('uses the metadata-store sessionType (freshclaude) so resume reopens the right runtime', async () => { + await writeTranscript('-home-u-proj', CLAUDE_V4, [JSON.stringify({ cwd: '/home/u/proj' })]) + const sessionMetadataStore = { + getAll: async () => ({ [`claude:${CLAUDE_V4}`]: { sessionType: 'freshclaude' } }), + } + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + sessionMetadataStore: sessionMetadataStore as any, + locateClaudeTranscript: locateInRoot, + }) + const match = await claudeTranscriptById!(CLAUDE_V4) + expect(match?.sessionType).toBe('freshclaude') + }) + + it('a REJECTING metadata store degrades to the provider default (never a provider error)', async () => { + await writeTranscript('-home-u-proj', CLAUDE_V4, [JSON.stringify({ cwd: '/home/u/proj' })]) + const sessionMetadataStore = { + getAll: async () => { throw new Error('metadata store corrupt') }, + } + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + sessionMetadataStore: sessionMetadataStore as any, + locateClaudeTranscript: locateInRoot, + }) + const match = await claudeTranscriptById!(CLAUDE_V4) + expect(match?.sessionType).toBe('claude') + }) + + it('resolves null on a genuine miss', async () => { + const { claudeTranscriptById } = buildResolveFallbacks([claudeStub()], { + locateClaudeTranscript: locateInRoot, + }) + expect(await claudeTranscriptById!(CLAUDE_V4)).toBeNull() + }) +}) + +describe('buildResolveFallbacks — opencode wiring', () => { + const row = { + sessionId: OPENCODE_ID, + cwd: '/home/u/oc', + title: 'oc root', + createdAt: 1, + lastActivityAt: 7, + projectPath: '/home/u/oc-proj', + } + + function opencodeStub(): CodingCliProvider { + return { name: 'opencode', getDatabasePath: () => '/tmp/x.db' } as unknown as CodingCliProvider + } + + it('returns the full match tuple from an injected runOpencodeById (metadata-driven sessionType)', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(row) + const sessionMetadataStore = { + getAll: async () => ({ [`opencode:${OPENCODE_ID}`]: { sessionType: 'freshopencode' } }), + } + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { + sessionMetadataStore: sessionMetadataStore as any, + runOpencodeById, + }) + const match = await opencodeSessionById!(OPENCODE_ID) + expect(runOpencodeById).toHaveBeenCalledWith('/tmp/x.db', OPENCODE_ID) + expect(match).toEqual({ + provider: 'opencode', + sessionId: OPENCODE_ID, + cwd: '/home/u/oc', + sessionType: 'freshopencode', + title: 'oc root', + lastActivityAt: 7, + matchKind: 'exact', + }) + }) + + it('floors a REAL lastActivityAt so the zod contract (int) stays satisfiable', async () => { + const runOpencodeById = vi.fn().mockResolvedValue({ ...row, lastActivityAt: 7.9 }) + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { runOpencodeById }) + const match = await opencodeSessionById!(OPENCODE_ID) + expect(match?.lastActivityAt).toBe(7) + }) + + it('REJECTS when runOpencodeById rejects (error propagation, not null)', async () => { + const runOpencodeById = vi.fn().mockRejectedValue(new Error('database is locked')) + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { runOpencodeById }) + await expect(opencodeSessionById!(OPENCODE_ID)).rejects.toThrow(/locked/) + }) + + it('resolves null when the DB has no such session', async () => { + const runOpencodeById = vi.fn().mockResolvedValue(null) + const { opencodeSessionById } = buildResolveFallbacks([opencodeStub()], { runOpencodeById }) + expect(await opencodeSessionById!(OPENCODE_ID)).toBeNull() + }) +}) + +describe('buildResolveFallbacks — provider absence', () => { + it('returns undefined fallbacks when no claude/opencode provider is in the set', () => { + const { claudeTranscriptById, opencodeSessionById } = buildResolveFallbacks([], { + locateClaudeTranscript: async () => null, + }) + expect(claudeTranscriptById).toBeUndefined() + expect(opencodeSessionById).toBeUndefined() + }) +}) diff --git a/test/unit/server/coding-cli/resolve-session.test.ts b/test/unit/server/coding-cli/resolve-session.test.ts new file mode 100644 index 000000000..3efa7c628 --- /dev/null +++ b/test/unit/server/coding-cli/resolve-session.test.ts @@ -0,0 +1,316 @@ +// @vitest-environment node +// Ported from the reference session-resolver suite (feat/resume-button), +// adapted to the merged resolveResumeInput API and resume-resolve contract. +// Pins the ordering rule: per candidate token — exact index hit, then exact +// fallback lookups, then and only then prefix matches. A prefix match must +// NEVER outrank any exact resolution of the same or higher-priority token. +import { describe, it, expect, vi } from 'vitest' +import { resolveResumeInput, RESOLVE_MATCH_CAP, type ResolveResumeDeps } from '../../../../server/coding-cli/resolve-session' +import type { ResolveFallbacks } from '../../../../server/coding-cli/resolve-fallbacks' +import { ClaudeTranscriptLocatorError } from '../../../../server/coding-cli/claude-transcript-locator' +import type { ResumeResolveMatch } from '../../../../shared/resume-resolve-contract' +import type { ProjectGroup, CodingCliSession } from '../../../../server/coding-cli/types' + +function session( + overrides: Partial & Pick, +): CodingCliSession { + return { + projectPath: '/home/u/proj', + lastActivityAt: 1000, + cwd: '/home/u/proj', + title: 'a session', + ...overrides, + } +} + +function projects(sessions: CodingCliSession[]): ProjectGroup[] { + return [{ projectPath: '/home/u/proj', sessions }] +} + +function deps(groups: ProjectGroup[], fallbacks?: ResolveFallbacks): ResolveResumeDeps { + return { + getProjects: () => groups, + isIndexReady: () => true, + fallbacks, + } +} + +const AMPLIFIER_FULL = '417e8345-90ab-4cde-8f01-234567890abc' +const CODEX_V7 = '019fac27-69d7-78a0-b972-b339d551042e' +const CLAUDE_V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' +const OPENCODE_ID = 'ses_root0000000000000000000000' + +const fourProviderSnapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, sessionType: 'claude' }), + session({ provider: 'codex', sessionId: CODEX_V7, sessionType: 'codex' }), + session({ provider: 'opencode', sessionId: OPENCODE_ID, sessionType: 'opencode' }), + session({ provider: 'amplifier', sessionId: AMPLIFIER_FULL, sessionType: 'amplifier' }), +]) + +describe('resolveResumeInput — matching core', () => { + it('exact match wins across all providers at once (claude UUID, no hint needed)', async () => { + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ + provider: 'claude', + sessionId: CLAUDE_V4, + sessionType: 'claude', + cwd: '/home/u/proj', + matchKind: 'exact', + }) + }) + + it('short hex prefix matches the amplifier session (spec row: 417e8345)', async () => { + const { matches } = await resolveResumeInput('417e8345', deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ + provider: 'amplifier', + sessionId: AMPLIFIER_FULL, + matchKind: 'prefix', + }) + }) + + it('exact-id match is case-insensitive for UUID/hex tokens', async () => { + const { matches } = await resolveResumeInput(CLAUDE_V4.toUpperCase(), deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].sessionId).toBe(CLAUDE_V4) + }) + + it('ses_ ids are case-SENSITIVE (base62): a case-variant does NOT match', async () => { + const { matches } = await resolveResumeInput( + 'ses_ROOT0000000000000000000000', + deps(fourProviderSnapshot), + ) + expect(matches).toHaveLength(0) + }) + + it('opencode ses_ id resolves to opencode even though other providers exist', async () => { + const { matches } = await resolveResumeInput(OPENCODE_ID, deps(fourProviderSnapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].provider).toBe('opencode') + }) + + it('exact match takes precedence over prefix matches of the same token', async () => { + const snapshot = projects([ + session({ provider: 'amplifier', sessionId: '417e8345', lastActivityAt: 1 }), + session({ provider: 'amplifier', sessionId: AMPLIFIER_FULL, lastActivityAt: 2 }), + ]) + const { matches } = await resolveResumeInput('417e8345', deps(snapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].matchKind).toBe('exact') + }) + + it('ambiguous prefix returns all matches most-recent first, capped', async () => { + const many = Array.from({ length: RESOLVE_MATCH_CAP + 5 }, (_, i) => + session({ + provider: 'amplifier', + sessionId: `417e8345-90ab-4cde-8f01-${String(i).padStart(12, '0')}`, + lastActivityAt: i, + })) + const { matches } = await resolveResumeInput('417e8345', deps(projects(many))) + expect(matches).toHaveLength(RESOLVE_MATCH_CAP) + expect(matches[0].lastActivityAt).toBe(RESOLVE_MATCH_CAP + 4) + expect(matches[matches.length - 1].lastActivityAt).toBeGreaterThanOrEqual(5) + }) + + it('tries candidates in priority order until one resolves', async () => { + // ses_ token (highest parser priority) misses everywhere; the UUID resolves. + const { matches } = await resolveResumeInput( + `ses_zzzzzzzzzzzzzzzzzzzzzzzzzz ${CLAUDE_V4}`, + deps(fourProviderSnapshot), + ) + expect(matches).toHaveLength(1) + expect(matches[0].sessionId).toBe(CLAUDE_V4) + }) + + it('an EXACT id finds a subagent/child session (spec: scan ALL sessions)', async () => { + const snapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, isSubagent: true }), + ]) + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(snapshot)) + expect(matches).toHaveLength(1) + expect(matches[0].sessionId).toBe(CLAUDE_V4) + }) + + it('prefix DISCOVERY does not surface subagent sessions', async () => { + const snapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, isSubagent: true }), + ]) + const { matches } = await resolveResumeInput('ed2afda6', deps(snapshot)) + expect(matches).toHaveLength(0) + }) + + it('an exact FALLBACK hit beats an indexed PREFIX match of the same token', async () => { + // Token exactly equals an unindexed session id AND is a prefix of an + // indexed one: exact must win or the wrong session gets resumed. + const indexedPrefix = projects([ + session({ provider: 'amplifier', sessionId: `${CLAUDE_V4}9999`, lastActivityAt: 999 }), + ]) + const fallbackMatch: ResumeResolveMatch = { + provider: 'claude', + sessionId: CLAUDE_V4, + cwd: '/tmp/exact', + sessionType: 'claude', + lastActivityAt: 1, + matchKind: 'exact', + } + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(indexedPrefix, { + claudeTranscriptById: async (id) => (id === CLAUDE_V4 ? fallbackMatch : null), + })) + expect(matches).toEqual([fallbackMatch]) + }) + + it('sessionType defaults to the provider name when the index has none', async () => { + const snapshot = projects([session({ provider: 'codex', sessionId: CODEX_V7 })]) + const { matches } = await resolveResumeInput(CODEX_V7, deps(snapshot)) + expect(matches[0].sessionType).toBe('codex') + }) + + it('index miss consults exact-id fallbacks (claude transcript locator)', async () => { + const fallbackMatch: ResumeResolveMatch = { + provider: 'claude', + sessionId: CLAUDE_V4, + cwd: '/tmp/found', + sessionType: 'claude', + lastActivityAt: 42, + matchKind: 'exact', + } + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(projects([]), { + claudeTranscriptById: async (id) => (id === CLAUDE_V4 ? fallbackMatch : null), + })) + expect(matches).toEqual([fallbackMatch]) + }) + + it('index miss consults opencode by-id fallback', async () => { + const fallbackMatch: ResumeResolveMatch = { + provider: 'opencode', + sessionId: OPENCODE_ID, + cwd: '/tmp/oc', + sessionType: 'opencode', + lastActivityAt: 7, + matchKind: 'exact', + } + const { matches } = await resolveResumeInput(OPENCODE_ID, deps(projects([]), { + opencodeSessionById: async () => fallbackMatch, + })) + expect(matches).toEqual([fallbackMatch]) + }) + + it('zero matches when nothing resolves anywhere', async () => { + const { matches } = await resolveResumeInput('deadbeef1234', deps(fourProviderSnapshot, { + claudeTranscriptById: async () => null, + opencodeSessionById: async () => null, + })) + expect(matches).toEqual([]) + }) + + it('a THROWING fallback never fails the request: it degrades with a provider error summary', async () => { + // Provider unavailable (locked/corrupt DB) is NOT "not found": the + // response must carry the failing provider so the route/client can say + // "something's wrong" instead of "no matching session". + const response = await resolveResumeInput(OPENCODE_ID, deps(projects([]), { + opencodeSessionById: async () => { + throw new Error('database is locked') + }, + })) + expect(response).toMatchObject({ status: 'degraded', matches: [] }) + expect(response.providerErrors).toEqual([ + { provider: 'opencode', message: 'database is locked' }, + ]) + }) + + it('provider identity in providerErrors comes from the fallback PAIR, not its position', async () => { + // Only the opencode fallback is supplied. If identity were positional + // (index 0 = claude), the error would be misattributed to claude. + const response = await resolveResumeInput(OPENCODE_ID, deps(projects([]), { + opencodeSessionById: async () => { + throw new Error('worker crashed') + }, + })) + expect(response.providerErrors.map((e) => e.provider)).toEqual(['opencode']) + }) + + it('a typed ClaudeTranscriptLocatorError surfaces its errno code in the provider error', async () => { + const cause = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }) + const response = await resolveResumeInput(CLAUDE_V4, deps(projects([]), { + claudeTranscriptById: async () => { + throw new ClaudeTranscriptLocatorError('failed to list claude projects dir: /tmp/x', cause) + }, + })) + expect(response.status).toBe('degraded') + expect(response.providerErrors).toEqual([ + { + provider: 'claude', + code: 'EACCES', + message: 'failed to list claude projects dir: /tmp/x', + }, + ]) + }) + + it('a healthy resolve reports NO provider errors', async () => { + const response = await resolveResumeInput(CLAUDE_V4, deps(fourProviderSnapshot, { + claudeTranscriptById: async () => null, + opencodeSessionById: async () => null, + })) + expect(response.status).toBe('ready') + expect(response.providerErrors).toEqual([]) + }) + + it('a failed exact-id fallback does NOT hide a later lower-priority match — but marks the response degraded', async () => { + // First token: full claude UUID, index miss, claude fallback THROWS. + // Second token: prefix that matches an indexed amplifier session. + // Resolution must continue so the surviving match is still returned — + // AND the response must be degraded (a failed HIGHER-priority exact + // search means the surviving match may be the wrong session, so the + // client must never auto-resume it). + const MISSING_V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b9' + const snapshot = projects([session({ provider: 'amplifier', sessionId: AMPLIFIER_FULL })]) + const response = await resolveResumeInput(`${MISSING_V4} 417e8345`, deps(snapshot, { + claudeTranscriptById: async () => { + throw new Error('EACCES') + }, + })) + expect(response.matches).toHaveLength(1) + expect(response.matches[0]).toMatchObject({ provider: 'amplifier', matchKind: 'prefix' }) + expect(response.status).toBe('degraded') + expect(response.providerErrors).toEqual([{ provider: 'claude', message: 'EACCES' }]) + }) + + it('a fallback exact hit for a HIGHER-priority token beats an indexed exact hit of a LOWER-priority token', async () => { + // ses_ (priority 1) resolves only via fallback; the UUID (priority 2) is + // an indexed exact hit. The higher-priority token must win. + const fallbackMatch: ResumeResolveMatch = { + provider: 'opencode', + sessionId: OPENCODE_ID, + cwd: '/tmp/oc', + sessionType: 'opencode', + matchKind: 'exact', + } + const snapshot = projects([session({ provider: 'claude', sessionId: CLAUDE_V4 })]) + const opencodeSessionById = vi.fn(async () => fallbackMatch) + const { matches } = await resolveResumeInput( + `${OPENCODE_ID} ${CLAUDE_V4}`, + deps(snapshot, { opencodeSessionById }), + ) + expect(matches).toEqual([fallbackMatch]) + expect(opencodeSessionById).toHaveBeenCalledWith(OPENCODE_ID) + }) + + it('dedupes duplicate (provider, sessionId) snapshot entries, keeping the most recent', async () => { + const snapshot = projects([ + session({ provider: 'claude', sessionId: CLAUDE_V4, title: 'older file', lastActivityAt: 100 }), + session({ provider: 'claude', sessionId: CLAUDE_V4, title: 'newer file', lastActivityAt: 500 }), + ]) + const { matches } = await resolveResumeInput(CLAUDE_V4, deps(snapshot)) + expect(matches).toHaveLength(1) + expect(matches[0]).toMatchObject({ title: 'newer file', lastActivityAt: 500 }) + }) + + it('returns warming (not "not found") while the index is not ready', async () => { + const response = await resolveResumeInput(CLAUDE_V4, { + getProjects: () => fourProviderSnapshot, + isIndexReady: () => false, + }) + expect(response).toMatchObject({ status: 'warming', matches: [] }) + }) +}) diff --git a/test/unit/server/coding-cli/session-indexer.test.ts b/test/unit/server/coding-cli/session-indexer.test.ts index 82f910bb5..505a75f52 100644 --- a/test/unit/server/coding-cli/session-indexer.test.ts +++ b/test/unit/server/coding-cli/session-indexer.test.ts @@ -2899,3 +2899,62 @@ describe('CodingCliSessionIndexer', () => { expect(titles).toEqual(['Session A', 'Session B']) }) }) + +describe('readiness + scan-failure channel (resume resolve)', () => { + it('records a provider whose listing rejects and still reports ready after the refresh completes', async () => { + const provider = makeProvider([], { + listSessionFiles: async () => { throw new Error('EACCES: permission denied') }, + }) + const indexer = new CodingCliSessionIndexer([provider]) + expect(indexer.isReady()).toBe(false) + await indexer.refresh() + expect(indexer.isReady()).toBe(true) + expect(indexer.getScanFailures()).toEqual(['claude']) + }) + + it('clears the failure once a subsequent refresh succeeds', async () => { + const file = path.join(tempDir, 'session-a.jsonl') + await fsp.writeFile(file, JSON.stringify({ cwd: '/project/a' }) + '\n') + let fail = true + const provider = makeProvider([], { + listSessionFiles: async () => { + if (fail) throw new Error('EIO: i/o error') + return [file] + }, + }) + const indexer = new CodingCliSessionIndexer([provider]) + await indexer.refresh() + expect(indexer.getScanFailures()).toEqual(['claude']) + fail = false + await indexer.refresh() + expect(indexer.getScanFailures()).toEqual([]) + }) + + it('prunes a failed provider that is later DISABLED (unsearched, not failed — no retry trap)', async () => { + const provider = makeProvider([], { + listSessionFiles: async () => { throw new Error('EACCES: permission denied') }, + }) + const indexer = new CodingCliSessionIndexer([provider]) + await indexer.refresh() + expect(indexer.getScanFailures()).toEqual(['claude']) + vi.mocked(configStore.snapshot).mockResolvedValue({ + sessionOverrides: {}, + settings: { + codingCli: { + enabledProviders: [], + providers: {}, + }, + }, + }) + await indexer.refresh() + expect(indexer.getScanFailures()).toEqual([]) + }) + + it('requestRefresh() schedules a refresh (fire-and-forget wrapper over scheduleRefresh)', async () => { + const provider = makeProvider([]) + const indexer = new CodingCliSessionIndexer([provider]) + const spy = vi.spyOn(indexer, 'scheduleRefresh').mockImplementation(() => {}) + indexer.requestRefresh() + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/shared/resume-input-parser.test.ts b/test/unit/shared/resume-input-parser.test.ts index f1814c6ea..fbfa2f6fa 100644 --- a/test/unit/shared/resume-input-parser.test.ts +++ b/test/unit/shared/resume-input-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { parseResumeInput } from '@shared/resume-input-parser' +import { parseResumeInput, MAX_RESUME_CANDIDATES } from '@shared/resume-input-parser' const V4 = 'ed2afda6-a340-443e-ba60-024a1b3554b4' const V7 = '019fac27-69d7-78a0-b972-b339d551042e' @@ -57,6 +57,25 @@ describe('parseResumeInput — candidate extraction', () => { it('caps hex tokens at 32 chars (git shas do not match)', () => { expect(parseResumeInput('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2').candidates).toEqual([]) }) + + it('rejects arbitrary snake_case identifiers (only known xxx_ id families match)', () => { + expect(parseResumeInput('my_function123 snake_casedword9').candidates).toEqual([]) + }) + + it('accepts other known xxx_ id families (thread_)', () => { + expect(parseResumeInput('thread_abc123456').candidates).toEqual([ + { token: 'thread_abc123456', kind: 'prefixed-id' }, + ]) + }) + + it('caps candidates at MAX_RESUME_CANDIDATES (server work budget)', () => { + const tokens = Array.from( + { length: MAX_RESUME_CANDIDATES + 4 }, + (_, i) => `417e83450a${String(i).padStart(2, '0')}`, + ) + const { candidates } = parseResumeInput(tokens.join(' ')) + expect(candidates).toHaveLength(MAX_RESUME_CANDIDATES) + }) }) describe('parseResumeInput — advisory hint', () => { diff --git a/test/unit/shared/resume-resolve-contract.test.ts b/test/unit/shared/resume-resolve-contract.test.ts new file mode 100644 index 000000000..652e0d00e --- /dev/null +++ b/test/unit/shared/resume-resolve-contract.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +// Provider-health contract extension: a broken provider must surface as +// "something's wrong" (status 'degraded' + providerErrors), never as +// "session not found". The extension is ADDITIVE and backward-tolerant: +// legacy responses without the new fields still parse on the client. +import { describe, it, expect } from 'vitest' +import { ResumeResolveResponseSchema } from '../../../shared/resume-resolve-contract' + +describe('ResumeResolveResponseSchema (provider-health extension)', () => { + it('accepts a legacy response without the health fields, defaulting them (backward tolerance)', () => { + const parsed = ResumeResolveResponseSchema.parse({ + status: 'ready', + matches: [], + hint: null, + }) + expect(parsed.providerErrors).toEqual([]) + expect(parsed.unsearchedProviders).toEqual([]) + expect(parsed.homeDir).toBeUndefined() + }) + + it("accepts status 'degraded' with per-provider error summaries", () => { + const parsed = ResumeResolveResponseSchema.parse({ + status: 'degraded', + matches: [], + hint: null, + providerErrors: [ + { provider: 'claude', code: 'EACCES', message: 'failed to list claude projects dir' }, + { provider: 'opencode', message: 'database is locked' }, + ], + unsearchedProviders: ['codex'], + homeDir: '/home/testuser', + }) + expect(parsed.status).toBe('degraded') + expect(parsed.providerErrors).toHaveLength(2) + expect(parsed.providerErrors[0]).toEqual({ + provider: 'claude', + code: 'EACCES', + message: 'failed to list claude projects dir', + }) + expect(parsed.unsearchedProviders).toEqual(['codex']) + expect(parsed.homeDir).toBe('/home/testuser') + }) + + it('rejects a provider error entry without a provider name', () => { + expect(() => + ResumeResolveResponseSchema.parse({ + status: 'degraded', + matches: [], + hint: null, + providerErrors: [{ code: 'EACCES' }], + }), + ).toThrow() + }) +})