omnirush 0.7.1 → 0.8.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.
Files changed (31) hide show
  1. package/assets/collect-once.ts +72 -141
  2. package/assets/extensions/omnirush/agents-lib.ts +23 -7
  3. package/assets/extensions/omnirush/agents.ts +12 -8
  4. package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
  5. package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
  6. package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
  7. package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
  8. package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
  9. package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
  10. package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
  11. package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
  12. package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
  13. package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
  14. package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
  15. package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
  16. package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
  17. package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
  18. package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
  19. package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
  20. package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
  21. package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
  22. package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
  23. package/assets/extensions/omnirush/collector.ts +539 -171
  24. package/assets/extensions/omnirush/commands.ts +1 -1
  25. package/assets/extensions/omnirush/pi-engine.ts +655 -0
  26. package/assets/extensions/omnirush/plan.ts +6 -7
  27. package/package.json +5 -2
  28. package/scripts/build-capture-vendor.js +55 -0
  29. package/src/bin.js +30 -5
  30. package/assets/extensions/omnirush/collector-lib.ts +0 -2433
  31. package/assets/extensions/omnirush/trace-format.ts +0 -311
@@ -0,0 +1,661 @@
1
+ /**
2
+ * The "turn.diff" trace event: at the end of each turn, a unified diff per
3
+ * workspace file the turn changed, of the scrubbed text the collector last
4
+ * sent for it before the turn against the scrubbed text it sent after, so the
5
+ * trace pairs the turn with exactly what it changed. The texts come from
6
+ * TurnBaseStore, a bounded content-addressed store of the scrubbed texts the
7
+ * collector sent (keyed by their redacted sha256, the digest the manifest
8
+ * names), kept under the collector state dir so bases survive a restart.
9
+ * Everything here runs on the capture worker (see capture-host.ts).
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+
15
+ /** A file diff is cut at this many bytes (as JSON-encoded in the event). */
16
+ export const MAX_TURN_DIFF_FILE_BYTES = 256 * 1024;
17
+ /** One "turn.diff" trace event, JSON-encoded with its envelope, is at most this many bytes. */
18
+ export const MAX_TURN_DIFF_EVENT_BYTES = 2 * 1024 * 1024;
19
+ /** A turn's diffs get this much time in all; the files still waiting once it is spent are only counted. */
20
+ export const MAX_TURN_DIFF_MS = 250;
21
+ /** Budget of the base store, least recently used out first. */
22
+ export const TURN_BASE_STORE_BYTES = 64 * 1024 * 1024;
23
+ /** Texts over the collector's per-file cap are never stored. */
24
+ const MAX_BASE_TEXT_BYTES = 4 * 1024 * 1024;
25
+ const DIFF_CONTEXT_LINES = 3;
26
+ /** A diff gets a place in the event only while at least this much of the budget is left. */
27
+ const MIN_DIFF_ROOM_BYTES = 1024;
28
+ /** Past this edit distance, or this much work, the exact diff gives way to a greedy one. */
29
+ const MAX_EDIT_DISTANCE = 1_500;
30
+ /**
31
+ * Line comparisons one file's diff may make, exact and greedy passes together;
32
+ * past them the rest of the change is written as removed and added.
33
+ */
34
+ const MAX_DIFF_COMPARISONS = 5_000_000;
35
+ /** How far ahead the greedy diff looks, on each side, for the lines to line up again. */
36
+ const GREEDY_WINDOW = 32;
37
+ /** The event around its entries, as the trace carries it, every count at its widest. */
38
+ const EVENT_ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({
39
+ at: new Date(0).toISOString(),
40
+ type: "turn.diff",
41
+ data: { schema_version: 1, files: [], file_count: Number.MAX_SAFE_INTEGER, omitted_file_count: Number.MAX_SAFE_INTEGER, truncated: false },
42
+ }));
43
+ const BASE_WRITE_CONCURRENCY = 8;
44
+ /** Texts waiting to be written beyond this make writable() wait, so a start snapshot never holds more. */
45
+ const BASE_PENDING_HIGH_WATER = 8 * 1024 * 1024;
46
+ const BASE_INDEX_FILE = "index.json";
47
+ const BASE_INDEX_SAVE_MS = 2_000;
48
+ const SHA256_NAME = /^[0-9a-f]{64}$/;
49
+
50
+ export type TurnDiffStatus = "added" | "modified" | "deleted" | "skipped" | "no_base";
51
+
52
+ /** One file of a "turn.diff" event. */
53
+ export type TurnDiffFile = {
54
+ /** Workspace-relative, redacted as upload paths are. */
55
+ path: string;
56
+ status: TurnDiffStatus;
57
+ /**
58
+ * Redacted sha256 of the text before the turn: null for an added or skipped
59
+ * file, and for a no_base one the collector first saw already changed by the turn.
60
+ */
61
+ before_sha256: string | null;
62
+ /** Redacted sha256 of the text after the turn (null for a deleted or skipped file). */
63
+ after_sha256: string | null;
64
+ /** Lines added and removed: exact, or an upper bound where the diff's comparison budget ran out (a correct patch still). */
65
+ additions: number | null;
66
+ deletions: number | null;
67
+ /** Unified diff of the scrubbed texts; null when a side is unavailable (no_base) or the file was skipped. */
68
+ diff: string | null;
69
+ /** The diff was cut at MAX_TURN_DIFF_FILE_BYTES or at the event's remaining budget. */
70
+ truncated: boolean;
71
+ };
72
+
73
+ export type TurnDiffEvent = {
74
+ schema_version: 1;
75
+ files: TurnDiffFile[];
76
+ /** Files the turn changed, including any the event cap left out of `files`. */
77
+ file_count: number;
78
+ /** Files left out of `files`: past the event cap, or waiting for a diff once the turn's diff time was spent. */
79
+ omitted_file_count: number;
80
+ /** A diff was cut or a file left out. */
81
+ truncated: boolean;
82
+ };
83
+
84
+ /**
85
+ * A changed file with its texts resolved: null where that side has no text
86
+ * (added, deleted, skipped, no base) or where the texts were not read (the
87
+ * event could take no further diff).
88
+ */
89
+ export type TurnDiffInput = {
90
+ path: string;
91
+ status: TurnDiffStatus;
92
+ before_sha256: string | null;
93
+ after_sha256: string | null;
94
+ before: string | null;
95
+ after: string | null;
96
+ };
97
+
98
+ type Lines = { lines: string[]; eofNewline: boolean };
99
+
100
+ function splitLines(text: string): Lines {
101
+ if (text === "") return { lines: [], eofNewline: true };
102
+ const lines = text.split("\n");
103
+ const eofNewline = lines.at(-1) === "";
104
+ if (eofNewline) lines.pop();
105
+ return { lines, eofNewline };
106
+ }
107
+
108
+ /**
109
+ * Integer ids for the lines between the common prefix and suffix, equal
110
+ * lines sharing one. A line gets its id only once the diff first reaches it,
111
+ * so a diff that stops at its budget never pays for the lines past that point.
112
+ */
113
+ class LineIds {
114
+ private readonly ids = new Map<string, number>();
115
+ private readonly a: Int32Array;
116
+ private readonly b: Int32Array;
117
+ private doneA = 0;
118
+ private doneB = 0;
119
+
120
+ constructor(private readonly keyA: (index: number) => string, private readonly keyB: (index: number) => string, n: number, m: number) {
121
+ this.a = new Int32Array(n);
122
+ this.b = new Int32Array(m);
123
+ }
124
+
125
+ private intern(text: string): number {
126
+ let id = this.ids.get(text);
127
+ if (id === undefined) {
128
+ id = this.ids.size;
129
+ this.ids.set(text, id);
130
+ }
131
+ return id;
132
+ }
133
+
134
+ idA(index: number): number {
135
+ for (; this.doneA <= index; this.doneA += 1) this.a[this.doneA] = this.intern(this.keyA(this.doneA));
136
+ return this.a[index]!;
137
+ }
138
+
139
+ idB(index: number): number {
140
+ for (; this.doneB <= index; this.doneB += 1) this.b[this.doneB] = this.intern(this.keyB(this.doneB));
141
+ return this.b[index]!;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Marks the lines outside a longest common subsequence of the `n` lines of
147
+ * one side and the `m` of the other (Myers' O(ND) algorithm), or, once the
148
+ * edit distance or the work passes its bound, outside a greedy matching: a
149
+ * correct diff either way, only not always a minimal one.
150
+ */
151
+ function markChanges(ids: LineIds, n: number, m: number, removed: Uint8Array, added: Uint8Array, offset: number, budget: number): void {
152
+ if (n === 0 || m === 0) {
153
+ removed.fill(1, offset, offset + n);
154
+ added.fill(1, offset, offset + m);
155
+ return;
156
+ }
157
+ const limit = Math.min(n + m, MAX_EDIT_DISTANCE);
158
+ const middle = limit + 1;
159
+ const v = new Int32Array(2 * limit + 3);
160
+ const trace: Int32Array[] = [];
161
+ let work = 0;
162
+ // The exact search gets half the budget, the greedy pass (which starts over from the top) the rest.
163
+ const exactBudget = budget / 2;
164
+ exact: for (let d = 0; d <= limit; d += 1) {
165
+ // What round d reads of round d-1: v[k-1] and v[k+1] for k in [-d, d].
166
+ trace.push(v.slice(middle - d - 1, middle + d + 2));
167
+ for (let k = -d; k <= d; k += 2) {
168
+ let x = k === -d || (k !== d && v[middle + k - 1]! < v[middle + k + 1]!) ? v[middle + k + 1]! : v[middle + k - 1]! + 1;
169
+ let y = x - k;
170
+ const start = x;
171
+ while (x < n && y < m && ids.idA(x) === ids.idB(y)) {
172
+ x += 1;
173
+ y += 1;
174
+ }
175
+ work += 1 + x - start;
176
+ v[middle + k] = x;
177
+ if (x >= n && y >= m) return backtrack(trace, n, m, removed, added, offset);
178
+ if (work > exactBudget) break exact;
179
+ }
180
+ }
181
+ greedy(ids, n, m, removed, added, offset, budget - work);
182
+ }
183
+
184
+ /**
185
+ * Walks both sides together; at a mismatch, the nearest point within the
186
+ * window where they line up again ends the edit. Linear in the lines times
187
+ * the window squared, whatever the edit distance, and stops once `budget`
188
+ * comparisons are spent: everything past that point is removed and added.
189
+ */
190
+ function greedy(ids: LineIds, n: number, m: number, removed: Uint8Array, added: Uint8Array, offset: number, budget: number): void {
191
+ let i = 0;
192
+ let j = 0;
193
+ let work = 0;
194
+ while (i < n && j < m && work < budget) {
195
+ work += 1;
196
+ if (ids.idA(i) === ids.idB(j)) {
197
+ i += 1;
198
+ j += 1;
199
+ continue;
200
+ }
201
+ let skipA = 1;
202
+ let skipB = 1;
203
+ search: for (let distance = 1; distance <= 2 * GREEDY_WINDOW; distance += 1) {
204
+ for (let x = Math.max(0, distance - GREEDY_WINDOW); x <= Math.min(distance, GREEDY_WINDOW); x += 1) {
205
+ work += 1;
206
+ if (i + x < n && j + distance - x < m && ids.idA(i + x) === ids.idB(j + distance - x)) {
207
+ skipA = x;
208
+ skipB = distance - x;
209
+ break search;
210
+ }
211
+ }
212
+ }
213
+ removed.fill(1, offset + i, offset + i + skipA);
214
+ added.fill(1, offset + j, offset + j + skipB);
215
+ i += skipA;
216
+ j += skipB;
217
+ }
218
+ removed.fill(1, offset + i, offset + n);
219
+ added.fill(1, offset + j, offset + m);
220
+ }
221
+
222
+ /** Walks the recorded rounds back from the end, marking the one edit each round made. */
223
+ function backtrack(trace: Int32Array[], n: number, m: number, removed: Uint8Array, added: Uint8Array, offset: number): void {
224
+ let x = n;
225
+ let y = m;
226
+ for (let d = trace.length - 1; d > 0; d -= 1) {
227
+ const v = trace[d]!;
228
+ const at = (k: number) => v[k + d + 1]!;
229
+ const k = x - y;
230
+ const down = k === -d || (k !== d && at(k - 1) < at(k + 1));
231
+ const previousX = at(down ? k + 1 : k - 1);
232
+ const previousY = previousX - (down ? k + 1 : k - 1);
233
+ if (down) added[offset + previousY] = 1;
234
+ else removed[offset + previousX] = 1;
235
+ x = previousX;
236
+ y = previousY;
237
+ }
238
+ }
239
+
240
+ /** Bytes a line costs in the event: JSON-escaped, with its escaped newline in place of the quotes. */
241
+ function lineCost(line: string): number {
242
+ return Buffer.byteLength(JSON.stringify(line));
243
+ }
244
+
245
+ /**
246
+ * A unified diff (3 lines of context) of `before` into `after`, a null side
247
+ * standing for /dev/null. The counts cover the whole change (exact, or an
248
+ * upper bound once `maxComparisons` cut the line matching short); the text
249
+ * stops at the last whole line within `maxBytes` (measured JSON-encoded),
250
+ * marked truncated. The patch always turns `before` into `after`.
251
+ */
252
+ export function unifiedDiff(path: string, before: string | null, after: string | null, maxBytes = MAX_TURN_DIFF_FILE_BYTES, maxComparisons = MAX_DIFF_COMPARISONS): { diff: string; additions: number; deletions: number; truncated: boolean; bytes: number } {
253
+ const a = splitLines(before ?? "");
254
+ const b = splitLines(after ?? "");
255
+ // A last line without a newline differs from the same line with one.
256
+ const key = (side: Lines, index: number) => (index === side.lines.length - 1 && !side.eofNewline ? `${side.lines[index]}\u0000` : side.lines[index]!);
257
+ const nA = a.lines.length;
258
+ const nB = b.lines.length;
259
+ let prefix = 0;
260
+ while (prefix < nA && prefix < nB && key(a, prefix) === key(b, prefix)) prefix += 1;
261
+ let suffix = 0;
262
+ while (suffix < nA - prefix && suffix < nB - prefix && key(a, nA - 1 - suffix) === key(b, nB - 1 - suffix)) suffix += 1;
263
+ const n = nA - prefix - suffix;
264
+ const m = nB - prefix - suffix;
265
+ const ids = new LineIds((index) => key(a, prefix + index), (index) => key(b, prefix + index), n, m);
266
+ const removed = new Uint8Array(nA);
267
+ const added = new Uint8Array(nB);
268
+ markChanges(ids, n, m, removed, added, prefix, maxComparisons);
269
+
270
+ type Block = { aStart: number; aEnd: number; bStart: number; bEnd: number };
271
+ const blocks: Block[] = [];
272
+ let additions = 0;
273
+ let deletions = 0;
274
+ for (let i = prefix, j = prefix; i < nA - suffix || j < nB - suffix;) {
275
+ if (i < nA && j < nB && !removed[i] && !added[j]) {
276
+ i += 1;
277
+ j += 1;
278
+ continue;
279
+ }
280
+ const block = { aStart: i, aEnd: i, bStart: j, bEnd: j };
281
+ while (i < nA && removed[i]) i += 1;
282
+ while (j < nB && added[j]) j += 1;
283
+ block.aEnd = i;
284
+ block.bEnd = j;
285
+ deletions += i - block.aStart;
286
+ additions += j - block.bStart;
287
+ blocks.push(block);
288
+ }
289
+
290
+ const out: string[] = [];
291
+ let bytes = 0;
292
+ let truncated = false;
293
+ const push = (line: string): boolean => {
294
+ const cost = lineCost(line);
295
+ if (bytes + cost > maxBytes) {
296
+ truncated = true;
297
+ return false;
298
+ }
299
+ out.push(line);
300
+ bytes += cost;
301
+ return true;
302
+ };
303
+ const noNewline = "\";
304
+ const emit = (prefixChar: string, side: Lines, index: number): boolean => {
305
+ if (!push(`${prefixChar}${side.lines[index]}`)) return false;
306
+ return index === side.lines.length - 1 && !side.eofNewline ? push(noNewline) : true;
307
+ };
308
+ const range = (start: number, length: number) => (length === 1 ? `${start + 1}` : `${length === 0 ? start : start + 1},${length}`);
309
+ if (blocks.length > 0 && push(`--- ${before === null ? "/dev/null" : `a/${path}`}`) && push(`+++ ${after === null ? "/dev/null" : `b/${path}`}`)) {
310
+ hunks: for (let index = 0; index < blocks.length;) {
311
+ // A hunk runs over every block within twice the context of the one before.
312
+ let last = index;
313
+ while (last + 1 < blocks.length && blocks[last + 1]!.aStart - blocks[last]!.aEnd <= 2 * DIFF_CONTEXT_LINES) last += 1;
314
+ const first = blocks[index]!;
315
+ const end = blocks[last]!;
316
+ const lead = Math.min(DIFF_CONTEXT_LINES, first.aStart);
317
+ const trail = Math.min(DIFF_CONTEXT_LINES, nA - end.aEnd);
318
+ const aStart = first.aStart - lead;
319
+ const bStart = first.bStart - lead;
320
+ const aLength = end.aEnd + trail - aStart;
321
+ const bLength = end.bEnd + trail - bStart;
322
+ if (!push(`@@ -${range(aStart, aLength)} +${range(bStart, bLength)} @@`)) break;
323
+ let i = aStart;
324
+ let j = bStart;
325
+ for (let at = index; at <= last; at += 1) {
326
+ const block = blocks[at]!;
327
+ for (; i < block.aStart; i += 1, j += 1) if (!emit(" ", b, j)) break hunks;
328
+ for (; i < block.aEnd; i += 1) if (!emit("-", a, i)) break hunks;
329
+ for (; j < block.bEnd; j += 1) if (!emit("+", b, j)) break hunks;
330
+ }
331
+ for (let count = 0; count < trail; count += 1, i += 1, j += 1) if (!emit(" ", b, j)) break hunks;
332
+ index = last + 1;
333
+ }
334
+ }
335
+ return { diff: out.length > 0 ? `${out.join("\n")}\n` : "", additions, deletions, truncated, bytes };
336
+ }
337
+
338
+ /**
339
+ * Assembles one "turn.diff" event under the event cap: each file's diff gets
340
+ * at most MAX_TURN_DIFF_FILE_BYTES and what is left of the event's budget.
341
+ * Once no further diff fits, in the event or in the turn's diff time, a file
342
+ * that needs one is only counted, while an entry without diff text (skipped,
343
+ * no_base) still goes in as long as it fits.
344
+ */
345
+ export class TurnDiffBuilder {
346
+ private readonly files: TurnDiffFile[] = [];
347
+ /** Bytes of the event so far, its envelope reserved from the start. */
348
+ private used = EVENT_ENVELOPE_BYTES;
349
+ private omitted = 0;
350
+ private truncated = false;
351
+ /** Time spent diffing so far. */
352
+ private diffMs = 0;
353
+
354
+ constructor(
355
+ private readonly maxEventBytes = MAX_TURN_DIFF_EVENT_BYTES,
356
+ private readonly maxFileBytes = MAX_TURN_DIFF_FILE_BYTES,
357
+ private readonly maxDiffMs = MAX_TURN_DIFF_MS,
358
+ ) {}
359
+
360
+ /** No further diff fits: the caller need not resolve texts. */
361
+ get full(): boolean {
362
+ return this.used + MIN_DIFF_ROOM_BYTES > this.maxEventBytes || this.diffMs >= this.maxDiffMs;
363
+ }
364
+
365
+ omit(): void {
366
+ this.omitted += 1;
367
+ this.truncated = true;
368
+ }
369
+
370
+ add(input: TurnDiffInput): void {
371
+ const diffed = input.status === "added" || input.status === "modified" || input.status === "deleted";
372
+ if (diffed && this.full) return this.omit();
373
+ const before = input.status === "added" ? null : input.before;
374
+ const after = input.status === "deleted" ? null : input.after;
375
+ const hasText = diffed && (before !== null || after !== null);
376
+ const file: TurnDiffFile = {
377
+ path: input.path,
378
+ status: input.status,
379
+ before_sha256: input.before_sha256,
380
+ after_sha256: input.after_sha256,
381
+ // At their widest while the entry is measured: a count never exceeds its text's length.
382
+ additions: hasText ? after?.length ?? 0 : null,
383
+ deletions: hasText ? before?.length ?? 0 : null,
384
+ diff: null,
385
+ truncated: false,
386
+ };
387
+ // The entry without its diff, plus the comma between entries.
388
+ const overhead = Buffer.byteLength(JSON.stringify(file)) + 1;
389
+ const room = this.maxEventBytes - this.used - overhead;
390
+ if (room < (hasText ? MIN_DIFF_ROOM_BYTES : 0)) return this.omit();
391
+ if (hasText) {
392
+ const started = performance.now();
393
+ const result = unifiedDiff(input.path, before, after, Math.min(this.maxFileBytes, room));
394
+ this.diffMs += performance.now() - started;
395
+ file.additions = result.additions;
396
+ file.deletions = result.deletions;
397
+ file.diff = result.diff;
398
+ file.truncated = result.truncated;
399
+ if (result.truncated) this.truncated = true;
400
+ }
401
+ this.files.push(file);
402
+ this.used += Buffer.byteLength(JSON.stringify(file)) + 1;
403
+ }
404
+
405
+ finish(): TurnDiffEvent {
406
+ return {
407
+ schema_version: 1,
408
+ files: this.files,
409
+ file_count: this.files.length + this.omitted,
410
+ omitted_file_count: this.omitted,
411
+ truncated: this.truncated,
412
+ };
413
+ }
414
+ }
415
+
416
+ function sha256Hex(text: string): string {
417
+ return createHash("sha256").update(text).digest("hex");
418
+ }
419
+
420
+ /**
421
+ * The scrubbed texts the collector sent, by redacted sha256, least recently
422
+ * used out first once `budget` bytes are held. The texts are files under
423
+ * `dir` (one per digest, owner-only) and an index keeps their order across
424
+ * restarts; a text is read back only while its digest still matches. Writes
425
+ * happen in the background with bounded concurrency, and a text still being
426
+ * written is served from memory.
427
+ */
428
+ export class TurnBaseStore {
429
+ /** Digest -> bytes, least recently used first. */
430
+ private readonly index = new Map<string, number>();
431
+ private readonly pending = new Map<string, { text: string; bytes: number }>();
432
+ private readonly queue: string[] = [];
433
+ private pendingBytes = 0;
434
+ private used = 0;
435
+ private active = 0;
436
+ private generation = 0;
437
+ /** The index changed since it was last saved. */
438
+ private dirty = false;
439
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
440
+ private saveTail: Promise<void> = Promise.resolve();
441
+ private readonly ready: Promise<void>;
442
+ private waiters: Array<{ limit: number; resolve: () => void }> = [];
443
+ /** Removals of evicted texts still under way, by digest: a write of the same digest waits for its removal. */
444
+ private readonly removals = new Map<string, Promise<void>>();
445
+ /** Writes under way: clear() lets them settle before it removes the directory. */
446
+ private readonly writing = new Set<Promise<void>>();
447
+
448
+ constructor(private readonly dir: string, private readonly budget = TURN_BASE_STORE_BYTES) {
449
+ this.ready = this.load(dir);
450
+ }
451
+
452
+ private async load(dir: string): Promise<void> {
453
+ try {
454
+ const parsed: unknown = JSON.parse(await readFile(join(dir, BASE_INDEX_FILE), "utf8"));
455
+ const entries = typeof parsed === "object" && parsed !== null && "entries" in parsed && Array.isArray(parsed.entries) ? parsed.entries : [];
456
+ for (const entry of entries) {
457
+ if (!Array.isArray(entry) || typeof entry[0] !== "string" || !SHA256_NAME.test(entry[0]) || typeof entry[1] !== "number") continue;
458
+ if (entry[1] < 0 || entry[1] > MAX_BASE_TEXT_BYTES || this.index.has(entry[0])) continue;
459
+ this.index.set(entry[0], entry[1]);
460
+ this.used += entry[1];
461
+ }
462
+ } catch {
463
+ // No index yet, or an unreadable one: start empty.
464
+ }
465
+ try {
466
+ // Texts no index names (written just before a crash) are never read: remove them.
467
+ for (const name of await readdir(dir)) {
468
+ if (name !== BASE_INDEX_FILE && !this.index.has(name)) await rm(join(dir, name), { force: true });
469
+ }
470
+ } catch {
471
+ // No directory yet.
472
+ }
473
+ this.evict();
474
+ }
475
+
476
+ private touch(sha256: string, bytes: number): void {
477
+ this.index.delete(sha256);
478
+ this.index.set(sha256, bytes);
479
+ this.dirty = true;
480
+ }
481
+
482
+ private evict(): void {
483
+ for (const [sha256, bytes] of this.index) {
484
+ if (this.used <= this.budget) break;
485
+ this.index.delete(sha256);
486
+ this.used -= bytes;
487
+ const removal: Promise<void> = rm(join(this.dir, sha256), { force: true }).catch(() => undefined).finally(() => {
488
+ if (this.removals.get(sha256) === removal) this.removals.delete(sha256);
489
+ });
490
+ this.removals.set(sha256, removal);
491
+ }
492
+ }
493
+
494
+ /** Whether a text is held under `sha256`, without reading it back. */
495
+ async has(sha256: string): Promise<boolean> {
496
+ if (this.pending.has(sha256)) return true;
497
+ await this.ready;
498
+ return this.index.has(sha256);
499
+ }
500
+
501
+ /** Remembers `text` (the scrubbed text whose redacted sha256 is `sha256`, `bytes` long). */
502
+ put(sha256: string, text: string, bytes: number): void {
503
+ if (bytes > MAX_BASE_TEXT_BYTES) return;
504
+ if (this.index.has(sha256)) return this.touch(sha256, bytes);
505
+ // Writes that cannot keep up are dropped rather than held: a missing base only costs a no_base entry.
506
+ if (this.pending.has(sha256) || this.pendingBytes + bytes > this.budget) return;
507
+ this.pending.set(sha256, { text, bytes });
508
+ this.pendingBytes += bytes;
509
+ this.queue.push(sha256);
510
+ this.pump();
511
+ }
512
+
513
+ private pump(): void {
514
+ while (this.active < BASE_WRITE_CONCURRENCY && this.queue.length > 0) {
515
+ const sha256 = this.queue.shift()!;
516
+ this.active += 1;
517
+ const writing: Promise<void> = this.write(sha256).finally(() => {
518
+ this.writing.delete(writing);
519
+ this.active -= 1;
520
+ const held = this.pending.get(sha256);
521
+ if (held) {
522
+ this.pending.delete(sha256);
523
+ this.pendingBytes -= held.bytes;
524
+ }
525
+ this.pump();
526
+ this.wake();
527
+ });
528
+ this.writing.add(writing);
529
+ }
530
+ }
531
+
532
+ private wake(): void {
533
+ const outstanding = this.active > 0 || this.queue.length > 0 ? this.pendingBytes + 1 : 0;
534
+ this.waiters = this.waiters.filter((waiter) => {
535
+ if (outstanding > waiter.limit) return true;
536
+ waiter.resolve();
537
+ return false;
538
+ });
539
+ }
540
+
541
+ /** Resolves once queued writes hold at most `limit` bytes (0: once none are left). */
542
+ private below(limit: number): Promise<void> {
543
+ return new Promise((resolve) => {
544
+ this.waiters.push({ limit, resolve });
545
+ this.wake();
546
+ });
547
+ }
548
+
549
+ /** Resolves once the write queue has room again: a caller putting many texts awaits it between puts. */
550
+ writable(): Promise<void> {
551
+ return this.pendingBytes <= BASE_PENDING_HIGH_WATER ? Promise.resolve() : this.below(BASE_PENDING_HIGH_WATER);
552
+ }
553
+
554
+ private async write(sha256: string): Promise<void> {
555
+ const generation = this.generation;
556
+ const held = this.pending.get(sha256);
557
+ const dir = this.dir;
558
+ try {
559
+ await this.ready;
560
+ // An evicted copy of the same digest is removed first, or its late removal would take this one.
561
+ await this.removals.get(sha256);
562
+ if (!held || generation !== this.generation) return;
563
+ const { text, bytes } = held;
564
+ if (this.index.has(sha256)) return this.touch(sha256, bytes);
565
+ await mkdir(dir, { recursive: true, mode: 0o700 });
566
+ await writeFile(join(dir, sha256), text, { mode: 0o600 });
567
+ if (generation !== this.generation) {
568
+ await rm(join(dir, sha256), { force: true });
569
+ return;
570
+ }
571
+ this.index.set(sha256, bytes);
572
+ this.used += bytes;
573
+ this.dirty = true;
574
+ this.evict();
575
+ this.scheduleSave();
576
+ } catch {
577
+ // A text that could not be written is simply not a base.
578
+ }
579
+ }
580
+
581
+ /** The text stored under `sha256`, or null. */
582
+ async get(sha256: string): Promise<string | null> {
583
+ const pending = this.pending.get(sha256);
584
+ if (pending) return pending.text;
585
+ await this.ready;
586
+ const bytes = this.index.get(sha256);
587
+ if (bytes === undefined) return null;
588
+ try {
589
+ const text = await readFile(join(this.dir, sha256), "utf8");
590
+ if (sha256Hex(text) !== sha256) throw new Error("base text changed on disk");
591
+ if (this.index.has(sha256)) this.touch(sha256, bytes);
592
+ return text;
593
+ } catch {
594
+ if (this.index.get(sha256) === bytes) {
595
+ this.index.delete(sha256);
596
+ this.used -= bytes;
597
+ }
598
+ return null;
599
+ }
600
+ }
601
+
602
+ private scheduleSave(): void {
603
+ if (this.saveTimer) return;
604
+ this.saveTimer = setTimeout(() => {
605
+ this.saveTimer = null;
606
+ void this.save();
607
+ }, BASE_INDEX_SAVE_MS);
608
+ this.saveTimer.unref?.();
609
+ }
610
+
611
+ private save(): Promise<void> {
612
+ const dir = this.dir;
613
+ const generation = this.generation;
614
+ this.dirty = false;
615
+ const index = JSON.stringify({ version: 1, entries: [...this.index] });
616
+ this.saveTail = this.saveTail.then(async () => {
617
+ if (generation !== this.generation) return;
618
+ await mkdir(dir, { recursive: true, mode: 0o700 });
619
+ await writeFile(join(dir, `${BASE_INDEX_FILE}.tmp`), index, { mode: 0o600 });
620
+ await rename(join(dir, `${BASE_INDEX_FILE}.tmp`), join(dir, BASE_INDEX_FILE));
621
+ }).catch(() => undefined);
622
+ return this.saveTail;
623
+ }
624
+
625
+ /** Waits for queued writes and saves the index (shutdown). */
626
+ async flush(): Promise<void> {
627
+ await this.below(0);
628
+ if (this.saveTimer) {
629
+ clearTimeout(this.saveTimer);
630
+ this.saveTimer = null;
631
+ }
632
+ await this.ready;
633
+ if (this.dirty) await this.save();
634
+ }
635
+
636
+ /** Forgets every text and removes the directory (sign-out). */
637
+ async clear(): Promise<void> {
638
+ this.generation += 1;
639
+ if (this.saveTimer) {
640
+ clearTimeout(this.saveTimer);
641
+ this.saveTimer = null;
642
+ }
643
+ // Queued texts are never written. One already being written sees the new
644
+ // generation and removes itself, and settles before the directory goes:
645
+ // nothing it does can recreate the directory afterwards.
646
+ this.queue.length = 0;
647
+ const writing = [...this.writing];
648
+ // After the load, which would otherwise fill the index again.
649
+ await this.ready;
650
+ this.index.clear();
651
+ this.pending.clear();
652
+ this.pendingBytes = 0;
653
+ this.used = 0;
654
+ this.dirty = false;
655
+ this.wake();
656
+ await Promise.all(writing);
657
+ await Promise.all(this.removals.values());
658
+ await this.saveTail;
659
+ await rm(this.dir, { recursive: true, force: true });
660
+ }
661
+ }