-
Notifications
You must be signed in to change notification settings - Fork 46
Expand file tree
/
Copy pathjournal.ts
More file actions
259 lines (238 loc) · 9.96 KB
/
Copy pathjournal.ts
File metadata and controls
259 lines (238 loc) · 9.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import { generateId } from "./helpers.js";
import type { ChatCompletionRequest, Fixture, FixtureMatch, JournalEntry } from "./types.js";
import { DEFAULT_TEST_ID } from "./constants.js";
export { DEFAULT_TEST_ID } from "./constants.js";
/**
* Maximum UTF-8 byte length of a serialized request body retained in a
* journal entry. Bodies whose JSON serialization exceeds this byte size are
* replaced with a truncation marker.
*
* Only the request `body` field is capped here. `headers` and `path` are
* retained uncapped, but they are naturally bounded by Node's default ~16 KB
* maximum HTTP header size, so the per-entry overhead beyond the body cap
* remains small. Combined with the journal's maxEntries limit (default 1000),
* the total `JSON.stringify(journal.getAll())` output stays well under V8's
* ~512 MB string limit even at maximum capacity.
*/
const JOURNAL_BODY_CAP_BYTES = 64 * 1024; // 64 KB
/**
* If `body` serializes to more than JOURNAL_BODY_CAP_BYTES (in UTF-8 bytes),
* replace it with a truncation marker. Returns the original body when within
* the cap, or null when `body` is null.
*
* The gate uses Buffer.byteLength (UTF-8 bytes) rather than .length (UTF-16
* code units) to match the constant name and to correctly cap multibyte
* content (e.g. CJK/emoji) whose code-unit count falls under the threshold
* but whose byte size does not.
*/
function capBody(body: ChatCompletionRequest | null): ChatCompletionRequest | null {
if (body === null) return null;
const serialized = JSON.stringify(body);
if (Buffer.byteLength(serialized, "utf8") <= JOURNAL_BODY_CAP_BYTES) return body;
// Cast: the marker is not a real ChatCompletionRequest, but the field type
// is already nullable-union and downstream consumers (e.g. GET /journal)
// treat the body as opaque JSON — the truncation marker is safe to store.
return {
__aimock_truncated: true,
originalByteSize: Buffer.byteLength(serialized, "utf8"),
note: "body truncated by aimock journal cap (64 KB limit)",
} as unknown as ChatCompletionRequest;
}
/**
* Compare two field values, handling RegExp by source+flags rather than reference.
*/
function fieldEqual(a: unknown, b: unknown): boolean {
if (a instanceof RegExp && b instanceof RegExp)
return a.source === b.source && a.flags === b.flags;
return a === b;
}
/**
* Compare two systemMessage values. Handles string, string[], and RegExp.
* Both-undefined is treated as equal.
*/
function systemMessageEqual(
a: string | string[] | RegExp | undefined,
b: string | string[] | RegExp | undefined,
): boolean {
if (a === undefined && b === undefined) return true;
if (a === undefined || b === undefined) return false;
if (typeof a === "string" && typeof b === "string") return a === b;
if (Array.isArray(a) && Array.isArray(b))
return a.length === b.length && a.every((v, i) => v === b[i]);
if (a instanceof RegExp && b instanceof RegExp)
return a.source === b.source && a.flags === b.flags;
return false;
}
/**
* Check whether two fixture match objects have the same criteria
* (ignoring sequenceIndex). Used to group sequenced fixtures.
*/
function matchCriteriaEqual(a: FixtureMatch, b: FixtureMatch): boolean {
return (
fieldEqual(a.userMessage, b.userMessage) &&
systemMessageEqual(a.systemMessage, b.systemMessage) &&
fieldEqual(a.inputText, b.inputText) &&
fieldEqual(a.toolCallId, b.toolCallId) &&
fieldEqual(a.toolName, b.toolName) &&
fieldEqual(a.model, b.model) &&
fieldEqual(a.responseFormat, b.responseFormat) &&
fieldEqual(a.predicate, b.predicate) &&
fieldEqual(a.endpoint, b.endpoint) &&
fieldEqual(a.turnIndex, b.turnIndex) &&
fieldEqual(a.hasToolResult, b.hasToolResult)
);
}
export interface JournalOptions {
/**
* Maximum number of entries to retain. When exceeded, oldest entries are
* dropped FIFO. Set to 0 (or omit) for unbounded retention (the historical
* default — suitable for short-lived test runs only). Negative values are
* rejected at the CLI parse layer; programmatically they are treated as 0
* (unbounded) for back-compat.
*
* Long-running servers (e.g. mock proxies in CI/demo environments) should
* always set a finite cap: every request appends an entry holding the
* request body + headers + fixture reference, and without a cap the
* journal grows until the process OOMs.
*/
maxEntries?: number;
/**
* Maximum number of unique testIds retained in the fixture match-count
* map (`fixtureMatchCountsByTestId`). When exceeded, the oldest testId
* (by first-insertion order) is evicted FIFO. Set to 0 (or omit) for
* unbounded retention. Negative values are rejected at the CLI parse
* layer; programmatically they are treated as 0 (unbounded) for
* back-compat. Without a cap this map can grow over time in long-running
* servers that see many unique testIds.
*/
fixtureCountsMaxTestIds?: number;
}
export class Journal {
private entries: JournalEntry[] = [];
private readonly fixtureMatchCountsByTestId: Map<string, Map<Fixture, number>> = new Map();
private readonly maxEntries: number;
private readonly fixtureCountsMaxTestIds: number;
constructor(options: JournalOptions = {}) {
// Treat 0 or negative as "unbounded" to preserve prior behavior when
// the option is omitted or explicitly disabled.
const cap = options.maxEntries;
this.maxEntries = cap !== undefined && cap > 0 ? cap : 0;
const testIdCap = options.fixtureCountsMaxTestIds;
this.fixtureCountsMaxTestIds = testIdCap !== undefined && testIdCap > 0 ? testIdCap : 0;
}
/** Backwards-compatible accessor — returns the default (no testId) count map. */
get fixtureMatchCounts(): Map<Fixture, number> {
return this.getFixtureMatchCountsForTest(DEFAULT_TEST_ID);
}
add(entry: Omit<JournalEntry, "id" | "timestamp">): JournalEntry {
const full: JournalEntry = {
id: generateId("req"),
timestamp: Date.now(),
...entry,
body: capBody(entry.body),
};
this.entries.push(full);
// FIFO eviction when over capacity. Array.prototype.shift() is O(n)
// regardless of how many we drop per add; we accept it at small caps
// (default 1000) because the constant factor is tiny and this runs once
// per request. For much larger caps, switch to a ring buffer for true
// O(1) eviction.
if (this.maxEntries > 0 && this.entries.length > this.maxEntries) {
this.entries.shift();
}
return full;
}
getAll(opts?: { limit?: number }): JournalEntry[] {
if (opts?.limit !== undefined) {
return this.entries.slice(-opts.limit);
}
return this.entries.slice();
}
getLast(): JournalEntry | null {
return this.entries.length > 0 ? this.entries[this.entries.length - 1] : null;
}
findByFixture(fixture: Fixture): JournalEntry[] {
return this.entries.filter((e) => e.response.fixture === fixture);
}
/**
* READ-ONLY accessor. Returns the existing count map for `testId`, or an
* empty transient Map if none exists. Does NOT insert into the cache and
* does NOT trigger FIFO eviction — callers may read freely without
* perturbing cache state. For the write path, see
* `getOrCreateFixtureMatchCountsForTest`.
*/
getFixtureMatchCountsForTest(testId: string): Map<Fixture, number> {
return this.fixtureMatchCountsByTestId.get(testId) ?? new Map();
}
/**
* WRITE path: get the count map for `testId`, inserting a fresh empty Map
* if missing and running FIFO eviction when the testId cap is exceeded.
* Only callers that intend to mutate the map (e.g. incrementing a count)
* should use this.
*/
private getOrCreateFixtureMatchCountsForTest(testId: string): Map<Fixture, number> {
let counts = this.fixtureMatchCountsByTestId.get(testId);
if (!counts) {
counts = new Map();
this.fixtureMatchCountsByTestId.set(testId, counts);
// FIFO eviction when over capacity. JS Map preserves insertion order,
// so the first key returned by keys() is the oldest. Same O(n) shift
// caveat as `entries`: acceptable at small caps (default 500).
if (
this.fixtureCountsMaxTestIds > 0 &&
this.fixtureMatchCountsByTestId.size > this.fixtureCountsMaxTestIds
) {
const oldest = this.fixtureMatchCountsByTestId.keys().next().value;
if (oldest !== undefined) {
this.fixtureMatchCountsByTestId.delete(oldest);
}
}
}
return counts;
}
getFixtureMatchCount(fixture: Fixture, testId = DEFAULT_TEST_ID): number {
return this.getFixtureMatchCountsForTest(testId).get(fixture) ?? 0;
}
incrementFixtureMatchCount(
fixture: Fixture,
allFixtures?: readonly Fixture[],
testId = DEFAULT_TEST_ID,
): void {
const counts = this.getOrCreateFixtureMatchCountsForTest(testId);
counts.set(fixture, (counts.get(fixture) ?? 0) + 1);
// When a sequenced fixture matches, also increment all siblings with matching criteria
if (fixture.match.sequenceIndex !== undefined && allFixtures) {
for (const sibling of allFixtures) {
if (sibling === fixture) continue;
if (sibling.match.sequenceIndex === undefined) continue;
if (matchCriteriaEqual(fixture.match, sibling.match)) {
counts.set(sibling, (counts.get(sibling) ?? 0) + 1);
}
}
}
}
clearMatchCounts(testId?: string): void {
if (testId !== undefined) {
this.fixtureMatchCountsByTestId.delete(testId);
} else {
this.fixtureMatchCountsByTestId.clear();
}
}
/**
* Clear ONLY the request journal entries, preserving fixture match-counts.
* Match-counts are fixture-matching/sequencing state, not journal data, so
* clearing the journal must not silently rewind sequenced fixtures. Used by
* `POST /__aimock/reset/journal`. For a full reset (entries + match-counts),
* use `clear()` instead.
*/
clearEntries(): void {
this.entries = [];
}
clear(): void {
this.entries = [];
this.fixtureMatchCountsByTestId.clear();
}
get size(): number {
return this.entries.length;
}
}