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,481 @@
1
+ /**
2
+ * Pass 2 of a capture (sections 5.5, 5.9, 5.10, 13.1): a streaming POSIX pax
3
+ * tar writer, and the pipeline file -> tar -> zstd -> ORSEAL01 -> temp file.
4
+ * Memory is bounded by a few output blocks and stream buffers whatever the
5
+ * project size.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { lstat, open, readlink } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { once } from "node:events";
11
+ import { PassThrough, Writable, type Readable } from "node:stream";
12
+ import { pipeline } from "node:stream/promises";
13
+ import { constants as zlibConstants, createZstdCompress } from "node:zlib";
14
+
15
+ import {
16
+ OPEN_ENTRY_FLAGS,
17
+ compareArchivePaths,
18
+ isSameFileIdentity,
19
+ statKeyPrefix,
20
+ type ManifestSource,
21
+ type ScannedEntry,
22
+ } from "./manifest.js";
23
+ import { hintGarbageCollection } from "./files.js";
24
+ import { SealStream, type SealOptions } from "./seal.js";
25
+
26
+ export const MANIFEST_MEMBER = "__omnirush__/manifest.json";
27
+ export const UNSTABLE_MEMBER = "__omnirush__/unstable.json";
28
+ export const UNSTABLE_SCHEMA = "omnirush.archive.unstable.v1";
29
+ const TAR_BLOCK = 512;
30
+ /** Tar output is assembled in blocks of this size; file content is read straight into them. */
31
+ const OUTPUT_BLOCK = 1 << 20;
32
+ const MAX_OCTAL_11 = 8 ** 11 - 1;
33
+ const PAX_HEADER_NAME = Buffer.from("././@PaxHeader", "ascii");
34
+ const ZSTD_LEVEL = 3;
35
+ /** Output blocks (64 MiB), or members, between two garbage collection hints. */
36
+ const GC_HINT_BLOCKS = 64;
37
+ const GC_HINT_MEMBERS = 10_000;
38
+
39
+ type Typeflag = "0" | "2" | "5" | "x";
40
+
41
+ function isAscii(bytes: Buffer): boolean {
42
+ for (const byte of bytes) if (byte >= 0x80) return false;
43
+ return true;
44
+ }
45
+
46
+ /** The ustar fallback when a pax record carries the real value: non-ASCII bytes become `_`, cut to 100 bytes. */
47
+ function asciiFallback(bytes: Buffer): Buffer {
48
+ const out = Buffer.from(bytes.subarray(0, 100));
49
+ for (let index = 0; index < out.length; index += 1) if (out[index]! >= 0x80) out[index] = 0x5f;
50
+ return out;
51
+ }
52
+
53
+ function writeOctal(block: Buffer, offset: number, width: number, value: number): void {
54
+ const digits = value.toString(8).padStart(width - 1, "0");
55
+ if (digits.length > width - 1) throw new RangeError("tar numeric field overflow");
56
+ block.write(digits, offset, width - 1, "ascii");
57
+ block[offset + width - 1] = 0;
58
+ }
59
+
60
+ function clampMtime(seconds: number): number {
61
+ return Math.min(Math.max(Math.floor(seconds), 0), MAX_OCTAL_11);
62
+ }
63
+
64
+ function ustarHeader(name: Buffer, mode: number, size: number, mtime: number, typeflag: Typeflag, linkname: Buffer | null): Buffer {
65
+ const block = Buffer.alloc(TAR_BLOCK);
66
+ name.copy(block, 0, 0, 100);
67
+ writeOctal(block, 100, 8, mode & 0o7777);
68
+ writeOctal(block, 108, 8, 0);
69
+ writeOctal(block, 116, 8, 0);
70
+ writeOctal(block, 124, 12, size);
71
+ writeOctal(block, 136, 12, mtime);
72
+ block.fill(0x20, 148, 156);
73
+ block.write(typeflag, 156, 1, "ascii");
74
+ linkname?.copy(block, 157, 0, 100);
75
+ block.write("ustar\0", 257, 6, "ascii");
76
+ block.write("00", 263, 2, "ascii");
77
+ writeOctal(block, 329, 8, 0);
78
+ writeOctal(block, 337, 8, 0);
79
+ let sum = 0;
80
+ for (const byte of block) sum += byte;
81
+ block.write(sum.toString(8).padStart(6, "0"), 148, 6, "ascii");
82
+ block[154] = 0;
83
+ block[155] = 0x20;
84
+ return block;
85
+ }
86
+
87
+ /** One `"<len> <key>=<value>\n"` record; `len` counts the whole record, its own digits included. */
88
+ function paxRecord(key: string, value: string): string {
89
+ const rest = Buffer.byteLength(` ${key}=${value}\n`, "utf8");
90
+ let digits = 1;
91
+ while (String(rest + digits).length !== digits) digits += 1;
92
+ return `${rest + digits} ${key}=${value}\n`;
93
+ }
94
+
95
+ function paddingFor(size: number): number {
96
+ return (TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK;
97
+ }
98
+
99
+ export type TarMember = { name: string; mode: number; size: number; mtime: number; typeflag: "0" | "2" | "5"; linkname?: string };
100
+
101
+ /** The header blocks of one member: a pax `x` header first when the path, target or size needs one. */
102
+ export function tarMemberHeader(member: TarMember): Buffer {
103
+ const mtime = clampMtime(member.mtime);
104
+ const records: string[] = [];
105
+ let name: Buffer = Buffer.from(member.name, "utf8");
106
+ if (name.length > 100 || !isAscii(name)) {
107
+ records.push(paxRecord("path", member.name));
108
+ name = asciiFallback(name);
109
+ }
110
+ let linkname: Buffer | null = null;
111
+ if (member.linkname !== undefined) {
112
+ linkname = Buffer.from(member.linkname, "utf8");
113
+ if (linkname.length > 100 || !isAscii(linkname)) {
114
+ records.push(paxRecord("linkpath", member.linkname));
115
+ linkname = asciiFallback(linkname);
116
+ }
117
+ }
118
+ let size = member.size;
119
+ if (size > MAX_OCTAL_11) {
120
+ records.push(paxRecord("size", String(size)));
121
+ size = 0;
122
+ }
123
+ const header = ustarHeader(name, member.mode, size, mtime, member.typeflag, linkname);
124
+ if (records.length === 0) return header;
125
+ const body = Buffer.from(records.join(""), "utf8");
126
+ return Buffer.concat([
127
+ ustarHeader(PAX_HEADER_NAME, 0o644, body.length, mtime, "x", null),
128
+ body,
129
+ Buffer.alloc(paddingFor(body.length)),
130
+ header,
131
+ ]);
132
+ }
133
+
134
+ export type PackInput = {
135
+ root: string;
136
+ /** `__omnirush__/manifest.json`: its bytes, or a source that serialises it while it is written. */
137
+ manifest: Buffer | ManifestSource;
138
+ /** created_at in seconds: the mtime of the manifest and unstable members. */
139
+ createdAtSeconds: number;
140
+ /** Exactly the manifest's `files`, in the same order. */
141
+ entries: readonly ScannedEntry[];
142
+ /** Stops the writer between members and output blocks: it then rejects with the signal's reason. */
143
+ signal?: AbortSignal;
144
+ };
145
+
146
+ export type PackOutcome = { unstable: string[]; tarBytes: number };
147
+
148
+ export type TarPack = { stream: Readable; outcome: () => PackOutcome };
149
+
150
+ /** Takes a filled output block and resolves with the next (empty) one to fill. */
151
+ type EmitBlock = (block: Buffer) => Promise<Buffer>;
152
+
153
+ /**
154
+ * Writes the tar stream of an archive into output blocks: the manifest, the
155
+ * entries streamed from disk, then `__omnirush__/unstable.json` when an entry
156
+ * changed after pass 1. File content is read straight into the blocks. Only
157
+ * a full block waits on the consumer; the members themselves are plain async
158
+ * calls (async generators, one chain per member, cost several times the
159
+ * memory on a 200k-member tree).
160
+ */
161
+ class TarWriter {
162
+ private readonly unstable: string[] = [];
163
+ private tarBytes = 0;
164
+ private finished = false;
165
+ private block: Buffer;
166
+ private used = 0;
167
+ private rotations = 0;
168
+ /** The directories above the last entry read, from the root down, and whether each was a real directory. */
169
+ private readonly parents: Array<{ name: string; real: boolean }> = [];
170
+
171
+ constructor(private readonly input: PackInput, first: Buffer, private readonly emit: EmitBlock) {
172
+ this.block = first;
173
+ }
174
+
175
+ outcome(): PackOutcome {
176
+ if (!this.finished) throw new Error("the tar stream has not finished");
177
+ return { unstable: [...this.unstable], tarBytes: this.tarBytes };
178
+ }
179
+
180
+ private async rotate(): Promise<void> {
181
+ this.input.signal?.throwIfAborted();
182
+ this.tarBytes += this.used;
183
+ this.block = await this.emit(this.block);
184
+ this.used = 0;
185
+ this.rotations += 1;
186
+ if (this.rotations % GC_HINT_BLOCKS === 0) hintGarbageCollection();
187
+ }
188
+
189
+ private async put(bytes: Buffer): Promise<void> {
190
+ let offset = 0;
191
+ while (offset < bytes.length) {
192
+ if (this.used === OUTPUT_BLOCK) await this.rotate();
193
+ const take = Math.min(bytes.length - offset, OUTPUT_BLOCK - this.used);
194
+ bytes.copy(this.block, this.used, offset, offset + take);
195
+ this.used += take;
196
+ offset += take;
197
+ }
198
+ }
199
+
200
+ private async zeros(count: number): Promise<void> {
201
+ let left = count;
202
+ while (left > 0) {
203
+ if (this.used === OUTPUT_BLOCK) await this.rotate();
204
+ const take = Math.min(left, OUTPUT_BLOCK - this.used);
205
+ this.block.fill(0, this.used, this.used + take);
206
+ this.used += take;
207
+ left -= take;
208
+ }
209
+ }
210
+
211
+ private async smallFile(name: string, content: Buffer | ManifestSource): Promise<void> {
212
+ const source = Buffer.isBuffer(content) ? { size: content.length, chunks: () => [content] } : content;
213
+ await this.put(tarMemberHeader({ name, mode: 0o644, size: source.size, mtime: this.input.createdAtSeconds, typeflag: "0" }));
214
+ let written = 0;
215
+ for (const piece of source.chunks()) {
216
+ written += piece.length;
217
+ if (written > source.size) break;
218
+ await this.put(piece);
219
+ }
220
+ if (written !== source.size) throw new Error(`${name} changed while it was written`);
221
+ await this.zeros(paddingFor(source.size));
222
+ }
223
+
224
+ /**
225
+ * Whether every directory above `parts` (a root-relative path, split) is
226
+ * still a real directory and not a symlink, so that opening the path cannot
227
+ * leave the root. Each directory is lstat'ed once, parents first, when the
228
+ * walk enters it, and the result holds for its subtree (contiguous in byte
229
+ * order). A swap after that is caught per file by fileContent()'s identity
230
+ * check.
231
+ */
232
+ private async parentsInRoot(parts: readonly string[]): Promise<boolean> {
233
+ const { parents } = this;
234
+ const depth = parts.length - 1;
235
+ let kept = 0;
236
+ while (kept < parents.length && kept < depth && parents[kept]!.name === parts[kept]) kept += 1;
237
+ parents.length = kept;
238
+ for (let index = kept; index < depth; index += 1) {
239
+ let real = index === 0 || parents[index - 1]!.real;
240
+ if (real) {
241
+ try {
242
+ real = (await lstat(join(this.input.root, ...parts.slice(0, index + 1)))).isDirectory();
243
+ } catch {
244
+ real = false;
245
+ }
246
+ }
247
+ parents.push({ name: parts[index]!, real });
248
+ }
249
+ return depth === 0 || parents[depth - 1]!.real;
250
+ }
251
+
252
+ /**
253
+ * Streams exactly entry.size bytes (truncated or NUL-padded); false when the
254
+ * file is not what pass 1 saw. Bytes are copied only from the regular file
255
+ * pass 1 lstat'ed (same st_dev and st_ino) under real directories; anything
256
+ * else (a file replaced, reached through a directory swapped for a symlink,
257
+ * a FIFO) is zero-filled.
258
+ */
259
+ private async fileContent(entry: ScannedEntry, absolute: string, parentsInRoot: boolean): Promise<boolean> {
260
+ let stable = false;
261
+ let handle: Awaited<ReturnType<typeof open>> | null = null;
262
+ if (parentsInRoot) {
263
+ try {
264
+ handle = await open(absolute, OPEN_ENTRY_FLAGS);
265
+ const stats = await handle.stat({ bigint: true });
266
+ if (stats.isFile() && isSameFileIdentity(entry, stats)) {
267
+ stable = stats.size === BigInt(entry.size) && entry.statKey.startsWith(statKeyPrefix(stats.size, stats.mtimeNs));
268
+ } else {
269
+ await handle.close();
270
+ handle = null;
271
+ }
272
+ } catch {
273
+ await handle?.close().catch(() => undefined);
274
+ handle = null;
275
+ }
276
+ }
277
+ const hash = createHash("sha256");
278
+ let remaining = entry.size;
279
+ try {
280
+ while (handle && remaining > 0) {
281
+ if (this.used === OUTPUT_BLOCK) await this.rotate();
282
+ const want = Math.min(remaining, OUTPUT_BLOCK - this.used);
283
+ let bytesRead = 0;
284
+ try {
285
+ ({ bytesRead } = await handle.read(this.block, this.used, want, null));
286
+ } catch {
287
+ stable = false;
288
+ }
289
+ if (bytesRead === 0) break;
290
+ hash.update(this.block.subarray(this.used, this.used + bytesRead));
291
+ this.used += bytesRead;
292
+ remaining -= bytesRead;
293
+ }
294
+ } finally {
295
+ await handle?.close().catch(() => undefined);
296
+ }
297
+ if (remaining > 0) {
298
+ stable = false;
299
+ await this.zeros(remaining);
300
+ } else if (hash.digest("hex") !== entry.sha256) {
301
+ stable = false;
302
+ }
303
+ return stable;
304
+ }
305
+
306
+ /** Writes the whole archive; the last, partial block is emitted too. */
307
+ async write(): Promise<void> {
308
+ const { input, unstable } = this;
309
+ await this.smallFile(MANIFEST_MEMBER, input.manifest);
310
+ let members = 0;
311
+ for (const entry of input.entries) {
312
+ input.signal?.throwIfAborted();
313
+ members += 1;
314
+ if (members % GC_HINT_MEMBERS === 0) hintGarbageCollection();
315
+ if (entry.type === "dir") {
316
+ await this.put(tarMemberHeader({ name: `${entry.path}/`, mode: entry.mode, size: 0, mtime: entry.mtime, typeflag: "5" }));
317
+ continue;
318
+ }
319
+ const parts = entry.path.split("/");
320
+ const absolute = join(input.root, ...parts);
321
+ const parentsInRoot = await this.parentsInRoot(parts);
322
+ if (entry.type === "symlink") {
323
+ const target = entry.target ?? "";
324
+ await this.put(tarMemberHeader({ name: entry.path, mode: entry.mode, size: 0, mtime: entry.mtime, typeflag: "2", linkname: target }));
325
+ try {
326
+ const stats = parentsInRoot ? await lstat(absolute) : null;
327
+ if (!stats?.isSymbolicLink() || (await readlink(absolute)) !== target) unstable.push(entry.path);
328
+ } catch {
329
+ unstable.push(entry.path);
330
+ }
331
+ continue;
332
+ }
333
+ await this.put(tarMemberHeader({ name: entry.path, mode: entry.mode, size: entry.size, mtime: entry.mtime, typeflag: "0" }));
334
+ const stable = await this.fileContent(entry, absolute, parentsInRoot);
335
+ await this.zeros(paddingFor(entry.size));
336
+ if (!stable) unstable.push(entry.path);
337
+ }
338
+ if (unstable.length > 0) {
339
+ unstable.sort(compareArchivePaths);
340
+ await this.smallFile(UNSTABLE_MEMBER, Buffer.from(JSON.stringify({ schema: UNSTABLE_SCHEMA, paths: unstable }), "utf8"));
341
+ }
342
+ await this.zeros(2 * TAR_BLOCK);
343
+ if (this.used > 0) {
344
+ this.tarBytes += this.used;
345
+ await this.emit(this.block.subarray(0, this.used));
346
+ this.used = 0;
347
+ }
348
+ this.finished = true;
349
+ }
350
+ }
351
+
352
+ /** The tar stream of an archive as a Readable (fresh blocks: safe for any consumer). */
353
+ export function packArchiveTar(input: PackInput): TarPack {
354
+ const stream = new PassThrough({ highWaterMark: OUTPUT_BLOCK });
355
+ const writer = new TarWriter(input, Buffer.allocUnsafe(OUTPUT_BLOCK), async (block) => {
356
+ if (!stream.write(block)) await once(stream, "drain");
357
+ return Buffer.allocUnsafe(OUTPUT_BLOCK);
358
+ });
359
+ writer.write().then(() => stream.end(), (error: unknown) => stream.destroy(error instanceof Error ? error : new Error(String(error))));
360
+ return { stream, outcome: () => writer.outcome() };
361
+ }
362
+
363
+ /**
364
+ * A few output blocks recycled between the tar writer and the compressor: a
365
+ * block returns to the pool once the compressor's write callback says it has
366
+ * consumed it, so pass 2 allocates the same few MiB whatever the archive size.
367
+ */
368
+ class BlockPool {
369
+ private readonly free: Buffer[] = [];
370
+ private readonly waiters: Array<(block: Buffer) => void> = [];
371
+ private readonly owned = new Map<ArrayBufferLike, Buffer>();
372
+
373
+ constructor(private readonly capacity: number) {}
374
+
375
+ acquire(): Promise<Buffer> {
376
+ const block = this.free.pop();
377
+ if (block) return Promise.resolve(block);
378
+ if (this.owned.size < this.capacity) {
379
+ const fresh = Buffer.allocUnsafeSlow(OUTPUT_BLOCK);
380
+ this.owned.set(fresh.buffer, fresh);
381
+ return Promise.resolve(fresh);
382
+ }
383
+ return new Promise((resolvePromise) => this.waiters.push(resolvePromise));
384
+ }
385
+
386
+ /** Takes back the block a (possibly partial) view belongs to. */
387
+ release(view: Buffer): void {
388
+ const block = this.owned.get(view.buffer);
389
+ if (!block) return;
390
+ const waiter = this.waiters.shift();
391
+ if (waiter) waiter(block);
392
+ else this.free.push(block);
393
+ }
394
+
395
+ /** After a failure: nobody may wait forever for a block. */
396
+ unblock(): void {
397
+ for (const waiter of this.waiters.splice(0)) waiter(Buffer.allocUnsafe(OUTPUT_BLOCK));
398
+ }
399
+ }
400
+
401
+ const POOL_BLOCKS = 4;
402
+
403
+ type FileHandle = Awaited<ReturnType<typeof open>>;
404
+
405
+ /** The last stage: writes each chunk to the file and waits for it, then fsyncs. */
406
+ class FileSink extends Writable {
407
+ constructor(private readonly handle: FileHandle) {
408
+ super({ highWaterMark: OUTPUT_BLOCK });
409
+ }
410
+
411
+ override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
412
+ this.writeAll([chunk]).then(() => callback(), (error: unknown) => callback(error instanceof Error ? error : new Error(String(error))));
413
+ }
414
+
415
+ override _writev(chunks: Array<{ chunk: Buffer }>, callback: (error?: Error | null) => void): void {
416
+ this.writeAll(chunks.map((item) => item.chunk)).then(() => callback(), (error: unknown) => callback(error instanceof Error ? error : new Error(String(error))));
417
+ }
418
+
419
+ private async writeAll(buffers: Buffer[]): Promise<void> {
420
+ let pending = buffers.filter((buffer) => buffer.length > 0);
421
+ while (pending.length > 0) {
422
+ let { bytesWritten } = await this.handle.writev(pending);
423
+ while (pending.length > 0 && bytesWritten >= pending[0]!.length) {
424
+ bytesWritten -= pending[0]!.length;
425
+ pending = pending.slice(1);
426
+ }
427
+ if (pending.length > 0 && bytesWritten > 0) pending = [pending[0]!.subarray(bytesWritten), ...pending.slice(1)];
428
+ }
429
+ }
430
+
431
+ override _final(callback: (error?: Error | null) => void): void {
432
+ this.handle.sync().then(() => callback(), (error: unknown) => callback(error instanceof Error ? error : new Error(String(error))));
433
+ }
434
+ }
435
+
436
+ export type SealedArchive = PackOutcome & { size: number; sha256: string; kid: string };
437
+
438
+ /**
439
+ * file -> tar -> zstd (level 3, checksum, one frame) -> ORSEAL01 -> `outputPath`,
440
+ * then fsync. The output file must not exist yet.
441
+ */
442
+ export async function writeSealedArchive(input: PackInput, seal: SealOptions, outputPath: string): Promise<SealedArchive> {
443
+ const sealer = new SealStream(seal);
444
+ const compressor = createZstdCompress({
445
+ chunkSize: 256 * 1024,
446
+ params: {
447
+ [zlibConstants.ZSTD_c_compressionLevel]: ZSTD_LEVEL,
448
+ [zlibConstants.ZSTD_c_checksumFlag]: 1,
449
+ },
450
+ });
451
+ const handle = await open(outputPath, "wx", 0o600);
452
+ try {
453
+ const pool = new BlockPool(POOL_BLOCKS);
454
+ let failure: unknown = null;
455
+ const downstream = pipeline(compressor, sealer, new FileSink(handle));
456
+ downstream.catch((error: unknown) => {
457
+ failure ??= error;
458
+ pool.unblock();
459
+ });
460
+ const writer = new TarWriter(input, await pool.acquire(), async (block) => {
461
+ if (failure) throw failure;
462
+ compressor.write(block, () => pool.release(block));
463
+ return pool.acquire();
464
+ });
465
+ try {
466
+ await writer.write();
467
+ } catch (error) {
468
+ failure ??= error;
469
+ }
470
+ if (failure) {
471
+ compressor.destroy();
472
+ await downstream.catch(() => undefined);
473
+ throw failure;
474
+ }
475
+ compressor.end();
476
+ await downstream;
477
+ return { ...writer.outcome(), ...sealer.result(), kid: sealer.kid };
478
+ } finally {
479
+ await handle.close();
480
+ }
481
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The archive policy GET /archives/key sends beside the key (backend spec
3
+ * 4.4). `policy.all_folders: true` archives every folder a session starts
4
+ * in, not only git repositories; `policy.touched_files: true` archives, in
5
+ * a folder that is neither, the files the agent touched there. Anything
6
+ * else (no policy, a server that predates it, a value that is not the
7
+ * boolean true) is off: git only.
8
+ */
9
+ import { z } from "../vendor/zod.js";
10
+
11
+ export type ArchivePolicy = { allFolders: boolean; touchedFiles: boolean };
12
+
13
+ /** Both off: what a failed probe, a missing policy or a disabled account means. */
14
+ export const POLICY_OFF: ArchivePolicy = { allFolders: false, touchedFiles: false };
15
+
16
+ const allFoldersSchema = z.object({ policy: z.object({ all_folders: z.literal(true) }) });
17
+ const touchedFilesSchema = z.object({ policy: z.object({ touched_files: z.literal(true) }) });
18
+
19
+ export function parseArchivePolicy(body: unknown): ArchivePolicy {
20
+ return { allFolders: allFoldersSchema.safeParse(body).success, touchedFiles: touchedFilesSchema.safeParse(body).success };
21
+ }