@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.
- package/CHANGELOG.md +109 -1
- package/README.md +202 -9
- package/dist/capture-adapters.d.ts +178 -0
- package/dist/capture-adapters.js +432 -0
- package/dist/capture-serialize.d.ts +97 -0
- package/dist/capture-serialize.js +481 -0
- package/dist/capture-text.d.ts +14 -0
- package/dist/capture-text.js +13 -0
- package/dist/capture.d.ts +288 -0
- package/dist/capture.js +1326 -0
- package/dist/cell.d.ts +7 -0
- package/dist/cell.js +13 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +21 -4
- package/dist/egress.d.ts +18 -0
- package/dist/errors.d.ts +8 -1
- package/dist/generated/app-api.d.ts +215 -0
- package/dist/http.d.ts +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.js +4 -1
- package/dist/lifecycle.d.ts +3 -1
- package/dist/lifecycle.js +5 -1
- package/dist/progress.d.ts +3 -1
- package/dist/tar.d.ts +40 -0
- package/dist/tar.js +150 -0
- package/dist/template-file.d.ts +92 -0
- package/dist/template-file.js +326 -0
- package/dist/templates.d.ts +318 -9
- package/dist/templates.js +432 -13
- package/dist/tools.d.ts +49 -33
- package/dist/tools.js +25 -4
- package/dist/workspace.d.ts +25 -2
- package/dist/workspace.js +49 -2
- package/package.json +25 -2
package/dist/capture.js
ADDED
|
@@ -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
|
+
}
|