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
44 changes: 44 additions & 0 deletions docs/observability/trajectory-bundles.md
Original file line number Diff line number Diff line change
@@ -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/<trajectory_id>/`:

```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/<id>/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/<id>
```

7. Start/stop a normal recording without `trajectoryBundle:true`; no new trajectory directory should be created.
1 change: 1 addition & 0 deletions src/config/tool-tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const TOOL_TIERS: Record<string, ToolTier> = {
// 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,

Expand Down
1 change: 1 addition & 0 deletions src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const SKIP_RECORDING_TOOLS = new Set([
'oc_recording_start',
'oc_recording_stop',
'oc_recording_list',
'oc_recording_status',
'oc_recording_export',
]);

Expand Down
70 changes: 70 additions & 0 deletions src/recording/action-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -89,6 +94,8 @@ export class ActionRecorder {
* never interleave with an in-flight recordAction().
*/
private _writeChain: Promise<void> = Promise.resolve();
private _trajectoryBundle: TrajectoryBundleWriter | null = null;
private _lastTrajectoryReport: TrajectoryReport | null = null;

constructor(store?: RecordingStore, configOverrides?: Partial<RecordingConfig>) {
this.store = store ?? getRecordingStore();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown> };
}

await this.store.writeMetadata(metadata);

// Reset state
this._isRecording = false;
this._activeMetadata = null;
this._activeRecordingId = null;
this._trajectoryBundle = null;
this._seq = 0;

return metadata;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>): Promise<void> {
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.
Expand Down
2 changes: 2 additions & 0 deletions src/recording/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/tools/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -122,7 +123,7 @@ async function collectTabStates(): Promise<Array<{ tabId: string; url: string; t
// ─── Handler ───────────────────────────────────────────────────────────────

const handler: ToolHandler = async (
_sessionId: string,
sessionId: string,
args: Record<string, unknown>,
): Promise<MCPResult> => {
const checkpointPath = path.join(CHECKPOINT_DIR, CHECKPOINT_FILE);
Expand All @@ -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<string, unknown>);
} catch {
// Best-effort trajectory linkage must never fail checkpoint save.
}

return {
content: [
{
Expand Down
52 changes: 49 additions & 3 deletions src/tools/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.',
},
},
},
};
Expand All @@ -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.',
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string, unknown> | undefined;
if (report) lines.push(` Events: ${String(report.total_events ?? 'unknown')}`);
}
unregisterSessionRecorder(metadata.sessionId);

return { content: [{ type: 'text', text: lines.join('\n') }] };
} catch (err) {
Expand All @@ -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<string, unknown>,
): Promise<MCPResult> => {
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<string, unknown> };
};

// ─── oc_recording_list ────────────────────────────────────────────────────────

const listDefinition: MCPToolDefinition = {
Expand Down Expand Up @@ -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);
}
Loading
Loading