@hue-run/sdk 0.16.0 → 0.18.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";
6
- import { TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
5
+ import { digestPart, hashInlineFiles, hashNestedInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, isMessageKey, keepsPrefix, literalKeys, mediaBytes, mediaContentType, mediaIn, isTextMediaType, mayInlineFiles, isMediaContentType, MAX_INLINE_FILE_TEXT, placeable, placedMediaText, prefixPart, readMediaText, reencodeMedia, } from "./inline-files.js";
6
+ import { capLogBody, 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,28 @@ 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
  };
56
+ /** The `hue.blobs` key of a staged file: its part's for an attachment placed in a value, else
57
+ * its `hue.file` event's. */
58
+ export function mediaKey(file) {
59
+ return file.key ?? `event:hue.file:${file.digest.sha256}`;
60
+ }
58
61
  export function newTally() {
59
62
  return { uploaded: 0, fallbacks: new Map() };
60
63
  }
61
- function countFallback(tally, reason, traceId) {
64
+ export function countFallback(tally, reason, traceId) {
62
65
  const entry = tally.fallbacks.get(reason) ?? { count: 0, traceIds: new Set() };
63
66
  entry.count++;
64
67
  entry.traceIds.add(traceId);
65
68
  tally.fallbacks.set(reason, entry);
66
69
  }
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!#$&^_.+-]+(?: *;[ -~]*)?$/;
70
+ /** A media type accepted by the upload contract. */
70
71
  export function isBlobContentType(value) {
71
- return (typeof value === "string" &&
72
- value.length >= 3 &&
73
- value.length <= 255 &&
74
- CONTENT_TYPE.test(value));
72
+ return isMediaContentType(value);
75
73
  }
76
74
  function isBlobKey(key) {
77
75
  if (key.length < 1 || key.length > MAX_BLOB_KEY_LENGTH)
@@ -93,16 +91,6 @@ export function textContentType(text) {
93
91
  ? "application/json"
94
92
  : "text/plain; charset=utf-8";
95
93
  }
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
94
  export function blobPrefix(value) {
107
95
  if (typeof value !== "string")
108
96
  return Uint8Array.prototype.slice.call(value, 0, BLOB_PREFIX_BYTES);
@@ -149,8 +137,14 @@ export async function digestOf(value) {
149
137
  return { sha256: hash.digest("hex"), size };
150
138
  }
151
139
  /** 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 });
140
+ export function blobEntry(key, sha256, size, contentType, name) {
141
+ return JSON.stringify({
142
+ key,
143
+ sha256,
144
+ size,
145
+ content_type: contentType,
146
+ ...(name ? { name } : {}),
147
+ });
154
148
  }
155
149
  /** HTTP header names (tokens) and values without line breaks or NUL. */
156
150
  const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
@@ -189,6 +183,7 @@ export class BlobUploader {
189
183
  running = 0;
190
184
  waiting = [];
191
185
  requests = new Set();
186
+ outcomes = new Map();
192
187
  constructor(options) {
193
188
  this.options = options;
194
189
  }
@@ -206,6 +201,7 @@ export class BlobUploader {
206
201
  for (const request of this.requests)
207
202
  request.destroy();
208
203
  this.requests.clear();
204
+ this.outcomes.clear();
209
205
  for (const run of this.waiting.splice(0)) {
210
206
  this.running++;
211
207
  run();
@@ -244,9 +240,49 @@ export class BlobUploader {
244
240
  }
245
241
  /**
246
242
  * Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
247
- * completed. The caller holds a {@link limit} slot.
243
+ * completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
244
+ * trace is already storing shares that upload's outcome without one, so repeated files never
245
+ * hold the slots new files need. The caller must not hold a slot.
248
246
  */
249
247
  async store(traceId, key, value, contentType, digest, upload) {
248
+ // Every occurrence is validated even when a previous occurrence already uploaded the bytes.
249
+ if (!isBlobKey(key) ||
250
+ !isBlobContentType(contentType) ||
251
+ !/^[0-9a-f]{32}$/.test(traceId) ||
252
+ /^0+$/.test(traceId) ||
253
+ !/^[0-9a-f]{64}$/.test(digest.sha256) ||
254
+ !Number.isSafeInteger(digest.size) ||
255
+ digest.size <= 0 ||
256
+ digest.size !== sizeOf(value))
257
+ return { ok: false, reason: "failed" };
258
+ if (digest.size > MAX_BLOB_BYTES)
259
+ return { ok: false, reason: "tooLarge" };
260
+ const identity = `${traceId}:${digest.sha256}`;
261
+ const known = this.outcomes.get(identity);
262
+ if (known)
263
+ return known.promise;
264
+ if (this.outcomes.size >= 4096) {
265
+ const oldest = [...this.outcomes].find(([, item]) => item.settled)?.[0];
266
+ if (oldest === undefined)
267
+ return { ok: false, reason: "budget" };
268
+ this.outcomes.delete(oldest);
269
+ }
270
+ const item = {
271
+ promise: Promise.resolve({ ok: false, reason: "failed" }),
272
+ settled: false,
273
+ };
274
+ this.outcomes.set(identity, item);
275
+ item.promise = this.limit(() => this.storeFresh(traceId, key, value, contentType, digest, upload))
276
+ .catch(() => ({ ok: false, reason: "failed" }))
277
+ .then((outcome) => {
278
+ item.settled = true;
279
+ if (!outcome.ok && this.outcomes.get(identity) === item)
280
+ this.outcomes.delete(identity);
281
+ return outcome;
282
+ });
283
+ return item.promise;
284
+ }
285
+ async storeFresh(traceId, key, value, contentType, digest, upload) {
250
286
  const refused = this.refusal();
251
287
  if (refused)
252
288
  return { ok: false, reason: refused };
@@ -295,10 +331,13 @@ export class BlobUploader {
295
331
  }
296
332
  if ("fallback" in reservation)
297
333
  return { ok: false, reason: reservation.fallback };
298
- if (!reservation.exists)
334
+ if (!reservation.exists) {
299
335
  // For Hue's accounting only: a value is read whether or not it was completed, so a failed
300
336
  // 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);
337
+ 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);
338
+ if (!completion || completion.status >= 500 || completion.status === 429)
339
+ this.pause();
340
+ }
302
341
  return { ok: true, contentType: reservation.contentType };
303
342
  }
304
343
  jsonHeaders() {
@@ -323,7 +362,7 @@ export class BlobUploader {
323
362
  return { fallback: "failed" };
324
363
  }
325
364
  const { status } = reply;
326
- const answer = status === 200 || status === 404 ? parseJson(reply.body) : undefined;
365
+ const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
327
366
  // A receiver without the route answers its framework's not-found (Hue's own 404, an
328
367
  // archived project, is JSON with an `error`), refuses the method, or answers something
329
368
  // that is no reservation at all: it is not asked again for a while.
@@ -342,12 +381,15 @@ export class BlobUploader {
342
381
  this.pause();
343
382
  return reservation;
344
383
  }
384
+ if (status === 409 && isObject(answer) && answer.status === "exists")
385
+ return this.reservation(answer, size);
345
386
  if (status === 413)
346
387
  return { fallback: "tooLarge" };
347
388
  if (status === 429) {
348
389
  const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
349
390
  if (Date.now() + wait < upload.deadline && (await upload.wait(wait)))
350
391
  continue;
392
+ this.pause();
351
393
  return { fallback: "failed" };
352
394
  }
353
395
  if (status >= 500) {
@@ -357,13 +399,12 @@ export class BlobUploader {
357
399
  this.pause();
358
400
  return { fallback: "failed" };
359
401
  }
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)
402
+ // Bad values and conflicts refuse only this value. Transient service failures pause uploads.
403
+ if (status !== 400 && status !== 409)
364
404
  this.pause();
365
405
  return { fallback: "failed" };
366
406
  }
407
+ this.pause();
367
408
  return { fallback: "failed" };
368
409
  }
369
410
  async delay(millis, upload) {
@@ -433,7 +474,7 @@ export class BlobUploader {
433
474
  return "stored";
434
475
  if (reply.status === 403)
435
476
  return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
436
- if (reply.status >= 500 || reply.status === 408)
477
+ if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
437
478
  return "transient";
438
479
  return "failed";
439
480
  }
@@ -536,13 +577,25 @@ export class BlobUploader {
536
577
  }
537
578
  }
538
579
  /**
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
580
+ * A span's own attributes with each candidate placed: binary media becomes a digest, while
581
+ * text media over 64 KiB retains a 16 KiB prefix after upload or a digest on failure.
582
+ * A value still over the inline limit is uploaded and replaced by its first
542
583
  * 16 KiB (or cut to the limit), each upload listed under `hue.blobs`, each whole value cut or
543
584
  * uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
544
585
  */
545
586
  export async function offloadAttributes(attributes, collector, options) {
587
+ return (await offloadRecord(attributes, collector, options)).attributes;
588
+ }
589
+ /**
590
+ * A record's own attributes, and a log record's body, with each candidate placed (see
591
+ * {@link offloadAttributes}). A file is uploaded and placed in its digest form (a text file in a
592
+ * provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed `<attribute>#<pointer>` or, in
593
+ * a log record's body, `body#<pointer>`. A file that is not uploaded keeps its bytes inline, except
594
+ * in a span's own recorded messages, where it keeps its digest as before. A log record's values
595
+ * over the inline limit are cut, never uploaded whole, and a body still over it is replaced by the
596
+ * receiver's marker, as before.
597
+ */
598
+ export async function offloadRecord(attributes, collector, options) {
546
599
  const { uploader, traceId, valueBytes, upload, tally } = options;
547
600
  const result = { ...attributes };
548
601
  const existing = Array.isArray(attributes[BLOBS_KEY])
@@ -558,59 +611,103 @@ export async function offloadAttributes(attributes, collector, options) {
558
611
  for (let index = 0; index < collector.cut.length; index++)
559
612
  fallback(uploader.unsupported() ? "unsupported" : "budget");
560
613
  let inspected = 0;
561
- 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)
568
- return text;
569
- if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
570
- return text;
571
- inspected += text.length;
572
- let parsed;
573
- let found;
574
- try {
575
- parsed = JSON.parse(text);
576
- found = largeInlineFileParts(parsed);
577
- }
578
- catch {
579
- return text;
580
- }
581
- if (!found.length)
582
- return text;
614
+ /** Uploads each file of a value and places it; whether any was placed. A file that is not
615
+ * uploaded keeps its bytes inline, except in a recorded message: a span's own message keeps its
616
+ * digest, and a log record's keeps the digest of a file over the inline limit, as nested
617
+ * messages kept them before log records uploaded. */
618
+ const placeFiles = async (index, key, files) => {
619
+ const message = isMessageKey(key);
583
620
  let changed = false;
584
- await Promise.all(found.map((file, position) => {
585
- const partKey = `${key}#${file.pointer}`;
621
+ await Promise.all(files.map((file, position) => {
622
+ const decoded = mediaBytes(file);
623
+ if (!placeable(file, decoded))
624
+ return Promise.resolve();
625
+ const contentType = mediaContentType(file, decoded);
626
+ const textual = isTextMediaType(contentType);
627
+ if (textual && decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
628
+ return Promise.resolve();
629
+ const prefix = keepsPrefix(file, textual);
630
+ const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
586
631
  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
- }
594
- changed = true;
595
- const digest = await digestOf(decoded.bytes);
632
+ return (async () => {
633
+ const digest = options.memo
634
+ ? options.memo.digest(file, decoded, traceId)
635
+ : await digestOf(decoded.bytes);
596
636
  const outcome = slot
597
- ? await uploader.store(traceId, partKey, decoded.bytes, fileContentType(file.part, decoded), digest, upload)
637
+ ? await uploader.store(traceId, partKey, decoded.bytes, contentType, digest, upload)
598
638
  : { ok: false, reason: "budget" };
599
639
  if (outcome.ok) {
600
- file.part[file.key] = blobPrefix(file.content);
640
+ if (prefix)
641
+ prefixPart(file, blobPrefix(file.content));
642
+ else
643
+ digestPart(file, digest);
644
+ changed = true;
601
645
  entries.push({
602
646
  order: [index, position],
603
- entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType),
647
+ entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
604
648
  });
605
649
  tally.uploaded++;
606
650
  }
607
651
  else {
608
- digestPart(file.part, file.key, digest);
652
+ if (message && (!collector.log || decoded.bytes.byteLength > INLINE_FILE_LIMIT)) {
653
+ digestPart(file, digest);
654
+ changed = true;
655
+ }
609
656
  fallback(outcome.reason);
610
657
  }
611
- });
658
+ })();
612
659
  }));
613
- return changed ? JSON.stringify(parsed) : text;
660
+ return changed;
661
+ };
662
+ const literal = literalKeys(attributes);
663
+ const parts = async (index, key, text) => {
664
+ // Both bounds count UTF-16 code units, as admission does. Literal text holds no file.
665
+ if (!mayInlineFiles(key, text) || text.length > MAX_INLINE_FILE_TEXT || literal.has(key))
666
+ return text;
667
+ if (collector.inspected) {
668
+ // A value admission skipped stays as recorded.
669
+ if (!collector.inspected.has(key))
670
+ return text;
671
+ }
672
+ else {
673
+ if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
674
+ return text;
675
+ inspected += text.length;
676
+ }
677
+ let media;
678
+ try {
679
+ media = readMediaText(key, text);
680
+ }
681
+ catch {
682
+ return text;
683
+ }
684
+ if (!media?.files.length)
685
+ return text;
686
+ return (await placeFiles(index, key, media.files)) ? placedMediaText(media) : text;
687
+ };
688
+ /** A log record's body with its files placed. Each file's content counts toward the record's
689
+ * inspection bounds, as an attribute's text does. */
690
+ const placeBody = async (body) => {
691
+ let media;
692
+ try {
693
+ media = mediaIn(body);
694
+ }
695
+ catch {
696
+ return body;
697
+ }
698
+ const files = media.files.filter((file) => {
699
+ // A string the queue cut holds only the start of its file: it stays as cut.
700
+ if (collector.bodyCut?.has(file.content))
701
+ return false;
702
+ const units = file.content.length;
703
+ if (units > MAX_INLINE_FILE_TEXT || inspected + units > INLINE_FILE_TEXT_PER_RECORD)
704
+ return false;
705
+ inspected += units;
706
+ return true;
707
+ });
708
+ if (await placeFiles(-1, "body", files))
709
+ reencodeMedia(media);
710
+ return media.box.value;
614
711
  };
615
712
  const place = async (index, { key, value }) => {
616
713
  const placed = typeof value === "string" ? await parts(index, key, value) : value;
@@ -621,23 +718,26 @@ export async function offloadAttributes(attributes, collector, options) {
621
718
  }
622
719
  listed.push({ order: index, key });
623
720
  const cut = () => typeof placed === "string" ? truncateUtf8(placed, valueBytes) : truncatedMarker(size);
721
+ // A log record's value is cut, as no log reader pages an uploaded text value.
722
+ if (collector.log) {
723
+ result[key] = cut();
724
+ return;
725
+ }
624
726
  if (slots <= 0) {
625
727
  result[key] = cut();
626
728
  fallback("budget");
627
729
  return;
628
730
  }
629
731
  slots--;
630
- const stored = await uploader.limit(async () => {
732
+ const stored = await (async () => {
631
733
  if (size > MAX_BLOB_BYTES)
632
734
  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);
735
+ // Hashing a large value takes a slot as its upload does; the upload takes its own.
736
+ const digest = await uploader.limit(() => digestOf(placed));
637
737
  const contentType = typeof placed === "string" ? textContentType(placed) : "application/octet-stream";
638
738
  const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
639
739
  return outcome.ok ? { ...outcome, digest } : outcome;
640
- });
740
+ })();
641
741
  if (stored.ok) {
642
742
  result[key] = typeof placed === "string" ? blobPrefix(placed) : blobPrefix(placed);
643
743
  entries.push({
@@ -651,14 +751,42 @@ export async function offloadAttributes(attributes, collector, options) {
651
751
  fallback(stored.reason);
652
752
  }
653
753
  };
754
+ const body = collector.body ? placeBody(collector.body.value) : undefined;
654
755
  await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
756
+ await Promise.all((collector.attachments ?? []).map((file, position) => {
757
+ if (!file.bytes.byteLength)
758
+ return Promise.resolve();
759
+ const slot = slots > 0 ? (slots--, true) : false;
760
+ return (async () => {
761
+ const key = mediaKey(file);
762
+ const outcome = slot
763
+ ? await uploader.store(traceId, key, file.bytes, file.contentType, file.digest, upload)
764
+ : { ok: false, reason: "budget" };
765
+ if (outcome.ok) {
766
+ entries.push({
767
+ order: [collector.candidates.length, position],
768
+ entry: blobEntry(key, file.digest.sha256, file.digest.size, outcome.contentType, file.name),
769
+ });
770
+ tally.uploaded++;
771
+ }
772
+ else
773
+ fallback(outcome.reason);
774
+ })();
775
+ }));
776
+ let placedBody;
777
+ if (body) {
778
+ const capped = capLogBody(await body, valueBytes);
779
+ placedBody = capped.body;
780
+ if (capped.cut)
781
+ listed.push({ order: -1, key: "body" });
782
+ }
655
783
  if (listed.length)
656
784
  result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed.sort((a, b) => a.order - b.order).map(({ key }) => key));
657
785
  if (entries.length) {
658
786
  entries.sort((a, b) => a.order[0] - b.order[0] || a.order[1] - b.order[1]);
659
787
  result[BLOBS_KEY] = [...existing, ...entries.map(({ entry }) => entry)];
660
788
  }
661
- return result;
789
+ return { attributes: result, ...(collector.body ? { body: placedBody } : {}) };
662
790
  }
663
791
  /**
664
792
  * The span's own attributes with every candidate placed as it was before uploads existed, for an
@@ -666,9 +794,24 @@ export async function offloadAttributes(attributes, collector, options) {
666
794
  * over the inline limit cut to it (bytes replaced by the receiver's marker) and listed under
667
795
  * `hue.truncated`.
668
796
  */
669
- export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId) {
797
+ export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId, memo) {
798
+ return fallbackRecord(attributes, collector, valueBytes, tally, traceId, memo).attributes;
799
+ }
800
+ /** A record's attributes, and a log record's body, placed as {@link fallbackAttributes} places
801
+ * them: a log record's files keep their bytes inline, and its body over the inline limit is
802
+ * replaced by the receiver's marker, as before. */
803
+ export function fallbackRecord(attributes, collector, valueBytes, tally, traceId, memo) {
670
804
  const result = { ...attributes };
671
805
  const listed = [];
806
+ let body;
807
+ if (collector.body) {
808
+ const capped = capLogBody(collector.body.value, valueBytes);
809
+ body = capped.body;
810
+ if (capped.cut)
811
+ listed.push("body");
812
+ }
813
+ for (const _file of collector.attachments ?? [])
814
+ countFallback(tally, "failed", traceId);
672
815
  for (let index = 0; index < collector.cut.length; index++)
673
816
  countFallback(tally, "failed", traceId);
674
817
  for (const { key, value } of collector.candidates) {
@@ -676,28 +819,36 @@ export function fallbackAttributes(attributes, collector, valueBytes, tally, tra
676
819
  // Each large inline file is reported as a value not uploaded, as the upload step reports it.
677
820
  const count = { files: 0 };
678
821
  try {
679
- placed = hashInlineFiles(key, value, count);
822
+ // A log record's files stay inline, its messages' files over the inline limit as their
823
+ // digest, as nested messages kept them before log records uploaded.
824
+ if (collector.log)
825
+ placed = hashNestedInlineFiles(key, value, count);
826
+ else if (!collector.inspected || collector.inspected.has(key))
827
+ placed = hashInlineFiles(key, value, count, memo, traceId);
680
828
  }
681
829
  catch {
682
830
  // Left as it is: cut below when over the limit.
683
831
  }
684
832
  for (let file = 0; file < count.files; file++)
685
833
  countFallback(tally, "failed", traceId);
834
+ // A log record's value over the limit is cut whether or not uploads work.
686
835
  if (typeof placed === "string") {
687
836
  if (Buffer.byteLength(placed, "utf8") > valueBytes) {
688
837
  placed = truncateUtf8(placed, valueBytes);
689
838
  listed.push(key);
690
- countFallback(tally, "failed", traceId);
839
+ if (!collector.log)
840
+ countFallback(tally, "failed", traceId);
691
841
  }
692
842
  }
693
843
  else if (placed instanceof Uint8Array && placed.byteLength > valueBytes) {
694
844
  placed = truncatedMarker(placed.byteLength);
695
845
  listed.push(key);
696
- countFallback(tally, "failed", traceId);
846
+ if (!collector.log)
847
+ countFallback(tally, "failed", traceId);
697
848
  }
698
849
  result[key] = placed;
699
850
  }
700
851
  if (listed.length)
701
852
  result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed);
702
- return result;
853
+ return { attributes: result, ...(collector.body ? { body } : {}) };
703
854
  }
@@ -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") {