agentfootprint 9.46.3 → 9.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +49 -2
- package/dist/adapters/code/agentcore.js +35 -7
- package/dist/adapters/code/agentcore.js.map +1 -1
- package/dist/adapters/code/local.js +94 -10
- package/dist/adapters/code/local.js.map +1 -1
- package/dist/core/Agent.js +18 -1
- package/dist/core/Agent.js.map +1 -1
- package/dist/core/agent/AgentBuilder.js +147 -3
- package/dist/core/agent/AgentBuilder.js.map +1 -1
- package/dist/core/agent/runManifest.js +7 -0
- package/dist/core/agent/runManifest.js.map +1 -1
- package/dist/core/checkin.js +16 -2
- package/dist/core/checkin.js.map +1 -1
- package/dist/core/pause.js +64 -1
- package/dist/core/pause.js.map +1 -1
- package/dist/doors/recipes.js +55 -0
- package/dist/doors/recipes.js.map +1 -0
- package/dist/esm/adapters/code/agentcore.js +35 -7
- package/dist/esm/adapters/code/agentcore.js.map +1 -1
- package/dist/esm/adapters/code/local.js +94 -10
- package/dist/esm/adapters/code/local.js.map +1 -1
- package/dist/esm/core/Agent.d.ts +9 -1
- package/dist/esm/core/Agent.js +19 -2
- package/dist/esm/core/Agent.js.map +1 -1
- package/dist/esm/core/agent/AgentBuilder.d.ts +68 -0
- package/dist/esm/core/agent/AgentBuilder.js +147 -3
- package/dist/esm/core/agent/AgentBuilder.js.map +1 -1
- package/dist/esm/core/agent/runManifest.d.ts +18 -0
- package/dist/esm/core/agent/runManifest.js +7 -0
- package/dist/esm/core/agent/runManifest.js.map +1 -1
- package/dist/esm/core/checkin.d.ts +58 -0
- package/dist/esm/core/checkin.js +16 -2
- package/dist/esm/core/checkin.js.map +1 -1
- package/dist/esm/core/pause.d.ts +44 -0
- package/dist/esm/core/pause.js +61 -0
- package/dist/esm/core/pause.js.map +1 -1
- package/dist/esm/doors/recipes.d.ts +38 -0
- package/dist/esm/doors/recipes.js +39 -0
- package/dist/esm/doors/recipes.js.map +1 -0
- package/dist/esm/events/payloads.d.ts +29 -0
- package/dist/esm/hosting/conformance/cases.js +23 -4
- package/dist/esm/hosting/conformance/cases.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/observe.d.ts +2 -0
- package/dist/esm/observe.js +12 -0
- package/dist/esm/observe.js.map +1 -1
- package/dist/esm/recipes/apply.d.ts +67 -0
- package/dist/esm/recipes/apply.js +117 -0
- package/dist/esm/recipes/apply.js.map +1 -0
- package/dist/esm/recipes/defineAgentRecipe.d.ts +68 -0
- package/dist/esm/recipes/defineAgentRecipe.js +112 -0
- package/dist/esm/recipes/defineAgentRecipe.js.map +1 -0
- package/dist/esm/recipes/identifier.d.ts +40 -0
- package/dist/esm/recipes/identifier.js +95 -0
- package/dist/esm/recipes/identifier.js.map +1 -0
- package/dist/esm/recipes/index.d.ts +19 -0
- package/dist/esm/recipes/index.js +19 -0
- package/dist/esm/recipes/index.js.map +1 -0
- package/dist/esm/recipes/provenance.d.ts +57 -0
- package/dist/esm/recipes/provenance.js +53 -0
- package/dist/esm/recipes/provenance.js.map +1 -0
- package/dist/esm/recipes/types.d.ts +134 -0
- package/dist/esm/recipes/types.js +63 -0
- package/dist/esm/recipes/types.js.map +1 -0
- package/dist/esm/recipes/version.d.ts +38 -0
- package/dist/esm/recipes/version.js +84 -0
- package/dist/esm/recipes/version.js.map +1 -0
- package/dist/esm/recorders/observability/fileRecordingSink.d.ts +104 -0
- package/dist/esm/recorders/observability/fileRecordingSink.js +195 -0
- package/dist/esm/recorders/observability/fileRecordingSink.js.map +1 -0
- package/dist/esm/recorders/observability/recordingEnvelope.d.ts +292 -0
- package/dist/esm/recorders/observability/recordingEnvelope.js +375 -0
- package/dist/esm/recorders/observability/recordingEnvelope.js.map +1 -0
- package/dist/hosting/conformance/cases.js +23 -4
- package/dist/hosting/conformance/cases.js.map +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/observe.js +23 -1
- package/dist/observe.js.map +1 -1
- package/dist/recipes/apply.js +125 -0
- package/dist/recipes/apply.js.map +1 -0
- package/dist/recipes/defineAgentRecipe.js +118 -0
- package/dist/recipes/defineAgentRecipe.js.map +1 -0
- package/dist/recipes/identifier.js +100 -0
- package/dist/recipes/identifier.js.map +1 -0
- package/dist/recipes/index.js +24 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/provenance.js +57 -0
- package/dist/recipes/provenance.js.map +1 -0
- package/dist/recipes/types.js +64 -0
- package/dist/recipes/types.js.map +1 -0
- package/dist/recipes/version.js +89 -0
- package/dist/recipes/version.js.map +1 -0
- package/dist/recorders/observability/fileRecordingSink.js +201 -0
- package/dist/recorders/observability/fileRecordingSink.js.map +1 -0
- package/dist/recorders/observability/recordingEnvelope.js +382 -0
- package/dist/recorders/observability/recordingEnvelope.js.map +1 -0
- package/dist/types/adapters/code/agentcore.d.ts.map +1 -1
- package/dist/types/adapters/code/local.d.ts.map +1 -1
- package/dist/types/core/Agent.d.ts +9 -1
- package/dist/types/core/Agent.d.ts.map +1 -1
- package/dist/types/core/agent/AgentBuilder.d.ts +68 -0
- package/dist/types/core/agent/AgentBuilder.d.ts.map +1 -1
- package/dist/types/core/agent/runManifest.d.ts +18 -0
- package/dist/types/core/agent/runManifest.d.ts.map +1 -1
- package/dist/types/core/checkin.d.ts +58 -0
- package/dist/types/core/checkin.d.ts.map +1 -1
- package/dist/types/core/pause.d.ts +44 -0
- package/dist/types/core/pause.d.ts.map +1 -1
- package/dist/types/doors/recipes.d.ts +39 -0
- package/dist/types/doors/recipes.d.ts.map +1 -0
- package/dist/types/events/payloads.d.ts +29 -0
- package/dist/types/events/payloads.d.ts.map +1 -1
- package/dist/types/hosting/conformance/cases.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/observe.d.ts +2 -0
- package/dist/types/observe.d.ts.map +1 -1
- package/dist/types/recipes/apply.d.ts +68 -0
- package/dist/types/recipes/apply.d.ts.map +1 -0
- package/dist/types/recipes/defineAgentRecipe.d.ts +69 -0
- package/dist/types/recipes/defineAgentRecipe.d.ts.map +1 -0
- package/dist/types/recipes/identifier.d.ts +41 -0
- package/dist/types/recipes/identifier.d.ts.map +1 -0
- package/dist/types/recipes/index.d.ts +20 -0
- package/dist/types/recipes/index.d.ts.map +1 -0
- package/dist/types/recipes/provenance.d.ts +58 -0
- package/dist/types/recipes/provenance.d.ts.map +1 -0
- package/dist/types/recipes/types.d.ts +135 -0
- package/dist/types/recipes/types.d.ts.map +1 -0
- package/dist/types/recipes/version.d.ts +39 -0
- package/dist/types/recipes/version.d.ts.map +1 -0
- package/dist/types/recorders/observability/fileRecordingSink.d.ts +105 -0
- package/dist/types/recorders/observability/fileRecordingSink.d.ts.map +1 -0
- package/dist/types/recorders/observability/recordingEnvelope.d.ts +293 -0
- package/dist/types/recorders/observability/recordingEnvelope.d.ts.map +1 -0
- package/package.json +15 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fileRecordingSink — one archived run, one JSON file, in a directory.
|
|
3
|
+
*
|
|
4
|
+
* The destination that needs nothing installed: an incident can be inspected
|
|
5
|
+
* with `ls` and `cat`, and a run archive is a folder you can tar. It is also
|
|
6
|
+
* the reference implementation of {@link RecordingSink} — a sink is one method,
|
|
7
|
+
* and this file is what "implement the other ones like this" points at.
|
|
8
|
+
*
|
|
9
|
+
* ## The file name is a key, and keys must be injective
|
|
10
|
+
*
|
|
11
|
+
* `runId` becomes a file name, which makes `runId ↦ name` a mapping used as a
|
|
12
|
+
* key: two different runs landing on one name means one archive silently
|
|
13
|
+
* overwrites another, and the evidence is gone with no error anywhere. So the
|
|
14
|
+
* mapping is `runId + '.json'` — appending a constant suffix, which is
|
|
15
|
+
* injective — over a DOMAIN that is asserted rather than assumed, and anything
|
|
16
|
+
* outside it is refused by name.
|
|
17
|
+
*
|
|
18
|
+
* The domain is `[a-z0-9]` followed by `[a-z0-9._-]*`, and every exclusion is
|
|
19
|
+
* load-bearing:
|
|
20
|
+
*
|
|
21
|
+
* • **no uppercase.** This is the one that looks like fussiness and is not.
|
|
22
|
+
* macOS/APFS and Windows/NTFS are case-INSENSITIVE by default, so `run-A`
|
|
23
|
+
* and `run-a` are two distinct strings that name ONE file. A mapping that
|
|
24
|
+
* is injective as a string can still collide as a file name, which is
|
|
25
|
+
* exactly the bug artifacts/scopePath.ts found on a stock Mac in 9.44.0 —
|
|
26
|
+
* its conformance battery had pairs for separators, absence markers and
|
|
27
|
+
* pre-escaped values, and no pair differing only in case. Excluding
|
|
28
|
+
* uppercase from the domain means no two valid ids differ by case alone, so
|
|
29
|
+
* there is nothing for a case-folding filesystem to fold together.
|
|
30
|
+
* • **no `/` or `\`.** A separator inside a field is separator donation: an
|
|
31
|
+
* id containing one would silently become a path with a directory hop.
|
|
32
|
+
* • **no leading `.` or `-`.** A leading dot hides the archive from `ls`; a
|
|
33
|
+
* leading dash is read as a flag by every CLI tool that would then handle
|
|
34
|
+
* it. Both are enforced by the first-character class.
|
|
35
|
+
* • **no bare `.` / `..`.** Path navigation, not names.
|
|
36
|
+
* • **length capped.** `NAME_MAX` is 255 on the common filesystems and the
|
|
37
|
+
* suffixes here add to it.
|
|
38
|
+
* • **no Windows reserved device names.** `con`, `nul`, `com1` … are devices
|
|
39
|
+
* with OR without an extension: `con.json` opens the console, not a file.
|
|
40
|
+
*
|
|
41
|
+
* Ids this library mints all satisfy it: `makeRunId()` produces
|
|
42
|
+
* `run-<epoch ms>-<seq>` and the footprintjs engine produces
|
|
43
|
+
* `<epoch ms>-<padded counter>`. The assertion is there for the ids a CALLER
|
|
44
|
+
* states, which is the case that is neither controlled nor rare.
|
|
45
|
+
*
|
|
46
|
+
* ## Atomic, so a reader never sees half an archive
|
|
47
|
+
*
|
|
48
|
+
* The bytes go to a temporary file in the same directory and are then renamed
|
|
49
|
+
* into place. `rename` within one filesystem is atomic, so a crash mid-write
|
|
50
|
+
* leaves a `.tmp` nobody reads rather than a truncated `.json` that parses as
|
|
51
|
+
* far as it got — a half-written archive is the one failure a bug report cannot
|
|
52
|
+
* survive, because it looks like evidence.
|
|
53
|
+
*
|
|
54
|
+
* Writing the same `runId` twice REPLACES the file, atomically. That is the
|
|
55
|
+
* intended behaviour and worth stating: the run id is the archive's identity,
|
|
56
|
+
* so a second envelope for one run is a newer version of one archive (a partial
|
|
57
|
+
* crash dump later superseded by the finished run), not a second archive.
|
|
58
|
+
*
|
|
59
|
+
* Node-only. `node:fs` is reached through `lazyRequire`, the same law the other
|
|
60
|
+
* filesystem adapters follow, so importing the door costs a browser bundle
|
|
61
|
+
* nothing and constructing one where there is no filesystem refuses by name.
|
|
62
|
+
*/
|
|
63
|
+
import { lazyRequire } from '../../lib/lazyRequire.js';
|
|
64
|
+
/** The archive file's extension. Also the tmp file's discriminator. */
|
|
65
|
+
const ENVELOPE_EXTENSION = '.json';
|
|
66
|
+
/** `NAME_MAX` is 255; leave room for the extension and the tmp suffix. */
|
|
67
|
+
const MAX_RUN_ID_LENGTH = 200;
|
|
68
|
+
/** Valid from the first character on — see the module header for each rule. */
|
|
69
|
+
const SAFE_RUN_ID = /^[a-z0-9][a-z0-9._-]*$/;
|
|
70
|
+
/**
|
|
71
|
+
* Reserved on Windows with any extension, so `con.json` is still the console.
|
|
72
|
+
* Checked against the whole id because the id IS the name's stem.
|
|
73
|
+
*/
|
|
74
|
+
const WINDOWS_RESERVED = new Set([
|
|
75
|
+
'con',
|
|
76
|
+
'prn',
|
|
77
|
+
'aux',
|
|
78
|
+
'nul',
|
|
79
|
+
...Array.from({ length: 9 }, (_, i) => `com${String(i + 1)}`),
|
|
80
|
+
...Array.from({ length: 9 }, (_, i) => `lpt${String(i + 1)}`),
|
|
81
|
+
]);
|
|
82
|
+
/**
|
|
83
|
+
* Raised when a run id cannot safely become a file name.
|
|
84
|
+
*
|
|
85
|
+
* Its own class because the fix is never a retry: the caller has to name the
|
|
86
|
+
* archive something a filesystem can hold one-to-one.
|
|
87
|
+
*/
|
|
88
|
+
export class UnsafeRecordingIdError extends Error {
|
|
89
|
+
code = 'ERR_UNSAFE_RECORDING_ID';
|
|
90
|
+
runId;
|
|
91
|
+
constructor(runId, reason) {
|
|
92
|
+
super(`[recording-sink] run id ${JSON.stringify(runId)} cannot become a file name: ${reason}\n\n` +
|
|
93
|
+
`A file name is a key: if two runs can land on one name, one archive overwrites the ` +
|
|
94
|
+
`other and the evidence is gone with nothing raised anywhere. So the safe set is ` +
|
|
95
|
+
`asserted rather than hoped for — lowercase letters, digits, '.', '_' and '-', ` +
|
|
96
|
+
`starting with a letter or digit, at most ${String(MAX_RUN_ID_LENGTH)} characters. ` +
|
|
97
|
+
`(Uppercase is excluded because macOS and Windows fold case: 'run-A' and 'run-a' are ` +
|
|
98
|
+
`two ids and one file.) Either state a run.runId in that set, or write to a sink whose ` +
|
|
99
|
+
`keys are not file names.`);
|
|
100
|
+
this.name = 'UnsafeRecordingIdError';
|
|
101
|
+
this.runId = runId;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The mapping under test: one run id → one file name, injectively.
|
|
106
|
+
*
|
|
107
|
+
* Exported so the collision battery can drive the mapping directly rather than
|
|
108
|
+
* inferring it from files on a disk.
|
|
109
|
+
*
|
|
110
|
+
* @throws {UnsafeRecordingIdError} for any id outside the safe domain.
|
|
111
|
+
*/
|
|
112
|
+
export function recordingFileName(runId) {
|
|
113
|
+
if (typeof runId !== 'string' || runId === '') {
|
|
114
|
+
throw new UnsafeRecordingIdError(String(runId), 'it is not a non-empty string.');
|
|
115
|
+
}
|
|
116
|
+
if (runId.length > MAX_RUN_ID_LENGTH) {
|
|
117
|
+
throw new UnsafeRecordingIdError(runId, `it is ${String(runId.length)} characters; the limit is ${String(MAX_RUN_ID_LENGTH)}.`);
|
|
118
|
+
}
|
|
119
|
+
if (/[A-Z]/.test(runId)) {
|
|
120
|
+
throw new UnsafeRecordingIdError(runId, 'it contains uppercase letters, and case-insensitive filesystems (macOS, Windows) would ' +
|
|
121
|
+
'let it share a file with its lowercase twin.');
|
|
122
|
+
}
|
|
123
|
+
if (!SAFE_RUN_ID.test(runId)) {
|
|
124
|
+
throw new UnsafeRecordingIdError(runId, 'it must start with a lowercase letter or digit and contain only [a-z0-9._-] — path ' +
|
|
125
|
+
'separators, spaces and control characters are not names.');
|
|
126
|
+
}
|
|
127
|
+
if (/^\.+$/.test(runId)) {
|
|
128
|
+
throw new UnsafeRecordingIdError(runId, 'it is only dots, which is path navigation.');
|
|
129
|
+
}
|
|
130
|
+
if (WINDOWS_RESERVED.has(runId)) {
|
|
131
|
+
throw new UnsafeRecordingIdError(runId, `'${runId}' is a reserved device name on Windows, with or without an extension.`);
|
|
132
|
+
}
|
|
133
|
+
return `${runId}${ENVELOPE_EXTENSION}`;
|
|
134
|
+
}
|
|
135
|
+
/** Process-local, so two sinks in one process cannot pick one tmp name. */
|
|
136
|
+
let _tmpSeq = 0;
|
|
137
|
+
/**
|
|
138
|
+
* A directory-backed recording sink — one JSON file per run, written atomically.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* const recorder = recordRun(agent);
|
|
143
|
+
* await agent.run({ message: 'hi' });
|
|
144
|
+
*
|
|
145
|
+
* await persistRecording(recorder, {
|
|
146
|
+
* sink: fileRecordingSink({ directory: './run-archive' }),
|
|
147
|
+
* run: { complete: true },
|
|
148
|
+
* });
|
|
149
|
+
* // → ./run-archive/run-1787093273110-1.json
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function fileRecordingSink(options) {
|
|
153
|
+
const directory = options?.directory;
|
|
154
|
+
if (typeof directory !== 'string' || directory.trim() === '') {
|
|
155
|
+
throw new TypeError(`[recording-sink] fileRecordingSink({ directory: ${JSON.stringify(directory)} }) names no ` +
|
|
156
|
+
`directory. Give it a path — it will be created if it does not exist.`);
|
|
157
|
+
}
|
|
158
|
+
const fs = lazyRequire('node:fs');
|
|
159
|
+
const path = lazyRequire('node:path');
|
|
160
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
161
|
+
return {
|
|
162
|
+
async write(envelope) {
|
|
163
|
+
// Assert the name BEFORE serializing: a refusal should cost nothing, and
|
|
164
|
+
// an id that cannot be filed should not first be turned into megabytes.
|
|
165
|
+
const name = recordingFileName(envelope?.run?.runId);
|
|
166
|
+
const target = path.join(directory, name);
|
|
167
|
+
let text;
|
|
168
|
+
try {
|
|
169
|
+
text = `${JSON.stringify(envelope, null, 2)}\n`;
|
|
170
|
+
}
|
|
171
|
+
catch (cause) {
|
|
172
|
+
// A circular reference or a BigInt inside the recording. Refuse by
|
|
173
|
+
// name: the alternative is a file that exists and is not the run.
|
|
174
|
+
throw new TypeError(`[recording-sink] this recording cannot be written as JSON: ${cause instanceof Error ? cause.message : String(cause)}. A run's state must survive structuredClone to be recorded at all, so this ` +
|
|
175
|
+
`normally means a value was put into state after the run (a circular object, a ` +
|
|
176
|
+
`BigInt). Nothing was written — a partial archive would be worse than none.`);
|
|
177
|
+
}
|
|
178
|
+
// Same directory, so the rename is a same-filesystem move and therefore
|
|
179
|
+
// atomic. `.tmp` can never collide with an archive name: archives always
|
|
180
|
+
// end in `.json`, these always end in `.tmp`.
|
|
181
|
+
const tmp = path.join(directory, `${name}.${String(process.pid)}.${String(++_tmpSeq)}.tmp`);
|
|
182
|
+
try {
|
|
183
|
+
await fs.promises.writeFile(tmp, text, 'utf8');
|
|
184
|
+
await fs.promises.rename(tmp, target);
|
|
185
|
+
}
|
|
186
|
+
catch (cause) {
|
|
187
|
+
// Leave no half-written debris behind, but never mask the real error.
|
|
188
|
+
await fs.promises.rm(tmp, { force: true }).catch(() => undefined);
|
|
189
|
+
throw cause;
|
|
190
|
+
}
|
|
191
|
+
return { id: name, uri: `file://${path.resolve(target)}` };
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=fileRecordingSink.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fileRecordingSink.js","sourceRoot":"","sources":["../../../../src/recorders/observability/fileRecordingSink.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recordingEnvelope — the versioned, serialized contract around a recording.
|
|
3
|
+
*
|
|
4
|
+
* `recordRun` already freezes a run into `{ events, snapshot, structure }`, and
|
|
5
|
+
* that shape is exactly right for handing to a viewer in the same process. What
|
|
6
|
+
* it is NOT is a thing you can put on disk and read back next quarter: it
|
|
7
|
+
* carries no format marker, no producer version, no statement of which run it
|
|
8
|
+
* is, and no statement of whether it is the WHOLE run. So every consumer that
|
|
9
|
+
* wanted to archive one, attach one to a bug report, or feed one to an analysis
|
|
10
|
+
* tool invented its own wrapper — and each wrapper made a different guess about
|
|
11
|
+
* the same missing facts.
|
|
12
|
+
*
|
|
13
|
+
* This is the producer-owned answer: one envelope, one format string, and every
|
|
14
|
+
* field either a fact the library can prove or a fact the caller stated.
|
|
15
|
+
*
|
|
16
|
+
* import { persistRecording, fileRecordingSink } from 'agentfootprint/observe';
|
|
17
|
+
*
|
|
18
|
+
* const recorder = recordRun(agent);
|
|
19
|
+
* await agent.run({ message });
|
|
20
|
+
* await persistRecording(recorder, {
|
|
21
|
+
* sink: fileRecordingSink({ directory: './run-archive' }),
|
|
22
|
+
* run: { complete: true },
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* ## The rule this file exists to keep: never stamp a fact you had to guess
|
|
26
|
+
*
|
|
27
|
+
* An envelope is read by people and tools that were not there when the run
|
|
28
|
+
* happened. That makes every field a claim, and a claim that turns out to be a
|
|
29
|
+
* guess is worse than a missing field — a missing field sends the reader to
|
|
30
|
+
* look, a wrong field stops them looking. So each one has a stated source:
|
|
31
|
+
*
|
|
32
|
+
* runId, sessionId, derived from the EVENT META, which is the only
|
|
33
|
+
* principal, tenant place these are recorded, or stated by the caller.
|
|
34
|
+
* Never synthesized. See "identity" below.
|
|
35
|
+
* startedAt / endedAt derived from event wall clocks, but only where the
|
|
36
|
+
* stream can honestly supply them (see below).
|
|
37
|
+
* complete CALLER INPUT, always. Nothing in a finished
|
|
38
|
+
* recording says whether the run reached its end.
|
|
39
|
+
* droppedEvents read off the live recorder, which counts them; a
|
|
40
|
+
* bare recording carries no count, so it is asked for
|
|
41
|
+
* rather than assumed to be 0.
|
|
42
|
+
* configuration lifted from the run's own `run_configured` event —
|
|
43
|
+
* the manifest, which is names-and-ids only by law.
|
|
44
|
+
* producer read from the package manifests at runtime.
|
|
45
|
+
*
|
|
46
|
+
* Where a fact is neither derivable nor supplied, this REFUSES
|
|
47
|
+
* ({@link IndeterminateRunFactError}) instead of filling in a plausible value.
|
|
48
|
+
*
|
|
49
|
+
* ## Identity is never invented
|
|
50
|
+
*
|
|
51
|
+
* `principal` and `tenant` come from `EventMeta`, and `EventMeta`'s own law
|
|
52
|
+
* (see src/events/types.ts) is that they are stamped ONLY from an explicit
|
|
53
|
+
* `run(input, { identity })` — never from the run's internal identity, and
|
|
54
|
+
* never from a session id, because "a conversation id is not an actor". By
|
|
55
|
+
* sourcing them from the meta and nowhere else, this envelope inherits that
|
|
56
|
+
* guarantee whole: an anonymous run produces an envelope with no `principal`
|
|
57
|
+
* key at all, not a placeholder and not a session id wearing an actor's name.
|
|
58
|
+
*
|
|
59
|
+
* ## `runId` has two namespaces, and this one is deliberate
|
|
60
|
+
*
|
|
61
|
+
* A footprintjs snapshot carries its OWN engine-level `runId`
|
|
62
|
+
* (`<timestamp>-<padded counter>`, minted per `executor.run()`), while the
|
|
63
|
+
* agentfootprint events carry `run-<timestamp>-<seq>` from `makeRunId()`. They
|
|
64
|
+
* are different ids for different layers and they do not match. This envelope
|
|
65
|
+
* uses the EVENT-meta one exclusively, because that is the id `sessionId`,
|
|
66
|
+
* `principal` and `tenant` ride beside and the one every typed-event consumer
|
|
67
|
+
* correlates on. When the events cannot supply it, this refuses rather than
|
|
68
|
+
* falling back to the snapshot's — quietly substituting an id from another
|
|
69
|
+
* namespace would make two envelopes look comparable when they are not.
|
|
70
|
+
*/
|
|
71
|
+
import type { RunManifestLike } from '../../lib/context-bisect/arms/types.js';
|
|
72
|
+
import type { Recording, RunRecorder } from './recordRun.js';
|
|
73
|
+
/**
|
|
74
|
+
* The format marker. Bumped only for a change an older reader could not
|
|
75
|
+
* survive — a reader that does not recognise the string must refuse the file,
|
|
76
|
+
* never half-read it.
|
|
77
|
+
*/
|
|
78
|
+
export declare const RECORDING_ENVELOPE_FORMAT = "agentfootprint.recording.v1";
|
|
79
|
+
/**
|
|
80
|
+
* The privacy policy id a `'full'` envelope carries when the caller names none.
|
|
81
|
+
* A stable string so a retention rule can match on it rather than on prose.
|
|
82
|
+
*/
|
|
83
|
+
export declare const FULL_PRIVACY_POLICY_ID = "agentfootprint.privacy.full.v1";
|
|
84
|
+
/**
|
|
85
|
+
* What a caller may ASK for. Only `'full'` is implemented in v1; the other two
|
|
86
|
+
* are named here so the refusal can name them back, and so a consumer writing
|
|
87
|
+
* against a future version gets a compile-time hint rather than a typo.
|
|
88
|
+
*/
|
|
89
|
+
export type RecordingPrivacyMode = 'full' | 'structure-only' | 'redacted';
|
|
90
|
+
/** Versions of the two libraries that produced the bytes. */
|
|
91
|
+
export interface RecordingProducer {
|
|
92
|
+
/** This library's version, or `'unknown'` when the manifest is unreadable. */
|
|
93
|
+
readonly agentfootprintVersion: string;
|
|
94
|
+
/** The engine underneath it, or `'unknown'`. */
|
|
95
|
+
readonly footprintjsVersion: string;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* WHICH run this is, and how much of it this is.
|
|
99
|
+
*
|
|
100
|
+
* Every field is either derived from the recording's own events or stated by
|
|
101
|
+
* the caller — see the module header for the per-field source.
|
|
102
|
+
*/
|
|
103
|
+
export interface RecordingRun {
|
|
104
|
+
/** From `EventMeta.runId` — the agentfootprint run id, not the engine's. */
|
|
105
|
+
readonly runId: string;
|
|
106
|
+
/** Present only when the run was session-bound. Never invented. */
|
|
107
|
+
readonly sessionId?: string;
|
|
108
|
+
/** The actor the caller NAMED. Absent for an anonymous run. */
|
|
109
|
+
readonly principal?: string;
|
|
110
|
+
/** The tenant boundary the caller NAMED. Absent for a single-tenant run. */
|
|
111
|
+
readonly tenant?: string;
|
|
112
|
+
/** ISO 8601. The first recorded event's wall clock, unless the caller said. */
|
|
113
|
+
readonly startedAt: string;
|
|
114
|
+
/**
|
|
115
|
+
* ISO 8601. Absent when the recording is not `complete` and the caller named
|
|
116
|
+
* no end — a run that had not finished has no end time to report.
|
|
117
|
+
*/
|
|
118
|
+
readonly endedAt?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Did this recording capture the run through to its end?
|
|
121
|
+
*
|
|
122
|
+
* ALWAYS the caller's statement. A frozen recording looks identical whether
|
|
123
|
+
* it was taken after the run or from a crash handler mid-run, and there is no
|
|
124
|
+
* run-terminal event in the registry to check, so the library cannot know.
|
|
125
|
+
* Defaulting it to `true` would make every crash dump claim to be whole.
|
|
126
|
+
*/
|
|
127
|
+
readonly complete: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Events discarded to stay under the recorder's `maxEvents` cap. `0` means
|
|
130
|
+
* "none were dropped", proven — never "we did not look".
|
|
131
|
+
*/
|
|
132
|
+
readonly droppedEvents: number;
|
|
133
|
+
}
|
|
134
|
+
/** What the agent was configured as — names and ids only, no values. */
|
|
135
|
+
export interface RecordingConfiguration {
|
|
136
|
+
readonly agentId?: string;
|
|
137
|
+
/**
|
|
138
|
+
* The run manifest, as the run itself declared it in
|
|
139
|
+
* `agentfootprint.agent.run_configured`.
|
|
140
|
+
*
|
|
141
|
+
* Safe to archive by construction: the manifest's own law is NAMES AND IDS
|
|
142
|
+
* ONLY — no endpoints, directories, connection strings or keys — precisely
|
|
143
|
+
* because it was built to ride into recordings and shared traces.
|
|
144
|
+
*/
|
|
145
|
+
readonly manifest?: RunManifestLike;
|
|
146
|
+
}
|
|
147
|
+
/** What was done to the bytes before they were stored. */
|
|
148
|
+
export interface RecordingPrivacy {
|
|
149
|
+
/**
|
|
150
|
+
* `'full'` — the recording is exactly what `recordRun` captured, unredacted.
|
|
151
|
+
*
|
|
152
|
+
* A v1 envelope can say nothing else: the redacting modes are not built, and
|
|
153
|
+
* this refuses to produce a label it cannot honour.
|
|
154
|
+
*/
|
|
155
|
+
readonly mode: 'full';
|
|
156
|
+
/** Names the policy the producer applied. See {@link FULL_PRIVACY_POLICY_ID}. */
|
|
157
|
+
readonly policyId: string;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* One archived run: the recording, plus everything a reader needs to know what
|
|
161
|
+
* they are holding.
|
|
162
|
+
*
|
|
163
|
+
* Plain JSON by construction — no Dates, no Maps, no live handles — so
|
|
164
|
+
* `JSON.parse(JSON.stringify(envelope))` is the same envelope. Absent optional
|
|
165
|
+
* fields are absent KEYS rather than keys with `undefined`, which is what makes
|
|
166
|
+
* that round trip exact.
|
|
167
|
+
*/
|
|
168
|
+
export interface RecordingEnvelope {
|
|
169
|
+
readonly format: typeof RECORDING_ENVELOPE_FORMAT;
|
|
170
|
+
readonly producer: RecordingProducer;
|
|
171
|
+
readonly run: RecordingRun;
|
|
172
|
+
readonly configuration?: RecordingConfiguration;
|
|
173
|
+
readonly privacy: RecordingPrivacy;
|
|
174
|
+
/** The recording itself, unmodified — `{ snapshot, events, structure }`. */
|
|
175
|
+
readonly recording: Recording;
|
|
176
|
+
}
|
|
177
|
+
/** A timestamp a caller may state, in any of the three obvious spellings. */
|
|
178
|
+
export type RecordingTimestamp = string | number | Date;
|
|
179
|
+
/** The run facts a caller states — the half the library cannot derive. */
|
|
180
|
+
export interface RecordingRunFacts {
|
|
181
|
+
/**
|
|
182
|
+
* Did the recording capture the run through to its end?
|
|
183
|
+
*
|
|
184
|
+
* Required, and deliberately so: this is the one field with no derivation and
|
|
185
|
+
* no safe default, so the API asks rather than guesses. Say `false` for a
|
|
186
|
+
* recording frozen from a crash handler, a timeout, or mid-stream.
|
|
187
|
+
*/
|
|
188
|
+
readonly complete: boolean;
|
|
189
|
+
/** Override the event-derived run id, or supply it when events cannot. */
|
|
190
|
+
readonly runId?: string;
|
|
191
|
+
readonly sessionId?: string;
|
|
192
|
+
readonly principal?: string;
|
|
193
|
+
readonly tenant?: string;
|
|
194
|
+
readonly startedAt?: RecordingTimestamp;
|
|
195
|
+
readonly endedAt?: RecordingTimestamp;
|
|
196
|
+
/**
|
|
197
|
+
* Events lost to the recorder's cap. Read off a live `recordRun` handle
|
|
198
|
+
* automatically; state it when persisting a bare `Recording`, whose shape
|
|
199
|
+
* carries no count.
|
|
200
|
+
*/
|
|
201
|
+
readonly droppedEvents?: number;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Where an envelope goes. One method, so a destination is a few lines: a
|
|
205
|
+
* directory, an object store, a table, an HTTP endpoint.
|
|
206
|
+
*/
|
|
207
|
+
export interface RecordingSink {
|
|
208
|
+
/**
|
|
209
|
+
* Store one envelope.
|
|
210
|
+
*
|
|
211
|
+
* @returns `id` the sink's own handle for what it just stored.
|
|
212
|
+
* `uri` where it landed, when the sink has a meaningful address.
|
|
213
|
+
*/
|
|
214
|
+
write(envelope: RecordingEnvelope): Promise<{
|
|
215
|
+
id: string;
|
|
216
|
+
uri?: string;
|
|
217
|
+
}>;
|
|
218
|
+
}
|
|
219
|
+
/** Options for {@link persistRecording}. */
|
|
220
|
+
export interface PersistRecordingOptions {
|
|
221
|
+
readonly sink: RecordingSink;
|
|
222
|
+
/**
|
|
223
|
+
* The run facts. Required because {@link RecordingRunFacts.complete} is:
|
|
224
|
+
* an envelope that did not state whether it holds a whole run is an envelope
|
|
225
|
+
* whose reader has to guess.
|
|
226
|
+
*/
|
|
227
|
+
readonly run: RecordingRunFacts;
|
|
228
|
+
/** Override the manifest lifted from the run's own `run_configured` event. */
|
|
229
|
+
readonly configuration?: RecordingConfiguration;
|
|
230
|
+
readonly privacy?: {
|
|
231
|
+
readonly mode?: RecordingPrivacyMode;
|
|
232
|
+
readonly policyId?: string;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Options for {@link buildRecordingEnvelope}. */
|
|
236
|
+
export type BuildRecordingEnvelopeOptions = Omit<PersistRecordingOptions, 'sink'>;
|
|
237
|
+
/** A recording to envelope, or the live handle that is still collecting one. */
|
|
238
|
+
export type RecordingSource = Recording | RunRecorder;
|
|
239
|
+
/**
|
|
240
|
+
* Raised when a privacy mode is asked for that this version cannot honour.
|
|
241
|
+
*
|
|
242
|
+
* Its own class because the answer is never "retry": a caller catching this has
|
|
243
|
+
* to change what they store or how they store it.
|
|
244
|
+
*/
|
|
245
|
+
export declare class UnsupportedPrivacyModeError extends Error {
|
|
246
|
+
readonly code: "ERR_UNSUPPORTED_PRIVACY_MODE";
|
|
247
|
+
readonly mode: string;
|
|
248
|
+
constructor(mode: string);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Raised when a required run fact is neither derivable from the recording nor
|
|
252
|
+
* stated by the caller.
|
|
253
|
+
*
|
|
254
|
+
* Carries the `field` so a caller can catch it and supply exactly that one.
|
|
255
|
+
*/
|
|
256
|
+
export declare class IndeterminateRunFactError extends Error {
|
|
257
|
+
readonly code: "ERR_INDETERMINATE_RUN_FACT";
|
|
258
|
+
readonly field: string;
|
|
259
|
+
constructor(field: string, detail: string);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Freeze a recording into an archivable envelope, without storing it.
|
|
263
|
+
*
|
|
264
|
+
* The half of {@link persistRecording} that has no destination — useful when
|
|
265
|
+
* the envelope goes somewhere this library should not know about (a request
|
|
266
|
+
* body, a queue), and the unit under test for the contract itself.
|
|
267
|
+
*/
|
|
268
|
+
export declare function buildRecordingEnvelope(source: RecordingSource, options: BuildRecordingEnvelopeOptions): RecordingEnvelope;
|
|
269
|
+
/**
|
|
270
|
+
* Build the envelope and hand it to a sink.
|
|
271
|
+
*
|
|
272
|
+
* @param source the handle from `recordRun(agent)`, or the recording it made.
|
|
273
|
+
* Prefer the handle: it is the only thing that knows how many
|
|
274
|
+
* events the cap discarded.
|
|
275
|
+
* @param options the destination, the run facts, and the privacy statement.
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* const recorder = recordRun(agent);
|
|
280
|
+
* await agent.run({ message: 'Weather in San Francisco?' });
|
|
281
|
+
*
|
|
282
|
+
* const { uri } = await persistRecording(recorder, {
|
|
283
|
+
* sink: fileRecordingSink({ directory: './run-archive' }),
|
|
284
|
+
* run: { complete: true },
|
|
285
|
+
* });
|
|
286
|
+
* recorder.stop();
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
export declare function persistRecording(source: RecordingSource, options: PersistRecordingOptions): Promise<{
|
|
290
|
+
id: string;
|
|
291
|
+
uri?: string;
|
|
292
|
+
}>;
|