@sanity/ailf 3.0.0 → 3.1.1

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 +9 -7
  33. package/dist/orchestration/steps/finalize-run-step.js +40 -8
  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 +1 -1
@@ -28,7 +28,8 @@
28
28
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md
29
29
  */
30
30
  import { Storage } from "@google-cloud/storage";
31
- import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
31
+ import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
32
+ import { type UploadMetricsSink } from "./upload-metrics.js";
32
33
  export interface GcsArtifactWriterOptions {
33
34
  /** GCS bucket name (e.g., "ailf-artifacts") */
34
35
  bucket: string;
@@ -38,12 +39,37 @@ export interface GcsArtifactWriterOptions {
38
39
  * supplies a fake here to avoid real network calls.
39
40
  */
40
41
  storage?: Storage;
42
+ /**
43
+ * Optional progress reporter + phaseId (W0053). When set, the writer
44
+ * publishes `phaseProgress` on every successful emit / appendNdjson /
45
+ * writeManifest so the CLI can render per-batch updates during the
46
+ * long artifact upload phase.
47
+ */
48
+ progress?: ArtifactWriterProgressOptions;
49
+ /**
50
+ * W0056 — optional metrics sink that receives per-phase timing events.
51
+ * Defaults to a no-op so the hot path stays free when metrics are off.
52
+ */
53
+ metrics?: UploadMetricsSink;
41
54
  }
42
55
  export declare class GcsArtifactWriter implements ArtifactWriter {
43
56
  private client;
44
57
  private readonly options;
45
58
  private readonly ndjsonStreams;
59
+ private readonly metrics;
60
+ /**
61
+ * Bounds concurrent GCS PUTs.
62
+ *
63
+ * Producers after W0058 fire all emits synchronously and `Promise.all`
64
+ * once at the end — without an internal limiter, a full-literacy run
65
+ * would fan ~1,500 concurrent `file.save` calls, saturating the client
66
+ * connection pool and GCS's per-prefix write quota. The limiter
67
+ * preserves the pre-W0058 effective concurrency (8) that Prototype A
68
+ * measured safe, without forcing the producer to own that knob.
69
+ */
70
+ private readonly limiter;
46
71
  constructor(options: GcsArtifactWriterOptions);
72
+ private reportProgress;
47
73
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
48
74
  appendNdjson<T extends ArtifactType>(type: T, association: AssociationValues, rows: readonly unknown[]): Promise<ArtifactRef | null>;
49
75
  writeManifest(runId: RunId, manifest: RunManifest): Promise<ArtifactRef | null>;
@@ -29,7 +29,9 @@
29
29
  */
30
30
  import { Storage } from "@google-cloud/storage";
31
31
  import { ARTIFACT_REGISTRY, buildManifestPreview, } from "../_vendor/ailf-core/index.js";
32
+ import { resolveUploadConcurrency } from "./parallel-emit.js";
32
33
  import { redactArtifactData } from "./redact-artifact.js";
34
+ import { NO_OP_UPLOAD_METRICS, } from "./upload-metrics.js";
33
35
  /**
34
36
  * GCS's object compose operation accepts at most 32 source objects per call
35
37
  * ([docs](https://cloud.google.com/storage/docs/composite-objects)). When an
@@ -42,11 +44,36 @@ export class GcsArtifactWriter {
42
44
  client = null;
43
45
  options;
44
46
  ndjsonStreams = new Map();
47
+ metrics;
48
+ /**
49
+ * Bounds concurrent GCS PUTs.
50
+ *
51
+ * Producers after W0058 fire all emits synchronously and `Promise.all`
52
+ * once at the end — without an internal limiter, a full-literacy run
53
+ * would fan ~1,500 concurrent `file.save` calls, saturating the client
54
+ * connection pool and GCS's per-prefix write quota. The limiter
55
+ * preserves the pre-W0058 effective concurrency (8) that Prototype A
56
+ * measured safe, without forcing the producer to own that knob.
57
+ */
58
+ limiter;
45
59
  constructor(options) {
46
60
  this.options = options;
61
+ this.metrics = options.metrics ?? NO_OP_UPLOAD_METRICS;
47
62
  if (options.storage) {
48
63
  this.client = options.storage;
49
64
  }
65
+ this.limiter = new ConcurrencyLimiter(resolveUploadConcurrency());
66
+ }
67
+ reportProgress(ref) {
68
+ const progress = this.options.progress;
69
+ if (!progress)
70
+ return;
71
+ progress.reporter.phaseProgress({
72
+ phaseId: progress.phaseId,
73
+ items: 1,
74
+ bytes: ref.bytes,
75
+ label: ref.path,
76
+ });
50
77
  }
51
78
  // ---- Canonical W0049 API ------------------------------------------------
52
79
  async emit(type, association, payload) {
@@ -68,18 +95,21 @@ export class GcsArtifactWriter {
68
95
  layout: "bulk",
69
96
  mime: descriptor.mime,
70
97
  entryCount: entryCountOf(redacted),
98
+ type,
71
99
  });
72
100
  if (!ref)
73
101
  return null;
74
- return preview === undefined ? ref : { ...ref, preview };
102
+ const finalRef = preview === undefined ? ref : { ...ref, preview };
103
+ this.reportProgress(finalRef);
104
+ return finalRef;
75
105
  }
76
106
  // per-entry
77
107
  const entryKey = descriptor.formatEntryKey(association);
78
108
  const path = descriptor.objectPath(runId, entryKey);
79
- const ref = await this.putBody(path, serializeForMime(redacted, descriptor.mime), { layout: "per-entry", mime: descriptor.mime });
109
+ const ref = await this.putBody(path, serializeForMime(redacted, descriptor.mime), { layout: "per-entry", mime: descriptor.mime, type });
80
110
  if (!ref)
81
111
  return null;
82
- return {
112
+ const finalRef = {
83
113
  ...ref,
84
114
  path: `runs/${runId}/${descriptor.slug}`,
85
115
  entryCount: 1,
@@ -92,6 +122,8 @@ export class GcsArtifactWriter {
92
122
  },
93
123
  ],
94
124
  };
125
+ this.reportProgress(finalRef);
126
+ return finalRef;
95
127
  }
96
128
  async appendNdjson(type, association, rows) {
97
129
  const descriptor = ARTIFACT_REGISTRY[type];
@@ -119,21 +151,39 @@ export class GcsArtifactWriter {
119
151
  const redactedRows = rows.map((r) => redactArtifactData(r));
120
152
  const body = redactedRows.map((r) => JSON.stringify(r)).join("\n") + "\n";
121
153
  const bytes = Buffer.byteLength(body, "utf-8");
122
- try {
123
- const storage = this.getClient();
124
- await storage
125
- .bucket(this.options.bucket)
126
- .file(partPath)
127
- .save(body, { contentType: "application/x-ndjson" });
128
- state.partCount++;
129
- state.totalBytes += bytes;
130
- }
131
- catch (err) {
132
- const message = err instanceof Error ? err.message : String(err);
133
- console.warn(` ⚠️ NDJSON part upload failed (non-blocking): ${partPath} — ${message}`);
154
+ const saveOutcome = await this.limiter.run(async () => {
155
+ const start = Date.now();
156
+ let success = false;
157
+ try {
158
+ const storage = this.getClient();
159
+ await storage
160
+ .bucket(this.options.bucket)
161
+ .file(partPath)
162
+ .save(body, { contentType: "application/x-ndjson" });
163
+ success = true;
164
+ return { ok: true };
165
+ }
166
+ catch (err) {
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ console.warn(` ⚠️ NDJSON part upload failed (non-blocking): ${partPath} — ${message}`);
169
+ return { ok: false };
170
+ }
171
+ finally {
172
+ this.metrics.record({
173
+ phase: "put",
174
+ writer: "GcsArtifactWriter",
175
+ type: `${type}:ndjson-part`,
176
+ ms: Date.now() - start,
177
+ bytes,
178
+ success,
179
+ });
180
+ }
181
+ });
182
+ if (!saveOutcome.ok)
134
183
  return null;
135
- }
136
- return {
184
+ state.partCount++;
185
+ state.totalBytes += bytes;
186
+ const ref = {
137
187
  store: "gcs",
138
188
  bucket: this.options.bucket,
139
189
  path: `runs/${runId}/${descriptor.slug}`,
@@ -148,14 +198,20 @@ export class GcsArtifactWriter {
148
198
  },
149
199
  ],
150
200
  };
201
+ this.reportProgress(ref);
202
+ return ref;
151
203
  }
152
204
  async writeManifest(runId, manifest) {
153
205
  await this.finalizeNdjsonStreams();
154
206
  const path = `runs/${runId}/manifest.json`;
155
- return this.putBody(path, JSON.stringify(manifest), {
207
+ const ref = await this.putBody(path, JSON.stringify(manifest), {
156
208
  layout: "bulk",
157
209
  mime: "application/json",
210
+ type: "manifest",
158
211
  });
212
+ if (ref)
213
+ this.reportProgress(ref);
214
+ return ref;
159
215
  }
160
216
  // ---- Deprecated legacy surface (W0052) ----------------------------------
161
217
  /** @deprecated Use `emit()` instead. Routes through the same GCS I/O. */
@@ -171,6 +227,7 @@ export class GcsArtifactWriter {
171
227
  layout: "bulk",
172
228
  mime: descriptor.mime,
173
229
  entryCount: entryCountOf(redacted),
230
+ type,
174
231
  });
175
232
  }
176
233
  /** @deprecated Use `emit()` per entry instead. */
@@ -197,6 +254,8 @@ export class GcsArtifactWriter {
197
254
  const redacted = redactArtifactData(entry.data);
198
255
  const body = serializeForMime(redacted, descriptor.mime);
199
256
  const bytes = Buffer.byteLength(body, "utf-8");
257
+ const start = Date.now();
258
+ let success = false;
200
259
  try {
201
260
  await storage
202
261
  .bucket(this.options.bucket)
@@ -204,11 +263,22 @@ export class GcsArtifactWriter {
204
263
  .save(body, { contentType: descriptor.mime });
205
264
  uploaded.push({ key: entry.key, bytes });
206
265
  totalBytes += bytes;
266
+ success = true;
207
267
  }
208
268
  catch (err) {
209
269
  const message = err instanceof Error ? err.message : String(err);
210
270
  console.warn(` ⚠️ Artifact entry upload failed (non-blocking): ${path} — ${message}`);
211
271
  }
272
+ finally {
273
+ this.metrics.record({
274
+ phase: "put",
275
+ writer: "GcsArtifactWriter",
276
+ type,
277
+ ms: Date.now() - start,
278
+ bytes,
279
+ success,
280
+ });
281
+ }
212
282
  }
213
283
  if (uploaded.length === 0)
214
284
  return null;
@@ -240,41 +310,69 @@ export class GcsArtifactWriter {
240
310
  const storage = this.getClient();
241
311
  const bucket = storage.bucket(this.options.bucket);
242
312
  const partPaths = Array.from({ length: state.partCount }, (_, i) => `${state.finalPath}.part-${String(i).padStart(4, "0")}`);
313
+ const start = Date.now();
314
+ let success = false;
243
315
  try {
244
316
  const finalPath = await composeInGroups(bucket, partPaths, state.finalPath);
245
317
  if (finalPath !== state.finalPath) {
246
318
  console.warn(` ⚠️ NDJSON compose produced "${finalPath}" (expected "${state.finalPath}")`);
247
319
  }
320
+ success = true;
248
321
  }
249
322
  catch (err) {
250
323
  const message = err instanceof Error ? err.message : String(err);
251
324
  console.warn(` ⚠️ NDJSON compose failed (non-blocking): ${state.finalPath} — ${message}`);
252
325
  }
326
+ finally {
327
+ this.metrics.record({
328
+ phase: "compose",
329
+ writer: "GcsArtifactWriter",
330
+ type: `parts=${state.partCount}`,
331
+ ms: Date.now() - start,
332
+ bytes: state.totalBytes,
333
+ success,
334
+ });
335
+ }
253
336
  }
254
337
  this.ndjsonStreams.clear();
255
338
  }
256
339
  async putBody(path, body, meta) {
257
340
  const bytes = Buffer.byteLength(body, "utf-8");
258
- try {
259
- const storage = this.getClient();
260
- await storage
261
- .bucket(this.options.bucket)
262
- .file(path)
263
- .save(body, { contentType: meta.mime });
264
- return {
265
- store: "gcs",
266
- bucket: this.options.bucket,
267
- path,
268
- bytes,
269
- entryCount: meta.entryCount,
270
- layout: meta.layout,
271
- };
272
- }
273
- catch (err) {
274
- const message = err instanceof Error ? err.message : String(err);
275
- console.warn(` ⚠️ Artifact upload failed (non-blocking): ${path} — ${message}`);
276
- return null;
277
- }
341
+ return this.limiter.run(async () => {
342
+ const start = Date.now();
343
+ let success = false;
344
+ try {
345
+ const storage = this.getClient();
346
+ await storage
347
+ .bucket(this.options.bucket)
348
+ .file(path)
349
+ .save(body, { contentType: meta.mime });
350
+ success = true;
351
+ return {
352
+ store: "gcs",
353
+ bucket: this.options.bucket,
354
+ path,
355
+ bytes,
356
+ entryCount: meta.entryCount,
357
+ layout: meta.layout,
358
+ };
359
+ }
360
+ catch (err) {
361
+ const message = err instanceof Error ? err.message : String(err);
362
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${path} — ${message}`);
363
+ return null;
364
+ }
365
+ finally {
366
+ this.metrics.record({
367
+ phase: "put",
368
+ writer: "GcsArtifactWriter",
369
+ type: meta.type,
370
+ ms: Date.now() - start,
371
+ bytes,
372
+ success,
373
+ });
374
+ }
375
+ });
278
376
  }
279
377
  getClient() {
280
378
  if (this.client)
@@ -286,6 +384,38 @@ export class GcsArtifactWriter {
286
384
  // ---------------------------------------------------------------------------
287
385
  // Helpers
288
386
  // ---------------------------------------------------------------------------
387
+ /**
388
+ * Simple bounded-concurrency scheduler. Tasks beyond the limit queue
389
+ * FIFO; each completion wakes exactly one waiter. Stays FIFO-fair because
390
+ * no timer is involved. Used by `GcsArtifactWriter` to cap concurrent
391
+ * GCS I/O after producers stopped self-bounding via `parallelMap`.
392
+ */
393
+ class ConcurrencyLimiter {
394
+ inflight = 0;
395
+ waiters = [];
396
+ limit;
397
+ constructor(limit) {
398
+ // Clamp at 1 so a misconfigured `resolveUploadConcurrency()` (0 or
399
+ // negative) cannot deadlock — the admission check in `run()` is
400
+ // `inflight >= limit`, so `limit=0` would wedge every caller.
401
+ this.limit = Math.max(1, Math.floor(limit));
402
+ }
403
+ async run(task) {
404
+ if (this.inflight >= this.limit) {
405
+ await new Promise((resolve) => this.waiters.push(resolve));
406
+ }
407
+ this.inflight++;
408
+ try {
409
+ return await task();
410
+ }
411
+ finally {
412
+ this.inflight--;
413
+ const next = this.waiters.shift();
414
+ if (next)
415
+ next();
416
+ }
417
+ }
418
+ }
289
419
  function serializeForMime(payload, mime) {
290
420
  if (mime === "text/markdown" || mime === "application/yaml") {
291
421
  if (typeof payload === "string")
@@ -0,0 +1,32 @@
1
+ /**
2
+ * InstrumentedArtifactWriter — decorator for W0056 spike instrumentation.
3
+ *
4
+ * Wraps any `ArtifactWriter` and records end-to-end `emit` / `appendNdjson` /
5
+ * `writeManifest` durations into an `UploadMetrics` sink. The writer-internal
6
+ * phase timings (sign vs. PUT, compose groups) are recorded by the concrete
7
+ * writers; this decorator records the caller-observed totals so the summary
8
+ * shows both views.
9
+ *
10
+ * Triggers `UploadMetrics.summarize()` from `writeManifest` (after delegation)
11
+ * — that is the natural end-of-run signal and avoids a separate lifecycle
12
+ * hook in the composition root.
13
+ */
14
+ import type { ArtifactEntry, ArtifactRef, ArtifactType, ArtifactWriter, AssociationValues, RunId, RunManifest } from "../_vendor/ailf-core/index.d.ts";
15
+ import type { UploadMetrics } from "./upload-metrics.js";
16
+ export declare class InstrumentedArtifactWriter implements ArtifactWriter {
17
+ /**
18
+ * Exposed so `FinalizeRunStep` can walk decorator chains to reach the
19
+ * underlying `AccumulatingArtifactWriter` when `AILF_UPLOAD_METRICS=1`
20
+ * (which wraps the accumulator in this decorator). Mirrors the
21
+ * analogous field on `AccumulatingArtifactWriter`. Treat as read-only.
22
+ */
23
+ readonly inner: ArtifactWriter;
24
+ private readonly metrics;
25
+ private readonly writerName;
26
+ constructor(inner: ArtifactWriter, metrics: UploadMetrics);
27
+ emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
28
+ appendNdjson<T extends ArtifactType>(type: T, association: AssociationValues, rows: readonly unknown[]): Promise<ArtifactRef | null>;
29
+ writeManifest(runId: RunId, manifest: RunManifest): Promise<ArtifactRef | null>;
30
+ writeBulk(type: ArtifactType, runId: RunId, data: unknown): Promise<ArtifactRef | null>;
31
+ writePerEntry(type: ArtifactType, runId: RunId, entries: readonly ArtifactEntry[]): Promise<ArtifactRef | null>;
32
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * InstrumentedArtifactWriter — decorator for W0056 spike instrumentation.
3
+ *
4
+ * Wraps any `ArtifactWriter` and records end-to-end `emit` / `appendNdjson` /
5
+ * `writeManifest` durations into an `UploadMetrics` sink. The writer-internal
6
+ * phase timings (sign vs. PUT, compose groups) are recorded by the concrete
7
+ * writers; this decorator records the caller-observed totals so the summary
8
+ * shows both views.
9
+ *
10
+ * Triggers `UploadMetrics.summarize()` from `writeManifest` (after delegation)
11
+ * — that is the natural end-of-run signal and avoids a separate lifecycle
12
+ * hook in the composition root.
13
+ */
14
+ export class InstrumentedArtifactWriter {
15
+ /**
16
+ * Exposed so `FinalizeRunStep` can walk decorator chains to reach the
17
+ * underlying `AccumulatingArtifactWriter` when `AILF_UPLOAD_METRICS=1`
18
+ * (which wraps the accumulator in this decorator). Mirrors the
19
+ * analogous field on `AccumulatingArtifactWriter`. Treat as read-only.
20
+ */
21
+ inner;
22
+ metrics;
23
+ writerName;
24
+ constructor(inner, metrics) {
25
+ this.inner = inner;
26
+ this.metrics = metrics;
27
+ this.writerName = inner.constructor.name;
28
+ }
29
+ async emit(type, association, payload) {
30
+ const start = Date.now();
31
+ let success = false;
32
+ try {
33
+ const ref = await this.inner.emit(type, association, payload);
34
+ success = ref !== null;
35
+ this.metrics.record({
36
+ phase: "emit",
37
+ writer: this.writerName,
38
+ type,
39
+ ms: Date.now() - start,
40
+ bytes: ref?.bytes,
41
+ success,
42
+ });
43
+ return ref;
44
+ }
45
+ catch (err) {
46
+ this.metrics.record({
47
+ phase: "emit",
48
+ writer: this.writerName,
49
+ type,
50
+ ms: Date.now() - start,
51
+ success: false,
52
+ });
53
+ throw err;
54
+ }
55
+ }
56
+ async appendNdjson(type, association, rows) {
57
+ const start = Date.now();
58
+ try {
59
+ const ref = await this.inner.appendNdjson(type, association, rows);
60
+ this.metrics.record({
61
+ phase: "ndjson-part",
62
+ writer: this.writerName,
63
+ type,
64
+ ms: Date.now() - start,
65
+ bytes: ref?.bytes,
66
+ success: ref !== null,
67
+ });
68
+ return ref;
69
+ }
70
+ catch (err) {
71
+ this.metrics.record({
72
+ phase: "ndjson-part",
73
+ writer: this.writerName,
74
+ type,
75
+ ms: Date.now() - start,
76
+ success: false,
77
+ });
78
+ throw err;
79
+ }
80
+ }
81
+ async writeManifest(runId, manifest) {
82
+ const start = Date.now();
83
+ let ref = null;
84
+ try {
85
+ ref = await this.inner.writeManifest(runId, manifest);
86
+ this.metrics.record({
87
+ phase: "manifest",
88
+ writer: this.writerName,
89
+ type: "manifest",
90
+ ms: Date.now() - start,
91
+ bytes: ref?.bytes,
92
+ success: ref !== null,
93
+ });
94
+ return ref;
95
+ }
96
+ finally {
97
+ // Summarize once, after the manifest write resolves, regardless of outcome.
98
+ await this.metrics.summarize();
99
+ }
100
+ }
101
+ async writeBulk(type, runId, data) {
102
+ const start = Date.now();
103
+ try {
104
+ const ref = await this.inner.writeBulk(type, runId, data);
105
+ this.metrics.record({
106
+ phase: "emit",
107
+ writer: this.writerName,
108
+ type,
109
+ ms: Date.now() - start,
110
+ bytes: ref?.bytes,
111
+ success: ref !== null,
112
+ });
113
+ return ref;
114
+ }
115
+ catch (err) {
116
+ this.metrics.record({
117
+ phase: "emit",
118
+ writer: this.writerName,
119
+ type,
120
+ ms: Date.now() - start,
121
+ success: false,
122
+ });
123
+ throw err;
124
+ }
125
+ }
126
+ async writePerEntry(type, runId, entries) {
127
+ const start = Date.now();
128
+ try {
129
+ const ref = await this.inner.writePerEntry(type, runId, entries);
130
+ this.metrics.record({
131
+ phase: "emit",
132
+ writer: this.writerName,
133
+ type,
134
+ ms: Date.now() - start,
135
+ bytes: ref?.bytes,
136
+ success: ref !== null,
137
+ });
138
+ return ref;
139
+ }
140
+ catch (err) {
141
+ this.metrics.record({
142
+ phase: "emit",
143
+ writer: this.writerName,
144
+ type,
145
+ ms: Date.now() - start,
146
+ success: false,
147
+ });
148
+ throw err;
149
+ }
150
+ }
151
+ }
@@ -36,7 +36,7 @@
36
36
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md (§ M4)
37
37
  * @see packages/eval/src/artifact-capture/gcs-artifact-writer.ts (mirror)
38
38
  */
39
- import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
39
+ import { type ArtifactEntry, type ArtifactRef, type ArtifactType, type ArtifactWriter, type ArtifactWriterProgressOptions, type AssociationValues, type RunId, type RunManifest } from "../_vendor/ailf-core/index.d.ts";
40
40
  export interface LocalFilesystemArtifactWriterOptions {
41
41
  /**
42
42
  * Absolute or cwd-relative root directory under which `runs/{runId}/…`
@@ -50,11 +50,18 @@ export interface LocalFilesystemArtifactWriterOptions {
50
50
  * `--capture-exclude=LIST` in W0050 Slice 4.
51
51
  */
52
52
  exclude?: readonly ArtifactType[];
53
+ /**
54
+ * Optional progress reporter + phaseId (W0053). When set, the writer
55
+ * publishes `phaseProgress` on every successful write so the CLI can
56
+ * render per-batch updates during long export phases.
57
+ */
58
+ progress?: ArtifactWriterProgressOptions;
53
59
  }
54
60
  export declare class LocalFilesystemArtifactWriter implements ArtifactWriter {
55
61
  private readonly options;
56
62
  private readonly excludeSet;
57
63
  constructor(options: LocalFilesystemArtifactWriterOptions);
64
+ private reportProgress;
58
65
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
59
66
  appendNdjson<T extends ArtifactType>(type: T, association: AssociationValues, rows: readonly unknown[]): Promise<ArtifactRef | null>;
60
67
  writeManifest(runId: RunId, manifest: RunManifest): Promise<ArtifactRef | null>;
@@ -50,6 +50,17 @@ export class LocalFilesystemArtifactWriter {
50
50
  this.options = options;
51
51
  this.excludeSet = new Set(options.exclude ?? []);
52
52
  }
53
+ reportProgress(ref) {
54
+ const progress = this.options.progress;
55
+ if (!progress)
56
+ return;
57
+ progress.reporter.phaseProgress({
58
+ phaseId: progress.phaseId,
59
+ items: 1,
60
+ bytes: ref.bytes,
61
+ label: ref.path,
62
+ });
63
+ }
53
64
  // ---- Canonical W0049 API ------------------------------------------------
54
65
  async emit(type, association, payload) {
55
66
  if (this.excludeSet.has(type))
@@ -74,7 +85,7 @@ export class LocalFilesystemArtifactWriter {
74
85
  const wrote = await this.writeAtomic(absPath, body);
75
86
  if (!wrote)
76
87
  return null;
77
- return {
88
+ const ref = {
78
89
  store: "local",
79
90
  bucket: this.options.rootDir,
80
91
  path: relPath,
@@ -83,6 +94,8 @@ export class LocalFilesystemArtifactWriter {
83
94
  layout: "bulk",
84
95
  ...(preview === undefined ? {} : { preview }),
85
96
  };
97
+ this.reportProgress(ref);
98
+ return ref;
86
99
  }
87
100
  // per-entry
88
101
  const entryKey = descriptor.formatEntryKey(association);
@@ -91,7 +104,7 @@ export class LocalFilesystemArtifactWriter {
91
104
  const wrote = await this.writeAtomic(absPath, body);
92
105
  if (!wrote)
93
106
  return null;
94
- return {
107
+ const ref = {
95
108
  store: "local",
96
109
  bucket: this.options.rootDir,
97
110
  path: `runs/${runId}/${descriptor.slug}`,
@@ -107,6 +120,8 @@ export class LocalFilesystemArtifactWriter {
107
120
  },
108
121
  ],
109
122
  };
123
+ this.reportProgress(ref);
124
+ return ref;
110
125
  }
111
126
  async appendNdjson(type, association, rows) {
112
127
  if (this.excludeSet.has(type))
@@ -149,7 +164,7 @@ export class LocalFilesystemArtifactWriter {
149
164
  catch {
150
165
  // If stat fails we still have the current batch's bytes — acceptable.
151
166
  }
152
- return {
167
+ const ref = {
153
168
  store: "local",
154
169
  bucket: this.options.rootDir,
155
170
  path: `runs/${runId}/${descriptor.slug}`,
@@ -158,6 +173,8 @@ export class LocalFilesystemArtifactWriter {
158
173
  layout: "per-entry",
159
174
  entries: [{ key: entryKey, bytes: cumulative, association }],
160
175
  };
176
+ this.reportProgress(ref);
177
+ return ref;
161
178
  }
162
179
  async writeManifest(runId, manifest) {
163
180
  const relPath = `runs/${runId}/manifest.json`;
@@ -166,13 +183,15 @@ export class LocalFilesystemArtifactWriter {
166
183
  const wrote = await this.writeAtomic(absPath, body);
167
184
  if (!wrote)
168
185
  return null;
169
- return {
186
+ const ref = {
170
187
  store: "local",
171
188
  bucket: this.options.rootDir,
172
189
  path: relPath,
173
190
  bytes: Buffer.byteLength(body, "utf-8"),
174
191
  layout: "bulk",
175
192
  };
193
+ this.reportProgress(ref);
194
+ return ref;
176
195
  }
177
196
  // ---- Deprecated legacy surface (W0052) ----------------------------------
178
197
  /** @deprecated Use `emit()` instead. */