/** * recordingEnvelope — the versioned, serialized contract around a recording. * * `recordRun` already freezes a run into `{ events, snapshot, structure }`, and * that shape is exactly right for handing to a viewer in the same process. What * it is NOT is a thing you can put on disk and read back next quarter: it * carries no format marker, no producer version, no statement of which run it * is, and no statement of whether it is the WHOLE run. So every consumer that * wanted to archive one, attach one to a bug report, or feed one to an analysis * tool invented its own wrapper — and each wrapper made a different guess about * the same missing facts. * * This is the producer-owned answer: one envelope, one format string, and every * field either a fact the library can prove or a fact the caller stated. * * import { persistRecording, fileRecordingSink } from 'agentfootprint/observe'; * * const recorder = recordRun(agent); * await agent.run({ message }); * await persistRecording(recorder, { * sink: fileRecordingSink({ directory: './run-archive' }), * run: { complete: true }, * }); * * ## The rule this file exists to keep: never stamp a fact you had to guess * * An envelope is read by people and tools that were not there when the run * happened. That makes every field a claim, and a claim that turns out to be a * guess is worse than a missing field — a missing field sends the reader to * look, a wrong field stops them looking. So each one has a stated source: * * runId, sessionId, derived from the EVENT META, which is the only * principal, tenant place these are recorded, or stated by the caller. * Never synthesized. See "identity" below. * startedAt / endedAt derived from event wall clocks, but only where the * stream can honestly supply them (see below). * complete CALLER INPUT, always. Nothing in a finished * recording says whether the run reached its end. * droppedEvents read off the live recorder, which counts them; a * bare recording carries no count, so it is asked for * rather than assumed to be 0. * configuration lifted from the run's own `run_configured` event — * the manifest, which is names-and-ids only by law. * producer read from the package manifests at runtime. * * Where a fact is neither derivable nor supplied, this REFUSES * ({@link IndeterminateRunFactError}) instead of filling in a plausible value. * * ## Identity is never invented * * `principal` and `tenant` come from `EventMeta`, and `EventMeta`'s own law * (see src/events/types.ts) is that they are stamped ONLY from an explicit * `run(input, { identity })` — never from the run's internal identity, and * never from a session id, because "a conversation id is not an actor". By * sourcing them from the meta and nowhere else, this envelope inherits that * guarantee whole: an anonymous run produces an envelope with no `principal` * key at all, not a placeholder and not a session id wearing an actor's name. * * ## `runId` has two namespaces, and this one is deliberate * * A footprintjs snapshot carries its OWN engine-level `runId` * (`-`, minted per `executor.run()`), while the * agentfootprint events carry `run--` from `makeRunId()`. They * are different ids for different layers and they do not match. This envelope * uses the EVENT-meta one exclusively, because that is the id `sessionId`, * `principal` and `tenant` ride beside and the one every typed-event consumer * correlates on. When the events cannot supply it, this refuses rather than * falling back to the snapshot's — quietly substituting an id from another * namespace would make two envelopes look comparable when they are not. */ import type { RunManifestLike } from '../../lib/context-bisect/arms/types.js'; import type { Recording, RunRecorder } from './recordRun.js'; /** * The format marker. Bumped only for a change an older reader could not * survive — a reader that does not recognise the string must refuse the file, * never half-read it. */ export declare const RECORDING_ENVELOPE_FORMAT = "agentfootprint.recording.v1"; /** * The privacy policy id a `'full'` envelope carries when the caller names none. * A stable string so a retention rule can match on it rather than on prose. */ export declare const FULL_PRIVACY_POLICY_ID = "agentfootprint.privacy.full.v1"; /** * What a caller may ASK for. Only `'full'` is implemented in v1; the other two * are named here so the refusal can name them back, and so a consumer writing * against a future version gets a compile-time hint rather than a typo. */ export type RecordingPrivacyMode = 'full' | 'structure-only' | 'redacted'; /** Versions of the two libraries that produced the bytes. */ export interface RecordingProducer { /** This library's version, or `'unknown'` when the manifest is unreadable. */ readonly agentfootprintVersion: string; /** The engine underneath it, or `'unknown'`. */ readonly footprintjsVersion: string; } /** * WHICH run this is, and how much of it this is. * * Every field is either derived from the recording's own events or stated by * the caller — see the module header for the per-field source. */ export interface RecordingRun { /** From `EventMeta.runId` — the agentfootprint run id, not the engine's. */ readonly runId: string; /** Present only when the run was session-bound. Never invented. */ readonly sessionId?: string; /** The actor the caller NAMED. Absent for an anonymous run. */ readonly principal?: string; /** The tenant boundary the caller NAMED. Absent for a single-tenant run. */ readonly tenant?: string; /** ISO 8601. The first recorded event's wall clock, unless the caller said. */ readonly startedAt: string; /** * ISO 8601. Absent when the recording is not `complete` and the caller named * no end — a run that had not finished has no end time to report. */ readonly endedAt?: string; /** * Did this recording capture the run through to its end? * * ALWAYS the caller's statement. A frozen recording looks identical whether * it was taken after the run or from a crash handler mid-run, and there is no * run-terminal event in the registry to check, so the library cannot know. * Defaulting it to `true` would make every crash dump claim to be whole. */ readonly complete: boolean; /** * Events discarded to stay under the recorder's `maxEvents` cap. `0` means * "none were dropped", proven — never "we did not look". */ readonly droppedEvents: number; /** * The ORIGINAL stream position of the first retained event (9.60.0) — the * envelope's events are stream positions * `[firstRetainedEventIndex, firstRetainedEventIndex + events.length)`. * A drop COUNT says how much is gone; this says WHERE the kept window * starts, which is what lets a reader align this tail against another * record of the same run. Present exactly when the source could prove it * (the live recordRun handle, or the caller's own statement beside * `droppedEvents`); absent means "not proven", never 0-by-assumption. */ readonly firstRetainedEventIndex?: number; } /** What the agent was configured as — names and ids only, no values. */ export interface RecordingConfiguration { readonly agentId?: string; /** * The run manifest, as the run itself declared it in * `agentfootprint.agent.run_configured`. * * Safe to archive by construction: the manifest's own law is NAMES AND IDS * ONLY — no endpoints, directories, connection strings or keys — precisely * because it was built to ride into recordings and shared traces. */ readonly manifest?: RunManifestLike; } /** What was done to the bytes before they were stored. */ export interface RecordingPrivacy { /** * `'full'` — the recording is exactly what `recordRun` captured, unredacted. * * A v1 envelope can say nothing else: the redacting modes are not built, and * this refuses to produce a label it cannot honour. */ readonly mode: 'full'; /** Names the policy the producer applied. See {@link FULL_PRIVACY_POLICY_ID}. */ readonly policyId: string; } /** * One archived run: the recording, plus everything a reader needs to know what * they are holding. * * Plain JSON by construction — no Dates, no Maps, no live handles — so * `JSON.parse(JSON.stringify(envelope))` is the same envelope. Absent optional * fields are absent KEYS rather than keys with `undefined`, which is what makes * that round trip exact. */ export interface RecordingEnvelope { readonly format: typeof RECORDING_ENVELOPE_FORMAT; readonly producer: RecordingProducer; readonly run: RecordingRun; readonly configuration?: RecordingConfiguration; readonly privacy: RecordingPrivacy; /** The recording itself, unmodified — `{ snapshot, events, structure }`. */ readonly recording: Recording; } /** A timestamp a caller may state, in any of the three obvious spellings. */ export type RecordingTimestamp = string | number | Date; /** The run facts a caller states — the half the library cannot derive. */ export interface RecordingRunFacts { /** * Did the recording capture the run through to its end? * * Required, and deliberately so: this is the one field with no derivation and * no safe default, so the API asks rather than guesses. Say `false` for a * recording frozen from a crash handler, a timeout, or mid-stream. */ readonly complete: boolean; /** Override the event-derived run id, or supply it when events cannot. */ readonly runId?: string; readonly sessionId?: string; readonly principal?: string; readonly tenant?: string; readonly startedAt?: RecordingTimestamp; readonly endedAt?: RecordingTimestamp; /** * Events lost to the recorder's cap. Read off a live `recordRun` handle * automatically; state it when persisting a bare `Recording`, whose shape * carries no count. */ readonly droppedEvents?: number; /** * Where the kept event window starts in the original stream. Read off a * live handle automatically; state it only when persisting a bare * `Recording` AND you can prove it. Refused if negative or fractional. */ readonly firstRetainedEventIndex?: number; } /** * Where an envelope goes. One method, so a destination is a few lines: a * directory, an object store, a table, an HTTP endpoint. */ export interface RecordingSink { /** * Store one envelope. * * @returns `id` the sink's own handle for what it just stored. * `uri` where it landed, when the sink has a meaningful address. */ write(envelope: RecordingEnvelope): Promise<{ id: string; uri?: string; }>; } /** Options for {@link persistRecording}. */ export interface PersistRecordingOptions { readonly sink: RecordingSink; /** * The run facts. Required because {@link RecordingRunFacts.complete} is: * an envelope that did not state whether it holds a whole run is an envelope * whose reader has to guess. */ readonly run: RecordingRunFacts; /** Override the manifest lifted from the run's own `run_configured` event. */ readonly configuration?: RecordingConfiguration; readonly privacy?: { readonly mode?: RecordingPrivacyMode; readonly policyId?: string; }; } /** Options for {@link buildRecordingEnvelope}. */ export type BuildRecordingEnvelopeOptions = Omit; /** A recording to envelope, or the live handle that is still collecting one. */ export type RecordingSource = Recording | RunRecorder; /** * Raised when a privacy mode is asked for that this version cannot honour. * * Its own class because the answer is never "retry": a caller catching this has * to change what they store or how they store it. */ export declare class UnsupportedPrivacyModeError extends Error { readonly code: "ERR_UNSUPPORTED_PRIVACY_MODE"; readonly mode: string; constructor(mode: string); } /** * Raised when a required run fact is neither derivable from the recording nor * stated by the caller. * * Carries the `field` so a caller can catch it and supply exactly that one. */ export declare class IndeterminateRunFactError extends Error { readonly code: "ERR_INDETERMINATE_RUN_FACT"; readonly field: string; constructor(field: string, detail: string); } /** * Freeze a recording into an archivable envelope, without storing it. * * The half of {@link persistRecording} that has no destination — useful when * the envelope goes somewhere this library should not know about (a request * body, a queue), and the unit under test for the contract itself. */ export declare function buildRecordingEnvelope(source: RecordingSource, options: BuildRecordingEnvelopeOptions): RecordingEnvelope; /** * Build the envelope and hand it to a sink. * * @param source the handle from `recordRun(agent)`, or the recording it made. * Prefer the handle: it is the only thing that knows how many * events the cap discarded. * @param options the destination, the run facts, and the privacy statement. * * @example * ```ts * const recorder = recordRun(agent); * await agent.run({ message: 'Weather in San Francisco?' }); * * const { uri } = await persistRecording(recorder, { * sink: fileRecordingSink({ directory: './run-archive' }), * run: { complete: true }, * }); * recorder.stop(); * ``` */ export declare function persistRecording(source: RecordingSource, options: PersistRecordingOptions): Promise<{ id: string; uri?: string; }>;