@hue-run/sdk 0.16.0 → 0.17.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/dist/blobs.js CHANGED
@@ -2,19 +2,17 @@ import { createHash } from "node:crypto";
2
2
  import { context } from "@opentelemetry/api";
3
3
  import { suppressTracing } from "@opentelemetry/core";
4
4
  import { isLoopbackHost } from "./config.js";
5
- import { digestPart, hashInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, inlineFileBytes, isMessageKey, largeInlineFileParts, MAX_INLINE_FILE_TEXT, } from "./inline-files.js";
5
+ import { digestPart, hashInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, mediaBytes, mediaContentType, isTextMediaType, mayInlineFiles, isMediaContentType, largeInlineFileParts, MAX_INLINE_FILE_TEXT, } from "./inline-files.js";
6
6
  import { TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
7
7
  import { truncateUtf8 } from "./safety.js";
8
8
  /**
9
- * Values larger than Hue's inline limit travel apart from their span: the SDK uploads the value's
10
- * bytes straight to the project's evidence store through a presigned PUT that Hue answers at
11
- * `POST /api/v1/otlp/blobs`, keeps the value's first 16 KiB in the attribute it replaced, and
12
- * lists the value in the span attribute `hue.blobs`. A value that cannot be uploaded is exported
13
- * as it was before uploads existed: cut to the inline limit and listed under `hue.truncated` (an
14
- * inline file as its digest), reported as a warning and counted.
9
+ * Large values and binary media travel apart from their span through reservation, presigned PUT,
10
+ * and completion. Text keeps a 16 KiB prefix. Binary media keeps digest metadata on its part.
11
+ * Successful uploads are listed in `hue.blobs`; failures keep inline fallback or file metadata,
12
+ * are reported as warnings, and are counted.
15
13
  */
16
14
  /** The span attribute listing the values the SDK uploaded apart from the span: one compact JSON
17
- * object per value, `{"key", "sha256", "size", "content_type"}`, as Hue reads it. */
15
+ * object per value, `{"key", "sha256", "size", "content_type"}`, with an optional `name`. */
18
16
  export const BLOBS_KEY = "hue.blobs";
19
17
  /** The largest value one upload may hold (`Hue-Max-Blob-Bytes`): a larger value is cut inline. */
20
18
  export const MAX_BLOB_BYTES = 1_000_000_000;
@@ -50,28 +48,23 @@ export function uploadBudgetMillis(timeoutMillis) {
50
48
  }
51
49
  /** The fixed description of each fallback, as an export issue reports it. */
52
50
  export const FALLBACK_MESSAGES = {
53
- unsupported: "This Hue server does not accept uploaded values; values over its inline limit were exported cut to it (inline files as their digest)",
54
- failed: "Values over Hue's inline limit could not be uploaded; they were exported cut to it (inline files as their digest)",
55
- tooLarge: "Values over Hue's 1 GB upload limit were exported cut to the inline limit",
56
- budget: "Values over Hue's inline limit did not fit the SDK's upload budget; they were exported cut to it (inline files as their digest)",
51
+ unsupported: "This Hue server does not accept uploaded values; large values were cut inline and media kept digest metadata",
52
+ failed: "Content could not be uploaded; large values were cut inline and media kept digest metadata",
53
+ tooLarge: "Content over its upload limit kept inline fallback or file metadata",
54
+ budget: "Content did not fit the SDK's upload budget; large values were cut inline and media kept digest metadata",
57
55
  };
58
56
  export function newTally() {
59
57
  return { uploaded: 0, fallbacks: new Map() };
60
58
  }
61
- function countFallback(tally, reason, traceId) {
59
+ export function countFallback(tally, reason, traceId) {
62
60
  const entry = tally.fallbacks.get(reason) ?? { count: 0, traceIds: new Set() };
63
61
  entry.count++;
64
62
  entry.traceIds.add(traceId);
65
63
  tally.fallbacks.set(reason, entry);
66
64
  }
67
- /** A media type Hue accepts for a value: `type/subtype` with optional parameters, 3 to 255
68
- * printable ASCII characters. */
69
- const CONTENT_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/;
65
+ /** A media type accepted by the upload contract. */
70
66
  export function isBlobContentType(value) {
71
- return (typeof value === "string" &&
72
- value.length >= 3 &&
73
- value.length <= 255 &&
74
- CONTENT_TYPE.test(value));
67
+ return isMediaContentType(value);
75
68
  }
76
69
  function isBlobKey(key) {
77
70
  if (key.length < 1 || key.length > MAX_BLOB_KEY_LENGTH)
@@ -93,16 +86,6 @@ export function textContentType(text) {
93
86
  ? "application/json"
94
87
  : "text/plain; charset=utf-8";
95
88
  }
96
- /** The media type an inline file is stored under: the part's own (`mime_type`, `mediaType`), its
97
- * `data:` URL's, or else bytes, or UTF-8 text for content that was not encoded. */
98
- function fileContentType(part, file) {
99
- for (const declared of [part.mime_type, part.mimeType, part.mediaType, part.media_type])
100
- if (isBlobContentType(declared))
101
- return declared;
102
- if (isBlobContentType(file.urlMediaType))
103
- return file.urlMediaType;
104
- return file.decoded ? "application/octet-stream" : "text/plain; charset=utf-8";
105
- }
106
89
  export function blobPrefix(value) {
107
90
  if (typeof value !== "string")
108
91
  return Uint8Array.prototype.slice.call(value, 0, BLOB_PREFIX_BYTES);
@@ -149,8 +132,14 @@ export async function digestOf(value) {
149
132
  return { sha256: hash.digest("hex"), size };
150
133
  }
151
134
  /** The `hue.blobs` element for one uploaded value. */
152
- function blobEntry(key, sha256, size, contentType) {
153
- return JSON.stringify({ key, sha256, size, content_type: contentType });
135
+ export function blobEntry(key, sha256, size, contentType, name) {
136
+ return JSON.stringify({
137
+ key,
138
+ sha256,
139
+ size,
140
+ content_type: contentType,
141
+ ...(name ? { name } : {}),
142
+ });
154
143
  }
155
144
  /** HTTP header names (tokens) and values without line breaks or NUL. */
156
145
  const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
@@ -189,6 +178,7 @@ export class BlobUploader {
189
178
  running = 0;
190
179
  waiting = [];
191
180
  requests = new Set();
181
+ outcomes = new Map();
192
182
  constructor(options) {
193
183
  this.options = options;
194
184
  }
@@ -206,6 +196,7 @@ export class BlobUploader {
206
196
  for (const request of this.requests)
207
197
  request.destroy();
208
198
  this.requests.clear();
199
+ this.outcomes.clear();
209
200
  for (const run of this.waiting.splice(0)) {
210
201
  this.running++;
211
202
  run();
@@ -244,9 +235,49 @@ export class BlobUploader {
244
235
  }
245
236
  /**
246
237
  * Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
247
- * completed. The caller holds a {@link limit} slot.
238
+ * completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
239
+ * trace is already storing shares that upload's outcome without one, so repeated files never
240
+ * hold the slots new files need. The caller must not hold a slot.
248
241
  */
249
242
  async store(traceId, key, value, contentType, digest, upload) {
243
+ // Every occurrence is validated even when a previous occurrence already uploaded the bytes.
244
+ if (!isBlobKey(key) ||
245
+ !isBlobContentType(contentType) ||
246
+ !/^[0-9a-f]{32}$/.test(traceId) ||
247
+ /^0+$/.test(traceId) ||
248
+ !/^[0-9a-f]{64}$/.test(digest.sha256) ||
249
+ !Number.isSafeInteger(digest.size) ||
250
+ digest.size <= 0 ||
251
+ digest.size !== sizeOf(value))
252
+ return { ok: false, reason: "failed" };
253
+ if (digest.size > MAX_BLOB_BYTES)
254
+ return { ok: false, reason: "tooLarge" };
255
+ const identity = `${traceId}:${digest.sha256}`;
256
+ const known = this.outcomes.get(identity);
257
+ if (known)
258
+ return known.promise;
259
+ if (this.outcomes.size >= 4096) {
260
+ const oldest = [...this.outcomes].find(([, item]) => item.settled)?.[0];
261
+ if (oldest === undefined)
262
+ return { ok: false, reason: "budget" };
263
+ this.outcomes.delete(oldest);
264
+ }
265
+ const item = {
266
+ promise: Promise.resolve({ ok: false, reason: "failed" }),
267
+ settled: false,
268
+ };
269
+ this.outcomes.set(identity, item);
270
+ item.promise = this.limit(() => this.storeFresh(traceId, key, value, contentType, digest, upload))
271
+ .catch(() => ({ ok: false, reason: "failed" }))
272
+ .then((outcome) => {
273
+ item.settled = true;
274
+ if (!outcome.ok && this.outcomes.get(identity) === item)
275
+ this.outcomes.delete(identity);
276
+ return outcome;
277
+ });
278
+ return item.promise;
279
+ }
280
+ async storeFresh(traceId, key, value, contentType, digest, upload) {
250
281
  const refused = this.refusal();
251
282
  if (refused)
252
283
  return { ok: false, reason: refused };
@@ -295,10 +326,13 @@ export class BlobUploader {
295
326
  }
296
327
  if ("fallback" in reservation)
297
328
  return { ok: false, reason: reservation.fallback };
298
- if (!reservation.exists)
329
+ if (!reservation.exists) {
299
330
  // For Hue's accounting only: a value is read whether or not it was completed, so a failed
300
331
  // completion is not retried.
301
- await this.request("POST", new URL("/api/v1/otlp/blobs/complete", this.options.baseUrl), this.jsonHeaders(), JSON.stringify({ traceId, sha256: digest.sha256 }), Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
332
+ const completion = await this.request("POST", new URL("/api/v1/otlp/blobs/complete", this.options.baseUrl), this.jsonHeaders(), JSON.stringify({ traceId, sha256: digest.sha256 }), Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
333
+ if (!completion || completion.status >= 500 || completion.status === 429)
334
+ this.pause();
335
+ }
302
336
  return { ok: true, contentType: reservation.contentType };
303
337
  }
304
338
  jsonHeaders() {
@@ -323,7 +357,7 @@ export class BlobUploader {
323
357
  return { fallback: "failed" };
324
358
  }
325
359
  const { status } = reply;
326
- const answer = status === 200 || status === 404 ? parseJson(reply.body) : undefined;
360
+ const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
327
361
  // A receiver without the route answers its framework's not-found (Hue's own 404, an
328
362
  // archived project, is JSON with an `error`), refuses the method, or answers something
329
363
  // that is no reservation at all: it is not asked again for a while.
@@ -342,12 +376,15 @@ export class BlobUploader {
342
376
  this.pause();
343
377
  return reservation;
344
378
  }
379
+ if (status === 409 && isObject(answer) && answer.status === "exists")
380
+ return this.reservation(answer, size);
345
381
  if (status === 413)
346
382
  return { fallback: "tooLarge" };
347
383
  if (status === 429) {
348
384
  const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
349
385
  if (Date.now() + wait < upload.deadline && (await upload.wait(wait)))
350
386
  continue;
387
+ this.pause();
351
388
  return { fallback: "failed" };
352
389
  }
353
390
  if (status >= 500) {
@@ -357,13 +394,12 @@ export class BlobUploader {
357
394
  this.pause();
358
395
  return { fallback: "failed" };
359
396
  }
360
- // A conflict over this value alone (409) fails only it. Any other refusal (a refused key,
361
- // an archived project, a request Hue could not read, a redirect) would meet every value
362
- // alike, so uploads pause; the export's own requests report a key problem.
363
- if (status !== 409)
397
+ // Bad values and conflicts refuse only this value. Transient service failures pause uploads.
398
+ if (status !== 400 && status !== 409)
364
399
  this.pause();
365
400
  return { fallback: "failed" };
366
401
  }
402
+ this.pause();
367
403
  return { fallback: "failed" };
368
404
  }
369
405
  async delay(millis, upload) {
@@ -433,7 +469,7 @@ export class BlobUploader {
433
469
  return "stored";
434
470
  if (reply.status === 403)
435
471
  return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
436
- if (reply.status >= 500 || reply.status === 408)
472
+ if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
437
473
  return "transient";
438
474
  return "failed";
439
475
  }
@@ -536,9 +572,9 @@ export class BlobUploader {
536
572
  }
537
573
  }
538
574
  /**
539
- * A span's own attributes with each candidate placed: an inline file part over 64 KiB in a
540
- * recorded message uploaded and replaced by its first 16 KiB (or by its digest, as before, when
541
- * it could not be), and a value still over the inline limit uploaded and replaced by its first
575
+ * A span's own attributes with each candidate placed: binary media becomes a digest, while
576
+ * text media over 64 KiB retains a 16 KiB prefix after upload or a digest on failure.
577
+ * A value still over the inline limit is uploaded and replaced by its first
542
578
  * 16 KiB (or cut to the limit), each upload listed under `hue.blobs`, each whole value cut or
543
579
  * uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
544
580
  */
@@ -559,16 +595,19 @@ export async function offloadAttributes(attributes, collector, options) {
559
595
  fallback(uploader.unsupported() ? "unsupported" : "budget");
560
596
  let inspected = 0;
561
597
  const parts = async (index, key, text) => {
562
- if (!isMessageKey(key) ||
563
- text.length * 3 <= INLINE_FILE_LIMIT ||
564
- !(text.includes('"blob"') || text.includes('"file"')))
565
- return text;
566
- const bytes = Buffer.byteLength(text, "utf8");
567
- if (bytes <= INLINE_FILE_LIMIT || bytes > MAX_INLINE_FILE_TEXT)
598
+ // Both bounds count UTF-16 code units, as admission does.
599
+ if (!mayInlineFiles(key, text) || text.length > MAX_INLINE_FILE_TEXT)
568
600
  return text;
569
- if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
570
- return text;
571
- inspected += text.length;
601
+ if (collector.inspected) {
602
+ // A message admission skipped stays as recorded.
603
+ if (!collector.inspected.has(key))
604
+ return text;
605
+ }
606
+ else {
607
+ if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
608
+ return text;
609
+ inspected += text.length;
610
+ }
572
611
  let parsed;
573
612
  let found;
574
613
  try {
@@ -582,33 +621,39 @@ export async function offloadAttributes(attributes, collector, options) {
582
621
  return text;
583
622
  let changed = false;
584
623
  await Promise.all(found.map((file, position) => {
585
- const partKey = `${key}#${file.pointer}`;
624
+ const decoded = mediaBytes(file);
625
+ if (!decoded.bytes.byteLength)
626
+ return Promise.resolve();
627
+ const contentType = mediaContentType(file, decoded);
628
+ const textual = isTextMediaType(contentType);
629
+ if (textual && decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
630
+ return Promise.resolve();
631
+ const partKey = `${key}#${textual ? file.pointer : file.partPointer}`;
586
632
  const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
587
- return uploader.limit(async () => {
588
- const decoded = inlineFileBytes(file.content);
589
- if (decoded.bytes.byteLength <= INLINE_FILE_LIMIT) {
590
- if (slot)
591
- slots++;
592
- return;
593
- }
633
+ return (async () => {
594
634
  changed = true;
595
- const digest = await digestOf(decoded.bytes);
635
+ const digest = options.memo
636
+ ? options.memo.digest(file, decoded, traceId)
637
+ : await digestOf(decoded.bytes);
596
638
  const outcome = slot
597
- ? await uploader.store(traceId, partKey, decoded.bytes, fileContentType(file.part, decoded), digest, upload)
639
+ ? await uploader.store(traceId, partKey, decoded.bytes, contentType, digest, upload)
598
640
  : { ok: false, reason: "budget" };
599
641
  if (outcome.ok) {
600
- file.part[file.key] = blobPrefix(file.content);
642
+ if (textual)
643
+ file.owner[file.key] = blobPrefix(file.content);
644
+ else
645
+ digestPart(file, digest);
601
646
  entries.push({
602
647
  order: [index, position],
603
- entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType),
648
+ entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
604
649
  });
605
650
  tally.uploaded++;
606
651
  }
607
652
  else {
608
- digestPart(file.part, file.key, digest);
653
+ digestPart(file, digest);
609
654
  fallback(outcome.reason);
610
655
  }
611
- });
656
+ })();
612
657
  }));
613
658
  return changed ? JSON.stringify(parsed) : text;
614
659
  };
@@ -627,17 +672,15 @@ export async function offloadAttributes(attributes, collector, options) {
627
672
  return;
628
673
  }
629
674
  slots--;
630
- const stored = await uploader.limit(async () => {
675
+ const stored = await (async () => {
631
676
  if (size > MAX_BLOB_BYTES)
632
677
  return { ok: false, reason: "tooLarge" };
633
- const refused = uploader.refusal();
634
- if (refused)
635
- return { ok: false, reason: refused };
636
- const digest = await digestOf(placed);
678
+ // Hashing a large value takes a slot as its upload does; the upload takes its own.
679
+ const digest = await uploader.limit(() => digestOf(placed));
637
680
  const contentType = typeof placed === "string" ? textContentType(placed) : "application/octet-stream";
638
681
  const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
639
682
  return outcome.ok ? { ...outcome, digest } : outcome;
640
- });
683
+ })();
641
684
  if (stored.ok) {
642
685
  result[key] = typeof placed === "string" ? blobPrefix(placed) : blobPrefix(placed);
643
686
  entries.push({
@@ -652,6 +695,26 @@ export async function offloadAttributes(attributes, collector, options) {
652
695
  }
653
696
  };
654
697
  await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
698
+ await Promise.all((collector.attachments ?? []).map((file, position) => {
699
+ if (!file.bytes.byteLength)
700
+ return Promise.resolve();
701
+ const slot = slots > 0 ? (slots--, true) : false;
702
+ return (async () => {
703
+ const key = `event:hue.file:${file.digest.sha256}`;
704
+ const outcome = slot
705
+ ? await uploader.store(traceId, key, file.bytes, file.contentType, file.digest, upload)
706
+ : { ok: false, reason: "budget" };
707
+ if (outcome.ok) {
708
+ entries.push({
709
+ order: [collector.candidates.length, position],
710
+ entry: blobEntry(key, file.digest.sha256, file.digest.size, outcome.contentType, file.name),
711
+ });
712
+ tally.uploaded++;
713
+ }
714
+ else
715
+ fallback(outcome.reason);
716
+ })();
717
+ }));
655
718
  if (listed.length)
656
719
  result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed.sort((a, b) => a.order - b.order).map(({ key }) => key));
657
720
  if (entries.length) {
@@ -666,9 +729,11 @@ export async function offloadAttributes(attributes, collector, options) {
666
729
  * over the inline limit cut to it (bytes replaced by the receiver's marker) and listed under
667
730
  * `hue.truncated`.
668
731
  */
669
- export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId) {
732
+ export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId, memo) {
670
733
  const result = { ...attributes };
671
734
  const listed = [];
735
+ for (const _file of collector.attachments ?? [])
736
+ countFallback(tally, "failed", traceId);
672
737
  for (let index = 0; index < collector.cut.length; index++)
673
738
  countFallback(tally, "failed", traceId);
674
739
  for (const { key, value } of collector.candidates) {
@@ -676,7 +741,8 @@ export function fallbackAttributes(attributes, collector, valueBytes, tally, tra
676
741
  // Each large inline file is reported as a value not uploaded, as the upload step reports it.
677
742
  const count = { files: 0 };
678
743
  try {
679
- placed = hashInlineFiles(key, value, count);
744
+ if (!collector.inspected || collector.inspected.has(key))
745
+ placed = hashInlineFiles(key, value, count, memo, traceId);
680
746
  }
681
747
  catch {
682
748
  // Left as it is: cut below when over the limit.
@@ -11,8 +11,9 @@ import type { JsonValue } from "../types.js";
11
11
  * <case dir>/output/ where the command writes what it produced
12
12
  *
13
13
  * Everything under `output/` becomes the execution's generated files. Optional helpers there:
14
- * `manifest.json` (`{ "primary": "<filename>", "output": <json> }`), `result.json` (the JSON
15
- * output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
14
+ * `manifest.json` (`{ "primary": "<filename>", "output": <json>, "contentTypes": { "<path>": "<MIME>" } }`),
15
+ * `result.json` (the JSON output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
16
+ * MP4 and WebM outputs require a contentTypes declaration because either container can hold audio or video.
16
17
  */
17
18
  export declare const directCaseEnvironment: readonly ["HUE_CASE_DIR", "HUE_CASE_INPUTS", "HUE_CASE_OUTPUT_DIR", "HUE_CASE_ID", "HUE_CASE_KEY", "HUE_EXECUTION_ID"];
18
19
  /** Generated-file extensions Hue accepts and the content type recorded for each. */
@@ -14,8 +14,9 @@ import { withFiles } from "../evals/types.js";
14
14
  * <case dir>/output/ where the command writes what it produced
15
15
  *
16
16
  * Everything under `output/` becomes the execution's generated files. Optional helpers there:
17
- * `manifest.json` (`{ "primary": "<filename>", "output": <json> }`), `result.json` (the JSON
18
- * output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
17
+ * `manifest.json` (`{ "primary": "<filename>", "output": <json>, "contentTypes": { "<path>": "<MIME>" } }`),
18
+ * `result.json` (the JSON output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
19
+ * MP4 and WebM outputs require a contentTypes declaration because either container can hold audio or video.
19
20
  */
20
21
  export const directCaseEnvironment = [
21
22
  "HUE_CASE_DIR",
@@ -38,8 +39,25 @@ export const outputExtensions = {
38
39
  ".jpg": "image/jpeg",
39
40
  ".jpeg": "image/jpeg",
40
41
  ".webp": "image/webp",
42
+ ".gif": "image/gif",
43
+ ".avif": "image/avif",
44
+ ".mp3": "audio/mpeg",
45
+ ".mpeg": "audio/mpeg",
46
+ ".wav": "audio/wav",
47
+ ".ogg": "audio/ogg",
48
+ ".m4a": "audio/mp4",
49
+ ".flac": "audio/flac",
50
+ ".opus": "audio/opus",
51
+ ".aac": "audio/aac",
41
52
  };
42
- for (const type of Object.values(outputExtensions))
53
+ const ambiguousOutputExtensions = {
54
+ ".mp4": ["audio/mp4", "video/mp4"],
55
+ ".webm": ["audio/webm", "video/webm"],
56
+ };
57
+ for (const type of [
58
+ ...Object.values(outputExtensions),
59
+ ...Object.values(ambiguousOutputExtensions).flat(),
60
+ ])
43
61
  if (!outputContentTypes.includes(type))
44
62
  throw new Error(`outputExtensions maps to a content type Hue does not accept: ${type}`);
45
63
  const HELPER_FILES = new Set(["manifest.json", "result.json", "output.json"]);
@@ -251,18 +269,43 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput, seam
251
269
  const entries = listed.filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
252
270
  // Directory order differs between filesystems; keep uploads stable.
253
271
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
254
- const unsupported = entries.filter((entry) => !outputExtensions[extname(entry.name).toLowerCase()]);
272
+ const declaredTypes = manifest && typeof manifest === "object" && !Array.isArray(manifest)
273
+ ? manifest.contentTypes
274
+ : undefined;
275
+ if (declaredTypes !== undefined &&
276
+ (!declaredTypes || typeof declaredTypes !== "object" || Array.isArray(declaredTypes)))
277
+ throw new Error("manifest.json contentTypes must map output paths to MIME type strings");
278
+ for (const [name, type] of Object.entries(declaredTypes ?? {})) {
279
+ if (typeof type !== "string" || !outputContentTypes.includes(type))
280
+ throw new Error(`manifest.json declares an unsupported content type for ${name}`);
281
+ if (!entries.some((entry) => entry.name === name))
282
+ throw new Error(`manifest.json declares a content type for a file that was not written: ${name}`);
283
+ }
284
+ const unsupported = entries.filter((entry) => {
285
+ const extension = extname(entry.name).toLowerCase();
286
+ return !outputExtensions[extension] && !ambiguousOutputExtensions[extension];
287
+ });
255
288
  if (unsupported.length)
256
- throw new Error(`The agent wrote files Hue does not accept as generated documents: ${unsupported.map((entry) => entry.name).join(", ")} (accepted: ${Object.keys(outputExtensions).join(" ")})`);
289
+ throw new Error(`The agent wrote files Hue does not accept as generated documents: ${unsupported.map((entry) => entry.name).join(", ")} (accepted: ${[...Object.keys(outputExtensions), ...Object.keys(ambiguousOutputExtensions)].join(" ")})`);
257
290
  const files = [];
258
291
  for (const entry of entries) {
292
+ const extension = extname(entry.name).toLowerCase();
293
+ const declared = declaredTypes && Object.hasOwn(declaredTypes, entry.name)
294
+ ? declaredTypes[entry.name]
295
+ : undefined;
296
+ const contentType = declared ?? outputExtensions[extension];
297
+ const acceptedTypes = ambiguousOutputExtensions[extension] ?? [outputExtensions[extension]];
298
+ if (contentType === undefined)
299
+ throw new Error(`Declare ${entry.name} as ${acceptedTypes.join(" or ")} in manifest.json contentTypes`);
300
+ if (typeof contentType !== "string" || !acceptedTypes.includes(contentType))
301
+ throw new Error(`manifest.json content type for ${entry.name} must be ${acceptedTypes.join(" or ")}`);
259
302
  const bytes = (await readAgentFile(entry.path, `Generated file ${entry.name}`, outputFileLimits.bytes, entry.identity));
260
303
  if (!bytes.byteLength)
261
304
  throw new Error(`The agent wrote an empty file: ${entry.name}`);
262
305
  files.push({
263
306
  bytes,
264
307
  filename: artifactFilename(entry.name),
265
- contentType: outputExtensions[extname(entry.name).toLowerCase()],
308
+ contentType,
266
309
  });
267
310
  }
268
311
  if (typeof declaredPrimary === "string") {
package/dist/client.d.ts CHANGED
@@ -149,13 +149,15 @@ export declare class HueClient {
149
149
  * Adds a `hue.file` event to the active (or given) span for a file the work read, received or
150
150
  * produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
151
151
  * and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
152
- * never exported. The event is metadata, so it is recorded in both capture modes. An invalid
152
+ * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
153
153
  * record, or one without an active span, is omitted and counted, never thrown; an invalid name
154
154
  * alone is omitted and counted while the rest is recorded.
155
155
  */
156
156
  recordFile(file: FileRecord, explicitContext?: Context): void;
157
157
  /** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
158
158
  private truncatedKeys;
159
+ /** Distinguishes requests whose helper events otherwise have identical metadata. */
160
+ private fileEventTimes;
159
161
  /** A content value's bounds: Hue's value cap as the receiver last advertised it. */
160
162
  private contentLimits;
161
163
  /**