/** * fileRecordingSink — one archived run, one JSON file, in a directory. * * The destination that needs nothing installed: an incident can be inspected * with `ls` and `cat`, and a run archive is a folder you can tar. It is also * the reference implementation of {@link RecordingSink} — a sink is one method, * and this file is what "implement the other ones like this" points at. * * ## The file name is a key, and keys must be injective * * `runId` becomes a file name, which makes `runId ↦ name` a mapping used as a * key: two different runs landing on one name means one archive silently * overwrites another, and the evidence is gone with no error anywhere. So the * mapping is `runId + '.json'` — appending a constant suffix, which is * injective — over a DOMAIN that is asserted rather than assumed, and anything * outside it is refused by name. * * The domain is `[a-z0-9]` followed by `[a-z0-9._-]*`, and every exclusion is * load-bearing: * * • **no uppercase.** This is the one that looks like fussiness and is not. * macOS/APFS and Windows/NTFS are case-INSENSITIVE by default, so `run-A` * and `run-a` are two distinct strings that name ONE file. A mapping that * is injective as a string can still collide as a file name, which is * exactly the bug artifacts/scopePath.ts found on a stock Mac in 9.44.0 — * its conformance battery had pairs for separators, absence markers and * pre-escaped values, and no pair differing only in case. Excluding * uppercase from the domain means no two valid ids differ by case alone, so * there is nothing for a case-folding filesystem to fold together. * • **no `/` or `\`.** A separator inside a field is separator donation: an * id containing one would silently become a path with a directory hop. * • **no leading `.` or `-`.** A leading dot hides the archive from `ls`; a * leading dash is read as a flag by every CLI tool that would then handle * it. Both are enforced by the first-character class. * • **no bare `.` / `..`.** Path navigation, not names. * • **length capped.** `NAME_MAX` is 255 on the common filesystems and the * suffixes here add to it. * • **no Windows reserved device names.** `con`, `nul`, `com1` … are devices * with OR without an extension: `con.json` opens the console, not a file. * * Ids this library mints all satisfy it: `makeRunId()` produces * `run--` and the footprintjs engine produces * `-`. The assertion is there for the ids a CALLER * states, which is the case that is neither controlled nor rare. * * ## Atomic, so a reader never sees half an archive * * The bytes go to a temporary file in the same directory and are then renamed * into place. `rename` within one filesystem is atomic, so a crash mid-write * leaves a `.tmp` nobody reads rather than a truncated `.json` that parses as * far as it got — a half-written archive is the one failure a bug report cannot * survive, because it looks like evidence. * * Writing the same `runId` twice REPLACES the file, atomically. That is the * intended behaviour and worth stating: the run id is the archive's identity, * so a second envelope for one run is a newer version of one archive (a partial * crash dump later superseded by the finished run), not a second archive. * * Node-only. `node:fs` is reached through `lazyRequire`, the same law the other * filesystem adapters follow, so importing the door costs a browser bundle * nothing and constructing one where there is no filesystem refuses by name. */ import type { RecordingSink } from './recordingEnvelope.js'; /** * Raised when a run id cannot safely become a file name. * * Its own class because the fix is never a retry: the caller has to name the * archive something a filesystem can hold one-to-one. */ export declare class UnsafeRecordingIdError extends Error { readonly code: "ERR_UNSAFE_RECORDING_ID"; readonly runId: string; constructor(runId: string, reason: string); } /** * The mapping under test: one run id → one file name, injectively. * * Exported so the collision battery can drive the mapping directly rather than * inferring it from files on a disk. * * @throws {UnsafeRecordingIdError} for any id outside the safe domain. */ export declare function recordingFileName(runId: string): string; /** Options for {@link fileRecordingSink}. */ export interface FileRecordingSinkOptions { /** The archive directory. Created if missing, parents included. */ readonly directory: string; } /** * A directory-backed recording sink — one JSON file per run, written atomically. * * @example * ```ts * const recorder = recordRun(agent); * await agent.run({ message: 'hi' }); * * await persistRecording(recorder, { * sink: fileRecordingSink({ directory: './run-archive' }), * run: { complete: true }, * }); * // → ./run-archive/run-1787093273110-1.json * ``` */ export declare function fileRecordingSink(options: FileRecordingSinkOptions): RecordingSink;