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
59 changes: 59 additions & 0 deletions scripts/extract-recording-core.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// tsx bridge: run the PURE extraction core (redact + minimize + window +
// hard-gate) over one on-disk recording folder and print the resulting
// fixtures as JSON.
//
// WHY a tsx bridge instead of reimplementing redaction in the .mjs: the
// redaction regex, caps, and structural allowlist are the load-bearing safety
// gate. A second copy in plain JS could drift and let the gate pass a value
// the TS side considers a leak. So the .mjs stays a thin file-IO wrapper and
// shells to this bridge, which imports the ONE implementation
// (rendering/replay/redact.ts) — the exact same module the replay tests use.
// This mirrors how extract-rendering-fixtures.mjs shells to
// extract-codex-entries.mts to reuse the real in-app Codex mapper.
//
// Usage: tsx --tsconfig tsconfig.web.json scripts/extract-recording-core.mts \
// <recordingDir> <mode> [windowRadius]
// Prints a JSON array of fixtures to stdout. Exits non-zero (with the reason
// on stderr) if the hard gate trips.

import { readFileSync } from 'node:fs'
import { join } from 'node:path'

import {
extractRecordingFixture,
type RecordingEvent,
type RedactionMode,
} from '../src/renderer/src/rendering/replay/redact.ts'

const [dir, modeArg, radiusArg] = process.argv.slice(2)
if (!dir) {
console.error('usage: extract-recording-core.mts <recordingDir> <mode> [windowRadius]')
process.exit(2)
}
const mode = (modeArg ?? 'structure-only') as RedactionMode

// meta.json is a convenience header; the events stream is the source of truth.
// Tolerate a missing/torn meta so a crashed recording is still extractable.
let meta: Record<string, unknown> = {}
try {
meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'))
} catch {
/* header missing — reconstruct nothing; events carry sessionId etc. */
}

const events: RecordingEvent[] = []
for (const line of readFileSync(join(dir, 'events.jsonl'), 'utf8').split('\n')) {
if (!line.trim()) continue
try {
events.push(JSON.parse(line))
} catch {
/* torn tail line (recording read mid-write) — skip, same as the bundle
extractor's readJsonl tolerance. */
}
}

const radius = radiusArg ? Number(radiusArg) : undefined
// extractRecordingFixture THROWS if a SENSITIVE_KEY value survives — let it
// propagate as a non-zero exit so the .mjs skips this recording loudly.
const fixtures = extractRecordingFixture({ meta, events }, { mode, windowRadius: radius })
process.stdout.write(JSON.stringify(fixtures))
139 changes: 139 additions & 0 deletions scripts/extract-rendering-recordings.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env node
// Extract check-in-safe rendering fixtures from LOCAL session recordings.
//
// The successor to scripts/extract-rendering-fixtures.mjs (which mined frozen
// debug bundles). A session recording is the full rendering INPUT STREAM of a
// live session — a folder `session-recordings/<id>/` with meta.json + an
// append-only events.jsonl of the 9 SessionFeed channels (plan §§2-6). This
// script turns one (or many) local recording folders into redacted, minimized
// fixtures under testing/fixtures/rendering-recordings/, which the golden
// replay suite (recordingCorpus.test.ts) then replays through the real
// pipeline.
//
// The heavy lifting — redaction, structure-only minimization, windowing around
// __note markers, and the HARD GATE that refuses to emit a fixture still
// carrying a SENSITIVE_KEY value — lives in the PURE core
// (src/renderer/src/rendering/replay/redact.ts), reached through the tsx bridge
// scripts/extract-recording-core.mts. This file is only the file-IO wrapper:
// enumerate folders, invoke the core, write outputs, print a summary. Keeping
// the safety logic in one TS module (also exercised by redact.test.ts) is why
// there is no second copy of the regex/caps here to drift.
//
// Redaction mode (plan §5 two-tier model):
// --mode structure-only (default) drop free-text bodies, keep the
// identity/structure the pipeline keys on. The
// innovation — makes most recordings check-in-safe.
// --mode full-text-capped keep prose truncated to the corpus tiers.
//
// Windowing: with --window <N>, a recording that has __note markers emits one
// fixture per reserved note, each ±N events around the reaction tick, with the
// note's fill text as the fixture description. Without it (or with no notes),
// the whole recording becomes one fixture.
//
// Usage:
// node scripts/extract-rendering-recordings.mjs [--in <dir>] [--out <dir>]
// [--only <recordingId>] [--mode structure-only|full-text-capped]
// [--window <N>]
//
// After extracting, populate each fixture's golden `expected` rows by blessing:
// AGENT_CODE_RECORDING_BLESS=1 NODE_ENV=test npx vitest run --project unit \
// src/renderer/src/rendering/__tests__/recordingCorpus.test.ts

import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'

const args = process.argv.slice(2)
function flag(name, fallback = null) {
const i = args.indexOf(name)
return i >= 0 ? args[i + 1] : fallback
}

// Local recordings live in userData (macOS: ~/Library/Application Support/
// agent-code/session-recordings). Overridable so the script can run over an
// exported recording dir. Mirrors extract-rendering-fixtures.mjs's default
// paths.
const IN =
flag('--in') ??
join(homedir(), 'Library/Application Support/agent-code/session-recordings')
const OUT = flag('--out') ?? join(process.cwd(), 'testing/fixtures/rendering-recordings')
const ONLY = flag('--only')
const MODE = flag('--mode', 'structure-only')
const WINDOW = flag('--window')

if (!['structure-only', 'full-text-capped'].includes(MODE)) {
console.error(`invalid --mode ${MODE} (structure-only | full-text-capped)`)
process.exit(2)
}

if (!existsSync(IN)) {
console.log(`no recordings dir at ${IN} — nothing to extract (this is normal until a session is recorded).`)
process.exit(0)
}

// A recording folder is any child dir with an events.jsonl.
const recordingDirs = readdirSync(IN)
.map(name => join(IN, name))
.filter(p => {
try {
return statSync(p).isDirectory() && existsSync(join(p, 'events.jsonl'))
} catch {
return false
}
})

mkdirSync(OUT, { recursive: true })
const summary = []

for (const dir of recordingDirs) {
const id = dir.split('/').pop()
if (ONLY && id !== ONLY) continue
try {
// The core throws (non-zero exit) if the hard gate trips; execFileSync
// then throws here and we record the skip loudly — a recording that can't
// be scrubbed is NEVER written.
const coreArgs = [
'tsx',
'--tsconfig',
'tsconfig.web.json',
'scripts/extract-recording-core.mts',
dir,
MODE,
]
if (WINDOW) coreArgs.push(WINDOW)
const out = execFileSync('npx', coreArgs, {
cwd: process.cwd(),
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024,
})
const fixtures = JSON.parse(out)

fixtures.forEach((fixture, i) => {
// One fixture per note-window gets a suffix; a whole-session extraction
// is just `<id>.json`.
const suffix = fixtures.length > 1 ? `-w${i}` : ''
const outPath = join(OUT, `${id}${suffix}.json`)
writeFileSync(outPath, JSON.stringify(fixture))
summary.push({
id: `${id}${suffix}`,
mode: MODE,
events: fixture.events.length,
note: (fixture.meta.note ?? '').slice(0, 40),
kb: Math.round(JSON.stringify(fixture).length / 1024),
})
})
} catch (err) {
// execFileSync surfaces the core's stderr (gate reason) in err.stderr.
const reason = err?.stderr ? String(err.stderr).trim() : String(err).slice(0, 160)
summary.push({ id, error: reason.slice(0, 160) })
}
}

console.table(summary)
const ok = summary.filter(s => !s.error).length
console.log(`wrote ${ok}/${summary.length} fixtures to ${OUT}`)
console.log(
`next: AGENT_CODE_RECORDING_BLESS=1 NODE_ENV=test npx vitest run --project unit ` +
`src/renderer/src/rendering/__tests__/recordingCorpus.test.ts (fills golden expected rows)`,
)
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ async function startApp(): Promise<void> {
ghostJournals,
dictationDebugJournals,
pasteDebugJournals,
sessionRecorders,
worktreeActivityIndex,
orchestrationBridge,
aiWorkspaceRegistry,
Expand Down
43 changes: 42 additions & 1 deletion src/main/ipc/devDebug.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'

import { readRecentPasteSessions } from '../pasteDebugJournal.js'
import type { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js'

export type DevDebugConfig = {
enabled: boolean
Expand Down Expand Up @@ -39,7 +40,12 @@ export function isSessionRecordingEnabled(): boolean {
return isDevDebugEnabled() && envFlag('AGENT_CODE_SESSION_RECORD')
}

export function registerDevDebugIpc(): void {
// `sessionRecorders` is null in a normal build (the manager is only
// constructed when AGENT_CODE_DEV_DEBUG + AGENT_CODE_SESSION_RECORD are both
// on — see main/index.ts). It is threaded in from the IPC deps rather than
// imported as a singleton so the wiring stays visible at the registerAllIpc
// call site, exactly like every other manager here.
export function registerDevDebugIpc(sessionRecorders: SessionRecorderManager | null): void {
ipcMain.handle('dev-debug:get-config', (): DevDebugConfig => {
return {
// WHY this flag lives in main instead of import.meta.env:
Expand Down Expand Up @@ -70,4 +76,39 @@ export function registerDevDebugIpc(): void {
if (!isDevDebugEnabled()) return []
return readRecentPasteSessions(typeof limit === 'number' ? limit : 30)
})

// Attach-Recording-Note (plan §7b). The recording-era equivalent of "save
// debug logs": drop a timestamped bookmark into the LIVE recording stream so
// a soak operator can flag the exact tick they reacted to without stopping
// the session. Two phases, on purpose:
// reserve → writes the `reserved` marker INSTANTLY (before the user types)
// so the timestamp pins the reaction moment, not the moment they
// finished typing (several ticks later). Returns the noteId.
// fill → updates that noteId with the typed text on submit.
// A crash between the two still leaves the reserved line, which alone flags
// "something was wrong here" — the same two-phase crash-safety the ghost
// journal uses.
//
// Both handlers are gated by isSessionRecordingEnabled(): the flag is the
// trust boundary (a recording captures full session input, so a renderer
// with preload access must not reach the recorder unless the operator opted
// in), AND sessionRecorders is null unless recording was gated on at
// construction — so these are double-safe no-ops in a normal build. A null
// recorder (or a sessionId with no active recording) returns null from
// reserve; the renderer treats that as "nothing to annotate" and shows a
// toast rather than opening the note input.
ipcMain.handle(
'record-session:reserve-note',
(_evt, sessionId: string): string | null => {
if (!isSessionRecordingEnabled()) return null
return sessionRecorders?.reserveNote(sessionId) ?? null
},
)
ipcMain.handle(
'record-session:fill-note',
(_evt, sessionId: string, noteId: string, text: string): void => {
if (!isSessionRecordingEnabled()) return
sessionRecorders?.fillNote(sessionId, noteId, text)
},
)
}
7 changes: 6 additions & 1 deletion src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { LspManager } from '@main/lspManager.js'
import type { GhostJournalRegistry } from '@main/ghostJournal.js'
import type { DictationDebugJournalRegistry } from '@main/dictationJournal.js'
import type { PasteDebugJournalRegistry } from '@main/pasteDebugJournal.js'
import type { SessionRecorderManager } from '@main/recording/SessionRecorderManager.js'

import { registerSessionIpc } from '@main/ipc/session.js'
import { registerProviderIpc } from '@main/ipc/provider.js'
Expand Down Expand Up @@ -49,6 +50,10 @@ export type IpcDeps = {
ghostJournals: GhostJournalRegistry
dictationDebugJournals: DictationDebugJournalRegistry
pasteDebugJournals: PasteDebugJournalRegistry
// Null in a normal build — only constructed when session recording is gated
// on (main/index.ts). The dev-debug IPC needs it for the Attach-Recording-
// Note handlers (plan §7b).
sessionRecorders: SessionRecorderManager | null
worktreeActivityIndex: WorktreeActivityIndex
orchestrationBridge: OrchestrationBridge
aiWorkspaceRegistry: AiWorkspaceRegistry
Expand All @@ -74,7 +79,7 @@ export function registerAllIpc(deps: IpcDeps): void {
registerSetupIpc()
registerDictationIpc({ dictationDebugJournals: deps.dictationDebugJournals })
registerPasteDebugIpc({ pasteDebugJournals: deps.pasteDebugJournals })
registerDevDebugIpc()
registerDevDebugIpc(deps.sessionRecorders)
registerOrchestrationIpc(deps.orchestrationBridge)
registerAiWorkspaceIpc(deps.aiWorkspaceRegistry)
registerRenderedContentIpc()
Expand Down
74 changes: 74 additions & 0 deletions src/main/storage/debugRetention.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import { collectSessionRecordingDirs } from './debugRetention.js'

// Folder-atomic session-recording retention (plan §4, #388/#467).
//
// The one property that MUST hold: retention treats a recording folder as a
// single deletable unit, never as a bag of files. If the collector ever
// emitted per-file artifacts, a prune pass could shed events.jsonl and orphan
// meta.json — a half-recording that no longer loads and no longer counts
// against the bucket. These tests pin the collection layer where that bug
// would live (kind:'dir', one artifact per folder), which is what routes the
// prune to removeArtifact's `rm -rf` branch downstream.

let root: string

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'rec-retention-'))
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})

function makeRecording(id: string, eventsBytes: number): void {
const dir = join(root, id)
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'meta.json'),
JSON.stringify({ v: 1, recordingId: id, sessionId: id, startedAtWall: Date.now() }),
)
// A non-trivial events file so byte accounting is observable — the folder's
// reported size must include BOTH files, proving whole-folder accounting.
writeFileSync(join(dir, 'events.jsonl'), 'x'.repeat(eventsBytes))
}

describe('collectSessionRecordingDirs', () => {
it('emits exactly one dir-kind artifact per recording folder', async () => {
makeRecording('2026-07-07T00-00-00-000-s1', 1000)
makeRecording('2026-07-07T00-01-00-000-s2', 2000)

const artifacts = await collectSessionRecordingDirs(root)

expect(artifacts).toHaveLength(2)
// Every artifact is a directory: this is the whole-folder-atomic guarantee.
// removeArtifact(kind:'dir') is the only branch that does rm -rf, so a
// recording is deleted as one unit iff it arrives here as kind:'dir'.
for (const artifact of artifacts) {
expect(artifact.kind).toBe('dir')
expect(artifact.bucket).toBe('session-recordings')
}
})

it('accounts the whole folder (meta.json + events.jsonl), not a single file', async () => {
makeRecording('2026-07-07T00-00-00-000-s1', 5000)

const [artifact] = await collectSessionRecordingDirs(root)

// The events file alone is 5000 bytes; meta.json adds more. If the
// collector were per-file (the bug we guard against) it could report just
// one file's size. Whole-folder accounting must exceed the events size.
expect(artifact.bytes).toBeGreaterThan(5000)
})

it('returns nothing when the recordings root does not exist', async () => {
// First app run, or recording never enabled: the dir is absent. Retention
// must treat that as an empty bucket, not throw and abort the whole sweep.
const artifacts = await collectSessionRecordingDirs(join(root, 'does-not-exist'))
expect(artifacts).toEqual([])
})
})
Loading