Skip to content
140 changes: 110 additions & 30 deletions server/coding-cli/claude-transcript-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <projectsDir>/<project>/<id>.jsonl.
* skipped cwd-less transcripts, subagent child sessions). Claude stores
* transcripts in TWO layouts:
* 1. direct: <root>/<project-dir>/<sessionId>.jsonl
* 2. subagent: <root>/<project-dir>/<parent-session>/subagents/<sessionId>.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<ClaudeTranscriptHit | null> {
// 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<string[]> {
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<ClaudeTranscriptHit | null> {
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<string | undefined> {
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()
Expand Down
12 changes: 12 additions & 0 deletions server/coding-cli/providers/amplifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/sessions/<id>/metadata.json).
return files.filter((file) =>
Expand Down
15 changes: 12 additions & 3 deletions server/coding-cli/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions server/coding-cli/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},

Expand Down
56 changes: 56 additions & 0 deletions server/coding-cli/providers/opencode-by-id-query.ts
Original file line number Diff line number Diff line change
@@ -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<OpencodeSessionRow | null> {
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()
}
}
Loading
Loading