From be116487d7fd056a401107d04705bce269421635 Mon Sep 17 00:00:00 2001 From: shaun0927 <70629228+shaun0927@users.noreply.github.com> Date: Wed, 13 May 2026 00:44:11 +0900 Subject: [PATCH] Persist trajectory bundles for recorded episodes Constraint: Episode trajectory capture must remain default-off and observability-only so OpenChrome tool behavior does not change for normal sessions.\nRejected: A separate trajectory lifecycle tool | recording already owns episode lifecycle and a second control surface would add wandering/confusion.\nConfidence: medium\nScope-risk: moderate\nDirective: Keep trajectory writes best-effort and bounded; do not add recovery, retry, or LLM policy decisions to this core writer.\nTested: npm test -- --runInBand tests/recording/action-recorder-trajectory.test.ts tests/tools/recording.test.ts tests/recording/action-recorder.bounds.test.ts tests/tools/oc-assert.recorder-wiring.test.ts; npm run lint:changed; npm run build; npm test -- --runInBand tests/cross-env/cursor-verification.test.ts\nNot-tested: Live OpenChrome browser run with real oc_assert/oc_checkpoint artifacts --- docs/observability/trajectory-bundles.md | 44 ++++ src/config/tool-tiers.ts | 1 + src/mcp-server.ts | 1 + src/recording/action-recorder.ts | 70 +++++ src/recording/types.ts | 2 + src/tools/checkpoint.ts | 9 +- src/tools/recording.ts | 52 +++- src/trajectory/bundle-writer.ts | 248 ++++++++++++++++++ src/trajectory/index.ts | 1 + tests/cross-env/cursor-verification.test.ts | 4 +- .../action-recorder-trajectory.test.ts | 70 +++++ tests/tools/recording.test.ts | 11 +- 12 files changed, 506 insertions(+), 7 deletions(-) create mode 100644 docs/observability/trajectory-bundles.md create mode 100644 src/trajectory/bundle-writer.ts create mode 100644 src/trajectory/index.ts create mode 100644 tests/recording/action-recorder-trajectory.test.ts diff --git a/docs/observability/trajectory-bundles.md b/docs/observability/trajectory-bundles.md new file mode 100644 index 000000000..ed213ab70 --- /dev/null +++ b/docs/observability/trajectory-bundles.md @@ -0,0 +1,44 @@ +# Trajectory bundles + +Trajectory bundles are default-off, file-based observability artifacts for long-running OpenChrome episodes. They unify recording actions, contract results, and checkpoint snapshots without changing browser/tool behavior. + +Enable them when starting a recording: + +```json +{ "label": "debug run", "trajectoryBundle": true } +``` + +A bundle is written under `~/.openchrome/trajectories//`: + +```text +meta.json +events.jsonl +screenshots/ +checkpoints/ +contracts/ +report.json +``` + +`events.jsonl` is append-only and uses strictly increasing `seq` values. Event summaries are bounded to 4 KiB and redact password/token/secret/credential/api-key style fields. Sensitive tools such as cookies and HTTP auth are summarized as redacted. + +The writer is best-effort: bundle failures are logged and disabled, but the original MCP tool call continues. OpenChrome does not make LLM decisions, retry actions, recover checkpoints, or stop episodes based on the bundle. + +## Merge validation + +1. Run `oc_recording_start` with `{ "trajectoryBundle": true }`. +2. Navigate to `https://example.com`, run `read_page`, run one passing and one failing `oc_assert`, then run `oc_checkpoint` with completed and pending steps. +3. Run `oc_recording_stop`. +4. Verify `meta.json`, `events.jsonl`, `contracts/`, `checkpoints/`, and `report.json` exist in the returned bundle directory. +5. Verify sequence order: + +```bash +jq -r '.seq' ~/.openchrome/trajectories//events.jsonl | awk 'NR>1 && $1<=prev { exit 1 } { prev=$1 }' +``` + +6. Type a known fixture password and confirm it is absent: + +```bash +! grep -R "super-secret-fixture-password" ~/.openchrome/trajectories/ +``` + +7. Start/stop a normal recording without `trajectoryBundle:true`; no new trajectory directory should be created. diff --git a/src/config/tool-tiers.ts b/src/config/tool-tiers.ts index 63df6a8c9..b62510751 100644 --- a/src/config/tool-tiers.ts +++ b/src/config/tool-tiers.ts @@ -75,6 +75,7 @@ export const TOOL_TIERS: Record = { // Session recording tools (#572) — opt-in, not needed for every session oc_recording_start: 2, oc_recording_stop: 2, + oc_recording_status: 2, oc_recording_list: 2, oc_recording_export: 2, diff --git a/src/mcp-server.ts b/src/mcp-server.ts index d28f656d4..d4e52a35b 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -90,6 +90,7 @@ const SKIP_RECORDING_TOOLS = new Set([ 'oc_recording_start', 'oc_recording_stop', 'oc_recording_list', + 'oc_recording_status', 'oc_recording_export', ]); diff --git a/src/recording/action-recorder.ts b/src/recording/action-recorder.ts index 899f1eded..68c93afd4 100644 --- a/src/recording/action-recorder.ts +++ b/src/recording/action-recorder.ts @@ -16,6 +16,7 @@ import { NetworkEntry, ConsoleEntry, } from './types'; +import { TrajectoryBundleWriter, type TrajectoryReport } from '../trajectory/bundle-writer'; /** Arg keys that are always redacted */ const REDACT_KEYS = /password|token|secret|credential|api[_-]?key|authorization|auth[_-]token/i; @@ -47,6 +48,10 @@ export interface StartRecordingOptions { label?: string; /** Browser profile name */ profile?: string; + /** Enable default-off episode trajectory bundle capture (#1059). */ + trajectoryBundle?: boolean; + /** Test/internal override for the trajectory bundle root directory. */ + trajectoryRootDir?: string; } /** @@ -89,6 +94,8 @@ export class ActionRecorder { * never interleave with an in-flight recordAction(). */ private _writeChain: Promise = Promise.resolve(); + private _trajectoryBundle: TrajectoryBundleWriter | null = null; + private _lastTrajectoryReport: TrajectoryReport | null = null; constructor(store?: RecordingStore, configOverrides?: Partial) { this.store = store ?? getRecordingStore(); @@ -116,6 +123,16 @@ export class ActionRecorder { return this._activeRecordingId; } + /** A snapshot of the active trajectory bundle, if enabled for the current recording. */ + get activeTrajectoryBundle(): { enabled: true; trajectory_id: string; dir: string } | null { + return this._trajectoryBundle ? this._trajectoryBundle.snapshot : null; + } + + /** The last finalized trajectory report, if the just-stopped recording had one. */ + get lastTrajectoryReport(): TrajectoryReport | null { + return this._lastTrajectoryReport ? { ...this._lastTrajectoryReport } : null; + } + /** A snapshot copy of the active recording metadata, or null if not recording */ get activeMetadata(): RecordingMetadata | null { if (!this._activeMetadata) return null; @@ -145,6 +162,23 @@ export class ActionRecorder { await this.store.init(); await this.store.createRecording(metadata); + this._trajectoryBundle = null; + this._lastTrajectoryReport = null; + if (opts?.trajectoryBundle === true) { + try { + this._trajectoryBundle = await TrajectoryBundleWriter.create({ + sessionId, + recordingId: id, + rootDir: opts.trajectoryRootDir, + }); + metadata.trajectoryBundle = this._trajectoryBundle.snapshot; + await this.store.writeMetadata(metadata); + } catch (err) { + console.error('[ActionRecorder] Trajectory bundle disabled:', err instanceof Error ? err.message : err); + metadata.trajectoryBundle = { enabled: false }; + } + } + this._activeMetadata = metadata; this._activeRecordingId = id; this._isRecording = true; @@ -175,12 +209,19 @@ export class ActionRecorder { stoppedAt: new Date().toISOString(), }; + if (this._trajectoryBundle) { + const report = await this._trajectoryBundle.finalize(); + this._lastTrajectoryReport = report; + metadata.trajectoryBundle = { ...this._trajectoryBundle.snapshot, report: report as unknown as Record }; + } + await this.store.writeMetadata(metadata); // Reset state this._isRecording = false; this._activeMetadata = null; this._activeRecordingId = null; + this._trajectoryBundle = null; this._seq = 0; return metadata; @@ -228,6 +269,19 @@ export class ActionRecorder { }; await this.store.appendAction(id, action); + if (this._trajectoryBundle) { + await this._trajectoryBundle.appendToolCall({ + tool, + args: sanitizedArgs, + durationMs, + ok, + tabId: action.tabId, + url: action.url, + error: action.error, + screenshotBefore: action.screenshotBefore, + screenshotAfter: action.screenshotAfter, + }); + } // Only advance seq and actionCount after successful write this._seq = seq; @@ -266,12 +320,28 @@ export class ActionRecorder { const actionIndex = this._seq; try { await this.store.appendContractResultRow(id, actionIndex, entry); + if (this._trajectoryBundle) { + await this._trajectoryBundle.appendContract(entry); + } } catch (err) { console.error('[ActionRecorder] Failed to append contract result:', err instanceof Error ? err.message : err); } }); } + + /** + * Append a checkpoint artifact to the active trajectory bundle. + * No-op when recording or trajectory capture is disabled. + */ + async appendCheckpoint(checkpoint: Record): Promise { + if (!this._isRecording || !this._trajectoryBundle) return; + return this.enqueueWrite(async () => { + if (!this._isRecording || !this._trajectoryBundle) return; + await this._trajectoryBundle.appendCheckpoint(checkpoint); + }); + } + /** * Capture a screenshot and save it to the active recording. * Returns the filename on success, or null on failure. diff --git a/src/recording/types.ts b/src/recording/types.ts index 86f613483..bb94576ba 100644 --- a/src/recording/types.ts +++ b/src/recording/types.ts @@ -93,6 +93,8 @@ export interface RecordingMetadata { profile?: string; /** Optional user-supplied label for the recording */ label?: string; + /** Optional active trajectory bundle metadata (#1059). */ + trajectoryBundle?: { enabled: boolean; trajectory_id?: string; dir?: string; report?: Record }; } /** diff --git a/src/tools/checkpoint.ts b/src/tools/checkpoint.ts index 9e7560770..19448500c 100644 --- a/src/tools/checkpoint.ts +++ b/src/tools/checkpoint.ts @@ -12,6 +12,7 @@ import { MCPToolDefinition, MCPResult, ToolHandler } from '../types/mcp'; import { writeFileAtomicSafe, readFileSafe } from '../utils/atomic-file'; import { getSessionManager } from '../session-manager'; import { safeTitle } from '../utils/safe-title'; +import { getActiveActionRecorder } from '../recording/action-recorder'; // ─── Types ───────────────────────────────────────────────────────────────── @@ -122,7 +123,7 @@ async function collectTabStates(): Promise, ): Promise => { const checkpointPath = path.join(CHECKPOINT_DIR, CHECKPOINT_FILE); @@ -147,6 +148,12 @@ const handler: ToolHandler = async ( await fs.promises.mkdir(CHECKPOINT_DIR, { recursive: true }); await writeFileAtomicSafe(checkpointPath, checkpoint); + try { + await getActiveActionRecorder(sessionId)?.appendCheckpoint(checkpoint as unknown as Record); + } catch { + // Best-effort trajectory linkage must never fail checkpoint save. + } + return { content: [ { diff --git a/src/tools/recording.ts b/src/tools/recording.ts index de97cc0f2..b988464d4 100644 --- a/src/tools/recording.ts +++ b/src/tools/recording.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { MCPServer } from '../mcp-server'; import { MCPToolDefinition, MCPResult, ToolHandler } from '../types/mcp'; -import { getActionRecorder } from '../recording/action-recorder'; +import { getActionRecorder, registerSessionRecorder, unregisterSessionRecorder } from '../recording/action-recorder'; import { getRecordingStore } from '../recording/recording-store'; import { RecordingAction, RecordingMetadata } from '../recording/types'; @@ -36,6 +36,10 @@ const startDefinition: MCPToolDefinition = { type: 'string', description: 'Optional browser profile name to associate with this recording.', }, + trajectoryBundle: { + type: 'boolean', + description: 'Default false. When true, write a file-based trajectory bundle under ~/.openchrome/trajectories.', + }, }, }, }; @@ -54,10 +58,13 @@ const startHandler: ToolHandler = async ( } try { - const metadata = await recorder.start(sessionId, { + const startOptions = { label: args.label as string | undefined, profile: args.profile as string | undefined, - }); + ...(args.trajectoryBundle === true ? { trajectoryBundle: true } : {}), + }; + const metadata = await recorder.start(sessionId, startOptions); + registerSessionRecorder(sessionId, recorder); const lines = [ 'Recording started.', @@ -67,6 +74,11 @@ const startHandler: ToolHandler = async ( ]; if (metadata.label) lines.push(` Label: ${metadata.label}`); if (metadata.profile) lines.push(` Profile: ${metadata.profile}`); + const trajectory = recorder.activeTrajectoryBundle || metadata.trajectoryBundle; + if (trajectory?.enabled && trajectory.trajectory_id) { + lines.push(` Trajectory: ${trajectory.trajectory_id}`); + if (trajectory.dir) lines.push(` Bundle: ${trajectory.dir}`); + } return { content: [{ type: 'text', text: lines.join('\n') }] }; } catch (err) { @@ -121,6 +133,13 @@ const stopHandler: ToolHandler = async ( ` Stopped: ${metadata.stoppedAt ?? 'unknown'}`, ]; if (metadata.label) lines.push(` Label: ${metadata.label}`); + if (metadata.trajectoryBundle?.enabled && metadata.trajectoryBundle.trajectory_id) { + lines.push(` Trajectory: ${metadata.trajectoryBundle.trajectory_id}`); + if (metadata.trajectoryBundle.dir) lines.push(` Bundle: ${metadata.trajectoryBundle.dir}`); + const report = metadata.trajectoryBundle.report as Record | undefined; + if (report) lines.push(` Events: ${String(report.total_events ?? 'unknown')}`); + } + unregisterSessionRecorder(metadata.sessionId); return { content: [{ type: 'text', text: lines.join('\n') }] }; } catch (err) { @@ -132,6 +151,32 @@ const stopHandler: ToolHandler = async ( } }; + +// ─── oc_recording_status ───────────────────────────────────────────────────── + +const statusDefinition: MCPToolDefinition = { + name: 'oc_recording_status', + description: 'Report whether session recording is active, including trajectory bundle metadata when enabled.', + inputSchema: { type: 'object', properties: {} }, +}; + +const statusHandler: ToolHandler = async ( + _sessionId: string, + _args: Record, +): Promise => { + const recorder = getActionRecorder(); + const metadata = recorder.activeMetadata; + const trajectory = recorder.activeTrajectoryBundle || metadata?.trajectoryBundle; + const payload = { + active: recorder.isRecording, + recordingId: recorder.activeRecordingId, + sessionId: metadata?.sessionId, + actionCount: metadata?.actionCount ?? 0, + trajectoryBundle: trajectory ?? { enabled: false }, + }; + return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], structuredContent: payload as unknown as Record }; +}; + // ─── oc_recording_list ──────────────────────────────────────────────────────── const listDefinition: MCPToolDefinition = { @@ -421,6 +466,7 @@ function escapeHtml(str: string): string { export function registerRecordingTools(server: MCPServer): void { server.registerTool(startDefinition.name, startHandler, startDefinition); server.registerTool(stopDefinition.name, stopHandler, stopDefinition); + server.registerTool(statusDefinition.name, statusHandler, statusDefinition); server.registerTool(listDefinition.name, listHandler, listDefinition); server.registerTool(exportDefinition.name, exportHandler, exportDefinition); } diff --git a/src/trajectory/bundle-writer.ts b/src/trajectory/bundle-writer.ts new file mode 100644 index 000000000..e81a64268 --- /dev/null +++ b/src/trajectory/bundle-writer.ts @@ -0,0 +1,248 @@ +/** File-based episode trajectory bundle writer (#1059). */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { randomBytes, createHash } from 'crypto'; +import type { ContractResultEntry } from '../recording/types'; + +export type TrajectoryEventKind = 'tool_call_end' | 'checkpoint' | 'contract' | 'hint' | 'recovery' | 'error'; + +export interface TrajectoryEvent { + version: 1; + trajectory_id: string; + seq: number; + ts: number; + sessionId: string; + tabId?: string; + event: TrajectoryEventKind; + tool?: string; + ok?: boolean; + durationMs?: number; + argsSummary?: Record; + resultSummary?: Record; + state?: { url?: string; title?: string; domTextHash?: string; screenshotHash?: string }; + progress?: { status?: 'progressing' | 'stalling' | 'stuck'; noProgressStreak?: number; rule?: string; severity?: 'info' | 'warning' | 'critical' }; + refs?: { beforeScreenshot?: string; afterScreenshot?: string; checkpoint?: string; contractEvidence?: string }; +} + +export interface TrajectoryReport { + trajectory_id: string; + started_at: string; + ended_at?: string; + total_events: number; + tool_calls: number; + failures: number; + progress: { stalling_events: number; stuck_events: number }; + contracts: { pass: number; fail: number; inconclusive: number }; + artifacts: { events: string; screenshots: number; checkpoints: number; contracts: number }; +} + +export interface TrajectoryMeta { + version: 1; + trajectory_id: string; + sessionId: string; + recordingId: string; + started_at: string; + root: string; +} + +const SUMMARY_MAX_BYTES = 4096; +const REDACT_KEYS = /password|token|secret|credential|api[_-]?key|authorization|auth[_-]?token/i; +const REDACT_TOOLS = new Set(['cookies', 'http_auth']); + +export const DEFAULT_TRAJECTORY_ROOT = path.join(os.homedir(), '.openchrome', 'trajectories'); + +export function generateTrajectoryId(): string { + const now = new Date(); + const date = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, '0')}${String(now.getUTCDate()).padStart(2, '0')}`; + const time = `${String(now.getUTCHours()).padStart(2, '0')}${String(now.getUTCMinutes()).padStart(2, '0')}${String(now.getUTCSeconds()).padStart(2, '0')}`; + return `traj-${date}-${time}-${randomBytes(3).toString('hex')}`; +} + +function hashText(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function bounded(value: Record | undefined): Record | undefined { + if (!value) return undefined; + const json = JSON.stringify(value); + const bytes = Buffer.byteLength(json, 'utf8'); + if (bytes <= SUMMARY_MAX_BYTES) return value; + return { truncated: true, originalBytes: bytes, sha256: hashText(json) }; +} + +export function redactSummary(tool: string | undefined, value: Record | undefined): Record | undefined { + if (!value) return undefined; + if (tool && REDACT_TOOLS.has(tool)) return { _redacted: true }; + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (REDACT_KEYS.test(key)) { + out[key] = '[REDACTED]'; + } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + out[key] = redactSummary(undefined, raw as Record); + } else { + out[key] = raw; + } + } + return bounded(out); +} + +export class TrajectoryBundleWriter { + readonly trajectoryId: string; + readonly dir: string; + + private readonly sessionId: string; + private readonly startedAt: string; + private seq = 0; + private disabled = false; + private report: TrajectoryReport; + private writeChain: Promise = Promise.resolve(); + + private constructor(input: { trajectoryId: string; dir: string; sessionId: string; startedAt: string }) { + this.trajectoryId = input.trajectoryId; + this.dir = input.dir; + this.sessionId = input.sessionId; + this.startedAt = input.startedAt; + this.report = { + trajectory_id: input.trajectoryId, + started_at: input.startedAt, + total_events: 0, + tool_calls: 0, + failures: 0, + progress: { stalling_events: 0, stuck_events: 0 }, + contracts: { pass: 0, fail: 0, inconclusive: 0 }, + artifacts: { events: 'events.jsonl', screenshots: 0, checkpoints: 0, contracts: 0 }, + }; + } + + static async create(input: { sessionId: string; recordingId: string; rootDir?: string }): Promise { + const trajectoryId = generateTrajectoryId(); + const root = input.rootDir ?? DEFAULT_TRAJECTORY_ROOT; + const dir = path.join(root, trajectoryId); + const startedAt = new Date().toISOString(); + const writer = new TrajectoryBundleWriter({ trajectoryId, dir, sessionId: input.sessionId, startedAt }); + await fs.promises.mkdir(path.join(dir, 'screenshots'), { recursive: true }); + await fs.promises.mkdir(path.join(dir, 'checkpoints'), { recursive: true }); + await fs.promises.mkdir(path.join(dir, 'contracts'), { recursive: true }); + const meta: TrajectoryMeta = { version: 1, trajectory_id: trajectoryId, sessionId: input.sessionId, recordingId: input.recordingId, started_at: startedAt, root }; + await fs.promises.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2)); + return writer; + } + + get snapshot(): { enabled: true; trajectory_id: string; dir: string } { + return { enabled: true, trajectory_id: this.trajectoryId, dir: this.dir }; + } + + private enqueue(op: () => Promise): Promise { + const next = this.writeChain.then(op, op).catch((err) => { + if (!this.disabled) { + this.disabled = true; + console.error('[TrajectoryBundle] disabled after write failure:', err instanceof Error ? err.message : err); + } + }); + this.writeChain = next.then(() => undefined, () => undefined); + return next; + } + + appendToolCall(input: { tool: string; args: Record; durationMs: number; ok: boolean; tabId?: string; url?: string; error?: string; screenshotBefore?: string; screenshotAfter?: string }): Promise { + return this.appendEvent({ + event: 'tool_call_end', + tool: input.tool, + ok: input.ok, + durationMs: input.durationMs, + tabId: input.tabId, + argsSummary: redactSummary(input.tool, input.args), + resultSummary: redactSummary(undefined, input.error ? { error: input.error } : { ok: input.ok }), + ...(input.url ? { state: { url: input.url } } : {}), + refs: { + ...(input.screenshotBefore ? { beforeScreenshot: input.screenshotBefore } : {}), + ...(input.screenshotAfter ? { afterScreenshot: input.screenshotAfter } : {}), + }, + }); + } + + appendContract(entry: ContractResultEntry): Promise { + return this.enqueue(async () => { + if (this.disabled) return; + const seq = this.nextSeq(); + const filename = `${String(seq).padStart(6, '0')}.json`; + const artifact = redactSummary(undefined, entry as unknown as Record) ?? {}; + const details = redactSummary(undefined, entry.details) ?? {}; + await fs.promises.writeFile(path.join(this.dir, 'contracts', filename), JSON.stringify(artifact, null, 2)); + await this.writeEvent({ + version: 1, + trajectory_id: this.trajectoryId, + seq, + ts: Date.now(), + sessionId: this.sessionId, + event: 'contract', + ok: entry.verdict === 'pass', + resultSummary: { verdict: entry.verdict, ...details }, + refs: { contractEvidence: path.join('contracts', filename) }, + }); + this.report.contracts[entry.verdict] += 1; + this.report.artifacts.contracts += 1; + }); + } + + appendCheckpoint(checkpoint: Record): Promise { + return this.enqueue(async () => { + if (this.disabled) return; + const seq = this.nextSeq(); + const filename = `${String(seq).padStart(6, '0')}.json`; + const redacted = redactSummary(undefined, checkpoint) ?? {}; + await fs.promises.writeFile(path.join(this.dir, 'checkpoints', filename), JSON.stringify(redacted, null, 2)); + await this.writeEvent({ + version: 1, + trajectory_id: this.trajectoryId, + seq, + ts: Date.now(), + sessionId: this.sessionId, + event: 'checkpoint', + ok: true, + resultSummary: { + taskDescription: redacted.taskDescription, + completedSteps: Array.isArray(redacted.completedSteps) ? redacted.completedSteps.length : 0, + pendingSteps: Array.isArray(redacted.pendingSteps) ? redacted.pendingSteps.length : 0, + }, + refs: { checkpoint: path.join('checkpoints', filename) }, + }); + this.report.artifacts.checkpoints += 1; + }); + } + + appendEvent(event: Omit): Promise { + return this.enqueue(async () => { + if (this.disabled) return; + await this.writeEvent({ version: 1, trajectory_id: this.trajectoryId, seq: this.nextSeq(), ts: Date.now(), sessionId: this.sessionId, ...event }); + }); + } + + async finalize(): Promise { + await this.writeChain; + if (!this.disabled) { + this.report.ended_at = new Date().toISOString(); + await fs.promises.writeFile(path.join(this.dir, 'report.json'), JSON.stringify(this.report, null, 2)).catch((err) => { + console.error('[TrajectoryBundle] failed to write report:', err instanceof Error ? err.message : err); + }); + } + return { ...this.report, progress: { ...this.report.progress }, contracts: { ...this.report.contracts }, artifacts: { ...this.report.artifacts } }; + } + + private nextSeq(): number { + this.seq += 1; + return this.seq; + } + + private async writeEvent(event: TrajectoryEvent): Promise { + await fs.promises.appendFile(path.join(this.dir, 'events.jsonl'), JSON.stringify(event) + '\n'); + this.report.total_events += 1; + if (event.event === 'tool_call_end') { + this.report.tool_calls += 1; + if (event.ok === false) this.report.failures += 1; + } + if (event.progress?.status === 'stalling') this.report.progress.stalling_events += 1; + if (event.progress?.status === 'stuck') this.report.progress.stuck_events += 1; + } +} diff --git a/src/trajectory/index.ts b/src/trajectory/index.ts new file mode 100644 index 000000000..5fa745af3 --- /dev/null +++ b/src/trajectory/index.ts @@ -0,0 +1 @@ +export * from './bundle-writer'; diff --git a/tests/cross-env/cursor-verification.test.ts b/tests/cross-env/cursor-verification.test.ts index dc20351e3..e7bd0e9d0 100644 --- a/tests/cross-env/cursor-verification.test.ts +++ b/tests/cross-env/cursor-verification.test.ts @@ -200,7 +200,7 @@ suiteRunner('Cross-Env: Cursor IDE Verification (Issue #509)', () => { 'emulate_device', 'page_pdf', 'page_screenshot', 'page_content', 'console_capture', 'performance_metrics', 'file_upload', 'batch_execute', 'batch_paginate', - 'oc_recording_start', 'oc_recording_stop', 'oc_recording_list', 'oc_recording_export', + 'oc_recording_start', 'oc_recording_stop', 'oc_recording_status', 'oc_recording_list', 'oc_recording_export', ]; for (const tool of expectedTier2) { expect(toolNames).toContain(tool); @@ -294,7 +294,7 @@ suiteRunner('Cross-Env: Cursor IDE Verification (Issue #509)', () => { 'emulate_device', 'page_pdf', 'page_screenshot', 'page_content', 'console_capture', 'performance_metrics', 'file_upload', 'batch_execute', 'batch_paginate', - 'oc_recording_start', 'oc_recording_stop', 'oc_recording_list', 'oc_recording_export', + 'oc_recording_start', 'oc_recording_stop', 'oc_recording_status', 'oc_recording_list', 'oc_recording_export', 'crawl', 'crawl_sitemap', 'vision_find', 'extract_data', 'oc_totp_generate', ]; tier2Tools.forEach(tool => { diff --git a/tests/recording/action-recorder-trajectory.test.ts b/tests/recording/action-recorder-trajectory.test.ts new file mode 100644 index 000000000..d29bc96c8 --- /dev/null +++ b/tests/recording/action-recorder-trajectory.test.ts @@ -0,0 +1,70 @@ +/** Tests for default-off trajectory bundle integration (#1059). */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ActionRecorder } from '../../src/recording/action-recorder'; +import { RecordingStore } from '../../src/recording/recording-store'; + +function tmp(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function jsonl(file: string): any[] { + return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); +} + +describe('ActionRecorder trajectory bundle', () => { + let recordingDir: string; + let trajectoryRoot: string; + let recorder: ActionRecorder; + + beforeEach(() => { + recordingDir = tmp('oc-rec-traj-recording-'); + trajectoryRoot = tmp('oc-rec-traj-root-'); + recorder = new ActionRecorder(new RecordingStore(recordingDir), { captureScreenshots: false }); + }); + + afterEach(() => { + fs.rmSync(recordingDir, { recursive: true, force: true }); + fs.rmSync(trajectoryRoot, { recursive: true, force: true }); + }); + + it('is default-off when recording starts without trajectoryBundle', async () => { + await recorder.start('sess-default'); + await recorder.recordAction('navigate', { url: 'https://example.com' }, 10, true); + await recorder.stop(); + + expect(fs.readdirSync(trajectoryRoot)).toEqual([]); + }); + + it('writes ordered events, redacted summaries, contract artifact, checkpoint artifact, and report', async () => { + const metadata = await recorder.start('sess-traj', { trajectoryBundle: true, trajectoryRootDir: trajectoryRoot }); + expect(metadata.trajectoryBundle?.enabled).toBe(true); + const bundle = recorder.activeTrajectoryBundle!; + + await recorder.recordAction('form_input', { username: 'alice', password: 'super-secret-fixture-password' }, 25, true, { url: 'https://example.com/login' }); + await recorder.appendContractResult({ assertion: { kind: 'dom_text', secret: 'super-secret-fixture-password' }, verdict: 'fail', details: { token: 'super-secret-fixture-password', reason: 'missing text' } }); + await recorder.appendCheckpoint({ taskDescription: 'demo', completedSteps: ['open'], pendingSteps: ['assert'], extractedData: { apiKey: 'super-secret-fixture-password' } }); + const stopped = await recorder.stop(); + + const events = jsonl(path.join(bundle.dir, 'events.jsonl')); + expect(events.map((e) => e.seq)).toEqual([1, 2, 3]); + expect(events[0].event).toBe('tool_call_end'); + expect(events[1].event).toBe('contract'); + expect(events[2].event).toBe('checkpoint'); + expect(JSON.stringify(events)).not.toContain('super-secret-fixture-password'); + + const report = JSON.parse(fs.readFileSync(path.join(bundle.dir, 'report.json'), 'utf8')); + expect(report.tool_calls).toBe(1); + expect(report.contracts.fail).toBe(1); + expect(report.artifacts.checkpoints).toBe(1); + expect(report.artifacts.contracts).toBe(1); + expect(stopped.trajectoryBundle?.report).toBeDefined(); + + expect(fs.readdirSync(path.join(bundle.dir, 'contracts'))).toHaveLength(1); + expect(fs.readdirSync(path.join(bundle.dir, 'checkpoints'))).toHaveLength(1); + expect(fs.readFileSync(path.join(bundle.dir, 'contracts', '000002.json'), 'utf8')).not.toContain('super-secret-fixture-password'); + expect(fs.readFileSync(path.join(bundle.dir, 'checkpoints', '000003.json'), 'utf8')).not.toContain('super-secret-fixture-password'); + }); +}); diff --git a/tests/tools/recording.test.ts b/tests/tools/recording.test.ts index e4ca21878..3ff24f9a7 100644 --- a/tests/tools/recording.test.ts +++ b/tests/tools/recording.test.ts @@ -13,14 +13,20 @@ const mockStart = jest.fn(); const mockStop = jest.fn(); let mockIsRecording = false; let mockActiveRecordingId: string | null = null; +let mockActiveMetadata: Record | null = null; +let mockActiveTrajectoryBundle: Record | null = null; jest.mock('../../src/recording/action-recorder', () => ({ getActionRecorder: jest.fn(() => ({ get isRecording() { return mockIsRecording; }, get activeRecordingId() { return mockActiveRecordingId; }, + get activeMetadata() { return mockActiveMetadata; }, + get activeTrajectoryBundle() { return mockActiveTrajectoryBundle; }, start: mockStart, stop: mockStop, })), + registerSessionRecorder: jest.fn(), + unregisterSessionRecorder: jest.fn(), })); const mockListRecordings = jest.fn(); @@ -100,6 +106,8 @@ describe('recording tools', () => { jest.clearAllMocks(); mockIsRecording = false; mockActiveRecordingId = null; + mockActiveMetadata = null; + mockActiveTrajectoryBundle = null; // Default mock implementations mockListRecordings.mockResolvedValue([]); @@ -116,10 +124,11 @@ describe('recording tools', () => { // ─── Registration ────────────────────────────────────────────────────────── describe('registration', () => { - test('registers all four tools', () => { + test('registers recording tools', () => { const names = server.getToolNames(); expect(names).toContain('oc_recording_start'); expect(names).toContain('oc_recording_stop'); + expect(names).toContain('oc_recording_status'); expect(names).toContain('oc_recording_list'); expect(names).toContain('oc_recording_export'); });