Files
session-viewer/docs/prd-jsonl-first-discovery.md
teernisse 8fddd50193 Implement JSONL-first session discovery with tiered lookup
Rewrite session discovery to be filesystem-first, addressing the widespread
bug where Claude Code's sessions-index.json files are unreliable (87 MB of
unindexed sessions, 17% loss rate across all projects).

Architecture: Three-tier metadata lookup

Tier 1 - Index validation (instant):
  - Parse sessions-index.json into Map<sessionId, IndexEntry>
  - Validate entry.modified against actual file stat.mtimeMs
  - Use 1s tolerance to account for ISO string → filesystem mtime rounding
  - Trust content fields only (messageCount, summary, firstPrompt)
  - Timestamps always come from fs.stat, never from index

Tier 2 - Persistent cache hit (instant):
  - Check MetadataCache by (filePath, mtimeMs, size)
  - If match, use cached metadata
  - Survives server restarts

Tier 3 - Full JSONL parse (~5-50ms/file):
  - Call extractSessionMetadata() with shared parser helpers
  - Cache result for future lookups

Key correctness guarantees:
- All .jsonl files appear regardless of index state
- SessionEntry timestamps always from fs.stat (list ordering never stale)
- Message counts exact (shared helpers ensure parser parity)
- Duration computed from JSONL timestamps, not index

Performance:
- Bounded concurrency: 32 concurrent operations per project
- mapWithLimit() prevents file handle exhaustion
- Warm start <1s (stat all files, in-memory lookups)
- Cold start ~3-5s for 3,103 files (stat + parse phases)

TOCTOU handling:
- Files that disappear between readdir and stat: silently skipped
- Files that disappear between stat and read: silently skipped
- File actively being written: partial parse handled gracefully

Include PRD document that drove this implementation with detailed
requirements, edge cases, and verification plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:53:20 -05:00

314 lines
19 KiB
Markdown

# PRD: JSONL-First Session Discovery
## Status: Ready for Implementation
## Context
Session viewer relies exclusively on `sessions-index.json` files that Claude Code maintains. These indexes are unreliable — a known, widespread bug with multiple open GitHub issues ([#22030](https://github.com/anthropics/claude-code/issues/22030), [#21610](https://github.com/anthropics/claude-code/issues/21610), [#18619](https://github.com/anthropics/claude-code/issues/18619), [#22114](https://github.com/anthropics/claude-code/issues/22114)).
### Root cause
Claude Code updates `sessions-index.json` only at session end. If a session crashes, is killed, or is abandoned, the JSONL file is written but the index is never updated. Multiple concurrent Claude instances can also corrupt the index (last-write-wins on a single JSON file). There is no reindex command and no background repair process.
### Impact on this system
- **542 unindexed JSONL files** across all projects (87 MB total)
- **48 unindexed in last 7 days** (30.8 MB)
- **13 projects** have JSONL session files but no index at all
- **Zero sessions from today** (Feb 4, 2026) appear in any index
- **3,103 total JSONL files** vs **2,563 indexed entries** = 17% loss rate
### Key insight
The `.jsonl` files are the source of truth. The index is an unreliable convenience cache. The session viewer must treat it that way.
## Requirements
### Must have
1. **All sessions with a `.jsonl` file must appear in the session list**, regardless of whether they're in `sessions-index.json`
2. **Exact message counts** — no estimates, no approximations. Contract: Tier 3 extraction MUST reuse the same line-classification logic as `parseSessionContent` (shared helper), so list counts cannot drift from detail parsing.
3. **Performance**: Warm start (cache exists, few changes) must complete under 1 second. Cold start (no cache) is acceptable up to 5 seconds for first request
4. **Correctness over speed** — never show stale metadata if the file has been modified
5. **Zero config** — works out of the box with no setup or external dependencies
### Should have
6. Session `summary` extracted from the last `type="summary"` line in the JSONL
7. Session `firstPrompt` extracted from the first non-system-reminder user message
8. Session `duration` MUST be derivable without relying on `sessions-index.json` — extract first and last timestamps from JSONL when index is missing or stale
9. Persistent metadata cache survives server restarts
### Won't have (this iteration)
- Real-time push updates (sessions appearing in UI without refresh)
- Background file watcher daemon
- Integration with `cass` as a search/indexing backend
- Rebuilding Claude Code's `sessions-index.json`
## Technical Design
### Architecture: Filesystem-primary with tiered metadata lookup
```
discoverSessions()
|
+-- For each project directory under ~/.claude/projects/:
| |
| +-- fs.readdir() --> list all *.jsonl files
| +-- Read sessions-index.json (optional, used as pre-populated cache)
| |
| +-- Batch stat all .jsonl files (bounded concurrency)
| | Files that disappeared between readdir and stat are silently skipped (TOCTOU race)
| |
| +-- For each .jsonl file:
| | |
| | +-- Tier 1: Check index
| | | Entry exists AND normalize(index.modified) matches stat mtime?
| | | --> Use index content data (messageCount, summary, firstPrompt)
| | | --> Use stat-derived timestamps for created/modified (always)
| | |
| | +-- Tier 2: Check persistent metadata cache
| | | path + mtimeMs + size match?
| | | --> Use cached metadata (fast path)
| | |
| | +-- Tier 3: Extract metadata from JSONL content
| | Read file, lightweight parse using shared line iterator + counting helper
| | --> Cache result for future lookups
| |
| +-- Collect SessionEntry[] for this project
|
+-- Merge all projects
+-- Sort by modified (descending) — always stat-derived, never index-derived
+-- Async: persist metadata cache to disk (if dirty)
```
### Tier explanation
| Tier | Source | Speed | When used | Trusts from source |
|------|--------|-------|-----------|--------------------|
| 1 | `sessions-index.json` | Instant (in-memory lookup) | Index exists, entry present, `normalize(modified)` matches actual file mtime | `messageCount`, `summary`, `firstPrompt` only. Timestamps always from stat. |
| 2 | Persistent metadata cache | Instant (in-memory lookup) | Index missing/stale, but file hasn't changed since last extraction (mtimeMs + size match) | All cached fields |
| 3 | JSONL file parse | ~5-50ms/file | New or modified file, not in any cache | Extracted fresh |
Tier 1 reuses Claude's index when it's valid — no wasted work. The index `modified` field (ISO string) is normalized to milliseconds and compared against the real file `stat.mtimeMs`. If the index is missing or corrupt, discovery continues with Tier 2 and 3 without error. Even when Tier 1 is valid, `created` and `modified` timestamps on the `SessionEntry` always come from `fs.stat` — the index is a content cache only.
### Tier 1: Index validation details
The actual `sessions-index.json` format has `created` and `modified` as ISO strings, not a `fileMtime` field. Tier 1 validation must:
1. Map JSONL filename to sessionId: `sessionId := path.basename(jsonlFile, '.jsonl')`
2. Look up `sessionId` in the index `Map<string, IndexEntry>`
3. Compare `new Date(entry.modified).getTime()` against `stat.mtimeMs` — reject if they differ by more than 1000ms (accounts for ISO string → filesystem mtime rounding)
4. If the index entry has no `modified` field, skip Tier 1 (fall through to Tier 2)
5. When Tier 1 is valid, trust only content fields (`messageCount`, `summary`, `firstPrompt`). The `created`/`modified` on the resulting `SessionEntry` must come from `stat.birthtimeMs`/`stat.mtimeMs` respectively — this ensures list ordering is never stale even within the 1s mtime tolerance window.
### Shared line-iteration and counting (parser parity contract)
The biggest correctness risk in this design is duplicating any JSONL processing logic. The real parser in `session-parser.ts` has non-trivial expansion rules:
- User array content: expands `tool_result` and `text` blocks into separate messages
- `system-reminder` detection reclassifies user `text` blocks as `system_message`
- Assistant array content: `thinking`, `text`, and `tool_use` each become separate messages
- `progress`, `file-history-snapshot`, `summary` → 1 message each
- `system`, `queue-operation` → 0 (skipped)
It also has error-handling behavior: malformed/truncated JSON lines are skipped (common when sessions crash mid-write). If the metadata extractor and the full parser handle malformed lines differently, counts will drift.
Rather than reimplementing any of these rules, extract shared helpers at two levels:
```typescript
// In session-parser.ts (or a shared module):
// Level 1: Line iteration with consistent error handling
// Splits content by newlines, JSON.parse each, skips malformed lines identically
// to how parseSessionContent handles them. Returns parse error count for diagnostics.
export function forEachJsonlLine(
content: string,
onLine: (parsed: RawLine, lineIndex: number) => void
): { parseErrors: number }
// Level 2: Classification and counting (called per parsed line)
export function countMessagesForLine(parsed: RawLine): number
export function classifyLine(parsed: RawLine): LineClassification
```
Both `extractSessionMetadata()` and `parseSessionContent()` use `forEachJsonlLine()` for iteration, ensuring identical malformed-line handling. Both use `countMessagesForLine()` for counting. This two-level sharing guarantees that list counts can never drift from detail-view counts, regardless of future parser changes or edge cases in error handling.
### Metadata extraction (Tier 3)
A lightweight `extractSessionMetadata()` function reads the JSONL file and extracts only what the list view needs, without building full message content strings:
```typescript
export function extractSessionMetadata(content: string): SessionMetadata
```
Implementation:
1. Iterate lines via `forEachJsonlLine(content, ...)` — the shared iterator with identical malformed-line handling as the main parser
2. Call `countMessagesForLine(parsed)` per line — the shared helper that uses the **same classification rules** as `parseSessionContent` in `session-parser.ts`
3. Extract `firstPrompt`: content of the first user message that isn't a `<system-reminder>`, truncated to 200 characters
4. Extract `summary`: the `summary` field from the last `type="summary"` line
5. Capture first and last `timestamp` fields for duration computation
No string building, no `JSON.stringify`, no markdown processing — just counting, timestamp capture, and first-match extraction. This is exact (matches `parseSessionContent().length` via shared helpers) but 2-3x faster than full parsing.
### Persistent metadata cache
**Location:** `~/.cache/session-viewer/metadata.json`
```typescript
interface CacheFile {
version: 1;
entries: Record<string, { // keyed by absolute file path
mtimeMs: number;
size: number;
messageCount: number;
firstPrompt: string;
summary: string;
created: string; // ISO string from file birthtime
modified: string; // ISO string from file mtime
firstTimestamp: string; // ISO from first JSONL line with timestamp
lastTimestamp: string; // ISO from last JSONL line with timestamp
}>;
}
```
Behavior:
- Loaded once on first `discoverSessions()` call
- Entries validated by `(mtimeMs, size)` — if either changes, entry is re-extracted via Tier 3
- Written to disk asynchronously using a dirty-flag write-behind strategy: only when cache has new/updated entries, coalescing multiple discovery passes, non-blocking
- Flush any pending write on process exit (`SIGTERM`, `SIGINT`) and graceful server shutdown — prevents losing cache updates when the server stops before the async write fires
- Corrupt or missing cache file triggers graceful fallback (all files go through Tier 3, cache rebuilt)
- Atomic writes: write to temp file, then rename (prevents corruption from crashes during write)
- Stale entries (file no longer exists on disk) are pruned on save
### Concurrency model
Cold start with 3,103 files requires bounded parallelism to avoid file-handle exhaustion and IO thrash, while still meeting the <5s target:
- **Stat phase**: Batch all `fs.stat()` calls with concurrency limit (e.g., 64). This classifies each file into Tier 1/2 (cache hit) or Tier 3 (needs parse). Files that fail stat (ENOENT from deletion race, EACCES) are silently skipped with a debug log.
- **Parse phase**: Process Tier-3 misses with bounded concurrency (e.g., 8). Each parse reads + iterates via shared `forEachJsonlLine()` + shared counter. With max file size 4.5MB, each parse is ~5-50ms.
- Use a simple async work queue (e.g., `p-limit` or hand-rolled semaphore). No worker threads needed for this IO-bound workload.
### Performance expectations
| Scenario | Estimated time |
|----------|---------------|
| Cold start (no cache, no index) | ~3-5s for 3,103 files (~500MB), bounded concurrency: stat@64, parse@8 |
| Warm start (cache exists, few changes) | ~300-500ms (stat all files at bounded concurrency, in-memory lookups) |
| Incremental (cache + few new sessions) | ~500ms + ~50ms per new file |
| Subsequent API calls within 30s TTL | <1ms (in-memory session list cache) |
### Existing infrastructure leveraged
- **30-second in-memory cache** in `sessions.ts` (`getCachedSessions()`) — unchanged, provides the fast path for repeated API calls
- **`?refresh=1` query parameter** — forces cache invalidation, unchanged
- **Concurrent request deduplication** via `cachePromise` pattern — unchanged
- **Security validations** — path traversal rejection, containment checks, `.jsonl` extension enforcement — applied to filesystem-discovered files identically
## Implementation scope
### Checkpoints
#### CP0 — Parser parity foundations
- Extract `forEachJsonlLine()` shared line iterator from existing parser
- Extract `countMessagesForLine()` and `classifyLine()` shared helpers
- Refactor `extractMessages()` to use these internally (no behavior change to parseSessionContent)
- Tests verify identical behavior on malformed/truncated lines
#### CP1 — Filesystem-first correctness
- All `.jsonl` sessions appear even with missing/corrupt index
- `extractSessionMetadata()` uses shared line iterator + counting helpers; exact counts verified by tests
- Stat-derived `created`/`modified` are the single source for SessionEntry timestamps and list ordering
- Duration computed from JSONL timestamps, not index
- TOCTOU races (readdir/stat, stat/read) handled gracefully — disappeared files silently skipped
#### CP2 — Persistent cache
- Atomic writes with dirty-flag write-behind; prune stale entries
- Invalidation keyed on `(mtimeMs, size)`
- Flush pending writes on process exit / server shutdown
#### CP3 — Index fast path (Tier 1)
- Parse index into Map; normalize `modified` ISO → ms; validate against stat mtime with 1s tolerance
- sessionId mapping: `basename(file, '.jsonl')`
- Tier 1 trusts content fields only; timestamps always from stat
#### CP4 — Performance hardening
- Bounded concurrency for stat + parse phases
- Warm start <1s verified on real dataset
### Modified files
**`src/server/services/session-parser.ts`**
1. Extract `forEachJsonlLine(content, onLine): { parseErrors: number }` — shared line iterator with consistent malformed-line handling
2. Extract `countMessagesForLine(parsed: RawLine): number` — shared counting helper
3. Extract `classifyLine(parsed: RawLine): LineClassification` — shared classification
4. Refactor `extractMessages()` to use these shared helpers internally (no behavior change to parseSessionContent)
**`src/server/services/session-discovery.ts`**
1. Add `extractSessionMetadata(content: string): SessionMetadata` — lightweight JSONL metadata extractor using shared line iterator + counting helper
2. Add `MetadataCache` class — persistent cache with load/get/set/save, dirty-flag write-behind, shutdown flush
3. Rewrite per-project discovery loop — filesystem-first, tiered metadata lookup with bounded concurrency
4. Read `sessions-index.json` as optimization only — parse into `Map<sessionId, IndexEntry>`, normalize `modified` to ms, validate against stat mtime before trusting
5. Register shutdown hooks for cache flush on `SIGTERM`/`SIGINT`
### Unchanged files
- `src/server/routes/sessions.ts` — existing caching layer works as-is
- `src/shared/types.ts``SessionEntry` type already has `duration?: number`
- All client components — no changes needed
### New tests
- Unit test: `forEachJsonlLine()` skips malformed lines identically to how `parseSessionContent` handles them
- Unit test: `forEachJsonlLine()` reports parse error count for truncated/corrupted lines
- Unit test: `countMessagesForLine()` matches actual `extractMessages()` output length on sample lines
- Unit test: `extractSessionMetadata()` output matches `parseSessionContent().length` on sample fixtures (including malformed/truncated lines)
- Unit test: Duration extracted from JSONL timestamps matches expected values
- Unit test: SessionEntry `created`/`modified` always come from stat, even when Tier 1 index data is trusted
- Unit test: Tier 1 validation rejects stale index entries (mtime mismatch beyond 1s tolerance)
- Unit test: Tier 1 handles missing `modified` field gracefully (falls through to Tier 2)
- Unit test: Discovery works with no `sessions-index.json` present
- Unit test: Discovery silently skips files that disappear between readdir and stat (TOCTOU)
- Unit test: Cache hit/miss/invalidation behavior (mtimeMs + size)
- Unit test: Cache dirty-flag only triggers write when entries changed
## Edge cases
| Scenario | Behavior |
|----------|----------|
| File actively being written | mtime changes between stat and read. Next discovery pass re-extracts. Partial JSONL handled gracefully (malformed lines skipped via shared `forEachJsonlLine`, same behavior as real parser). |
| Deleted session files | File in cache but gone from disk. Entry silently dropped, pruned from cache on next save. |
| File disappears between readdir and stat | TOCTOU race. Stat failure (ENOENT) silently skipped with debug log. |
| File disappears between stat and read | Read failure silently skipped; file excluded from results. Next pass re-discovers if it reappears. |
| Index entry with wrong mtime | Tier 1 validation rejects it (>1s tolerance). Falls through to Tier 2/3. |
| Index entry with no `modified` field | Tier 1 skips it. Falls through to Tier 2/3. |
| Index `modified` in seconds vs milliseconds | Normalization handles both ISO strings and numeric timestamps. |
| Cache file locked or unwritable | Extraction still works, just doesn't persist. Warning logged to stderr. |
| Very large files | 4.5MB max observed. Tier 3 parse ~50ms. Acceptable. |
| Concurrent server restarts | Cache writes are atomic (temp file + rename). |
| Server killed before async cache write | Shutdown hooks flush pending writes on SIGTERM/SIGINT. Hard kills (SIGKILL) may lose updates — acceptable, cache rebuilt on next cold start. |
| Empty JSONL files | Returns `messageCount: 0`, empty `firstPrompt`, `summary`, and timestamps. Duration: 0. |
| Projects with no index file | Discovery proceeds normally via Tier 2/3. Common case (13 projects). |
| Non-JSONL files in project dirs | Filtered out by `.jsonl` extension check in `readdir` results. |
| File handle exhaustion | Bounded concurrency (stat@64, parse@8) prevents opening thousands of handles. |
| Future parser changes (new message types) | Shared line iterator + counting helper in session-parser.ts means Tier 3 automatically stays in sync. |
| Malformed JSONL lines (crash mid-write) | Shared `forEachJsonlLine()` skips identically in both metadata extraction and full parsing — no count drift. |
## Verification plan
1. Start dev server, confirm today's sessions appear immediately in the session list
2. Compare message counts for indexed sessions: Tier 1 data vs Tier 3 extraction (should match)
3. Verify duration is shown for sessions that have no index entry (JSONL-only sessions)
4. Delete a `sessions-index.json`, refresh — verify all sessions for that project still appear with correct counts and durations
5. Run existing test suite: `npm test`
6. Run new unit tests for shared line iterator, counting helper, `extractSessionMetadata()`, and `MetadataCache`
7. Verify `created`/`modified` in session list come from stat, not index (compare with `ls -l` output)
8. Verify cold start performance: delete `~/.cache/session-viewer/metadata.json`, time the first API request
9. Verify warm start performance: time a subsequent server start with cache in place
10. Verify cache dirty-flag: repeated refreshes with no file changes should not write cache to disk
11. Kill server with SIGTERM, restart — verify cache was flushed (no full re-parse on restart)