agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
105 lines (104 loc) • 5.11 kB
TypeScript
/**
* 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-<epoch ms>-<seq>` and the footprintjs engine produces
* `<epoch ms>-<padded counter>`. 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;