@hue-run/sdk 0.18.0 → 0.18.2

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/client.d.ts CHANGED
@@ -166,10 +166,15 @@ export declare class HueClient {
166
166
  /**
167
167
  * Adds a `hue.file` event to the active (or given) span for a file the work read, received or
168
168
  * produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
169
- * and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
170
- * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
171
- * record, or one without an active span, is omitted and counted, never thrown; an invalid name
172
- * alone is omitted and counted while the rest is recorded.
169
+ * and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
170
+ * measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
171
+ * held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
172
+ * hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
173
+ * it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
174
+ * as a named pipe, is refused without waiting on it. The event is metadata, so it is
175
+ * recorded in both capture modes. An invalid record, or one without an active span, is omitted
176
+ * and counted, never thrown; an invalid name alone is omitted and counted while the rest is
177
+ * recorded.
173
178
  */
174
179
  recordFile(file: FileRecord, explicitContext?: Context): void;
175
180
  /** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
package/dist/client.js CHANGED
@@ -13,8 +13,8 @@ import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, provider
13
13
  import { toolCatalogSummary } from "./tool-definitions.js";
14
14
  import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
15
15
  import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
16
- import { countFallback, MAX_BLOB_BYTES, newTally } from "./blobs.js";
17
- import { attachmentPart, attachmentState, holdsAttachment, MAX_ATTACHMENT_DEPTH, } from "./attachment.js";
16
+ import { countFallback, MAX_BLOB_BYTES, newTally, sizeOf } from "./blobs.js";
17
+ import { attachmentPart, attachmentState, fileSource, holdsAttachment, MAX_ATTACHMENT_DEPTH, readPath, } from "./attachment.js";
18
18
  import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
19
19
  import { verifyTrace } from "./receipt.js";
20
20
  import { sdkVersion } from "./version.js";
@@ -611,21 +611,22 @@ export class HueClient {
611
611
  const files = [];
612
612
  const tally = newTally();
613
613
  for (const { state, part, depth } of placed) {
614
- if (!part || !state.bytes || !state.sha256) {
614
+ if (!part || state.size === undefined || !state.sha256) {
615
615
  this.instrumentationFailed(signal);
616
616
  continue;
617
617
  }
618
618
  if (state.invalidName && captureContent)
619
619
  this.instrumentationFailed(signal);
620
- if (!this.transport.offloadsValues() || !state.bytes.byteLength)
620
+ if (!this.transport.offloadsValues() || !state.size)
621
621
  continue;
622
622
  if (cut || depth > MAX_ATTACHMENT_DEPTH) {
623
623
  countFallback(tally, "budget", traceId);
624
624
  continue;
625
625
  }
626
626
  files.push({
627
- bytes: state.bytes,
628
- digest: { sha256: state.sha256, size: state.bytes.byteLength },
627
+ // A file given by path over 25 MiB is read again from disk when it is uploaded.
628
+ bytes: state.file ? fileSource(state.file) : state.bytes,
629
+ digest: { sha256: state.sha256, size: state.size },
629
630
  contentType: state.contentType,
630
631
  ...(state.name === undefined ? {} : { name: state.name }),
631
632
  });
@@ -788,10 +789,15 @@ export class HueClient {
788
789
  /**
789
790
  * Adds a `hue.file` event to the active (or given) span for a file the work read, received or
790
791
  * produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
791
- * and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
792
- * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
793
- * record, or one without an active span, is omitted and counted, never thrown; an invalid name
794
- * alone is omitted and counted while the rest is recorded.
792
+ * and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
793
+ * measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
794
+ * held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
795
+ * hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
796
+ * it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
797
+ * as a named pipe, is refused without waiting on it. The event is metadata, so it is
798
+ * recorded in both capture modes. An invalid record, or one without an active span, is omitted
799
+ * and counted, never thrown; an invalid name alone is omitted and counted while the rest is
800
+ * recorded.
795
801
  */
796
802
  recordFile(file, explicitContext) {
797
803
  if (!this.enabled || this.closed)
@@ -800,7 +806,7 @@ export class HueClient {
800
806
  const span = trace.getSpan(explicitContext ?? this.storage.getStore()?.context ?? context.active());
801
807
  if (!span?.isRecording())
802
808
  throw new Error("File records require an active span");
803
- const { role, mediaType, data, name } = file;
809
+ const { role, mediaType, data, name, path } = file;
804
810
  if (role !== "input" && role !== "attachment" && role !== "output")
805
811
  throw new TypeError("Invalid file role");
806
812
  if (!isLabel(mediaType))
@@ -808,7 +814,31 @@ export class HueClient {
808
814
  let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
809
815
  let byteSize = file.byteSize;
810
816
  let bytes;
811
- if (data !== undefined) {
817
+ // A file given by path over 25 MiB: hashed now and read again from disk when it is uploaded.
818
+ let onDisk;
819
+ let given = data;
820
+ if (path !== undefined) {
821
+ if (data !== undefined)
822
+ throw new TypeError("A file takes data or a path, not both");
823
+ if (typeof path !== "string" || !path)
824
+ throw new TypeError("Invalid file path");
825
+ const read = readPath(path);
826
+ if (!read)
827
+ throw new RangeError("File must be a regular file within Hue's 1 GB upload limit");
828
+ if ("bytes" in read)
829
+ given = read.bytes;
830
+ else {
831
+ onDisk = read.file;
832
+ // A caller-supplied digest or size must describe the same bytes.
833
+ if ((sha256 !== undefined && sha256 !== onDisk.sha256) ||
834
+ (byteSize !== undefined && byteSize !== onDisk.size))
835
+ throw new TypeError("File digest or size does not match its data");
836
+ sha256 = onDisk.sha256;
837
+ byteSize = onDisk.size;
838
+ }
839
+ }
840
+ if (given !== undefined) {
841
+ const data = given;
812
842
  bytes =
813
843
  typeof data === "string"
814
844
  ? (() => {
@@ -888,10 +918,11 @@ export class HueClient {
888
918
  if (eventTime)
889
919
  this.fileEventTimes.set(source, eventTime);
890
920
  if (file.upload === true && this.transport.offloadsValues()) {
891
- if (!this.transport.stageMedia(span, bytes && eventTime
921
+ const upload = bytes ?? (onDisk ? fileSource(onDisk) : undefined);
922
+ if (!this.transport.stageMedia(span, upload && eventTime
892
923
  ? {
893
- bytes,
894
- digest: { sha256, size: bytes.byteLength },
924
+ bytes: upload,
925
+ digest: { sha256, size: sizeOf(upload) },
895
926
  contentType: mediaType,
896
927
  eventTime,
897
928
  ...(isMediaName(name) ? { name } : {}),
@@ -79,6 +79,7 @@ export declare class EvaluationClient {
79
79
  }): Promise<Uint8Array>;
80
80
  /** Stage bytes at the storage capability Hue issued. The Hue key is never sent to storage. */
81
81
  uploadArtifactBytes(upload: ArtifactUpload, bytes: Uint8Array, contentType: string): Promise<void>;
82
+ private putArtifact;
82
83
  private page;
83
84
  private registryPage;
84
85
  /** Reads the current project to confirm the key and origin. */
@@ -106,10 +107,14 @@ export declare class EvaluationClient {
106
107
  * `files` given as bytes or a path is checked against the artifact policy and uploaded as a
107
108
  * verified artifact first, under a key derived from the version, the case's `externalKey`, the
108
109
  * role and the bytes, so sending the same case again reuses the artifact. Every file and the
109
- * case itself are checked before the first upload.
110
+ * case itself are checked before the first upload. A file given by `path` is hashed a slice at
111
+ * a time when it is checked and read again when it is uploaded, one file at a time, so no more
112
+ * than one file is held in memory; only bytes that match the checked SHA-256 are sent.
110
113
  *
111
114
  * @throws TypeError or RangeError for `files` outside the artifact policy, or a case Hue would
112
- * refuse for its fields or size, before any request.
115
+ * refuse for its fields or size, before any request. TypeError when a file given by `path`
116
+ * changed after it was checked, or can no longer be read, before any of its bytes are sent: it
117
+ * is not staged and the case is not written.
113
118
  */
114
119
  addCase(id: string, input: CaseWrite): Promise<{
115
120
  /** The stored case. */
@@ -402,17 +402,29 @@ export class EvaluationClient {
402
402
  }
403
403
  }
404
404
  /** Stage bytes at the storage capability Hue issued. The Hue key is never sent to storage. */
405
- async uploadArtifactBytes(upload, bytes, contentType) {
405
+ uploadArtifactBytes(upload, bytes, contentType) {
406
+ return this.putArtifact(upload, contentType, bytes);
407
+ }
408
+ /** @internal Stage `byteSize` bytes read from `body` a slice at a time, without the copy a
409
+ * byte body is given; the request declares their length, as it does for bytes. */
410
+ uploadArtifactStream(upload, body, byteSize, contentType) {
411
+ return this.putArtifact(upload, contentType, body, byteSize);
412
+ }
413
+ async putArtifact(upload, contentType, body, byteSize) {
406
414
  const uploadUrl = signedUploadUrl(upload.uploadUrl);
407
415
  if (upload.method !== "PUT")
408
416
  throw new HueApiError();
409
417
  const headers = signedUploadHeaders(upload.headers, contentType);
418
+ // Storage refuses a body of unknown length, which a stream would otherwise be sent as.
419
+ if (byteSize !== undefined)
420
+ headers["content-length"] = String(byteSize);
410
421
  let response;
411
422
  try {
412
423
  response = await fetch(uploadUrl, {
413
424
  method: "PUT",
414
425
  headers,
415
- body: bytes,
426
+ body,
427
+ ...(byteSize !== undefined ? { duplex: "half" } : {}),
416
428
  redirect: "error",
417
429
  signal: AbortSignal.timeout(Math.max(this.timeoutMillis, 120_000)),
418
430
  });
@@ -480,10 +492,14 @@ export class EvaluationClient {
480
492
  * `files` given as bytes or a path is checked against the artifact policy and uploaded as a
481
493
  * verified artifact first, under a key derived from the version, the case's `externalKey`, the
482
494
  * role and the bytes, so sending the same case again reuses the artifact. Every file and the
483
- * case itself are checked before the first upload.
495
+ * case itself are checked before the first upload. A file given by `path` is hashed a slice at
496
+ * a time when it is checked and read again when it is uploaded, one file at a time, so no more
497
+ * than one file is held in memory; only bytes that match the checked SHA-256 are sent.
484
498
  *
485
499
  * @throws TypeError or RangeError for `files` outside the artifact policy, or a case Hue would
486
- * refuse for its fields or size, before any request.
500
+ * refuse for its fields or size, before any request. TypeError when a file given by `path`
501
+ * changed after it was checked, or can no longer be read, before any of its bytes are sent: it
502
+ * is not staged and the case is not written.
487
503
  */
488
504
  async addCase(id, input) {
489
505
  // The canonical spelling: the reservation keys of uploaded files name the version.
@@ -426,15 +426,22 @@ export async function uploadOutputFiles(client, executionId, files, save, timing
426
426
  }
427
427
  /** Reserve, stage and verify one artifact; its ID once Hue verified it. Replaying the key settles
428
428
  * on the same artifact, including one Hue finished verifying after an earlier attempt stopped. */
429
- async function publishArtifact(client, declared, bytes, label, timing) {
429
+ async function publishArtifact(client, declared, body, label, timing) {
430
430
  const reserved = await client.reserveArtifact(declared);
431
431
  let state = reserved.state;
432
432
  if (state !== "ready") {
433
433
  // A verification already running holds the uploaded bytes; Hue refuses another upload then.
434
434
  if (reserved.copyState !== "acknowledged" && state !== "verifying") {
435
+ // A checked file is read again, whole, only now and only if it still has the bytes its
436
+ // check hashed: a file that changed sends nothing.
437
+ const bytes = body instanceof Uint8Array ? body : await checkedBytes(body);
435
438
  const upload = await client.requestArtifactUpload(reserved.id);
436
439
  try {
437
- await client.uploadArtifactBytes(upload, bytes, declared.contentType);
440
+ if (body instanceof Uint8Array)
441
+ await client.uploadArtifactBytes(upload, bytes, declared.contentType);
442
+ // Sent from the write's own buffer a slice at a time, never copied whole.
443
+ else
444
+ await client.uploadArtifactStream(upload, slicesOf(bytes), bytes.byteLength, declared.contentType);
438
445
  }
439
446
  catch {
440
447
  // A lost staging acknowledgement or an immutable object already present can only be
@@ -447,6 +454,59 @@ async function publishArtifact(client, declared, bytes, label, timing) {
447
454
  throw new Error(`${label} was not verified by Hue (${state})`);
448
455
  return reserved.id;
449
456
  }
457
+ /** How much of a case file given by path its check reads at once, to hash it. */
458
+ const CASE_FILE_SLICE = 1024 * 1024;
459
+ /** Bytes as a stream of 1 MiB views of them, made as the request reads them. */
460
+ function slicesOf(bytes) {
461
+ let offset = 0;
462
+ return new ReadableStream({
463
+ pull(controller) {
464
+ if (offset >= bytes.byteLength)
465
+ return controller.close();
466
+ const end = Math.min(bytes.byteLength, offset + CASE_FILE_SLICE);
467
+ controller.enqueue(bytes.subarray(offset, end));
468
+ offset = end;
469
+ },
470
+ }, { highWaterMark: 0 });
471
+ }
472
+ /**
473
+ * The bytes of a checked case file, read whole when it is uploaded: case files are uploaded one
474
+ * at a time, into one buffer the write reuses, so no more than one is held in memory. Only bytes
475
+ * with the checked size and SHA-256 are returned, so the bytes sent are the bytes the check
476
+ * hashed; a file that changed after its check, or can no longer be read, is refused with a
477
+ * `TypeError` before anything is sent. The bytes are valid until the next file is read.
478
+ */
479
+ async function checkedBytes(file) {
480
+ const changed = () => new TypeError(`${file.label} changed after it was checked`);
481
+ const unreadable = (error) => new TypeError(`${file.label} could not be read`, { cause: error });
482
+ const handle = await open(file.path, "r").catch((error) => {
483
+ throw unreadable(error);
484
+ });
485
+ try {
486
+ const read = (buffer, offset) => handle
487
+ .read(buffer, offset, buffer.byteLength - offset, null)
488
+ .then(({ bytesRead }) => bytesRead)
489
+ .catch((error) => {
490
+ throw unreadable(error);
491
+ });
492
+ // Never more than its checked size and one byte, the byte that shows it grew.
493
+ if ((file.scratch.buffer?.byteLength ?? 0) < file.byteSize)
494
+ file.scratch.buffer = Buffer.allocUnsafe(file.byteSize);
495
+ const bytes = file.scratch.buffer.subarray(0, file.byteSize);
496
+ for (let offset = 0; offset < bytes.byteLength;) {
497
+ const bytesRead = await read(bytes, offset);
498
+ if (!bytesRead)
499
+ throw changed();
500
+ offset += bytesRead;
501
+ }
502
+ if ((await read(Buffer.alloc(1), 0)) || sha256(bytes) !== file.sha256)
503
+ throw changed();
504
+ return bytes;
505
+ }
506
+ finally {
507
+ await handle.close().catch(() => undefined);
508
+ }
509
+ }
450
510
  /** Roles a pinned case input can carry. */
451
511
  const caseFileRoles = [
452
512
  ...targetFileRoles,
@@ -466,8 +526,9 @@ function caseFileName(name) {
466
526
  throw new TypeError("A case file needs a well-formed filename of 1–255 UTF-16 code units without path separators or control characters");
467
527
  return name;
468
528
  }
469
- /** The bytes of a case file given by path: a regular file within the size limit, read once. */
470
- async function caseFileBytes(path, label) {
529
+ /** A case file given by path, checked: a regular file within the size limit, hashed a slice at a
530
+ * time so it is never held whole. Its emptiness is the caller's check, as for bytes. */
531
+ async function checkedFile(path, label, scratch) {
471
532
  if (typeof path !== "string" || !path)
472
533
  throw new TypeError(`${label} needs bytes or a path`);
473
534
  // A path with no file behind it, or one that cannot be read, is refused as any invalid file.
@@ -479,9 +540,32 @@ async function caseFileBytes(path, label) {
479
540
  throw new TypeError(`${label} is not a regular file`);
480
541
  if (info.size > outputFileLimits.bytes)
481
542
  throw new RangeError(`${label} exceeds 25 MiB`);
482
- return readFile(path).catch((error) => {
543
+ const handle = await open(path, "r").catch((error) => {
483
544
  throw unreadable(error);
484
545
  });
546
+ try {
547
+ const hash = createHash("sha256");
548
+ const slice = Buffer.allocUnsafe(CASE_FILE_SLICE);
549
+ let byteSize = 0;
550
+ for (;;) {
551
+ const { bytesRead } = await handle
552
+ .read(slice, 0, slice.byteLength, null)
553
+ .catch((error) => {
554
+ throw unreadable(error);
555
+ });
556
+ if (!bytesRead)
557
+ break;
558
+ byteSize += bytesRead;
559
+ // A file that grew past the limit since its size was read is refused, not read whole.
560
+ if (byteSize > outputFileLimits.bytes)
561
+ throw new RangeError(`${label} exceeds 25 MiB`);
562
+ hash.update(slice.subarray(0, bytesRead));
563
+ }
564
+ return { path, label, byteSize, sha256: hash.digest("hex"), scratch };
565
+ }
566
+ finally {
567
+ await handle.close().catch(() => undefined);
568
+ }
485
569
  }
486
570
  /** A stand-in for an artifact a case file is uploaded as, the length of a real ID. */
487
571
  const STAND_IN_ID = "00000000-0000-4000-8000-000000000000";
@@ -502,6 +586,7 @@ export async function caseFileReferences(client, versionId, input, beforeUpload
502
586
  throw new RangeError(`A case pins at most ${MAX_CASE_FILES} files`);
503
587
  const prepared = [];
504
588
  const seen = new Set();
589
+ const scratch = {};
505
590
  for (const [index, file] of files.entries()) {
506
591
  const label = `Case file ${index + 1}`;
507
592
  if (!file || typeof file !== "object")
@@ -530,18 +615,21 @@ export async function caseFileReferences(client, versionId, input, beforeUpload
530
615
  throw new TypeError(`${label} names bytes and a path; give one`);
531
616
  if (file.bytes !== undefined && !(file.bytes instanceof Uint8Array))
532
617
  throw new TypeError(`${label} bytes must be a Uint8Array`);
533
- const bytes = file.bytes ?? (await caseFileBytes(file.path, label));
534
- if (!bytes.byteLength)
618
+ // Bytes are kept as given; a path is kept as its size and digest, and read again to upload.
619
+ const body = file.bytes ?? (await checkedFile(file.path, label, scratch));
620
+ const byteSize = body instanceof Uint8Array ? body.byteLength : body.byteSize;
621
+ if (!byteSize)
535
622
  throw new RangeError(`${label} is empty`);
536
- if (bytes.byteLength > outputFileLimits.bytes)
623
+ if (byteSize > outputFileLimits.bytes)
537
624
  throw new RangeError(`${label} exceeds 25 MiB`);
538
- const digest = sha256(bytes);
625
+ const digest = body instanceof Uint8Array ? sha256(body) : body.sha256;
539
626
  prepared.push({
540
627
  role: file.role,
541
628
  filename,
542
629
  contentType: file.contentType,
543
- bytes,
630
+ byteSize,
544
631
  sha256: digest,
632
+ body,
545
633
  });
546
634
  identity = `file:${digest}:${file.contentType}:${filename}`;
547
635
  }
@@ -574,9 +662,9 @@ export async function caseFileReferences(client, versionId, input, beforeUpload
574
662
  idempotencyKey: `hue-sdk:case:${versionId}:${caseKey}:${item.role}:${item.sha256}:${sha256(Buffer.from(item.filename)).slice(0, 16)}:${sha256(Buffer.from(item.contentType)).slice(0, 16)}`,
575
663
  filename: item.filename,
576
664
  contentType: item.contentType,
577
- byteSize: item.bytes.byteLength,
665
+ byteSize: item.byteSize,
578
666
  sha256: item.sha256,
579
- }, item.bytes, `Case file ${item.filename}`, timing);
667
+ }, item.body, `Case file ${item.filename}`, timing);
580
668
  pin({ artifactId, role: item.role });
581
669
  }
582
670
  return references;
@@ -38,8 +38,12 @@ export declare class MediaFetchError extends Error {
38
38
  /** HTTP status of the answer that failed, when there was one. */
39
39
  status?: number | undefined,
40
40
  /** Seconds Hue asked the caller to wait, on `unavailable`, when it said. */
41
- retryAfterSeconds?: number | undefined, options?: {
41
+ retryAfterSeconds?: number | undefined,
42
+ /** `cause`, the error behind a failure; `snapshot`, set when a snapshot's copy was read, so a
43
+ * `forbidden` message names the Read and write key it needs. */
44
+ options?: {
42
45
  cause?: unknown;
46
+ snapshot?: boolean;
43
47
  });
44
48
  }
45
49
  /** One recorded file, downloaded and checked against its digest. */
@@ -11,6 +11,8 @@ const messages = {
11
11
  digest_mismatch: "The downloaded bytes do not match the requested SHA-256",
12
12
  failed: "The media request failed",
13
13
  };
14
+ /** A snapshot's copies take only a Read and write key, where a trace's files take a Read key too. */
15
+ const snapshotForbidden = "The key cannot read a snapshot's files; use a Read and write key";
14
16
  /** Thrown by {@link MediaClient.fetch}. The message is fixed and never includes response text. */
15
17
  export class MediaFetchError extends Error {
16
18
  reason;
@@ -22,8 +24,16 @@ export class MediaFetchError extends Error {
22
24
  /** HTTP status of the answer that failed, when there was one. */
23
25
  status,
24
26
  /** Seconds Hue asked the caller to wait, on `unavailable`, when it said. */
25
- retryAfterSeconds, options) {
26
- super(reason === "failed" && status ? `${messages.failed} (HTTP ${status})` : messages[reason], options);
27
+ retryAfterSeconds,
28
+ /** `cause`, the error behind a failure; `snapshot`, set when a snapshot's copy was read, so a
29
+ * `forbidden` message names the Read and write key it needs. */
30
+ options) {
31
+ const { snapshot, ...rest } = options ?? {};
32
+ super(reason === "failed" && status
33
+ ? `${messages.failed} (HTTP ${status})`
34
+ : reason === "forbidden" && snapshot
35
+ ? snapshotForbidden
36
+ : messages[reason], rest);
27
37
  this.reason = reason;
28
38
  this.status = status;
29
39
  this.retryAfterSeconds = retryAfterSeconds;
@@ -354,7 +364,9 @@ export class MediaClient {
354
364
  }
355
365
  if (status === 401 || status === 403) {
356
366
  await response.body?.cancel().catch(() => undefined);
357
- throw new MediaFetchError("forbidden", status);
367
+ throw new MediaFetchError("forbidden", status, undefined, {
368
+ snapshot: path.startsWith("/trace-snapshots/"),
369
+ });
358
370
  }
359
371
  if (!response.ok) {
360
372
  await response.body?.cancel().catch(() => undefined);
@@ -186,9 +186,10 @@ export interface CaseFileReference {
186
186
  }
187
187
  /**
188
188
  * A pinned input file for {@link CaseWrite.files}: a {@link CaseFileReference}, or a file the
189
- * client uploads as a verified artifact, from `bytes` or read once from `path`. An uploaded file
190
- * must have one of `outputContentTypes`, a well-formed name of 1–255 UTF-16 code units without
191
- * path separators or control characters, and 1 byte to 25 MiB.
189
+ * client uploads as a verified artifact, from `bytes` or read from `path`: hashed a slice at a
190
+ * time when the case is checked, then read again as it is uploaded, one file at a time. An
191
+ * uploaded file must have one of `outputContentTypes`, a well-formed name of 1–255 UTF-16 code
192
+ * units without path separators or control characters, and 1 byte to 25 MiB.
192
193
  */
193
194
  export type CaseFileWrite = (CaseFileReference & {
194
195
  /** Not set on an artifact reference: the artifact keeps its own name. */
@@ -27,6 +27,9 @@ export interface InlineFileBytes {
27
27
  bytes: Buffer;
28
28
  decoded: boolean;
29
29
  urlMediaType?: string;
30
+ /** The bytes were decoded from text that reads as base64 by its alphabet alone, not from a
31
+ * `data:` URL or a field its convention declares base64. */
32
+ bareBase64?: boolean;
30
33
  }
31
34
  /**
32
35
  * The bytes an inline file part carries, the file's own bytes whatever its media type: a `data:`
@@ -134,14 +137,21 @@ export declare function placedMediaText(media: MediaValue): string;
134
137
  /** Each JSON-encoded string a placed file sits in, written again, innermost first: the string as
135
138
  * recorded with only its placed files changed. */
136
139
  export declare function reencodeMedia(media: MediaValue): void;
137
- /** Whether a found file has bytes to place: nonempty, and a whole string's `data:` URL decodes. */
138
- export declare function placeable(file: InlineFilePart, decoded: InlineFileBytes): boolean;
140
+ /** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
141
+ * URL decodes. */
142
+ export declare function placeable(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded">, size: number): boolean;
139
143
  /** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
140
- * provider part, whose entry names the content) rather than its digest. A whole string and a
141
- * file inside a JSON-encoded string always take the digest form. */
142
- export declare function keepsPrefix(file: InlineFilePart, textual: boolean): boolean;
144
+ * provider part, whose entry names the content) rather than its digest. A whole string, a file
145
+ * inside a JSON-encoded string, and a text file a part holds as bare base64 (a GenAI `blob`,
146
+ * Vertex `media` or AI SDK part, whose content is not the text itself) take the digest form. */
147
+ export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: Pick<InlineFileBytes, "bareBase64">): boolean;
148
+ /** A file's bytes: a field its convention declares base64 decoded, any other content read as
149
+ * Hue's reader reads it, whatever the part's media type (`inlineFileBytes`): a `data:` URL by its
150
+ * own encoding, text that reads as base64 decoded (what the GenAI conventions, and
151
+ * `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
152
+ * as its UTF-8. */
143
153
  export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
144
- export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
154
+ export declare function mediaContentType(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded" | "urlMediaType">): string;
145
155
  export interface FileDigest {
146
156
  sha256: string;
147
157
  size: number;
@@ -140,6 +140,7 @@ export function inlineFileBytes(content) {
140
140
  bytes: decoded ?? Buffer.from(content, "utf8"),
141
141
  decoded: decoded !== undefined,
142
142
  ...(mediaType ? { urlMediaType: mediaType } : {}),
143
+ ...(decoded !== undefined && !url ? { bareBase64: true } : {}),
143
144
  };
144
145
  }
145
146
  const audioTypes = {
@@ -706,23 +707,26 @@ export function reencodeMedia(media) {
706
707
  });
707
708
  }
708
709
  }
709
- /** Whether a found file has bytes to place: nonempty, and a whole string's `data:` URL decodes. */
710
- export function placeable(file, decoded) {
711
- return decoded.bytes.byteLength > 0 && (!file.whole || decoded.decoded);
710
+ /** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
711
+ * URL decodes. */
712
+ export function placeable(file, decoded, size) {
713
+ return size > 0 && (!file.whole || decoded.decoded);
712
714
  }
713
715
  /** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
714
- * provider part, whose entry names the content) rather than its digest. A whole string and a
715
- * file inside a JSON-encoded string always take the digest form. */
716
- export function keepsPrefix(file, textual) {
717
- return textual && !file.whole && !file.encoded?.length;
718
- }
716
+ * provider part, whose entry names the content) rather than its digest. A whole string, a file
717
+ * inside a JSON-encoded string, and a text file a part holds as bare base64 (a GenAI `blob`,
718
+ * Vertex `media` or AI SDK part, whose content is not the text itself) take the digest form. */
719
+ export function keepsPrefix(file, textual, decoded) {
720
+ return textual && !file.whole && !file.encoded?.length && !decoded.bareBase64;
721
+ }
722
+ /** A file's bytes: a field its convention declares base64 decoded, any other content read as
723
+ * Hue's reader reads it, whatever the part's media type (`inlineFileBytes`): a `data:` URL by its
724
+ * own encoding, text that reads as base64 decoded (what the GenAI conventions, and
725
+ * `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
726
+ * as its UTF-8. */
719
727
  export function mediaBytes(file) {
720
728
  if (file.base64)
721
729
  return { bytes: Buffer.from(file.content, "base64"), decoded: true };
722
- if (isMediaContentType(file.contentType) &&
723
- isTextMediaType(file.contentType) &&
724
- !file.content.startsWith("data:"))
725
- return { bytes: Buffer.from(file.content, "utf8"), decoded: false };
726
730
  return inlineFileBytes(file.content);
727
731
  }
728
732
  export function mediaContentType(file, decoded) {
@@ -838,7 +842,7 @@ export function digestMedia(media, nested, count, memo, traceId = "") {
838
842
  let changed = false;
839
843
  for (const file of media.files) {
840
844
  const decoded = mediaBytes(file);
841
- if (!placeable(file, decoded))
845
+ if (!placeable(file, decoded, decoded.bytes.byteLength))
842
846
  continue;
843
847
  if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
844
848
  decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
@@ -872,7 +876,7 @@ export function digestFormBytes(value) {
872
876
  continue;
873
877
  inspected += units;
874
878
  const decoded = mediaBytes(file);
875
- if (!placeable(file, decoded))
879
+ if (!placeable(file, decoded, decoded.bytes.byteLength))
876
880
  continue;
877
881
  if (isTextMediaType(mediaContentType(file, decoded)) &&
878
882
  decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
@@ -28,6 +28,11 @@ function locate(text, wanted) {
28
28
  }
29
29
  };
30
30
  const stringEnd = (index) => {
31
+ // A string without escapes ends at the next quote, found natively: a long file's content
32
+ // is passed over without a step per character.
33
+ const quote = text.indexOf('"', index + 1);
34
+ if (quote !== -1 && !text.slice(index + 1, quote).includes("\\"))
35
+ return quote + 1;
31
36
  let at = index + 1;
32
37
  for (;;) {
33
38
  const code = text.charCodeAt(at);
@@ -150,32 +155,46 @@ export function applyJsonEdits(text, edits) {
150
155
  start: span.start,
151
156
  end: span.end,
152
157
  render: () => {
153
- const kept = members.filter((member) => !edit.remove.has(member.key));
154
158
  const first = members[0]?.start ?? span.end - 1;
155
159
  const close = members.length ? members.at(-1).end : span.end - 1;
156
160
  let out = text.slice(span.start, first);
157
- kept.forEach((member, position) => {
158
- const index = members.indexOf(member);
161
+ let kept = 0;
162
+ members.forEach((member, index) => {
163
+ if (edit.remove.has(member.key))
164
+ return;
159
165
  // The separator that preceded this member, comma included, unless it now comes first.
160
- if (position > 0)
166
+ if (kept++ > 0)
161
167
  out += text.slice(members[index - 1].end, member.start);
162
168
  out += render(member.start, member.end);
163
169
  });
164
170
  let appended = "";
165
171
  for (const [key, value] of edit.set)
166
172
  if (!lastOf.has(key) || edit.remove.has(key))
167
- appended += `${kept.length || appended ? "," : ""}${JSON.stringify(key)}:${value}`;
173
+ appended += `${kept || appended ? "," : ""}${JSON.stringify(key)}:${value}`;
168
174
  return out + appended + text.slice(close, span.end);
169
175
  },
170
176
  });
171
177
  }
172
178
  pieces.sort((a, b) => a.start - b.start || b.end - a.end);
173
179
  /** The text from `start` to `end` with the pieces within it rendered; a piece inside another
174
- * is rendered by the outer one. */
180
+ * is rendered by the outer one. Only the pieces that start within the range are visited. */
175
181
  const render = (start, end) => {
176
182
  let out = "";
177
183
  let at = start;
178
- for (const piece of pieces) {
184
+ // The first piece starting at or after `start`, found by halving.
185
+ let low = 0;
186
+ let high = pieces.length;
187
+ while (low < high) {
188
+ const middle = (low + high) >> 1;
189
+ if (pieces[middle].start < start)
190
+ low = middle + 1;
191
+ else
192
+ high = middle;
193
+ }
194
+ for (let index = low; index < pieces.length; index++) {
195
+ const piece = pieces[index];
196
+ if (piece.start >= end)
197
+ break;
179
198
  if (piece.start < at || piece.end > end)
180
199
  continue;
181
200
  out += text.slice(at, piece.start) + piece.render();