Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import { cleanupClaudeImageCacheDir } from '@main/storage/claudeImageCache.js'
import { acquireStateProcessLock, type StateProcessLock } from '@main/storage/processLock.js'
import { createMainWindow, focusMainWindow, sendToMainWindow } from '@main/window/mainWindow.js'
import { wireSessionForwarder } from '@main/sessions/forwarder.js'
import { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js'
import { setOutboundObserver } from '@main/window/mainWindow.js'
import { isSessionRecordingEnabled } from '@main/ipc/devDebug.js'
import { registerAllIpc } from '@main/ipc/index.js'
import { cleanupDictationIpcResources } from '@main/ipc/dictation.js'
import { performanceService } from '@main/performance/PerformanceService.js'
Expand Down Expand Up @@ -80,6 +83,13 @@ const lspManager = new LspManager()
// See `./ghostJournal.ts` for the full rationale; see
// `src/renderer/src/workspace/ghosts.ts` for the renderer side.
const ghostJournals = new GhostJournalRegistry()
// Session recorder — one folder per recording under session-recordings/.
// Constructed and installed as the outbound-IPC observer ONLY when
// AGENT_CODE_DEV_DEBUG=1 AND AGENT_CODE_SESSION_RECORD=1, so a normal build
// pays nothing (no observer installed, sendToMainWindow's hook stays null).
// plan: docs/rendering/session-recording-plan-2026-07.md (#467).
const sessionRecorders = isSessionRecordingEnabled() ? new SessionRecorderManager() : null
if (sessionRecorders) setOutboundObserver(sessionRecorders.observe)
// Per-dictation-session debug-dump registry. Mirrors `ghostJournals`:
// constructed before IPC handlers register, flushed on before-quit. See
// `src/main/dictationJournal.ts` for the on-disk shape and the
Expand Down Expand Up @@ -572,6 +582,9 @@ app.on('before-quit', () => {
// worst-case; in practice drains are empty at quit time because
// streaming is idle.
void ghostJournals.flushAll()
// Same one-tick-before-teardown rationale as ghostJournals; recordings are
// usually mid-stream at quit, so this drain matters more than the ghost one.
void sessionRecorders?.flushAll()
// Same rationale as ghostJournals — Electron gives us one tick before
// teardown. 100 ms queue depth is the worst case; in practice the
// dictation journal is idle at quit unless the user is pressing Fn
Expand Down
13 changes: 13 additions & 0 deletions src/main/ipc/devDebug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type DevDebugConfig = {
* of deriveFeedRenderModel. Same temporary-channel rationale as the
* shadow flag above; the flag dies when the pipeline becomes default. */
renderPipelineEnabled: boolean
/** AGENT_CODE_SESSION_RECORD=1 (AND dev-debug on) — continuously record
* each session's rendering-input stream to session-recordings/<id>/,
* replayable in tests. plan: docs/rendering/session-recording-plan-2026-07.md,
* issue #467. Debug-gated because a recording captures full conversation
* input. */
sessionRecordingEnabled: boolean
}

function envFlag(name: string): boolean {
Expand All @@ -27,6 +33,12 @@ function isDevDebugEnabled(): boolean {
return envFlag('AGENT_CODE_DEV_DEBUG')
}

/** Session recording is doubly gated: it is a diagnostic (dev-debug) AND
* opt-in (its own flag), because it records full session input. */
export function isSessionRecordingEnabled(): boolean {
return isDevDebugEnabled() && envFlag('AGENT_CODE_SESSION_RECORD')
}

export function registerDevDebugIpc(): void {
ipcMain.handle('dev-debug:get-config', (): DevDebugConfig => {
return {
Expand All @@ -38,6 +50,7 @@ export function registerDevDebugIpc(): void {
// Vite-prefixed renderer variable or rebuild-time config.
enabled: isDevDebugEnabled(),
renderShadowEnabled: envFlag('AGENT_CODE_RENDER_SHADOW'),
sessionRecordingEnabled: isSessionRecordingEnabled(),
renderPipelineEnabled: envFlag('AGENT_CODE_RENDER_PIPELINE'),
}
})
Expand Down
236 changes: 236 additions & 0 deletions src/main/recording/SessionRecorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { appendFile, mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'

import { SESSION_RECORDING_DIR } from '@main/storage/paths.js'

// One SessionRecorder = ONE self-contained recording folder.
//
// docs/rendering/session-recording-plan-2026-07.md §2-§4. Each recording
// gets its own directory `session-recordings/<recordingId>/` holding:
// meta.json — provider/session identity + start/end/counters
// events.jsonl — the append-only event stream (one line per event)
// The per-folder shape is a deliberate cleanup affordance: `rm -rf` one
// folder removes exactly one recording, nothing else (the user's ask).
//
// The writer is cloned from GhostJournal (src/main/ghostJournal.ts) — one
// file, JSON+'\n', 100 ms batched drain, overlapping-drain guard, dir
// created lazily on first write — because that writer already absorbed the
// #388 OOM lessons. Added here on top of the ghost pattern, because a
// CONTINUOUS recorder re-opens the exact #388 crash vector that a
// per-turn ghost log did not:
// - BACKPRESSURE: a hard queue cap with drop-oldest + a counter, so a
// runaway stream can never unbounded-buffer in memory (the thing that
// took the heap 39MB→2554MB in incident #388).
// - SIZE CAP + TOMBSTONE: once the events file passes a byte cap the
// recorder stops appending events and writes a single tombstone line,
// matching feedDebugLog's cap-not-rotate policy.
// Both are non-negotiable per the plan; retention (a budgeted
// debugRetention bucket) is the sibling slice.

// 100 ms mirrors GhostJournal / upstream Claude FLUSH_INTERVAL_MS — one
// append per 100 ms per recording, not per event.
const FLUSH_INTERVAL_MS = 100
// Drop-oldest threshold. 2000 pending lines is the journal-plan rulebook's
// cap; a healthy recorder drains every 100 ms so the queue is near-empty,
// and hitting 2000 means the disk can't keep up — shed the oldest rather
// than grow the heap.
const MAX_QUEUE = 2000
// Per-recording events-file byte cap. 128 MiB mirrors feedDebugLog's cap;
// past it we stop appending and tombstone (cap, never rotate).
const MAX_BYTES = 128 * 1024 * 1024

export type RecordingMeta = {
v: 1
recordingId: string
sessionId: string
provider: string
providerSessionId: string | null
cwd: string | null
appVersion: string | null
startedAtWall: number
endedAtWall?: number
eventCount?: number
droppedCount?: number
capped?: boolean
}

export class SessionRecorder {
private readonly dir: string
private readonly eventsPath: string
private readonly startMono: number

private queue: string[] = []
private timer: ReturnType<typeof setTimeout> | null = null
private draining = false
private ensuredDir = false

private bytesWritten = 0
private eventCount = 0
private dropped = 0
private capped = false
private closed = false
private tombstoned = false

constructor(
private meta: RecordingMeta,
private readonly nowWall: () => number,
private readonly nowMono: () => number,
) {
this.dir = join(SESSION_RECORDING_DIR, meta.recordingId)
this.eventsPath = join(this.dir, 'events.jsonl')
this.startMono = this.nowMono()
// Write meta.json eagerly so the recording FOLDER appears the instant
// recording starts (the user sees it exists), and so a crash before any
// event still leaves an identifiable recording. Fire-and-forget; the
// events path lazily re-ensures the dir anyway.
void this.writeMeta()
}

/**
* Enqueue one event line. `t` is monotonic ms since recording start
* (drives replay ordering); `wall` is Date.now() (injected as the fold
* clock during replay so wall-clock-dependent fold behavior is
* deterministic — plan §3). Returns immediately; the disk write is up to
* 100 ms later.
*/
record(channel: string, payload: unknown): void {
if (this.closed || this.capped) return
const line =
JSON.stringify({
t: Math.round(this.nowMono() - this.startMono),
wall: this.nowWall(),
ch: channel,
payload,
}) + '\n'
this.enqueue(line)
this.eventCount += 1
}

/**
* Attach-Recording-Note marker (plan §7b). `__note` is a synthetic
* channel outside the 9 real SessionFeed channels, so replay ignores it
* for pipeline input while the triage/extraction tooling reads it. Two
* phases: `reserved` (written instantly on invoke to pin the reaction
* tick) then `filled` (the typed text). The reserved line alone survives
* a crash and still flags the moment.
*/
note(note: { id: string; status: 'reserved' | 'filled'; text?: string }): void {
if (this.closed) return
const line =
JSON.stringify({
t: Math.round(this.nowMono() - this.startMono),
wall: this.nowWall(),
ch: '__note',
note,
}) + '\n'
// Notes bypass the size cap — they are tiny and are the whole point of a
// long recording (you must be able to mark the bug even in a capped
// file). They still respect the queue backpressure.
this.enqueue(line)
}

private enqueue(line: string): void {
this.queue.push(line)
if (this.queue.length > MAX_QUEUE) {
// Drop-oldest: shed the front so the heap can't grow unbounded when
// the disk can't keep up. Count it; the count lands in meta.json and
// as a tombstone so a replay knows the recording has a gap.
const shed = this.queue.length - MAX_QUEUE
this.queue.splice(0, shed)
this.dropped += shed
}
this.scheduleDrain()
}

private scheduleDrain(): void {
if (this.timer) return
this.timer = setTimeout(() => {
this.timer = null
void this.drain()
}, FLUSH_INTERVAL_MS)
this.timer.unref?.()
}

private async drain(): Promise<void> {
if (this.draining || this.queue.length === 0) return
this.draining = true
try {
const batch = this.queue.splice(0).join('')
if (this.bytesWritten + batch.length > MAX_BYTES && !this.tombstoned) {
// Cross the cap: write a final tombstone once and stop appending
// events. Cap, never rotate (feedDebugLog policy).
this.tombstoned = true
this.capped = true
const mark =
JSON.stringify({
t: Math.round(this.nowMono() - this.startMono),
wall: this.nowWall(),
ch: '__truncated',
reason: 'size-cap',
bytes: this.bytesWritten,
}) + '\n'
await this.appendRaw(mark)
} else if (!this.capped) {
await this.appendRaw(batch)
this.bytesWritten += batch.length
}
} finally {
this.draining = false
}
if (this.queue.length > 0 && !this.timer && !this.capped) this.scheduleDrain()
}

private async appendRaw(content: string): Promise<void> {
try {
await appendFile(this.eventsPath, content, { mode: 0o600 })
} catch {
if (!this.ensuredDir) {
await mkdir(this.dir, { recursive: true, mode: 0o700 })
this.ensuredDir = true
await appendFile(this.eventsPath, content, { mode: 0o600 })
} else {
throw new Error(`session recording append failed for ${this.eventsPath}`)
}
}
}

private async writeMeta(): Promise<void> {
try {
await mkdir(this.dir, { recursive: true, mode: 0o700 })
this.ensuredDir = true
} catch {
/* re-ensured lazily on first event append */
}
try {
await writeFile(join(this.dir, 'meta.json'), JSON.stringify(this.meta, null, 1), {
mode: 0o600,
})
} catch {
/* non-fatal: the events stream is the source of truth; meta is a convenience */
}
}

/** Force a drain and wait — used on shutdown and by tests. */
async flush(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
await this.drain()
}

/** Finalize: flush, then rewrite meta.json with end stats. Idempotent. */
async close(): Promise<void> {
if (this.closed) return
this.closed = true
await this.flush()
this.meta = {
...this.meta,
endedAtWall: this.nowWall(),
eventCount: this.eventCount,
droppedCount: this.dropped,
capped: this.capped,
}
await this.writeMeta()
}
}
Loading