@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
@@ -33,3 +33,40 @@ export interface AssocContext {
33
33
  * boundary actually lives.
34
34
  */
35
35
  export declare function assoc(ctx: AssocContext, partial?: Omit<AssociationValues, "run">): AssociationValues;
36
+ /**
37
+ * Literacy-mode task descriptions carry a `(gold)` / `(baseline)` suffix that
38
+ * encodes which half of the with-docs / without-docs pair the judgment belongs
39
+ * to. The D0033 artifact axes treat that variant as the `mode` axis — a single
40
+ * promptfoo run of evaluation mode "literacy" produces artifacts whose `mode`
41
+ * axis is "gold" or "baseline". All producers and all readers must agree on
42
+ * this decomposition, or signed-URL lookups 404.
43
+ *
44
+ * This helper is the single source of truth for the split. It's shared between:
45
+ * - eval's `emit-eval-results.ts` (rawResults, renderedPrompts,
46
+ * graderPrompts, graderJudgments)
47
+ * - eval's `upload-test-outputs.ts` (testOutputs)
48
+ * - core's `slim-report-summary.ts` (slim judgment / failure-mode ids)
49
+ * - studio's `JudgmentList#testOutputsKeyFor` (hover prefetch keys)
50
+ *
51
+ * Non-literacy modes (mcp-server, agent-harness, …) do not suffix their task
52
+ * descriptions; `splitTaskVariant` returns `variant: null` and the caller
53
+ * falls back to the high-level eval mode. See `resolveVariantMode`.
54
+ *
55
+ * Pure function — safe to call from browser or Node.
56
+ */
57
+ export interface TaskVariantSplit {
58
+ /** Task id with any trailing `(gold)` / `(baseline)` suffix stripped. */
59
+ readonly task: string;
60
+ /** Lowercased variant (`"gold"` | `"baseline"`), or `null` when absent. */
61
+ readonly variant: "baseline" | "gold" | null;
62
+ }
63
+ export declare function splitTaskVariant(taskId: string): TaskVariantSplit;
64
+ /**
65
+ * Resolve the `mode` axis value for an artifact association: prefer the
66
+ * per-task variant when present, fall back to the high-level evaluation mode.
67
+ * Mirrors `mode = variant ?? defaultMode` in `slim-report-summary#slimJudgments`.
68
+ */
69
+ export declare function resolveVariantMode(taskId: string, defaultMode: string): {
70
+ mode: string;
71
+ task: string;
72
+ };
@@ -26,3 +26,22 @@
26
26
  export function assoc(ctx, partial = {}) {
27
27
  return { run: ctx.runId, ...partial };
28
28
  }
29
+ const VARIANT_SUFFIX_PATTERN = /\s*\((gold|baseline)\)\s*$/i;
30
+ export function splitTaskVariant(taskId) {
31
+ const match = VARIANT_SUFFIX_PATTERN.exec(taskId);
32
+ if (!match)
33
+ return { task: taskId, variant: null };
34
+ return {
35
+ task: taskId.slice(0, match.index).trim(),
36
+ variant: match[1].toLowerCase(),
37
+ };
38
+ }
39
+ /**
40
+ * Resolve the `mode` axis value for an artifact association: prefer the
41
+ * per-task variant when present, fall back to the high-level evaluation mode.
42
+ * Mirrors `mode = variant ?? defaultMode` in `slim-report-summary#slimJudgments`.
43
+ */
44
+ export function resolveVariantMode(taskId, defaultMode) {
45
+ const { task, variant } = splitTaskVariant(taskId);
46
+ return { mode: variant ?? defaultMode, task };
47
+ }
@@ -21,4 +21,4 @@ export { defineConfig, defineFeatures, defineModeBase, defineModels, definePrici
21
21
  export type { PricingEntry, PromptEntry, SourceEntry, } from "./config-helpers.js";
22
22
  export { env } from "./env-helper.js";
23
23
  export { NoOpArtifactWriter, NotImplementedError, } from "./ports/artifact-writer.js";
24
- export { assoc, type AssocContext } from "./artifact-capture/association.js";
24
+ export { assoc, resolveVariantMode, splitTaskVariant, type AssocContext, type TaskVariantSplit, } from "./artifact-capture/association.js";
@@ -23,4 +23,4 @@ export * from "./batch-signing.js";
23
23
  export { defineConfig, defineFeatures, defineModeBase, defineModels, definePricingTable, definePreset, definePrompts, defineRubrics, defineSchedules, defineSinks, defineSources, defineTask, defineThresholds, } from "./config-helpers.js";
24
24
  export { env } from "./env-helper.js";
25
25
  export { NoOpArtifactWriter, NotImplementedError, } from "./ports/artifact-writer.js";
26
- export { assoc } from "./artifact-capture/association.js";
26
+ export { assoc, resolveVariantMode, splitTaskVariant, } from "./artifact-capture/association.js";
@@ -18,6 +18,7 @@ import type { CacheStore } from "./cache-store.js";
18
18
  import type { DocFetcher } from "./doc-fetcher.js";
19
19
  import type { EvalRunner } from "./eval-runner.js";
20
20
  import type { Logger } from "./logger.js";
21
+ import type { ProgressReporter } from "./progress-reporter.js";
21
22
  import type { TaskSource } from "./task-source.js";
22
23
  /**
23
24
  * Resolved pipeline configuration — the typed, validated result of
@@ -215,6 +216,13 @@ export interface AppContext {
215
216
  readonly evalRunner: EvalRunner;
216
217
  /** Structured logger */
217
218
  readonly logger: Logger;
219
+ /**
220
+ * Progress reporter — carries `phase-start / phase-progress / phase-complete`
221
+ * events for long-running pipeline spans (W0053). The composition root always
222
+ * wires one; adapters default to `NoOpProgressReporter` for quiet / JSON /
223
+ * test loggers.
224
+ */
225
+ readonly progress: ProgressReporter;
218
226
  /** Plugin registry — mode handlers, assertions, rubric templates, etc. */
219
227
  readonly registry: PluginRegistry;
220
228
  /**
@@ -14,5 +14,7 @@ export type { EvalRunConfig, EvalRunner } from "./eval-runner.js";
14
14
  export type { CompilationContext, CompileResultAssertion, CompileResultPrompt, CompileResultProvider, CompileResultTestCase, ModeCompileResult, ModeHandler, ModeProviderEntry, ModeRubricConfig, PromptTemplate, } from "./mode-handler.js";
15
15
  export type { Logger } from "./logger.js";
16
16
  export type { PipelineStep } from "./pipeline-step.js";
17
+ export type { ArtifactWriterProgressOptions, PhaseCompleteEvent, PhaseProgressEvent, PhaseStartEvent, ProgressReporter, } from "./progress-reporter.js";
18
+ export { ARTIFACT_EXPORT_PHASE_ID, NoOpProgressReporter, } from "./progress-reporter.js";
17
19
  export type { TaskSource } from "./task-source.js";
18
20
  export { canonicalDocRefLabel, isIdRef, isPathRef, isPerspectiveRef, isSlugRef, isTemplatedAssertion, } from "./task-source.js";
@@ -5,4 +5,5 @@
5
5
  * Adapters (in packages/eval) implement these interfaces.
6
6
  */
7
7
  export { NoOpArtifactWriter } from "./artifact-writer.js";
8
+ export { ARTIFACT_EXPORT_PHASE_ID, NoOpProgressReporter, } from "./progress-reporter.js";
8
9
  export { canonicalDocRefLabel, isIdRef, isPathRef, isPerspectiveRef, isSlugRef, isTemplatedAssertion, } from "./task-source.js";
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Port: ProgressReporter — carries user-visible progress for long-running
3
+ * pipeline phases (artifact export, eval runs, etc.).
4
+ *
5
+ * Events follow a `start / progress (batch) / complete` shape. Producers
6
+ * publish events without formatting; the CLI adapter decides cadence,
7
+ * layout, and whether to render at all.
8
+ *
9
+ * Scope today: the artifact export phase that runs after promptfoo finishes
10
+ * evaluation (W0053). Forward-compatible with future callers — e.g. the eval
11
+ * phase itself once we replace promptfoo's terminal-owned progress bar.
12
+ *
13
+ * @see docs/work-items/W0053-cli-artifact-upload-progress.json
14
+ */
15
+ export interface PhaseStartEvent {
16
+ /** Stable phase identifier. Adapters may use this to disambiguate if phases overlap. */
17
+ phaseId: string;
18
+ /** Human-readable label (e.g. `"Exporting run artifacts"`). */
19
+ label: string;
20
+ /**
21
+ * Optional extra context shown only on the header line (e.g. destination
22
+ * info such as `"local + GCS"`). Kept separate from `label` so the
23
+ * per-progress-line prefix stays short and scannable on every line.
24
+ */
25
+ detail?: string;
26
+ /** Total work expected, when known up front. */
27
+ totalItems?: number;
28
+ /** Total bytes expected, when known up front. */
29
+ totalBytes?: number;
30
+ /** Wall-clock timestamp when the phase started (ms since epoch). */
31
+ startedAt: number;
32
+ }
33
+ export interface PhaseProgressEvent {
34
+ phaseId: string;
35
+ /** Items completed in this batch. Default `1`. */
36
+ items?: number;
37
+ /** Bytes transferred in this batch. Default `0`. */
38
+ bytes?: number;
39
+ /** Label for the most recent item (e.g. artifact path). */
40
+ label?: string;
41
+ }
42
+ export interface PhaseCompleteEvent {
43
+ phaseId: string;
44
+ itemsCompleted: number;
45
+ bytesCompleted: number;
46
+ durationMs: number;
47
+ /** Optional override for the final summary line. */
48
+ summary?: string;
49
+ }
50
+ export interface ProgressReporter {
51
+ phaseStart(event: PhaseStartEvent): void;
52
+ phaseProgress(event: PhaseProgressEvent): void;
53
+ phaseComplete(event: PhaseCompleteEvent): void;
54
+ }
55
+ /** No-op reporter — used when progress rendering is disabled (quiet / JSON logger / tests). */
56
+ export declare class NoOpProgressReporter implements ProgressReporter {
57
+ phaseStart(): void;
58
+ phaseProgress(): void;
59
+ phaseComplete(): void;
60
+ }
61
+ /**
62
+ * Canonical phase identifier for the post-eval artifact export span (W0053).
63
+ * Shared between writer wiring and the pipeline orchestrator so both sides
64
+ * publish to the same phase.
65
+ */
66
+ export declare const ARTIFACT_EXPORT_PHASE_ID = "artifact-export";
67
+ /**
68
+ * Options shared by artifact writer adapters that publish progress events.
69
+ * Kept in the port module so every writer adapter imports the same shape.
70
+ */
71
+ export interface ArtifactWriterProgressOptions {
72
+ reporter: ProgressReporter;
73
+ phaseId: string;
74
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Port: ProgressReporter — carries user-visible progress for long-running
3
+ * pipeline phases (artifact export, eval runs, etc.).
4
+ *
5
+ * Events follow a `start / progress (batch) / complete` shape. Producers
6
+ * publish events without formatting; the CLI adapter decides cadence,
7
+ * layout, and whether to render at all.
8
+ *
9
+ * Scope today: the artifact export phase that runs after promptfoo finishes
10
+ * evaluation (W0053). Forward-compatible with future callers — e.g. the eval
11
+ * phase itself once we replace promptfoo's terminal-owned progress bar.
12
+ *
13
+ * @see docs/work-items/W0053-cli-artifact-upload-progress.json
14
+ */
15
+ /** No-op reporter — used when progress rendering is disabled (quiet / JSON logger / tests). */
16
+ export class NoOpProgressReporter {
17
+ phaseStart() { }
18
+ phaseProgress() { }
19
+ phaseComplete() { }
20
+ }
21
+ /**
22
+ * Canonical phase identifier for the post-eval artifact export span (W0053).
23
+ * Shared between writer wiring and the pipeline orchestrator so both sides
24
+ * publish to the same phase.
25
+ */
26
+ export const ARTIFACT_EXPORT_PHASE_ID = "artifact-export";
@@ -15,6 +15,7 @@
15
15
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md (§ M7)
16
16
  * @see docs/work-items/W0051-report-slim-down-manifest-preview-hooks.json
17
17
  */
18
+ import { splitTaskVariant } from "../artifact-capture/association.js";
18
19
  import { ARTIFACT_REGISTRY } from "../artifact-registry.js";
19
20
  /**
20
21
  * Transform a full pipeline `ScoreSummary` into its slim Report counterpart.
@@ -49,22 +50,6 @@ export function buildSlimReportSummary(summary, mode) {
49
50
  // ---------------------------------------------------------------------------
50
51
  // Judgments — axes {mode, task, model, grader}
51
52
  // ---------------------------------------------------------------------------
52
- /**
53
- * Variant-suffix stripper. Judgments' `taskId` today carries `(gold)` /
54
- * `(baseline)` suffixes that encode the pipeline mode. We strip the suffix
55
- * to build the canonical `task` axis value and use the caller-supplied
56
- * `mode` for the `mode` axis — matching how `formatEntryKey` is computed
57
- * at producer emit time.
58
- */
59
- function splitTaskVariant(taskId) {
60
- const match = /\s*\((gold|baseline)\)\s*$/i.exec(taskId);
61
- if (!match)
62
- return { task: taskId, variant: null };
63
- return {
64
- task: taskId.slice(0, match.index).trim(),
65
- variant: match[1].toLowerCase(),
66
- };
67
- }
68
53
  function slimJudgments(full, defaultMode) {
69
54
  const descriptor = ARTIFACT_REGISTRY.graderJudgments;
70
55
  const formatKey = descriptor.formatEntryKey;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * ConsoleProgressReporter — the default terminal adapter for the
3
+ * ProgressReporter port (W0053).
4
+ *
5
+ * Renders phase-start / phase-progress / phase-complete as human-readable
6
+ * lines on stdout. Progress lines are rate-limited so a high-frequency
7
+ * producer (one event per artifact) never floods the terminal; the adapter
8
+ * flushes at most once per `tickMs` (default 1s) and always flushes the
9
+ * final line on `phaseComplete`.
10
+ *
11
+ * `verbose: true` emits one line per item — matches the CLI's existing
12
+ * `--verbose` semantics without re-rendering the rate-limited line twice.
13
+ */
14
+ import type { PhaseCompleteEvent, PhaseProgressEvent, PhaseStartEvent, ProgressReporter } from "../../_vendor/ailf-core/index.d.ts";
15
+ export interface ConsoleProgressReporterOptions {
16
+ /** Minimum interval between rate-limited progress lines, in ms. Default 1000. */
17
+ tickMs?: number;
18
+ /** Emit one line per item rather than rate-limiting. */
19
+ verbose?: boolean;
20
+ /** Sink for output (tests override). Defaults to `console.log`. */
21
+ write?: (line: string) => void;
22
+ /** Clock (tests override). Defaults to `Date.now`. */
23
+ now?: () => number;
24
+ }
25
+ export declare class ConsoleProgressReporter implements ProgressReporter {
26
+ private readonly tickMs;
27
+ private readonly verbose;
28
+ private readonly write;
29
+ private readonly now;
30
+ private readonly phases;
31
+ constructor(options?: ConsoleProgressReporterOptions);
32
+ phaseStart(event: PhaseStartEvent): void;
33
+ phaseProgress(event: PhaseProgressEvent): void;
34
+ phaseComplete(event: PhaseCompleteEvent): void;
35
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * ConsoleProgressReporter — the default terminal adapter for the
3
+ * ProgressReporter port (W0053).
4
+ *
5
+ * Renders phase-start / phase-progress / phase-complete as human-readable
6
+ * lines on stdout. Progress lines are rate-limited so a high-frequency
7
+ * producer (one event per artifact) never floods the terminal; the adapter
8
+ * flushes at most once per `tickMs` (default 1s) and always flushes the
9
+ * final line on `phaseComplete`.
10
+ *
11
+ * `verbose: true` emits one line per item — matches the CLI's existing
12
+ * `--verbose` semantics without re-rendering the rate-limited line twice.
13
+ */
14
+ export class ConsoleProgressReporter {
15
+ tickMs;
16
+ verbose;
17
+ write;
18
+ now;
19
+ phases = new Map();
20
+ constructor(options = {}) {
21
+ this.tickMs = options.tickMs ?? 1000;
22
+ this.verbose = options.verbose ?? false;
23
+ this.write = options.write ?? ((line) => console.log(line));
24
+ this.now = options.now ?? (() => Date.now());
25
+ }
26
+ phaseStart(event) {
27
+ this.phases.set(event.phaseId, {
28
+ phaseId: event.phaseId,
29
+ label: event.label,
30
+ totalItems: event.totalItems,
31
+ totalBytes: event.totalBytes,
32
+ startedAt: event.startedAt,
33
+ itemsCompleted: 0,
34
+ bytesCompleted: 0,
35
+ lastFlushAt: event.startedAt,
36
+ });
37
+ const total = event.totalItems !== undefined ? ` (${event.totalItems} items)` : "";
38
+ const detail = event.detail ? ` → ${event.detail}` : "";
39
+ this.write("");
40
+ this.write(` ➜ ${event.label}${detail}${total}…`);
41
+ }
42
+ phaseProgress(event) {
43
+ const state = this.phases.get(event.phaseId);
44
+ if (!state)
45
+ return;
46
+ state.itemsCompleted += event.items ?? 1;
47
+ state.bytesCompleted += event.bytes ?? 0;
48
+ if (this.verbose) {
49
+ const item = event.label ?? `item ${state.itemsCompleted}`;
50
+ const bytes = event.bytes ?? 0;
51
+ this.write(` · ${progressPrefix(state)} ${item} (${formatBytes(bytes)})`);
52
+ return;
53
+ }
54
+ const now = this.now();
55
+ if (now - state.lastFlushAt < this.tickMs)
56
+ return;
57
+ state.lastFlushAt = now;
58
+ this.write(` ${renderProgressLine(state, now)}`);
59
+ }
60
+ phaseComplete(event) {
61
+ const state = this.phases.get(event.phaseId);
62
+ // Prefer the adapter's running totals — we've been counting each batch
63
+ // event anyway, and most producers call `phaseComplete` without knowing
64
+ // cumulative numbers themselves. Fall back to the event fields when the
65
+ // reporter has no state (e.g. first phaseComplete without a phaseStart).
66
+ const itemsCompleted = Math.max(state?.itemsCompleted ?? 0, event.itemsCompleted);
67
+ const bytesCompleted = Math.max(state?.bytesCompleted ?? 0, event.bytesCompleted);
68
+ const durationMs = event.durationMs;
69
+ const label = state?.label ?? event.phaseId;
70
+ const summary = event.summary ??
71
+ `${itemsCompleted} item${itemsCompleted === 1 ? "" : "s"} · ${formatBytes(bytesCompleted)} · ${formatDuration(durationMs)} elapsed`;
72
+ this.write(` ✓ ${label} — ${summary}`);
73
+ this.phases.delete(event.phaseId);
74
+ }
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers
78
+ // ---------------------------------------------------------------------------
79
+ /**
80
+ * Shared prefix used on every rate-limited and verbose progress line so the
81
+ * context is visible without having to scroll up to the phase header.
82
+ */
83
+ function progressPrefix(state) {
84
+ return `${state.label}:`;
85
+ }
86
+ function renderProgressLine(state, now) {
87
+ const elapsed = formatDuration(now - state.startedAt);
88
+ const itemsPart = state.totalItems !== undefined
89
+ ? `${state.itemsCompleted}/${state.totalItems}`
90
+ : String(state.itemsCompleted);
91
+ return `${progressPrefix(state)} ${itemsPart} · ${formatBytes(state.bytesCompleted)} · ${elapsed} elapsed`;
92
+ }
93
+ function formatBytes(bytes) {
94
+ if (bytes < 1024)
95
+ return `${bytes} B`;
96
+ if (bytes < 1024 * 1024)
97
+ return `${(bytes / 1024).toFixed(1)} KB`;
98
+ if (bytes < 1024 * 1024 * 1024)
99
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
100
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
101
+ }
102
+ function formatDuration(ms) {
103
+ if (ms < 1000)
104
+ return `${ms}ms`;
105
+ if (ms < 60_000)
106
+ return `${(ms / 1000).toFixed(1)}s`;
107
+ const minutes = Math.floor(ms / 60_000);
108
+ const seconds = Math.round((ms % 60_000) / 1000);
109
+ return `${minutes}m${seconds}s`;
110
+ }
@@ -8,7 +8,7 @@
8
8
  * Endpoints:
9
9
  * - Bulk: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/{type}/upload-url
10
10
  * - Per-entry: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/{type}/{entryKey}/upload-url
11
- * - Manifest: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/manifest/upload-url
11
+ * - Manifest: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/upload-url
12
12
  *
13
13
  * ## W0049 API surface
14
14
  *
@@ -28,6 +28,7 @@
28
28
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md
29
29
  */
30
30
  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 UploadMetricsSink } from "./upload-metrics.js";
31
32
  export interface ApiGatewayArtifactWriterOptions {
32
33
  /** Base URL of the API gateway (e.g., "https://ailf-api.sanity.build"). */
33
34
  apiBaseUrl: string;
@@ -35,9 +36,15 @@ export interface ApiGatewayArtifactWriterOptions {
35
36
  apiKey: string;
36
37
  /** GCS bucket name — included in the returned ArtifactRef. */
37
38
  bucket: string;
39
+ /**
40
+ * W0056 spike — optional metrics sink that receives per-phase timing events.
41
+ * Defaults to a no-op so the hot path stays free when metrics are off.
42
+ */
43
+ metrics?: UploadMetricsSink;
38
44
  }
39
45
  export declare class ApiGatewayArtifactWriter implements ArtifactWriter {
40
46
  private readonly options;
47
+ private readonly metrics;
41
48
  constructor(options: ApiGatewayArtifactWriterOptions);
42
49
  emit<T extends ArtifactType>(type: T, association: AssociationValues, payload: unknown): Promise<ArtifactRef | null>;
43
50
  appendNdjson(): Promise<ArtifactRef | null>;
@@ -8,7 +8,7 @@
8
8
  * Endpoints:
9
9
  * - Bulk: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/{type}/upload-url
10
10
  * - Per-entry: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/{type}/{entryKey}/upload-url
11
- * - Manifest: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/manifest/upload-url
11
+ * - Manifest: GET {apiBaseUrl}/v1/runs/{runId}/artifacts/upload-url
12
12
  *
13
13
  * ## W0049 API surface
14
14
  *
@@ -28,10 +28,13 @@
28
28
  * @see docs/decisions/D0033-unified-run-anchored-artifact-capture.md
29
29
  */
30
30
  import { ARTIFACT_REGISTRY, NotImplementedError, } from "../_vendor/ailf-core/index.js";
31
+ import { NO_OP_UPLOAD_METRICS, } from "./upload-metrics.js";
31
32
  export class ApiGatewayArtifactWriter {
32
33
  options;
34
+ metrics;
33
35
  constructor(options) {
34
36
  this.options = options;
37
+ this.metrics = options.metrics ?? NO_OP_UPLOAD_METRICS;
35
38
  }
36
39
  // ---- Canonical W0049 API ------------------------------------------------
37
40
  async emit(type, association, payload) {
@@ -46,12 +49,13 @@ export class ApiGatewayArtifactWriter {
46
49
  return this.putJson(uploadUrlPath, payload, {
47
50
  layout: "bulk",
48
51
  entryCount: entryCountOf(payload),
52
+ type,
49
53
  });
50
54
  }
51
55
  // per-entry
52
56
  const entryKey = descriptor.formatEntryKey(association);
53
57
  const uploadUrlPath = `/v1/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(type)}/${encodeURIComponent(entryKey)}/upload-url`;
54
- const result = await this.putJsonRaw(uploadUrlPath, payload);
58
+ const result = await this.putJsonRaw(uploadUrlPath, payload, type);
55
59
  if (!result)
56
60
  return null;
57
61
  return {
@@ -72,8 +76,11 @@ export class ApiGatewayArtifactWriter {
72
76
  "the batch route.");
73
77
  }
74
78
  async writeManifest(runId, manifest) {
75
- const uploadUrlPath = `/v1/runs/${encodeURIComponent(runId)}/artifacts/manifest/upload-url`;
76
- return this.putJson(uploadUrlPath, manifest, { layout: "bulk" });
79
+ const uploadUrlPath = `/v1/runs/${encodeURIComponent(runId)}/artifacts/upload-url`;
80
+ return this.putJson(uploadUrlPath, manifest, {
81
+ layout: "bulk",
82
+ type: "manifest",
83
+ });
77
84
  }
78
85
  // ---- Deprecated legacy surface (W0052) ----------------------------------
79
86
  /** @deprecated Use `emit()` instead. */
@@ -82,6 +89,7 @@ export class ApiGatewayArtifactWriter {
82
89
  return this.putJson(uploadUrlPath, data, {
83
90
  layout: "bulk",
84
91
  entryCount: entryCountOf(data),
92
+ type,
85
93
  });
86
94
  }
87
95
  /** @deprecated Use `emit()` per entry instead. */
@@ -101,7 +109,7 @@ export class ApiGatewayArtifactWriter {
101
109
  continue;
102
110
  }
103
111
  const uploadUrlPath = `/v1/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(type)}/${encodeURIComponent(entry.key)}/upload-url`;
104
- const result = await this.putJsonRaw(uploadUrlPath, entry.data);
112
+ const result = await this.putJsonRaw(uploadUrlPath, entry.data, type);
105
113
  if (!result)
106
114
  continue;
107
115
  bucket = result.bucket;
@@ -122,7 +130,7 @@ export class ApiGatewayArtifactWriter {
122
130
  }
123
131
  // ---- Internals ----------------------------------------------------------
124
132
  async putJson(uploadUrlPath, data, meta) {
125
- const result = await this.putJsonRaw(uploadUrlPath, data);
133
+ const result = await this.putJsonRaw(uploadUrlPath, data, meta.type);
126
134
  if (!result)
127
135
  return null;
128
136
  return {
@@ -134,23 +142,38 @@ export class ApiGatewayArtifactWriter {
134
142
  layout: meta.layout,
135
143
  };
136
144
  }
137
- async putJsonRaw(uploadUrlPath, data) {
145
+ async putJsonRaw(uploadUrlPath, data, type) {
138
146
  const json = JSON.stringify(data);
139
147
  const bytes = Buffer.byteLength(json, "utf-8");
140
148
  try {
141
- const signed = await this.fetchSignedUrl(uploadUrlPath);
149
+ const signed = await this.fetchSignedUrl(uploadUrlPath, type);
142
150
  if (!signed)
143
151
  return null;
144
- const putRes = await fetch(signed.url, {
145
- body: json,
146
- headers: signed.requiredHeaders,
147
- method: "PUT",
148
- });
149
- if (!putRes.ok) {
150
- console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${putRes.status} ${putRes.statusText}`);
151
- return null;
152
+ const putStart = Date.now();
153
+ let putSuccess = false;
154
+ try {
155
+ const putRes = await fetch(signed.url, {
156
+ body: json,
157
+ headers: signed.requiredHeaders,
158
+ method: "PUT",
159
+ });
160
+ if (!putRes.ok) {
161
+ console.warn(` ⚠️ Artifact upload failed (non-blocking): ${signed.path} — GCS PUT ${putRes.status} ${putRes.statusText}`);
162
+ return null;
163
+ }
164
+ putSuccess = true;
165
+ return { bucket: signed.bucket, path: signed.path, bytes };
166
+ }
167
+ finally {
168
+ this.metrics.record({
169
+ phase: "put",
170
+ writer: "ApiGatewayArtifactWriter",
171
+ type,
172
+ ms: Date.now() - putStart,
173
+ bytes,
174
+ success: putSuccess,
175
+ });
152
176
  }
153
- return { bucket: signed.bucket, path: signed.path, bytes };
154
177
  }
155
178
  catch (err) {
156
179
  const message = err instanceof Error ? err.message : String(err);
@@ -158,33 +181,47 @@ export class ApiGatewayArtifactWriter {
158
181
  return null;
159
182
  }
160
183
  }
161
- async fetchSignedUrl(uploadUrlPath) {
162
- const url = `${this.options.apiBaseUrl.replace(/\/$/, "")}${uploadUrlPath}`;
163
- const res = await fetch(url, {
164
- headers: { Authorization: `Bearer ${this.options.apiKey}` },
165
- method: "GET",
166
- });
167
- if (!res.ok) {
168
- console.warn(` ⚠️ Signed-URL request failed: ${res.status} ${res.statusText}`);
169
- return null;
184
+ async fetchSignedUrl(uploadUrlPath, type) {
185
+ const start = Date.now();
186
+ let success = false;
187
+ try {
188
+ const url = `${this.options.apiBaseUrl.replace(/\/$/, "")}${uploadUrlPath}`;
189
+ const res = await fetch(url, {
190
+ headers: { Authorization: `Bearer ${this.options.apiKey}` },
191
+ method: "GET",
192
+ });
193
+ if (!res.ok) {
194
+ console.warn(` ⚠️ Signed-URL request failed: ${res.status} ${res.statusText}`);
195
+ return null;
196
+ }
197
+ const body = (await res.json());
198
+ if (body.object !== "signed_upload_url" ||
199
+ typeof body.url !== "string" ||
200
+ typeof body.path !== "string" ||
201
+ typeof body.bucket !== "string" ||
202
+ !body.requiredHeaders) {
203
+ console.warn(` ⚠️ Signed-URL response was malformed`);
204
+ return null;
205
+ }
206
+ success = true;
207
+ return {
208
+ bucket: body.bucket,
209
+ method: "PUT",
210
+ object: "signed_upload_url",
211
+ path: body.path,
212
+ requiredHeaders: body.requiredHeaders,
213
+ url: body.url,
214
+ };
170
215
  }
171
- const body = (await res.json());
172
- if (body.object !== "signed_upload_url" ||
173
- typeof body.url !== "string" ||
174
- typeof body.path !== "string" ||
175
- typeof body.bucket !== "string" ||
176
- !body.requiredHeaders) {
177
- console.warn(` ⚠️ Signed-URL response was malformed`);
178
- return null;
216
+ finally {
217
+ this.metrics.record({
218
+ phase: "sign",
219
+ writer: "ApiGatewayArtifactWriter",
220
+ type,
221
+ ms: Date.now() - start,
222
+ success,
223
+ });
179
224
  }
180
- return {
181
- bucket: body.bucket,
182
- method: "PUT",
183
- object: "signed_upload_url",
184
- path: body.path,
185
- requiredHeaders: body.requiredHeaders,
186
- url: body.url,
187
- };
188
225
  }
189
226
  }
190
227
  function entryCountOf(data) {