@sanity/ailf 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/_vendor/ailf-core/artifact-capture/association.d.ts +37 -0
  2. package/dist/_vendor/ailf-core/artifact-capture/association.js +19 -0
  3. package/dist/_vendor/ailf-core/index.d.ts +1 -1
  4. package/dist/_vendor/ailf-core/index.js +1 -1
  5. package/dist/_vendor/ailf-core/ports/context.d.ts +8 -0
  6. package/dist/_vendor/ailf-core/ports/index.d.ts +2 -0
  7. package/dist/_vendor/ailf-core/ports/index.js +1 -0
  8. package/dist/_vendor/ailf-core/ports/progress-reporter.d.ts +74 -0
  9. package/dist/_vendor/ailf-core/ports/progress-reporter.js +26 -0
  10. package/dist/_vendor/ailf-core/services/slim-report-summary.js +1 -16
  11. package/dist/adapters/progress/console-progress-reporter.d.ts +35 -0
  12. package/dist/adapters/progress/console-progress-reporter.js +110 -0
  13. package/dist/artifact-capture/api-gateway-artifact-writer.d.ts +8 -1
  14. package/dist/artifact-capture/api-gateway-artifact-writer.js +79 -42
  15. package/dist/artifact-capture/batching-api-gateway-artifact-writer.d.ts +108 -0
  16. package/dist/artifact-capture/batching-api-gateway-artifact-writer.js +492 -0
  17. package/dist/artifact-capture/fanout-artifact-writer.d.ts +14 -2
  18. package/dist/artifact-capture/fanout-artifact-writer.js +25 -4
  19. package/dist/artifact-capture/gcs-artifact-writer.d.ts +27 -1
  20. package/dist/artifact-capture/gcs-artifact-writer.js +168 -38
  21. package/dist/artifact-capture/instrumented-artifact-writer.d.ts +32 -0
  22. package/dist/artifact-capture/instrumented-artifact-writer.js +151 -0
  23. package/dist/artifact-capture/local-fs-artifact-writer.d.ts +8 -1
  24. package/dist/artifact-capture/local-fs-artifact-writer.js +23 -4
  25. package/dist/artifact-capture/parallel-emit.d.ts +43 -0
  26. package/dist/artifact-capture/parallel-emit.js +84 -0
  27. package/dist/artifact-capture/upload-metrics.d.ts +62 -0
  28. package/dist/artifact-capture/upload-metrics.js +125 -0
  29. package/dist/composition-root.d.ts +2 -2
  30. package/dist/composition-root.js +97 -11
  31. package/dist/orchestration/pipeline-orchestrator.js +97 -1
  32. package/dist/orchestration/steps/calculate-scores-step.js +1 -1
  33. package/dist/orchestration/steps/finalize-run-step.js +33 -2
  34. package/dist/pipeline/emit-eval-results.js +29 -11
  35. package/dist/pipeline/upload-test-outputs.d.ts +12 -5
  36. package/dist/pipeline/upload-test-outputs.js +27 -10
  37. package/package.json +3 -3
@@ -0,0 +1,108 @@
1
+ /**
2
+ * BatchingApiGatewayArtifactWriter — W0056 prototype B.
3
+ *
4
+ * Drop-in replacement for `ApiGatewayArtifactWriter` that coalesces `emit()`
5
+ * calls into batches, signs many URLs per Vercel round trip via
6
+ * `POST /v1/runs/:runId/artifacts/batch/upload-urls` (W0052), and PUTs to GCS
7
+ * with bounded concurrency.
8
+ *
9
+ * Why: the baseline in `docs/design-docs/artifact-upload-throughput.md` showed
10
+ * that parallel single-URL signs trigger `429 Too Many Requests` on the Vercel
11
+ * signing function. Batch signing eliminates one Vercel round trip per
12
+ * artifact and makes client-side parallelism safe on the API Gateway path.
13
+ *
14
+ * Flushing:
15
+ * - An `emit()` call pushes a `PendingEmit` onto a queue and returns a
16
+ * deferred Promise. The queue self-flushes via `queueMicrotask` when
17
+ * the first entry lands, and again whenever the buffer reaches
18
+ * `batchSize`.
19
+ * - `writeManifest(...)` awaits any in-flight flush and drains the queue
20
+ * before writing the manifest through a single-URL sign (the manifest
21
+ * isn't in the registry's per-entry scheme).
22
+ *
23
+ * NDJSON streaming for `traces` is still not implemented here — the
24
+ * upstream `ApiGatewayArtifactWriter` throws `NotImplementedError` and so
25
+ * does this writer. Traces flow through the GCS-direct writer when ADC
26
+ * credentials are present.
27
+ */
28
+ import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
29
+ import { type UploadMetricsSink } from "./upload-metrics.js";
30
+ export interface BatchingApiGatewayArtifactWriterOptions {
31
+ /** Base URL of the API gateway (e.g., "https://ailf-api.sanity.build"). */
32
+ apiBaseUrl: string;
33
+ /** AILF API key with the `artifact:write` scope. */
34
+ apiKey: string;
35
+ /** GCS bucket name — included in the returned ArtifactRef. */
36
+ bucket: string;
37
+ /**
38
+ * Maximum entries per `/batch/upload-urls` request. The existing Vercel
39
+ * route signs URLs in parallel inside a single Function invocation, so
40
+ * the cap protects the Function's CPU budget. Defaults to 256 (see
41
+ * `DEFAULT_BATCH_SIZE` for the rationale and the W0058 measurement
42
+ * that motivated the bump from 32).
43
+ */
44
+ batchSize?: number;
45
+ /** Bounded concurrency for the PUT fan-out after a batch sign resolves. */
46
+ putConcurrency?: number;
47
+ /** Optional metrics sink; defaults to no-op. */
48
+ metrics?: UploadMetricsSink;
49
+ }
50
+ export declare class BatchingApiGatewayArtifactWriter implements ArtifactWriter {
51
+ private readonly options;
52
+ private readonly pending;
53
+ private flushing;
54
+ private microtaskScheduled;
55
+ constructor(options: BatchingApiGatewayArtifactWriterOptions);
56
+ emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
57
+ appendNdjson(): Promise<ArtifactRef | null>;
58
+ writeManifest(runId: RunId, manifest: RunManifest): Promise<ArtifactRef | null>;
59
+ /** @deprecated — routes through `emit()` for backward compat. */
60
+ writeBulk(type: ArtifactType, runId: RunId, data: unknown): Promise<ArtifactRef | null>;
61
+ /**
62
+ * @deprecated The legacy `writePerEntry` surface requires back-deriving an
63
+ * `AssociationValues` from a wire entryKey, which the registry no longer
64
+ * exposes. Producers on this writer must use `emit()`. All current producers
65
+ * have already migrated.
66
+ */
67
+ writePerEntry(type: ArtifactType, _runId: RunId, _entries: readonly ArtifactEntry[]): Promise<ArtifactRef | null>;
68
+ private scheduleFlush;
69
+ /**
70
+ * Drain the pending queue with overlapped sign + PUT.
71
+ *
72
+ * Pre-pipeline behaviour was `sign(N) → put(N) → sign(N+1) → put(N+1)`.
73
+ * After this change the order becomes
74
+ * `sign(N) → (put(N) || sign(N+1)) → put(N+1)`:
75
+ *
76
+ * - We wait for stage N's sign to resolve, then launch stage N+1's sign
77
+ * immediately (before awaiting stage N's PUT phase). This is the moment
78
+ * emits that arrived while stage N's sign was in flight first become
79
+ * visible to `startNextStage`.
80
+ * - Stage N's PUT fan-out then runs concurrently with stage N+1's sign
81
+ * request, removing the per-batch sign-RTT pre-amble that made the
82
+ * W0056 prototype ~10 % slower than the (unsafe) single-URL parallel
83
+ * variant.
84
+ *
85
+ * Invariants preserved from the pre-pipeline implementation:
86
+ * - Emits resolve in batch-commit order within a stage.
87
+ * - All entries in a `Stage` share one `runId` (enforced by `startNextStage`).
88
+ * - A sign failure fails only that stage's group; later stages proceed.
89
+ * - run() keeps looping until `pending` is empty AND no prefetched stage
90
+ * remains — so emits that arrive during a PUT phase are picked up in the
91
+ * next iteration.
92
+ */
93
+ private drain;
94
+ /**
95
+ * Pop the next same-runId batch (up to `batchSize` entries) off the queue
96
+ * and launch its batch-sign request immediately. Returns a `Stage` whose
97
+ * `signPromise` is already in flight, so the caller can start the sign for
98
+ * the *next* batch while this one's PUTs are still pending.
99
+ *
100
+ * Returning `null` means the queue is empty.
101
+ */
102
+ private startNextStage;
103
+ private buildSignBody;
104
+ private batchSign;
105
+ private putToSignedUrl;
106
+ /** Single-URL sign + PUT — used for the manifest. */
107
+ private writeSingle;
108
+ }
@@ -0,0 +1,492 @@
1
+ /**
2
+ * BatchingApiGatewayArtifactWriter — W0056 prototype B.
3
+ *
4
+ * Drop-in replacement for `ApiGatewayArtifactWriter` that coalesces `emit()`
5
+ * calls into batches, signs many URLs per Vercel round trip via
6
+ * `POST /v1/runs/:runId/artifacts/batch/upload-urls` (W0052), and PUTs to GCS
7
+ * with bounded concurrency.
8
+ *
9
+ * Why: the baseline in `docs/design-docs/artifact-upload-throughput.md` showed
10
+ * that parallel single-URL signs trigger `429 Too Many Requests` on the Vercel
11
+ * signing function. Batch signing eliminates one Vercel round trip per
12
+ * artifact and makes client-side parallelism safe on the API Gateway path.
13
+ *
14
+ * Flushing:
15
+ * - An `emit()` call pushes a `PendingEmit` onto a queue and returns a
16
+ * deferred Promise. The queue self-flushes via `queueMicrotask` when
17
+ * the first entry lands, and again whenever the buffer reaches
18
+ * `batchSize`.
19
+ * - `writeManifest(...)` awaits any in-flight flush and drains the queue
20
+ * before writing the manifest through a single-URL sign (the manifest
21
+ * isn't in the registry's per-entry scheme).
22
+ *
23
+ * NDJSON streaming for `traces` is still not implemented here — the
24
+ * upstream `ApiGatewayArtifactWriter` throws `NotImplementedError` and so
25
+ * does this writer. Traces flow through the GCS-direct writer when ADC
26
+ * credentials are present.
27
+ */
28
+ import { ARTIFACT_REGISTRY, BULK_ENTRY_KEY, NotImplementedError, } from "../_vendor/ailf-core/index.js";
29
+ import { NO_OP_UPLOAD_METRICS, } from "./upload-metrics.js";
30
+ /**
31
+ * How many entries to bundle into a single `/batch/upload-urls` request.
32
+ *
33
+ * Raised from 32 to 256 after W0058 measurement: full-literacy producers
34
+ * emit ~1,477 artifacts per run. With a tight batch size, the sign-RTT
35
+ * count scales roughly as `artifactCount / batchSize`. At 32 we issued
36
+ * ~420 sign requests and tripped Vercel's per-origin rate limits (71 %
37
+ * sign-failure rate). At 256 we issue ~6 sign requests, well under any
38
+ * per-origin burst cap. The gateway's batch route signs URLs locally
39
+ * with `file.getSignedUrl` in `Promise.all`, so CPU scales sub-linearly
40
+ * in request size — 256 entries fit comfortably in the default Vercel
41
+ * function budget.
42
+ */
43
+ const DEFAULT_BATCH_SIZE = 256;
44
+ const DEFAULT_PUT_CONCURRENCY = 8;
45
+ // ---------------------------------------------------------------------------
46
+ // Implementation
47
+ // ---------------------------------------------------------------------------
48
+ export class BatchingApiGatewayArtifactWriter {
49
+ options;
50
+ pending = [];
51
+ flushing = null;
52
+ microtaskScheduled = false;
53
+ constructor(options) {
54
+ this.options = {
55
+ apiBaseUrl: options.apiBaseUrl,
56
+ apiKey: options.apiKey,
57
+ bucket: options.bucket,
58
+ batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
59
+ putConcurrency: options.putConcurrency ?? DEFAULT_PUT_CONCURRENCY,
60
+ metrics: options.metrics ?? NO_OP_UPLOAD_METRICS,
61
+ };
62
+ }
63
+ // ---- ArtifactWriter surface --------------------------------------------
64
+ async emit(type, association, payload) {
65
+ const descriptor = ARTIFACT_REGISTRY[type];
66
+ const runId = association.run;
67
+ if (!runId) {
68
+ console.warn(` ⚠️ emit("${type}"): association.run is required, skipping`);
69
+ return null;
70
+ }
71
+ const entryKey = descriptor.layout === "bulk"
72
+ ? BULK_ENTRY_KEY
73
+ : descriptor.formatEntryKey(association);
74
+ return new Promise((resolve) => {
75
+ this.pending.push({
76
+ type,
77
+ runId,
78
+ entryKey,
79
+ payload,
80
+ association,
81
+ resolve,
82
+ });
83
+ this.scheduleFlush();
84
+ });
85
+ }
86
+ async appendNdjson() {
87
+ throw new NotImplementedError("BatchingApiGatewayArtifactWriter.appendNdjson is not supported. " +
88
+ "NDJSON streaming for traces flows through GcsArtifactWriter when " +
89
+ "ADC credentials are present.");
90
+ }
91
+ async writeManifest(runId, manifest) {
92
+ await this.drain();
93
+ return this.writeSingle(`/v1/runs/${encodeURIComponent(runId)}/artifacts/upload-url`, manifest, "manifest");
94
+ }
95
+ /** @deprecated — routes through `emit()` for backward compat. */
96
+ async writeBulk(type, runId, data) {
97
+ return this.emit(type, { run: runId }, data);
98
+ }
99
+ /**
100
+ * @deprecated The legacy `writePerEntry` surface requires back-deriving an
101
+ * `AssociationValues` from a wire entryKey, which the registry no longer
102
+ * exposes. Producers on this writer must use `emit()`. All current producers
103
+ * have already migrated.
104
+ */
105
+ async writePerEntry(type, _runId, _entries) {
106
+ console.warn(` ⚠️ BatchingApiGatewayArtifactWriter.writePerEntry("${type}"): ` +
107
+ `deprecated path unsupported on batching writer — callers must use emit()`);
108
+ return null;
109
+ }
110
+ // ---- Internals ---------------------------------------------------------
111
+ scheduleFlush() {
112
+ if (this.pending.length >= this.options.batchSize) {
113
+ // Size threshold hit — kick off a drain immediately.
114
+ void this.drain();
115
+ return;
116
+ }
117
+ if (this.microtaskScheduled)
118
+ return;
119
+ this.microtaskScheduled = true;
120
+ queueMicrotask(() => {
121
+ this.microtaskScheduled = false;
122
+ void this.drain();
123
+ });
124
+ }
125
+ /**
126
+ * Drain the pending queue with overlapped sign + PUT.
127
+ *
128
+ * Pre-pipeline behaviour was `sign(N) → put(N) → sign(N+1) → put(N+1)`.
129
+ * After this change the order becomes
130
+ * `sign(N) → (put(N) || sign(N+1)) → put(N+1)`:
131
+ *
132
+ * - We wait for stage N's sign to resolve, then launch stage N+1's sign
133
+ * immediately (before awaiting stage N's PUT phase). This is the moment
134
+ * emits that arrived while stage N's sign was in flight first become
135
+ * visible to `startNextStage`.
136
+ * - Stage N's PUT fan-out then runs concurrently with stage N+1's sign
137
+ * request, removing the per-batch sign-RTT pre-amble that made the
138
+ * W0056 prototype ~10 % slower than the (unsafe) single-URL parallel
139
+ * variant.
140
+ *
141
+ * Invariants preserved from the pre-pipeline implementation:
142
+ * - Emits resolve in batch-commit order within a stage.
143
+ * - All entries in a `Stage` share one `runId` (enforced by `startNextStage`).
144
+ * - A sign failure fails only that stage's group; later stages proceed.
145
+ * - run() keeps looping until `pending` is empty AND no prefetched stage
146
+ * remains — so emits that arrive during a PUT phase are picked up in the
147
+ * next iteration.
148
+ */
149
+ async drain() {
150
+ if (this.flushing) {
151
+ await this.flushing;
152
+ return;
153
+ }
154
+ const run = async () => {
155
+ // Holds every `PendingEmit` that run() has already popped but not yet
156
+ // resolved. If anything throws unexpectedly (e.g. an async sink throws
157
+ // inside the PUT loop), the outer finally resolves each one to `null`
158
+ // so the producer's `Promise.all([1500 emits])` can't hang forever.
159
+ const unresolved = new Set();
160
+ const releaseAll = (refIfResolved = null) => {
161
+ for (const p of unresolved)
162
+ p.resolve(refIfResolved);
163
+ unresolved.clear();
164
+ };
165
+ try {
166
+ let prefetched = null;
167
+ while (true) {
168
+ const stage = prefetched ?? this.startNextStage();
169
+ prefetched = null;
170
+ if (!stage)
171
+ return;
172
+ for (const p of stage.group)
173
+ unresolved.add(p);
174
+ const signed = await stage.signPromise;
175
+ // Overlap point: launch the next stage's sign BEFORE awaiting this
176
+ // stage's PUTs. At this point, any emits that arrived while we
177
+ // were awaiting `stage.signPromise` are visible in `pending`.
178
+ prefetched = this.startNextStage();
179
+ if (prefetched) {
180
+ for (const p of prefetched.group)
181
+ unresolved.add(p);
182
+ }
183
+ if (!signed) {
184
+ for (const p of stage.group) {
185
+ p.resolve(null);
186
+ unresolved.delete(p);
187
+ }
188
+ continue;
189
+ }
190
+ const pendingPuts = stage.group.map((p) => async () => {
191
+ const typeMap = signed.urls[p.type];
192
+ const signedUrl = typeMap ? typeMap[p.entryKey] : undefined;
193
+ if (!signedUrl) {
194
+ console.warn(` ⚠️ Batch response missing URL for ${p.type}/${p.entryKey || "(bulk)"}`);
195
+ p.resolve(null);
196
+ unresolved.delete(p);
197
+ return;
198
+ }
199
+ const ref = await this.putToSignedUrl(signedUrl, p);
200
+ p.resolve(ref);
201
+ unresolved.delete(p);
202
+ });
203
+ await runWithConcurrency(pendingPuts, this.options.putConcurrency);
204
+ }
205
+ }
206
+ finally {
207
+ // Any PendingEmit still in `unresolved` reflects a code path that
208
+ // didn't reach its `resolve(ref)`. Resolve to `null` so the producer
209
+ // never hangs; the P5 non-blocking policy is the right default here.
210
+ releaseAll(null);
211
+ }
212
+ };
213
+ this.flushing = run();
214
+ try {
215
+ await this.flushing;
216
+ }
217
+ finally {
218
+ this.flushing = null;
219
+ }
220
+ }
221
+ /**
222
+ * Pop the next same-runId batch (up to `batchSize` entries) off the queue
223
+ * and launch its batch-sign request immediately. Returns a `Stage` whose
224
+ * `signPromise` is already in flight, so the caller can start the sign for
225
+ * the *next* batch while this one's PUTs are still pending.
226
+ *
227
+ * Returning `null` means the queue is empty.
228
+ */
229
+ startNextStage() {
230
+ if (this.pending.length === 0)
231
+ return null;
232
+ const runId = this.pending[0].runId;
233
+ const group = [];
234
+ while (this.pending.length > 0 &&
235
+ group.length < this.options.batchSize &&
236
+ this.pending[0].runId === runId) {
237
+ group.push(this.pending.shift());
238
+ }
239
+ const signPromise = this.batchSign(runId, this.buildSignBody(group));
240
+ return { runId, group, signPromise };
241
+ }
242
+ buildSignBody(group) {
243
+ const types = new Set();
244
+ const keys = {};
245
+ for (const p of group) {
246
+ types.add(p.type);
247
+ const descriptor = ARTIFACT_REGISTRY[p.type];
248
+ if (descriptor.layout === "bulk")
249
+ continue;
250
+ let list = keys[p.type];
251
+ if (!list) {
252
+ list = [];
253
+ keys[p.type] = list;
254
+ }
255
+ if (!list.includes(p.entryKey))
256
+ list.push(p.entryKey);
257
+ }
258
+ return { types: Array.from(types), keys };
259
+ }
260
+ async batchSign(runId, body) {
261
+ const url = `${this.options.apiBaseUrl.replace(/\/$/, "")}/v1/runs/${encodeURIComponent(runId)}/artifacts/batch/upload-urls`;
262
+ const start = Date.now();
263
+ let success = false;
264
+ try {
265
+ const res = await fetch(url, {
266
+ method: "POST",
267
+ headers: {
268
+ Authorization: `Bearer ${this.options.apiKey}`,
269
+ "Content-Type": "application/json",
270
+ },
271
+ body: JSON.stringify(body),
272
+ });
273
+ if (!res.ok) {
274
+ console.warn(` ⚠️ Batch sign failed: ${res.status} ${res.statusText}`);
275
+ return null;
276
+ }
277
+ // API envelope is flat: { object, apiVersion, createdAt, bucket, urls }.
278
+ const parsed = (await res.json());
279
+ if (parsed.object !== "batch_signed_upload_urls" ||
280
+ typeof parsed.bucket !== "string" ||
281
+ typeof parsed.urls !== "object" ||
282
+ parsed.urls === null) {
283
+ console.warn(` ⚠️ Batch sign response malformed`);
284
+ return null;
285
+ }
286
+ success = true;
287
+ return { bucket: parsed.bucket, urls: parsed.urls };
288
+ }
289
+ catch (err) {
290
+ const message = err instanceof Error ? err.message : String(err);
291
+ console.warn(` ⚠️ Batch sign failed: ${message}`);
292
+ return null;
293
+ }
294
+ finally {
295
+ // One `sign` event per batch (not per artifact) — a natural
296
+ // amortization signal in the metrics.
297
+ this.options.metrics.record({
298
+ phase: "sign",
299
+ writer: "BatchingApiGatewayArtifactWriter",
300
+ type: `batch(${body.types.length}-types)`,
301
+ ms: Date.now() - start,
302
+ success,
303
+ });
304
+ }
305
+ }
306
+ async putToSignedUrl(signed, pending) {
307
+ const start = Date.now();
308
+ let ok = false;
309
+ // Tracked outside the try so the `finally` metrics event still gets a
310
+ // bytes figure when JSON.stringify itself throws (circular payload,
311
+ // bigint, etc.) — P5 requires we never hang the producer on a
312
+ // pathological payload.
313
+ let bytes = 0;
314
+ try {
315
+ const json = JSON.stringify(pending.payload);
316
+ bytes = Buffer.byteLength(json, "utf-8");
317
+ const res = await fetch(signed.url, {
318
+ method: "PUT",
319
+ body: json,
320
+ headers: signed.requiredHeaders,
321
+ });
322
+ if (!res.ok) {
323
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${res.status} ${res.statusText}`);
324
+ return null;
325
+ }
326
+ ok = true;
327
+ const descriptor = ARTIFACT_REGISTRY[pending.type];
328
+ if (descriptor.layout === "bulk") {
329
+ return {
330
+ store: "gcs",
331
+ bucket: this.options.bucket,
332
+ path: signed.path,
333
+ bytes,
334
+ entryCount: entryCountOf(pending.payload),
335
+ layout: "bulk",
336
+ };
337
+ }
338
+ return {
339
+ store: "gcs",
340
+ bucket: this.options.bucket,
341
+ path: `runs/${pending.runId}/${descriptor.slug}`,
342
+ bytes,
343
+ entryCount: 1,
344
+ layout: "per-entry",
345
+ entries: [
346
+ {
347
+ key: pending.entryKey,
348
+ bytes,
349
+ association: pending.association,
350
+ },
351
+ ],
352
+ };
353
+ }
354
+ catch (err) {
355
+ const message = err instanceof Error ? err.message : String(err);
356
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — ${message}`);
357
+ return null;
358
+ }
359
+ finally {
360
+ this.options.metrics.record({
361
+ phase: "put",
362
+ writer: "BatchingApiGatewayArtifactWriter",
363
+ type: pending.type,
364
+ ms: Date.now() - start,
365
+ bytes,
366
+ success: ok,
367
+ });
368
+ }
369
+ }
370
+ /** Single-URL sign + PUT — used for the manifest. */
371
+ async writeSingle(uploadUrlPath, payload, metricType) {
372
+ const signStart = Date.now();
373
+ let signOk = false;
374
+ let signed = null;
375
+ try {
376
+ const res = await fetch(`${this.options.apiBaseUrl.replace(/\/$/, "")}${uploadUrlPath}`, {
377
+ method: "GET",
378
+ headers: { Authorization: `Bearer ${this.options.apiKey}` },
379
+ });
380
+ if (!res.ok) {
381
+ console.warn(` ⚠️ Signed-URL request failed: ${res.status} ${res.statusText}`);
382
+ return null;
383
+ }
384
+ const body = (await res.json());
385
+ if (body.object !== "signed_upload_url" ||
386
+ !body.url ||
387
+ !body.path ||
388
+ !body.bucket ||
389
+ !body.requiredHeaders) {
390
+ console.warn(` ⚠️ Signed-URL response malformed`);
391
+ return null;
392
+ }
393
+ signOk = true;
394
+ signed = {
395
+ url: body.url,
396
+ path: body.path,
397
+ bucket: body.bucket,
398
+ requiredHeaders: body.requiredHeaders,
399
+ };
400
+ }
401
+ catch (err) {
402
+ const message = err instanceof Error ? err.message : String(err);
403
+ console.warn(` ⚠️ Signed-URL request failed: ${message}`);
404
+ return null;
405
+ }
406
+ finally {
407
+ this.options.metrics.record({
408
+ phase: "sign",
409
+ writer: "BatchingApiGatewayArtifactWriter",
410
+ type: metricType,
411
+ ms: Date.now() - signStart,
412
+ success: signOk,
413
+ });
414
+ }
415
+ if (!signed)
416
+ return null;
417
+ const json = JSON.stringify(payload);
418
+ const bytes = Buffer.byteLength(json, "utf-8");
419
+ const putStart = Date.now();
420
+ let putOk = false;
421
+ try {
422
+ const res = await fetch(signed.url, {
423
+ method: "PUT",
424
+ body: json,
425
+ headers: signed.requiredHeaders,
426
+ });
427
+ if (!res.ok) {
428
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${res.status} ${res.statusText}`);
429
+ return null;
430
+ }
431
+ putOk = true;
432
+ return {
433
+ store: "gcs",
434
+ bucket: signed.bucket,
435
+ path: signed.path,
436
+ bytes,
437
+ layout: "bulk",
438
+ };
439
+ }
440
+ catch (err) {
441
+ const message = err instanceof Error ? err.message : String(err);
442
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — ${message}`);
443
+ return null;
444
+ }
445
+ finally {
446
+ this.options.metrics.record({
447
+ phase: "put",
448
+ writer: "BatchingApiGatewayArtifactWriter",
449
+ type: metricType,
450
+ ms: Date.now() - putStart,
451
+ bytes,
452
+ success: putOk,
453
+ });
454
+ }
455
+ }
456
+ }
457
+ // ---------------------------------------------------------------------------
458
+ // Helpers
459
+ // ---------------------------------------------------------------------------
460
+ async function runWithConcurrency(tasks, concurrency) {
461
+ if (tasks.length === 0)
462
+ return [];
463
+ if (concurrency <= 1) {
464
+ const out = [];
465
+ for (const task of tasks)
466
+ out.push(await task());
467
+ return out;
468
+ }
469
+ const results = new Array(tasks.length);
470
+ let cursor = 0;
471
+ async function worker() {
472
+ while (true) {
473
+ const i = cursor++;
474
+ if (i >= tasks.length)
475
+ return;
476
+ results[i] = await tasks[i]();
477
+ }
478
+ }
479
+ const width = Math.min(concurrency, tasks.length);
480
+ await Promise.all(Array.from({ length: width }, () => worker()));
481
+ return results;
482
+ }
483
+ function entryCountOf(data) {
484
+ if (typeof data === "object" &&
485
+ data !== null &&
486
+ "entries" in data &&
487
+ typeof data.entries === "object") {
488
+ return Object.keys(data.entries)
489
+ .length;
490
+ }
491
+ return undefined;
492
+ }
@@ -20,10 +20,22 @@
20
20
  *
21
21
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md (§ M4)
22
22
  */
23
- import type { ArtifactEntry, ArtifactRef, ArtifactType, ArtifactWriter, AssociationValues, RunId, RunManifest } from "../_vendor/ailf-core/index.d.ts";
23
+ import type { ArtifactEntry, ArtifactRef, ArtifactType, ArtifactWriter, ArtifactWriterProgressOptions, AssociationValues, RunId, RunManifest } from "../_vendor/ailf-core/index.d.ts";
24
+ export interface FanoutArtifactWriterOptions {
25
+ /**
26
+ * Optional progress reporter + phaseId (W0053). When set, the fanout
27
+ * publishes `phaseProgress` once per caller-visible write (emit /
28
+ * appendNdjson / writeManifest), attributing one item and the returned
29
+ * ref's bytes. Delegates should NOT also be configured with progress —
30
+ * double-reporting would inflate counters.
31
+ */
32
+ progress?: ArtifactWriterProgressOptions;
33
+ }
24
34
  export declare class FanoutArtifactWriter implements ArtifactWriter {
25
35
  private readonly writers;
26
- constructor(writers: readonly ArtifactWriter[]);
36
+ private readonly progress?;
37
+ constructor(writers: readonly ArtifactWriter[], options?: FanoutArtifactWriterOptions);
38
+ private reportProgress;
27
39
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
28
40
  appendNdjson<T extends ArtifactType>(type: T, association: AssociationValues, rows: readonly unknown[]): Promise<ArtifactRef | null>;
29
41
  writeManifest(runId: RunId, manifest: RunManifest): Promise<ArtifactRef | null>;
@@ -22,23 +22,44 @@
22
22
  */
23
23
  export class FanoutArtifactWriter {
24
24
  writers;
25
- constructor(writers) {
25
+ progress;
26
+ constructor(writers, options = {}) {
26
27
  if (writers.length === 0) {
27
28
  throw new Error("FanoutArtifactWriter requires at least one delegate writer");
28
29
  }
29
30
  this.writers = writers;
31
+ this.progress = options.progress;
32
+ }
33
+ reportProgress(ref) {
34
+ if (!this.progress)
35
+ return;
36
+ this.progress.reporter.phaseProgress({
37
+ phaseId: this.progress.phaseId,
38
+ items: 1,
39
+ bytes: ref.bytes,
40
+ label: ref.path,
41
+ });
30
42
  }
31
43
  async emit(type, association, payload) {
32
44
  const refs = await this.runAll((w) => w.emit(type, association, payload));
33
- return firstNonNull(refs);
45
+ const ref = firstNonNull(refs);
46
+ if (ref)
47
+ this.reportProgress(ref);
48
+ return ref;
34
49
  }
35
50
  async appendNdjson(type, association, rows) {
36
51
  const refs = await this.runAll((w) => w.appendNdjson(type, association, rows));
37
- return firstNonNull(refs);
52
+ const ref = firstNonNull(refs);
53
+ if (ref)
54
+ this.reportProgress(ref);
55
+ return ref;
38
56
  }
39
57
  async writeManifest(runId, manifest) {
40
58
  const refs = await this.runAll((w) => w.writeManifest(runId, manifest));
41
- return firstNonNull(refs);
59
+ const ref = firstNonNull(refs);
60
+ if (ref)
61
+ this.reportProgress(ref);
62
+ return ref;
42
63
  }
43
64
  /** @deprecated Use `emit()` instead. */
44
65
  async writeBulk(type, runId, data) {