@shardflux/sdk 0.6.2 → 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.
@@ -0,0 +1,1326 @@
1
+ /**
2
+ * Tool-call capture (docs/decisions/0006-tool-call-capture.md): tees every tool call of a customer's agent harness
3
+ * (its input and its full output) into the workspace filesystem, so the agent can compute on results in the VM and
4
+ * they are in snapshots and forks.
5
+ *
6
+ * const capture = workspace.captureToolCalls();
7
+ * const result = await capture.run(block, () => myTools[block.name](block.input));
8
+ *
9
+ * Invisible by construction: wrapped tools return the same value, the same promise object and the same thrown error;
10
+ * a synchronous tool stays synchronous; nothing capture does ever throws into the harness after setup. The only work on
11
+ * the call's path is taking a reference to its data: serialization runs in `setImmediate`, writes in the background.
12
+ *
13
+ * Write protocol: the output file first (atomic replace, key `cap:<run>:<seq>:o<part>`), then the index line, appended
14
+ * in batches (`cap:<run>:i:<batch>`) in `seq` order. A retry keeps its key while the outcome is unknown (network error:
15
+ * the write may have committed and is then replayed); after an error response it moves to `<key>:r<n>`, because the host
16
+ * replays a failed operation's error under its key (an append retried that way starts with a newline, so a torn line
17
+ * from the failed attempt stays a line of its own).
18
+ */
19
+ import { AsyncLocalStorage } from 'node:async_hooks';
20
+ import { cellPath } from "./cell.js";
21
+ import { ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
22
+ import { DEFAULT_MAX_OUTPUT_BYTES, JsonlCollector, asBytes, cutUtf8, dropped as droppedPlan, errorInfo, isUnreadable, padSeq, parseArguments, planOutput, sanitizeToolName, sha256Hex, toJsonSafe, } from "./capture-serialize.js";
23
+ import { PROMPT_HINT, WORKSPACE_README, fillTemplate } from "./capture-text.js";
24
+ import { createAdapters } from "./capture-adapters.js";
25
+ /** A capture problem, reported to `onError` (never thrown into the harness). */
26
+ export class CaptureError extends Error {
27
+ kind;
28
+ seq;
29
+ tool;
30
+ callId;
31
+ /** The workspace path of a failed write. */
32
+ path;
33
+ constructor(kind, message, extra = {}) {
34
+ super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
35
+ this.name = 'CaptureError';
36
+ this.kind = kind;
37
+ this.seq = extra.seq;
38
+ this.tool = extra.tool;
39
+ this.callId = extra.callId;
40
+ this.path = extra.path;
41
+ }
42
+ }
43
+ /** Internal hooks the registry calls. */
44
+ export const SETTLE = Symbol('shardflux.capture.settle');
45
+ export const DISCARD = Symbol('shardflux.capture.discard');
46
+ /**
47
+ * The captures of one client, by workspace id: Shardflux calls on the same client wait for writes recorded before them
48
+ * (read-your-writes), and delete/reset discard pending writes.
49
+ */
50
+ export class CaptureRegistry {
51
+ #byWorkspace = new Map();
52
+ add(workspaceId, capture) {
53
+ let set = this.#byWorkspace.get(workspaceId);
54
+ if (!set)
55
+ this.#byWorkspace.set(workspaceId, (set = new Set()));
56
+ set.add(capture);
57
+ }
58
+ remove(workspaceId, capture) {
59
+ const set = this.#byWorkspace.get(workspaceId);
60
+ if (!set)
61
+ return;
62
+ set.delete(capture);
63
+ if (set.size === 0)
64
+ this.#byWorkspace.delete(workspaceId);
65
+ }
66
+ /**
67
+ * Waits for writes recorded before this call (not for later ones), each capture bounded by its settle timeout.
68
+ * Undefined when nothing is pending, so callers skip the await. Never rejects.
69
+ */
70
+ settle(workspaceId) {
71
+ const set = this.#byWorkspace.get(workspaceId);
72
+ if (!set)
73
+ return undefined;
74
+ const waits = [];
75
+ for (const c of set) {
76
+ try {
77
+ const w = c[SETTLE]();
78
+ if (w)
79
+ waits.push(w);
80
+ }
81
+ catch {
82
+ // a barrier never fails the call it guards
83
+ }
84
+ }
85
+ return waits.length === 0 ? undefined : Promise.all(waits).then(() => undefined, () => undefined);
86
+ }
87
+ /** Drops pending writes of the workspace (delete: the captures also close; reset: the README is written again). */
88
+ discard(workspaceId, reason) {
89
+ const set = this.#byWorkspace.get(workspaceId);
90
+ if (!set)
91
+ return;
92
+ for (const c of [...set]) {
93
+ try {
94
+ c[DISCARD](reason);
95
+ }
96
+ catch {
97
+ // never fails the lifecycle call
98
+ }
99
+ }
100
+ }
101
+ }
102
+ const DEFAULT_DIR = '/home/user/tool-calls';
103
+ const INDEX_BATCH_BYTES = 1024 * 1024;
104
+ const DEDUP_IDS = 10_000;
105
+ const SERIALIZE_BUDGET_MS = 4;
106
+ /** Reserved per call for its index line before the line exists (the exact size replaces it). */
107
+ const LINE_RESERVE = 1024;
108
+ /** Error messages in a `queue_full` line are cut to this many characters. */
109
+ const DEGRADED_MESSAGE_MAX = 4096;
110
+ const EMPTY = new Uint8Array(0);
111
+ const STATUSES = new Set(['ok', 'error', 'cancelled', 'incomplete', 'retry']);
112
+ const encoder = new TextEncoder();
113
+ /** `YYYYMMDDTHHMMSSmmmZ-xxxxxx`: sortable UTC start time plus 6 random base36 characters. */
114
+ export function newRunId(now = new Date()) {
115
+ const ts = now.toISOString().replace(/[-:.]/g, '');
116
+ const rnd = crypto.getRandomValues(new Uint8Array(6));
117
+ let suffix = '';
118
+ for (const b of rnd)
119
+ suffix += (b % 36).toString(36);
120
+ return `${ts}-${suffix}`;
121
+ }
122
+ const isNativePromise = (v) => v instanceof Promise || Object.prototype.toString.call(v) === '[object Promise]';
123
+ const tag = (v) => Object.prototype.toString.call(v);
124
+ const isAsyncGeneratorObject = (v) => tag(v) === '[object AsyncGenerator]';
125
+ const isGeneratorObject = (v) => tag(v) === '[object Generator]';
126
+ const isAbortError = (e) => e instanceof Error && (e.name === 'AbortError' || e.code === 'ABORT_ERR');
127
+ /** Timestamps an index line can carry: `started_at` has a 4-digit year (0000-01-01 to 9999-12-31, UTC). */
128
+ const MIN_MS = -62_167_219_200_000;
129
+ const MAX_MS = 253_402_300_799_999;
130
+ /**
131
+ * Epoch milliseconds from a Date, a number or an ISO string; null when absent, invalid or out of range (epoch
132
+ * nanoseconds or seconds passed by mistake must not reach toISOString(), which throws for them).
133
+ */
134
+ export function toMs(v) {
135
+ let ms;
136
+ try {
137
+ if (v instanceof Date)
138
+ ms = v.getTime();
139
+ else if (typeof v === 'number')
140
+ ms = v;
141
+ else if (typeof v === 'string')
142
+ ms = Date.parse(v);
143
+ else
144
+ return null;
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ return Number.isFinite(ms) && ms >= MIN_MS && ms <= MAX_MS ? ms : null;
150
+ }
151
+ /** ISO 8601 with milliseconds and Z, or null; never throws. */
152
+ function isoOf(ms) {
153
+ if (ms === null)
154
+ return null;
155
+ try {
156
+ return new Date(ms).toISOString();
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ /** A non-negative integer duration, or null (NaN, Infinity, or past Number.MAX_SAFE_INTEGER). */
163
+ function durationOf(v) {
164
+ if (typeof v !== 'number' || !Number.isFinite(v))
165
+ return null;
166
+ const d = Math.max(0, Math.round(v));
167
+ return d <= Number.MAX_SAFE_INTEGER ? d : null;
168
+ }
169
+ function matches(sel, tool, source) {
170
+ if (typeof sel === 'function')
171
+ return sel(tool, source);
172
+ if (sel instanceof RegExp) {
173
+ sel.lastIndex = 0;
174
+ return sel.test(tool);
175
+ }
176
+ return sel.includes(tool);
177
+ }
178
+ const activeCapture = new AsyncLocalStorage();
179
+ /** Runs capture bookkeeping on the tool's path: whatever it throws stays here (`finish` reports its own failures). */
180
+ function quiet(fn) {
181
+ try {
182
+ fn();
183
+ }
184
+ catch {
185
+ // never into the harness
186
+ }
187
+ }
188
+ /**
189
+ * Wraps `fn` so each call is recorded into the capture active at call time (`capture.activate()`), and passes
190
+ * straight through when none is. For tools defined at import time in multi-tenant servers.
191
+ */
192
+ export function captureTool(name, fn, opts = {}) {
193
+ const wrapped = function (...args) {
194
+ const capture = activeCapture.getStore();
195
+ if (!capture)
196
+ return fn.apply(this, args);
197
+ return capture.wrap(name, fn, opts).apply(this, args);
198
+ };
199
+ Object.defineProperty(wrapped, 'name', { value: fn.name, configurable: true });
200
+ Object.defineProperty(wrapped, 'length', { value: fn.length, configurable: true });
201
+ return wrapped;
202
+ }
203
+ export class ToolCallCapture {
204
+ /** `YYYYMMDDTHHMMSSmmmZ-xxxxxx`. */
205
+ runId;
206
+ /** The capture directory (`dir`). */
207
+ dir;
208
+ /** `<dir>/<runId>`: index.jsonl and the output files. */
209
+ runDir;
210
+ workspaceId;
211
+ #host;
212
+ #cell;
213
+ #sleep;
214
+ #opts;
215
+ #adapters;
216
+ #core;
217
+ #closed = false;
218
+ #closing;
219
+ #gen = 0;
220
+ #abort = new AbortController();
221
+ #seq = 0;
222
+ #settled = 0;
223
+ #nextCommit = 1;
224
+ #items = new Map();
225
+ #toSerialize = [];
226
+ #serializeScheduled = false;
227
+ #fileQueue = [];
228
+ #activeWrites = 0;
229
+ #batch = [];
230
+ #batchBytes = 0;
231
+ #batchTimer;
232
+ #committing = false;
233
+ #batchNo = 0;
234
+ #readmeDone = false;
235
+ #readmeGen = 0;
236
+ #pendingBytes = 0;
237
+ #waiters = [];
238
+ #seen = new Set();
239
+ #stats = { recorded: 0, written: 0, failed: 0, dropped: 0, skipped: 0, duplicates: 0, discarded: 0, retries: 0 };
240
+ constructor(host, opts = {}) {
241
+ const dir = (opts.dir ?? DEFAULT_DIR).replace(/\/+$/, '');
242
+ if (!dir.startsWith('/') || dir.length < 2 || dir.split('/').some((p) => p === '..'))
243
+ throw new TypeError(`capture dir must be an absolute workspace path, got ${JSON.stringify(opts.dir)}`);
244
+ const pos = (v, d, name, min = 1) => {
245
+ if (v === undefined)
246
+ return d;
247
+ if (!Number.isFinite(v) || v < min)
248
+ throw new TypeError(`${name} must be a number >= ${min}`);
249
+ return v;
250
+ };
251
+ this.#host = host;
252
+ this.#cell = host.cell;
253
+ this.#sleep = host.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref()));
254
+ this.workspaceId = host.workspaceId;
255
+ this.dir = dir;
256
+ this.runId = newRunId();
257
+ this.runDir = `${dir}/${this.runId}`;
258
+ this.#opts = {
259
+ include: opts.include,
260
+ exclude: opts.exclude,
261
+ transform: opts.transform,
262
+ maxOutputBytes: pos(opts.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, 'maxOutputBytes'),
263
+ maxInlineInputBytes: pos(opts.maxInlineInputBytes, 64 * 1024, 'maxInlineInputBytes', 0),
264
+ maxPendingBytes: pos(opts.maxPendingBytes, 128 * 1024 * 1024, 'maxPendingBytes', 0),
265
+ maxPendingCalls: Math.floor(pos(opts.maxPendingCalls, 10_000, 'maxPendingCalls')),
266
+ concurrency: Math.floor(pos(opts.concurrency, 4, 'concurrency')),
267
+ batchDelayMs: pos(opts.batchDelayMs, 50, 'batchDelayMs', 0),
268
+ retryWindowMs: pos(opts.retryWindowMs, 120_000, 'retryWindowMs', 0),
269
+ settleTimeoutMs: pos(opts.settleTimeoutMs, 30_000, 'settleTimeoutMs', 0),
270
+ metadata: opts.metadata,
271
+ onError: opts.onError,
272
+ };
273
+ this.#core = {
274
+ observe: (spec, fn, thisArg, args) => this.#observe(spec, fn, thisArg, args),
275
+ hook: (call) => this.#hook(call),
276
+ settle: () => this[SETTLE](),
277
+ flush: (timeoutMs) => this.flush(timeoutMs === undefined ? {} : { timeoutMs }),
278
+ accepts: (tool, source) => this.#accepts(tool, source),
279
+ };
280
+ host.registry.add(this.workspaceId, this);
281
+ }
282
+ // ---- public API ----------------------------------------------------------------------------------------------
283
+ /** Counters (a snapshot). */
284
+ get stats() {
285
+ return { ...this.#stats, pending: this.#seq - this.#settled, pendingBytes: this.#pendingBytes };
286
+ }
287
+ /** True after close() (or when the workspace was deleted): records are ignored. */
288
+ get closed() {
289
+ return this.#closed;
290
+ }
291
+ /**
292
+ * Records one finished call. Synchronous and cheap (serialization happens later); never throws. Returns null when the
293
+ * capture is closed or the call id was already recorded.
294
+ */
295
+ record(call) {
296
+ try {
297
+ return this.#record(this.#normalize(call, call.source ?? 'manual'));
298
+ }
299
+ catch (e) {
300
+ this.#report(new CaptureError('serialize', `record failed: ${errorInfo(e).message}`, { cause: e }));
301
+ return null;
302
+ }
303
+ }
304
+ /**
305
+ * Runs `fn` (your tool) for a model's tool call and records it: Anthropic `tool_use {id, name, input}`, Responses
306
+ * `function_call {call_id, name, arguments}`, a Chat `tool_calls` item `{id, function: {name, arguments}}`, or
307
+ * `{name, input}`. Returns exactly what `fn` returns (the same promise object; a sync result stays sync).
308
+ */
309
+ run(call, fn) {
310
+ const tool = call.name ?? call.toolName ?? call.function?.name ?? 'tool';
311
+ // A Responses function_call has both: `id` is the item id (fc_…), `call_id` the id its output pairs on.
312
+ const callId = call.call_id ?? call.callId ?? call.toolCallId ?? call.id ?? null;
313
+ const input = call.input !== undefined ? call.input : call.args !== undefined ? call.args : parseArguments(call.arguments ?? call.function?.arguments);
314
+ return this.#observe({ tool, source: 'manual', input, callId }, fn, undefined, []);
315
+ }
316
+ /**
317
+ * Wraps a tool function: every call is recorded. The wrapper returns what `fn` returns (the same value, the same
318
+ * promise object, the same thrown error; sync stays sync). An async generator is passed through item by item and its
319
+ * items are stored as `.jsonl`.
320
+ */
321
+ wrap(name, fn, opts = {}) {
322
+ if (typeof fn !== 'function')
323
+ throw new TypeError('wrap() needs a function');
324
+ const observe = (spec, thisArg, args) => this.#observe(spec, fn, thisArg, args);
325
+ const wrapped = function (...args) {
326
+ let input;
327
+ let callId = null;
328
+ try {
329
+ input = opts.input ? opts.input(...args) : args.length === 1 ? args[0] : args;
330
+ callId = opts.callId?.(...args) ?? null;
331
+ }
332
+ catch {
333
+ input = args;
334
+ }
335
+ return observe({ tool: name, source: opts.source ?? 'wrap', input, callId, ...(opts.meta ? { meta: opts.meta } : {}) }, this, args);
336
+ };
337
+ Object.defineProperty(wrapped, 'name', { value: fn.name, configurable: true });
338
+ Object.defineProperty(wrapped, 'length', { value: fn.length, configurable: true });
339
+ return wrapped;
340
+ }
341
+ /**
342
+ * Wraps a collection of tools and returns a copy of the same shape (objects are copied with their prototype; the
343
+ * originals are not modified). Detects `execute` (AI SDK, Mastra, Shardflux workspaceTools), `run` (Anthropic tool
344
+ * runner), `invoke` (OpenAI Agents FunctionTool, LangChain tools) and plain functions in an object. Throws TypeError
345
+ * for any other shape.
346
+ */
347
+ tools(tools) {
348
+ return this.adapters.wrapAny(tools);
349
+ }
350
+ /** Runs `fn` with this capture active for `captureTool()` wrappers (AsyncLocalStorage). */
351
+ activate(fn) {
352
+ return activeCapture.run(this, fn);
353
+ }
354
+ /** The paragraph you can put in a system prompt so the agent knows where its tool calls are (never injected). */
355
+ promptHint() {
356
+ return fillTemplate(PROMPT_HINT, { dir: this.dir, run_dir: this.runDir }).trimEnd();
357
+ }
358
+ /**
359
+ * Waits until every call recorded before it is written (or has failed), at most `timeoutMs` (default
360
+ * settleTimeoutMs). Never rejects. Serverless: `waitUntil(capture.flush())`, or `await capture.flush()` before
361
+ * returning.
362
+ */
363
+ async flush(opts = {}) {
364
+ const complete = await this.#waitSettled(this.#seq, opts.timeoutMs ?? this.#opts.settleTimeoutMs);
365
+ return this.#result(complete);
366
+ }
367
+ /**
368
+ * Stops the capture: flushes what was recorded (bounded by `timeoutMs`), or drops it with `discard: true`, then
369
+ * ignores later records. Never rejects; calling it again returns the first close's result.
370
+ */
371
+ close(opts = {}) {
372
+ this.#closing ??= (async () => {
373
+ this.#closed = true;
374
+ let complete = true;
375
+ if (opts.discard)
376
+ this.#discardPending('discarded', 'close({ discard: true })');
377
+ else
378
+ complete = await this.#waitSettled(this.#seq, opts.timeoutMs ?? this.#opts.settleTimeoutMs);
379
+ if (!complete)
380
+ this.#discardPending('timeout', 'close timed out');
381
+ this.#host.registry.remove(this.workspaceId, this);
382
+ this.#cell.close();
383
+ return this.#result(complete);
384
+ })();
385
+ return this.#closing;
386
+ }
387
+ /** Framework adapters (created on first use). */
388
+ get adapters() {
389
+ return (this.#adapters ??= createAdapters(this.#core));
390
+ }
391
+ /** Vercel AI SDK: `tools: capture.aiSdk.tools(tools)` or `...capture.aiSdk.callbacks()`. */
392
+ get aiSdk() {
393
+ return this.adapters.aiSdk;
394
+ }
395
+ /** Mastra: `hooks: capture.mastra.hooks()` or `tools: capture.mastra.tools({...})`. */
396
+ get mastra() {
397
+ return this.adapters.mastra;
398
+ }
399
+ /** Anthropic SDK tool runner: `tools: capture.anthropic.tools([...])`. */
400
+ get anthropic() {
401
+ return this.adapters.anthropic;
402
+ }
403
+ /** OpenAI Agents JS: `capture.openaiAgents.attach(runner)` or `tools: capture.openaiAgents.tools([...])`. */
404
+ get openaiAgents() {
405
+ return this.adapters.openaiAgents;
406
+ }
407
+ /** Claude Agent SDK: `options.hooks = capture.claude.hooks(myHooks)`. */
408
+ get claude() {
409
+ return this.adapters.claude;
410
+ }
411
+ /** LangChain.js / LangGraph.js: `callbacks: [capture.langchain.handler()]`. */
412
+ get langchain() {
413
+ return this.adapters.langchain;
414
+ }
415
+ /** MCP client: `const release = capture.mcp.instrument(client, { server: 'github' })`. */
416
+ get mcp() {
417
+ return this.adapters.mcp;
418
+ }
419
+ // ---- registry hooks -------------------------------------------------------------------------------------------
420
+ /** Read-your-writes: waits for calls recorded so far (bounded, never rejects); undefined when nothing is pending. */
421
+ [SETTLE]() {
422
+ if (this.#settled >= this.#seq)
423
+ return undefined;
424
+ return this.#waitSettled(this.#seq, this.#opts.settleTimeoutMs);
425
+ }
426
+ /** delete: drops pending writes and closes; reset: drops pending writes, the README is written again. */
427
+ [DISCARD](reason) {
428
+ if (this.#seq > this.#settled)
429
+ this.#discardPending('discarded', `workspace ${reason}`);
430
+ if (reason === 'reset') {
431
+ this.#readmeDone = false;
432
+ this.#readmeGen += 1;
433
+ return;
434
+ }
435
+ this.#closed = true;
436
+ this.#host.registry.remove(this.workspaceId, this);
437
+ this.#cell.close();
438
+ }
439
+ // ---- recording ------------------------------------------------------------------------------------------------
440
+ #accepts(tool, source) {
441
+ try {
442
+ if (this.#opts.include && !matches(this.#opts.include, tool, source))
443
+ return false;
444
+ if (this.#opts.exclude && matches(this.#opts.exclude, tool, source))
445
+ return false;
446
+ return true;
447
+ }
448
+ catch {
449
+ return false; // a throwing selector fails closed
450
+ }
451
+ }
452
+ /** Hook-level record: filtered by include/exclude. */
453
+ #hook(call) {
454
+ if (this.#closed)
455
+ return null;
456
+ try {
457
+ const n = this.#normalize(call, call.source ?? 'manual');
458
+ if (!this.#accepts(n.tool, n.source))
459
+ return null;
460
+ return this.#record(n);
461
+ }
462
+ catch (e) {
463
+ this.#report(new CaptureError('serialize', `record failed: ${errorInfo(e).message}`, { cause: e }));
464
+ return null;
465
+ }
466
+ }
467
+ #normalize(call, source) {
468
+ const hasError = call.error !== undefined && call.error !== null;
469
+ const startedAtMs = toMs(call.startedAt);
470
+ const durationMs = durationOf(call.durationMs);
471
+ return {
472
+ tool: typeof call.tool === 'string' && call.tool.length > 0 ? call.tool : 'tool',
473
+ input: call.input,
474
+ output: call.output,
475
+ error: call.error,
476
+ hasError,
477
+ status: STATUSES.has(call.status) ? call.status : undefined,
478
+ callId: typeof call.callId === 'string' && call.callId.length > 0 ? call.callId.slice(0, 512) : null,
479
+ startedAtMs,
480
+ durationMs,
481
+ meta: call.meta,
482
+ source,
483
+ };
484
+ }
485
+ #record(call) {
486
+ if (this.#closed)
487
+ return null;
488
+ if (this.#seq - this.#settled >= this.#opts.maxPendingCalls) {
489
+ // The hard memory bound while the workspace does not take writes: the call is not kept at all.
490
+ this.#stats.dropped += 1;
491
+ this.#report(new CaptureError('queue_full', `${this.#opts.maxPendingCalls} calls are pending (maxPendingCalls); call ${call.tool} was not recorded`, { tool: call.tool, callId: call.callId }));
492
+ return null;
493
+ }
494
+ if (call.callId !== null) {
495
+ if (this.#seen.has(call.callId)) {
496
+ this.#stats.duplicates += 1;
497
+ return null;
498
+ }
499
+ this.#seen.add(call.callId);
500
+ if (this.#seen.size > DEDUP_IDS)
501
+ this.#seen.delete(this.#seen.values().next().value);
502
+ }
503
+ const seq = (this.#seq += 1);
504
+ this.#stats.recorded += 1;
505
+ const item = {
506
+ seq,
507
+ gen: this.#gen,
508
+ call,
509
+ state: 'serialize',
510
+ line: null,
511
+ reserved: 0,
512
+ plan: null,
513
+ inputPath: null,
514
+ inputValue: null,
515
+ inputTruncated: false,
516
+ inputDropped: null,
517
+ degraded: false,
518
+ lineReserve: 0,
519
+ filesLeft: 0,
520
+ outputFailed: null,
521
+ inputFailed: false,
522
+ };
523
+ this.#items.set(seq, item);
524
+ this.#toSerialize.push(item);
525
+ if (!this.#serializeScheduled) {
526
+ this.#serializeScheduled = true;
527
+ setImmediate(() => this.#serializeSome());
528
+ }
529
+ return { run: this.runId, seq, callId: call.callId };
530
+ }
531
+ /**
532
+ * Calls `fn` and records the call when it settles, returning exactly what `fn` returned. Everything capture does is
533
+ * guarded: only `fn`'s own exceptions reach the caller.
534
+ */
535
+ #observe(spec, fn, thisArg, args) {
536
+ const hookLevel = spec.hookLevel === true;
537
+ let active = !this.#closed;
538
+ if (active && hookLevel)
539
+ active = this.#accepts(spec.tool, spec.source);
540
+ if (!active)
541
+ return fn.apply(thisArg, args);
542
+ const startedAtMs = Date.now();
543
+ const t0 = performance.now();
544
+ const finish = (output, error, hasError, extra = {}) => {
545
+ try {
546
+ let out = output;
547
+ let err = hasError ? error : undefined;
548
+ let status = extra.status;
549
+ if (!hasError && spec.post) {
550
+ const p = spec.post(output);
551
+ out = p.output;
552
+ if (p.error !== undefined)
553
+ err = p.error;
554
+ if (p.status)
555
+ status = p.status;
556
+ }
557
+ const n = this.#normalize({ tool: spec.tool, input: spec.input, output: out, error: err, callId: spec.callId, startedAt: startedAtMs, durationMs: performance.now() - t0, ...(spec.meta ? { meta: spec.meta } : {}), ...(status ? { status } : {}) }, spec.source);
558
+ if (extra.jsonl)
559
+ n.jsonl = extra.jsonl;
560
+ if (extra.note)
561
+ n.note = extra.note;
562
+ this.#record(n);
563
+ }
564
+ catch (e) {
565
+ this.#report(new CaptureError('serialize', `record failed: ${errorInfo(e).message}`, { cause: e, tool: spec.tool }));
566
+ }
567
+ };
568
+ let result;
569
+ try {
570
+ result = fn.apply(thisArg, args);
571
+ }
572
+ catch (e) {
573
+ finish(undefined, e, true);
574
+ throw e;
575
+ }
576
+ try {
577
+ if (isNativePromise(result)) {
578
+ // Observing the rejection marks this promise handled: a caller that never awaits it gets no
579
+ // unhandledRejection for it (README "Limits"; no sound way to observe without handling in Node).
580
+ result.then((v) => {
581
+ try {
582
+ if (isAsyncGeneratorObject(v) || isGeneratorObject(v))
583
+ finish(undefined, undefined, false, { note: 'stream_not_captured' });
584
+ else
585
+ finish(v, undefined, false);
586
+ }
587
+ catch {
588
+ // finish reports its own failures
589
+ }
590
+ }, (e) => {
591
+ try {
592
+ finish(undefined, e, true);
593
+ }
594
+ catch {
595
+ // finish reports its own failures
596
+ }
597
+ });
598
+ return result;
599
+ }
600
+ if (typeof result === 'object' && result !== null && typeof result.then === 'function') {
601
+ // A lazy thenable (e.g. a query builder) may run again on every then(): never call it.
602
+ finish(undefined, undefined, false, { note: 'thenable_not_captured' });
603
+ return result;
604
+ }
605
+ if (isAsyncGeneratorObject(result))
606
+ return this.#teeAsync(result, spec.final === true, finish);
607
+ if (isGeneratorObject(result))
608
+ return this.#teeSync(result, spec.final === true, finish);
609
+ finish(result, undefined, false);
610
+ }
611
+ catch (e) {
612
+ this.#report(new CaptureError('serialize', `record failed: ${errorInfo(e).message}`, { cause: e, tool: spec.tool }));
613
+ }
614
+ return result;
615
+ }
616
+ /** A pass-through async generator: the consumer sees the same results; items go to .jsonl (or the last one is the output). */
617
+ #teeAsync(src, final, finish) {
618
+ const collector = final ? null : new JsonlCollector(this.#opts.maxOutputBytes);
619
+ let last;
620
+ let done = false;
621
+ const end = (status, error) => {
622
+ if (done)
623
+ return;
624
+ done = true;
625
+ const extra = collector ? { jsonl: collector } : {};
626
+ if (error !== undefined)
627
+ finish(undefined, error, true, extra);
628
+ else
629
+ finish(final ? last : null, undefined, false, { status, ...extra });
630
+ };
631
+ const seen = (r) => {
632
+ if (r.done)
633
+ end('ok');
634
+ else if (collector)
635
+ collector.push(r.value);
636
+ else
637
+ last = r.value;
638
+ };
639
+ const pass = Object.create(Object.getPrototypeOf(src));
640
+ Object.defineProperties(pass, {
641
+ next: {
642
+ value: async (...a) => {
643
+ let r;
644
+ try {
645
+ r = await src.next(...a);
646
+ }
647
+ catch (e) {
648
+ end('error', e);
649
+ throw e;
650
+ }
651
+ quiet(() => seen(r));
652
+ return r;
653
+ },
654
+ },
655
+ return: {
656
+ value: async (v) => {
657
+ let r;
658
+ try {
659
+ r = await src.return(v);
660
+ }
661
+ catch (e) {
662
+ end('error', e);
663
+ throw e;
664
+ }
665
+ quiet(() => end('incomplete'));
666
+ return r;
667
+ },
668
+ },
669
+ throw: {
670
+ value: async (err) => {
671
+ let r;
672
+ try {
673
+ r = await src.throw(err);
674
+ }
675
+ catch (e) {
676
+ end('error', e);
677
+ throw e;
678
+ }
679
+ quiet(() => seen(r));
680
+ return r;
681
+ },
682
+ },
683
+ [Symbol.asyncIterator]: { value: () => pass },
684
+ });
685
+ return pass;
686
+ }
687
+ #teeSync(src, final, finish) {
688
+ const collector = final ? null : new JsonlCollector(this.#opts.maxOutputBytes);
689
+ let last;
690
+ let done = false;
691
+ const end = (status, error) => {
692
+ if (done)
693
+ return;
694
+ done = true;
695
+ const extra = collector ? { jsonl: collector } : {};
696
+ if (error !== undefined)
697
+ finish(undefined, error, true, extra);
698
+ else
699
+ finish(final ? last : null, undefined, false, { status, ...extra });
700
+ };
701
+ const seen = (r) => {
702
+ if (r.done)
703
+ end('ok');
704
+ else if (collector)
705
+ collector.push(r.value);
706
+ else
707
+ last = r.value;
708
+ };
709
+ const pass = Object.create(Object.getPrototypeOf(src));
710
+ const guard = (f, after) => {
711
+ let r;
712
+ try {
713
+ r = f();
714
+ }
715
+ catch (e) {
716
+ end('error', e);
717
+ throw e;
718
+ }
719
+ quiet(() => after(r));
720
+ return r;
721
+ };
722
+ Object.defineProperties(pass, {
723
+ next: { value: (...a) => guard(() => src.next(...a), seen) },
724
+ return: { value: (v) => guard(() => src.return(v), () => end('incomplete')) },
725
+ throw: { value: (err) => guard(() => src.throw(err), seen) },
726
+ [Symbol.iterator]: { value: () => pass },
727
+ });
728
+ return pass;
729
+ }
730
+ // ---- serialization (setImmediate) -----------------------------------------------------------------------------
731
+ /** Runs in setImmediate: nothing may escape it (an exception there would end the harness process). */
732
+ #serializeSome() {
733
+ this.#serializeScheduled = false;
734
+ const until = performance.now() + SERIALIZE_BUDGET_MS;
735
+ while (this.#toSerialize.length > 0) {
736
+ const item = this.#toSerialize.shift();
737
+ if (item.gen === this.#gen)
738
+ this.#guard(() => this.#prepare(item), item);
739
+ if (performance.now() >= until)
740
+ break;
741
+ }
742
+ if (this.#toSerialize.length > 0) {
743
+ this.#serializeScheduled = true;
744
+ setImmediate(() => this.#serializeSome());
745
+ }
746
+ this.#guard(() => this.#pumpWrites());
747
+ this.#guard(() => this.#pumpCommit());
748
+ }
749
+ /**
750
+ * Last line of defence on the background side: reports what `fn` threw, and makes sure an item it was working on
751
+ * still settles (with no line) so the index and every barrier move on.
752
+ */
753
+ #guard(fn, item) {
754
+ try {
755
+ fn();
756
+ }
757
+ catch (e) {
758
+ this.#report(new CaptureError('serialize', `capture internal error${item ? ` on call ${item.seq}` : ''}: ${errorInfo(e).message}`, { cause: e, ...(item ? { seq: item.seq } : {}) }));
759
+ if (item && item.state !== 'ready' && item.gen === this.#gen && this.#items.get(item.seq) === item) {
760
+ this.#stats.failed += 1;
761
+ item.state = 'ready';
762
+ item.line = null;
763
+ try {
764
+ this.#pumpCommit();
765
+ }
766
+ catch {
767
+ // reported above
768
+ }
769
+ }
770
+ }
771
+ }
772
+ #copyForTransform(call) {
773
+ const event = {
774
+ tool: call.tool,
775
+ callId: call.callId,
776
+ source: call.source,
777
+ status: this.#statusOf(call),
778
+ input: call.input,
779
+ // A generator's items are handed over as an array (the transform may redact them too).
780
+ output: call.jsonl ? call.jsonl.values() : call.output,
781
+ error: call.hasError ? errorInfo(call.error) : null,
782
+ startedAt: isoOf(call.startedAtMs),
783
+ durationMs: call.durationMs,
784
+ meta: { ...(this.#opts.metadata ?? {}), ...(call.meta ?? {}) },
785
+ };
786
+ try {
787
+ return structuredClone(event);
788
+ }
789
+ catch {
790
+ const bytes = asBytes(event.output);
791
+ return {
792
+ ...event,
793
+ input: toJsonSafe(event.input),
794
+ output: bytes ? bytes.slice() : toJsonSafe(event.output),
795
+ meta: toJsonSafe(event.meta) ?? {},
796
+ };
797
+ }
798
+ }
799
+ #statusOf(call) {
800
+ if (call.status)
801
+ return call.status;
802
+ if (call.hasError)
803
+ return isAbortError(call.error) ? 'cancelled' : 'error';
804
+ return 'ok';
805
+ }
806
+ /** Applies `transform` to a copy; null when the call is dropped (transform returned null or threw). */
807
+ #transform(item, call) {
808
+ const transform = this.#opts.transform;
809
+ if (!transform)
810
+ return call;
811
+ let ev;
812
+ let copy;
813
+ try {
814
+ copy = this.#copyForTransform(call);
815
+ ev = transform(copy);
816
+ }
817
+ catch (e) {
818
+ this.#stats.dropped += 1;
819
+ this.#report(new CaptureError('transform', `transform threw; call ${item.seq} (${call.tool}) was dropped: ${errorInfo(e).message}`, { seq: item.seq, tool: call.tool, callId: call.callId, cause: e }));
820
+ return null;
821
+ }
822
+ if (ev === null || ev === undefined) {
823
+ this.#stats.skipped += 1;
824
+ return null;
825
+ }
826
+ const out = {
827
+ ...call,
828
+ tool: typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : call.tool,
829
+ callId: typeof ev.callId === 'string' && ev.callId.length > 0 ? ev.callId.slice(0, 512) : ev.callId === null ? null : call.callId,
830
+ source: typeof ev.source === 'string' && ev.source.length > 0 ? ev.source : call.source,
831
+ status: STATUSES.has(ev.status) ? ev.status : call.status,
832
+ input: ev.input,
833
+ output: ev.output,
834
+ error: ev.error ?? undefined,
835
+ hasError: ev.error !== null && ev.error !== undefined,
836
+ startedAtMs: ev.startedAt === null ? null : (toMs(ev.startedAt) ?? call.startedAtMs),
837
+ durationMs: ev.durationMs === null ? null : typeof ev.durationMs === 'number' ? durationOf(ev.durationMs) : call.durationMs,
838
+ meta: typeof ev.meta === 'object' && ev.meta !== null ? ev.meta : {},
839
+ metaFinal: true,
840
+ };
841
+ delete out.jsonl;
842
+ if (call.jsonl && Array.isArray(ev.output)) {
843
+ // Items stay items: written as .jsonl again, after the transform.
844
+ const again = new JsonlCollector(this.#opts.maxOutputBytes);
845
+ for (const v of ev.output)
846
+ again.push(v);
847
+ if (call.jsonl.truncated)
848
+ again.truncated = true;
849
+ out.jsonl = again;
850
+ }
851
+ return out;
852
+ }
853
+ /** Transform, serialize, reserve, and queue the item's files (or its index line at once when there are none). */
854
+ #prepare(item) {
855
+ let call = item.call;
856
+ try {
857
+ if (isUnreadable(call.output))
858
+ call = { ...call, output: undefined, note: call.note ?? 'stream_not_captured' };
859
+ const transformed = this.#transform(item, call);
860
+ if (!transformed)
861
+ return this.#ready(item, null);
862
+ call = item.call = transformed;
863
+ const base = `${padSeq(item.seq)}-${sanitizeToolName(call.tool)}`;
864
+ let plan = call.jsonl ? call.jsonl.plan(base) : planOutput(call.output, base, this.#opts.maxOutputBytes);
865
+ if (call.note && !plan.note)
866
+ plan = { ...plan, note: call.note };
867
+ if (plan.dropped === 'too_large' || plan.dropped === 'serialize_failed') {
868
+ this.#stats.dropped += 1;
869
+ this.#report(new CaptureError(plan.dropped === 'too_large' ? 'too_large' : 'serialize', `output of call ${item.seq} (${call.tool}) not stored: ${plan.dropped}`, { seq: item.seq, tool: call.tool, callId: call.callId }));
870
+ }
871
+ // The input: inline up to maxInlineInputBytes, else its own file.
872
+ let inputValue = toJsonSafe(call.input);
873
+ if (inputValue === undefined)
874
+ inputValue = null;
875
+ let inputFile = null;
876
+ const inputText = JSON.stringify(inputValue);
877
+ // A string of n UTF-16 units is at most 3n UTF-8 bytes: only long ones need encoding to decide.
878
+ let inlineBytes = inputText.length;
879
+ if (inputText.length * 3 > this.#opts.maxInlineInputBytes) {
880
+ const bytes = encoder.encode(inputText);
881
+ inlineBytes = bytes.length;
882
+ if (bytes.length > this.#opts.maxInlineInputBytes) {
883
+ const data = cutUtf8(bytes, this.#opts.maxOutputBytes);
884
+ item.inputTruncated = data.length < bytes.length;
885
+ inputFile = { path: `${base}.input.json`, data, contentType: 'application/json', sha256: sha256Hex(data) };
886
+ inputValue = null;
887
+ inlineBytes = 0;
888
+ }
889
+ }
890
+ // Everything this call holds until its line is appended counts: files, the inline input, the line itself.
891
+ const fileBytes = plan.files.reduce((n, f) => n + f.data.length, 0);
892
+ const inputBytes = inputFile ? inputFile.data.length : inlineBytes;
893
+ const room = this.#opts.maxPendingBytes - this.#pendingBytes - LINE_RESERVE;
894
+ if (fileBytes + inputBytes > room) {
895
+ const dropped = [];
896
+ if (fileBytes > 0) {
897
+ plan = droppedPlan('queue_full', plan.note);
898
+ dropped.push('output');
899
+ }
900
+ if (inputBytes > 0 && inputBytes > room) {
901
+ inputFile = null;
902
+ inputValue = null;
903
+ item.inputDropped = 'queue_full';
904
+ dropped.push('input');
905
+ }
906
+ if (dropped.length > 0) {
907
+ item.degraded = true;
908
+ this.#stats.dropped += 1;
909
+ this.#report(new CaptureError('queue_full', `pending data over maxPendingBytes (${this.#opts.maxPendingBytes}); the ${dropped.join(' and ')} of call ${item.seq} (${call.tool}) dropped`, { seq: item.seq, tool: call.tool, callId: call.callId }));
910
+ }
911
+ }
912
+ item.plan = plan;
913
+ item.inputValue = inputValue;
914
+ item.state = 'files';
915
+ // Only what the line needs stays referenced: the tool's input and output objects are released.
916
+ item.call = { ...call, input: undefined, output: undefined };
917
+ delete item.call.jsonl;
918
+ const tasks = [];
919
+ if (inputFile) {
920
+ item.inputPath = inputFile.path;
921
+ tasks.push({ item, file: inputFile, key: `cap:${this.runId}:${item.seq}:in`, kind: 'input' });
922
+ }
923
+ plan.files.forEach((f, i) => {
924
+ const part = plan.files.length === 1 ? 'o0' : i === plan.files.length - 1 ? 'or' : `o${i + 1}`;
925
+ tasks.push({ item, file: f, key: `cap:${this.runId}:${item.seq}:${part}`, kind: 'output' });
926
+ });
927
+ item.lineReserve = LINE_RESERVE + (item.inputValue === null ? 0 : inlineBytes);
928
+ item.reserved = tasks.reduce((n, t) => n + t.file.data.length, 0) + item.lineReserve;
929
+ this.#pendingBytes += item.reserved;
930
+ item.filesLeft = tasks.length;
931
+ if (tasks.length === 0)
932
+ return this.#finishFiles(item);
933
+ this.#fileQueue.push(...tasks);
934
+ }
935
+ catch (e) {
936
+ this.#stats.dropped += 1;
937
+ this.#report(new CaptureError('serialize', `call ${item.seq} (${call.tool}) could not be serialized: ${errorInfo(e).message}`, { seq: item.seq, tool: call.tool, callId: call.callId, cause: e }));
938
+ item.plan = droppedPlan('serialize_failed');
939
+ item.inputValue = null;
940
+ item.state = 'files';
941
+ item.call = { ...item.call, input: undefined, output: undefined };
942
+ delete item.call.jsonl;
943
+ this.#finishFiles(item);
944
+ }
945
+ }
946
+ /**
947
+ * Builds the index line once every file of the item is written or has failed. Never throws: a line that cannot be
948
+ * built falls back to a minimal one, and failing that the call is counted as failed (it never wedges the index).
949
+ */
950
+ #finishFiles(item) {
951
+ if (item.outputFailed)
952
+ this.#stats.dropped += 1;
953
+ let line = null;
954
+ try {
955
+ line = this.#buildLine(item, false);
956
+ }
957
+ catch (e) {
958
+ try {
959
+ line = this.#buildLine(item, true);
960
+ this.#report(new CaptureError('serialize', `index line of call ${item.seq} built without meta: ${errorInfo(e).message}`, { seq: item.seq, cause: e }));
961
+ }
962
+ catch (e2) {
963
+ this.#stats.failed += 1;
964
+ this.#report(new CaptureError('serialize', `index line of call ${item.seq} could not be built: ${errorInfo(e2).message}`, { seq: item.seq, cause: e2 }));
965
+ }
966
+ }
967
+ this.#ready(item, line);
968
+ }
969
+ #buildLine(item, minimal) {
970
+ const call = item.call;
971
+ const plan = item.plan ?? droppedPlan('serialize_failed');
972
+ const failed = item.outputFailed;
973
+ const short = minimal || item.degraded;
974
+ let error = null;
975
+ if (call.hasError) {
976
+ const info = errorInfo(call.error);
977
+ error = { type: String(info.type).slice(0, 256), message: short ? String(info.message).slice(0, DEGRADED_MESSAGE_MAX) : String(info.message) };
978
+ }
979
+ const meta = short ? {} : call.metaFinal ? { ...(call.meta ?? {}) } : { ...(this.#opts.metadata ?? {}), ...(call.meta ?? {}) };
980
+ if (plan.note)
981
+ meta.note = plan.note;
982
+ if (item.inputTruncated)
983
+ meta.input_truncated = true;
984
+ if (item.inputDropped || item.inputFailed)
985
+ meta.input_dropped = item.inputDropped ?? 'write_failed';
986
+ const line = {
987
+ v: 1,
988
+ run: this.runId,
989
+ seq: item.seq,
990
+ call_id: typeof call.callId === 'string' ? call.callId : null,
991
+ tool: typeof call.tool === 'string' && call.tool.length > 0 ? call.tool : 'tool',
992
+ source: typeof call.source === 'string' && call.source.length > 0 ? call.source : 'manual',
993
+ status: this.#statusOf(call),
994
+ error,
995
+ started_at: minimal ? null : isoOf(call.startedAtMs),
996
+ duration_ms: minimal ? null : durationOf(call.durationMs),
997
+ };
998
+ if (item.inputPath && !item.inputFailed && !item.inputDropped)
999
+ line.input_path = item.inputPath;
1000
+ else
1001
+ line.input = item.inputFailed || item.inputDropped || minimal ? null : item.inputValue;
1002
+ const stored = !failed && plan.outputPath !== null;
1003
+ line.output_path = stored ? plan.outputPath : null;
1004
+ line.content_type = stored ? plan.contentType : null;
1005
+ line.bytes = stored ? plan.bytes : 0;
1006
+ line.sha256 = stored ? plan.sha256 : null;
1007
+ line.truncated = stored ? plan.truncated : false;
1008
+ if (stored && plan.parts)
1009
+ line.parts = plan.parts;
1010
+ const drop = failed ?? plan.dropped ?? (item.inputDropped === 'queue_full' ? 'queue_full' : undefined);
1011
+ if (drop)
1012
+ line.dropped = drop;
1013
+ const safeMeta = toJsonSafe(meta);
1014
+ if (typeof safeMeta === 'object' && safeMeta !== null && !Array.isArray(safeMeta) && Object.keys(safeMeta).length > 0)
1015
+ line.meta = safeMeta;
1016
+ else if (Object.keys(meta).length > 0 && (typeof safeMeta !== 'object' || safeMeta === null || Array.isArray(safeMeta)))
1017
+ line.meta = { value: safeMeta };
1018
+ return `${JSON.stringify(line)}\n`;
1019
+ }
1020
+ #ready(item, line) {
1021
+ item.line = line;
1022
+ item.state = 'ready';
1023
+ item.inputValue = null;
1024
+ // The exact size of the line replaces what was reserved for it.
1025
+ const n = line ? encoder.encode(line).length : 0;
1026
+ item.reserved += n - item.lineReserve;
1027
+ this.#pendingBytes += n - item.lineReserve;
1028
+ item.lineReserve = 0;
1029
+ this.#pumpCommit();
1030
+ }
1031
+ // ---- file writes ----------------------------------------------------------------------------------------------
1032
+ #pumpWrites() {
1033
+ while (this.#activeWrites < this.#opts.concurrency && this.#fileQueue.length > 0) {
1034
+ const task = this.#fileQueue.shift();
1035
+ if (task.item.gen !== this.#gen)
1036
+ continue;
1037
+ this.#activeWrites += 1;
1038
+ const gen = this.#gen;
1039
+ const done = (r) => {
1040
+ if (gen !== this.#gen)
1041
+ return;
1042
+ this.#activeWrites -= 1;
1043
+ const item = task.item;
1044
+ // The bytes are written (or given up on): release them now, not when the line is appended.
1045
+ const size = task.file.data.length;
1046
+ task.file.data = EMPTY;
1047
+ item.reserved -= size;
1048
+ this.#pendingBytes -= size;
1049
+ if (!r.ok) {
1050
+ if (task.kind === 'input')
1051
+ item.inputFailed = true;
1052
+ else
1053
+ item.outputFailed ??= r.reason === 'too_large' ? 'too_large' : 'write_failed';
1054
+ }
1055
+ item.filesLeft -= 1;
1056
+ if (item.filesLeft === 0)
1057
+ this.#finishFiles(item);
1058
+ this.#pumpWrites();
1059
+ };
1060
+ void this.#put(`${this.runDir}/${task.file.path}`, task.file.data, task.key, false, gen)
1061
+ .catch((e) => ({ ok: false, reason: 'permanent', error: e }))
1062
+ .then(done)
1063
+ .catch((e) => this.#report(new CaptureError('write', `capture write bookkeeping failed: ${errorInfo(e).message}`, { cause: e })));
1064
+ }
1065
+ }
1066
+ /**
1067
+ * One PUT /files with retries: network errors keep the key (the write may have committed: replayed); an error
1068
+ * response moves to `<key>:r<n>` (the host replays a failed operation's error under its key) and an append then
1069
+ * starts with a newline. 400/403/413/422 are final; 404/410 mean the workspace is gone.
1070
+ */
1071
+ async #put(path, data, key, append, gen) {
1072
+ try {
1073
+ return await this.#putWithRetries(path, data, key, append, gen);
1074
+ }
1075
+ catch (e) {
1076
+ // Anything unexpected (an injected sleep that rejects, a broken signal): the write fails, capture goes on.
1077
+ return this.#fail(path, e, 'permanent');
1078
+ }
1079
+ }
1080
+ async #putWithRetries(path, data, key, append, gen) {
1081
+ const deadline = Date.now() + this.#opts.retryWindowMs;
1082
+ const url = cellPath('/v1/workspaces/{workspace_id}/files', { workspace_id: this.workspaceId });
1083
+ let n = 0;
1084
+ let delay = 250;
1085
+ for (let attempt = 0;; attempt += 1) {
1086
+ if (gen !== this.#gen || this.#abort.signal.aborted)
1087
+ return { ok: false, reason: 'aborted' };
1088
+ const k = n === 0 ? key : `${key}:r${n}`;
1089
+ let body = data;
1090
+ if (append && n > 0) {
1091
+ body = new Uint8Array(data.length + 1);
1092
+ body[0] = 0x0a;
1093
+ body.set(data, 1);
1094
+ }
1095
+ const left = deadline - Date.now();
1096
+ const signal = left > 0 ? AbortSignal.any([this.#abort.signal, AbortSignal.timeout(left)]) : this.#abort.signal;
1097
+ try {
1098
+ const res = await this.#cell.request('PUT', url, {
1099
+ query: { path, create_parents: true, ...(append ? { append: true } : {}) },
1100
+ body,
1101
+ contentType: 'application/octet-stream',
1102
+ idempotencyKey: k,
1103
+ signal,
1104
+ });
1105
+ await res.arrayBuffer().catch(() => undefined);
1106
+ return { ok: true };
1107
+ }
1108
+ catch (e) {
1109
+ if (gen !== this.#gen || this.#abort.signal.aborted)
1110
+ return { ok: false, reason: 'aborted' };
1111
+ let definitive = false;
1112
+ if (e instanceof ShardfluxApiError) {
1113
+ if (e.status === 404 || e.status === 410) {
1114
+ this.#gone(e);
1115
+ return { ok: false, reason: 'gone', error: e };
1116
+ }
1117
+ if (e.status === 413)
1118
+ return this.#fail(path, e, 'too_large');
1119
+ if (e.status === 400 || e.status === 401 || e.status === 403 || e.status === 422)
1120
+ return this.#fail(path, e, 'permanent');
1121
+ definitive = true;
1122
+ }
1123
+ else if (e instanceof ShardfluxProtocolError) {
1124
+ if (e.status === 404 || e.status === 410) {
1125
+ this.#gone(e);
1126
+ return { ok: false, reason: 'gone', error: e };
1127
+ }
1128
+ if (e.status === 400 || e.status === 403 || e.status === 413 || e.status === 422)
1129
+ return this.#fail(path, e, e.status === 413 ? 'too_large' : 'permanent');
1130
+ definitive = e.status > 0;
1131
+ }
1132
+ if (definitive)
1133
+ n += 1;
1134
+ const hint = e instanceof ShardfluxApiError && e.retryAfterSeconds !== undefined ? e.retryAfterSeconds * 1000 : 0;
1135
+ const wait = Math.min(5_000, Math.max(delay, hint));
1136
+ if (Date.now() + wait >= deadline)
1137
+ return this.#fail(path, e, 'window');
1138
+ this.#stats.retries += 1;
1139
+ await this.#sleep(wait);
1140
+ delay = Math.min(5_000, delay * 2);
1141
+ }
1142
+ }
1143
+ }
1144
+ #fail(path, error, reason) {
1145
+ const why = reason === 'window' ? `still failing after retryWindowMs (${this.#opts.retryWindowMs} ms)` : reason === 'too_large' ? 'too large for the files API' : 'refused';
1146
+ this.#report(new CaptureError('write', `write of ${path} ${why}: ${errorInfo(error).message}`, { path, cause: error }));
1147
+ return { ok: false, reason, error };
1148
+ }
1149
+ #gone(error) {
1150
+ if (this.#closed && this.#seq === this.#settled)
1151
+ return;
1152
+ this.#report(new CaptureError('gone', `the workspace is gone (${errorInfo(error).message}); the capture is closed`, { cause: error }));
1153
+ this.#closed = true;
1154
+ this.#host.registry.remove(this.workspaceId, this);
1155
+ this.#discardPending(null, 'workspace gone');
1156
+ this.#cell.close();
1157
+ }
1158
+ // ---- the index ------------------------------------------------------------------------------------------------
1159
+ #pumpCommit() {
1160
+ if (this.#committing)
1161
+ return;
1162
+ for (;;) {
1163
+ const item = this.#items.get(this.#nextCommit);
1164
+ if (!item || item.state !== 'ready' || item.gen !== this.#gen)
1165
+ break;
1166
+ if (item.line && this.#batchBytes > 0 && this.#batchBytes + item.line.length > INDEX_BATCH_BYTES)
1167
+ break;
1168
+ this.#batch.push(item);
1169
+ if (item.line)
1170
+ this.#batchBytes += item.line.length;
1171
+ this.#nextCommit += 1;
1172
+ }
1173
+ if (this.#batch.length === 0)
1174
+ return;
1175
+ if (this.#batchBytes === 0) {
1176
+ // Only calls without a line (dropped by transform): settled in order, nothing to write.
1177
+ const last = this.#batch[this.#batch.length - 1];
1178
+ this.#batch = [];
1179
+ this.#settleThrough(last.seq);
1180
+ return this.#pumpCommit();
1181
+ }
1182
+ const full = this.#items.get(this.#nextCommit)?.state === 'ready' && this.#batchBytes > 0;
1183
+ if (full || this.#waiters.length > 0 || this.#opts.batchDelayMs === 0) {
1184
+ void this.#commit();
1185
+ return;
1186
+ }
1187
+ this.#batchTimer ??= setTimeout(() => {
1188
+ this.#batchTimer = undefined;
1189
+ this.#guard(() => void this.#commit());
1190
+ }, this.#opts.batchDelayMs);
1191
+ }
1192
+ async #commit() {
1193
+ if (this.#committing || this.#batch.length === 0)
1194
+ return;
1195
+ if (this.#batchTimer) {
1196
+ clearTimeout(this.#batchTimer);
1197
+ this.#batchTimer = undefined;
1198
+ }
1199
+ const gen = this.#gen;
1200
+ const batch = this.#batch;
1201
+ this.#batch = [];
1202
+ this.#batchBytes = 0;
1203
+ this.#committing = true;
1204
+ try {
1205
+ if (!this.#readmeDone) {
1206
+ const readme = encoder.encode(fillTemplate(WORKSPACE_README, { dir: this.dir, run_dir: this.runDir }));
1207
+ const r = await this.#put(`${this.dir}/README.md`, readme, `cap:${this.runId}:readme${this.#readmeGen > 0 ? `:${this.#readmeGen}` : ''}`, false, gen);
1208
+ if (gen !== this.#gen)
1209
+ return;
1210
+ this.#readmeDone = true; // written, or reported: never blocks the index
1211
+ if (!r.ok && r.reason === 'gone')
1212
+ return;
1213
+ }
1214
+ const lines = batch.filter((i) => i.line).map((i) => i.line);
1215
+ const data = encoder.encode(lines.join(''));
1216
+ this.#batchNo += 1;
1217
+ const r = await this.#put(`${this.runDir}/index.jsonl`, data, `cap:${this.runId}:i:${this.#batchNo}`, true, gen);
1218
+ if (gen !== this.#gen)
1219
+ return;
1220
+ if (r.ok)
1221
+ this.#stats.written += lines.length;
1222
+ else if (r.reason !== 'gone' && r.reason !== 'aborted')
1223
+ this.#stats.failed += lines.length;
1224
+ this.#settleThrough(batch[batch.length - 1].seq);
1225
+ }
1226
+ catch (e) {
1227
+ if (gen === this.#gen) {
1228
+ this.#stats.failed += batch.filter((i) => i.line).length;
1229
+ this.#report(new CaptureError('write', `index append failed: ${errorInfo(e).message}`, { cause: e }));
1230
+ this.#settleThrough(batch[batch.length - 1].seq);
1231
+ }
1232
+ }
1233
+ finally {
1234
+ if (gen === this.#gen) {
1235
+ this.#committing = false;
1236
+ this.#pumpCommit();
1237
+ }
1238
+ }
1239
+ }
1240
+ #settleThrough(seq) {
1241
+ for (let s = this.#settled + 1; s <= seq; s += 1) {
1242
+ const item = this.#items.get(s);
1243
+ if (item) {
1244
+ this.#pendingBytes -= item.reserved;
1245
+ this.#items.delete(s);
1246
+ }
1247
+ }
1248
+ if (seq > this.#settled)
1249
+ this.#settled = seq;
1250
+ if (this.#waiters.length === 0)
1251
+ return;
1252
+ for (let i = this.#waiters.length - 1; i >= 0; i -= 1) {
1253
+ const w = this.#waiters[i];
1254
+ if (w.seq <= this.#settled) {
1255
+ this.#waiters.splice(i, 1);
1256
+ w.resolve();
1257
+ }
1258
+ }
1259
+ }
1260
+ /** Resolves true once every seq <= `seq` is settled, false at the timeout. Never rejects. */
1261
+ #waitSettled(seq, timeoutMs) {
1262
+ if (this.#settled >= seq)
1263
+ return Promise.resolve(true);
1264
+ return new Promise((resolve) => {
1265
+ const waiter = {
1266
+ seq,
1267
+ resolve: () => {
1268
+ clearTimeout(timer);
1269
+ resolve(true);
1270
+ },
1271
+ };
1272
+ this.#waiters.push(waiter);
1273
+ const timer = setTimeout(() => {
1274
+ const i = this.#waiters.indexOf(waiter);
1275
+ if (i >= 0)
1276
+ this.#waiters.splice(i, 1);
1277
+ resolve(this.#settled >= seq);
1278
+ }, Math.max(0, timeoutMs));
1279
+ // Someone is waiting: append what is ready now instead of after the batch delay.
1280
+ if (this.#batchTimer && !this.#committing)
1281
+ this.#guard(() => void this.#commit());
1282
+ else
1283
+ this.#guard(() => this.#pumpCommit());
1284
+ });
1285
+ }
1286
+ /** Drops every pending call (delete, reset, close with discard or after its timeout, workspace gone). */
1287
+ #discardPending(kind, why) {
1288
+ const pending = this.#seq - this.#settled;
1289
+ this.#gen += 1;
1290
+ this.#abort.abort(new DOMException('capture discarded', 'AbortError'));
1291
+ this.#abort = new AbortController();
1292
+ if (this.#batchTimer)
1293
+ clearTimeout(this.#batchTimer);
1294
+ this.#batchTimer = undefined;
1295
+ this.#toSerialize = [];
1296
+ this.#fileQueue = [];
1297
+ this.#activeWrites = 0;
1298
+ this.#batch = [];
1299
+ this.#batchBytes = 0;
1300
+ this.#committing = false;
1301
+ if (kind === 'discarded')
1302
+ this.#stats.discarded += pending;
1303
+ else
1304
+ this.#stats.failed += pending;
1305
+ if (pending > 0 && kind)
1306
+ this.#report(new CaptureError(kind, `${pending} pending call(s) dropped: ${why}`));
1307
+ this.#nextCommit = this.#seq + 1;
1308
+ this.#settleThrough(this.#seq);
1309
+ this.#items.clear();
1310
+ this.#pendingBytes = 0;
1311
+ }
1312
+ #result(complete) {
1313
+ return { complete, written: this.#stats.written, failed: this.#stats.failed, dropped: this.#stats.dropped };
1314
+ }
1315
+ #report(e) {
1316
+ const cb = this.#opts.onError;
1317
+ if (!cb)
1318
+ return;
1319
+ try {
1320
+ cb(e);
1321
+ }
1322
+ catch {
1323
+ // the listener's problem
1324
+ }
1325
+ }
1326
+ }