@hue-run/sdk 0.15.2 → 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/cli/eval.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { access, mkdir, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, extname, join, resolve } from "node:path";
@@ -22,7 +22,7 @@ import { matchByName, parseScenarioSelector, resolveEvalSetPins, resolveScenario
22
22
  import { collectDirectOutputs, stageDirectCase } from "./eval-direct.js";
23
23
  import { runSimulation, } from "../evals/simulation.js";
24
24
  import { collectExperimentVerdicts, compareVerdicts, metricPassed, } from "../evals/verdicts.js";
25
- import { envFileArgument, envFileOptions } from "./env-file.js";
25
+ import { DEFAULT_ENV_FILE, envFileArgument, envFileOptions } from "./env-file.js";
26
26
  const USAGE = `Usage: hue eval [adapter-file] [options]
27
27
 
28
28
  Run a local agent against a published case or eval set, then print Hue's verdicts.
@@ -70,13 +70,15 @@ Modes:
70
70
  environment-files:v1 and input:pdf for world cases with files
71
71
 
72
72
  Connection:
73
- --env-file <path> Load a dotenv file (HUE_API_KEY, HUE_BASE_URL) first
73
+ --env-file <path> Load a dotenv file (HUE_API_KEY, HUE_BASE_URL) first;
74
+ ./.env.hue loads by default when HUE_API_KEY is not
75
+ set. Variables already set in the environment win
74
76
  --env-path <path> Same as --env-file
75
77
  --origin <url> Hue origin (default: HUE_BASE_URL or https://app.hue.run)
76
78
 
77
79
  Output and limits:
78
80
  --name <run name> Run name (default: <agent key> @ <revision>, hashes shortened)
79
- --baseline <experiment id|url> Compare verdicts with a previous experiment
81
+ --baseline <run id|url> Compare verdicts with a previous run
80
82
  --json Print one JSON document on stdout; progress goes to stderr
81
83
  --content Capture telemetry content (the default)
82
84
  --no-content Omit telemetry inputs, outputs and messages
@@ -84,6 +86,8 @@ Output and limits:
84
86
  explanations (stored by default; --worker always stores); with
85
87
  --content the case span still carries the output
86
88
  --save-version Freeze an unsaved eval-set version before running
89
+ --check One-shot: check the key, selection and agent, print what the
90
+ run would do and exit without creating a run or a world
87
91
  --checkpoint-dir <path> Private checkpoint directory (default: .hue/eval/<agent-key>)
88
92
  --concurrency <n> Cases in flight, 1-100 (default: 1)
89
93
  --trace-not-accepted <policy> A case whose telemetry Hue did not accept in time: fail_case
@@ -147,6 +151,7 @@ function parse(argv) {
147
151
  "no-output": { type: "boolean", default: false },
148
152
  "trace-not-accepted": { type: "string" },
149
153
  "save-version": { type: "boolean", default: false },
154
+ check: { type: "boolean", default: false },
150
155
  "checkpoint-dir": { type: "string" },
151
156
  concurrency: { type: "string" },
152
157
  timeout: { type: "string" },
@@ -971,20 +976,27 @@ function parseMode(value) {
971
976
  return value;
972
977
  throw new UsageError("--mode must be auto, direct or simulation");
973
978
  }
974
- /** Direct when nothing pins a simulated world: not a Scenario, and no case of the version does. */
975
- async function detectDirect(client, pins, mode) {
976
- if (mode !== "auto")
977
- return mode === "direct";
978
- if (pins.scenarioId || pins.environmentVersionId)
979
- return false;
979
+ /** The selection's cases, and whether the run is direct: when `--mode` leaves it open, direct
980
+ * means nothing pins a simulated world, not a Scenario and no case of the version. A simulation
981
+ * run cannot execute a case that pins no world, so a selection mixing both kinds is refused. */
982
+ async function inspectSelection(client, pins, mode) {
983
+ let cases = 0;
984
+ let worldCases = 0;
980
985
  let after;
981
986
  do {
982
987
  const page = await client.listCases(pins.datasetVersionId, { after });
983
- if (page.items.some((item) => item.environmentVersionId))
984
- return false;
988
+ cases += page.items.length;
989
+ worldCases += page.items.filter((item) => item.environmentVersionId).length;
985
990
  after = page.nextCursor ?? undefined;
986
991
  } while (after);
987
- return true;
992
+ const direct = mode === "auto"
993
+ ? !pins.scenarioId && !pins.environmentVersionId && worldCases === 0
994
+ : mode === "direct";
995
+ const answerOnly = cases - worldCases;
996
+ const refusal = !direct && answerOnly > 0
997
+ ? `${answerOnly} of the ${cases} ${cases === 1 ? "case pins" : "cases pin"} no world, so a simulation run would fail ${answerOnly === 1 ? "it" : "them"} before the agent starts. Put the answer-only cases in their own eval set${mode === "simulation" ? ", or drop --mode simulation" : ""}.`
998
+ : undefined;
999
+ return { direct, cases, worldCases, ...(refusal ? { refusal } : {}) };
988
1000
  }
989
1001
  function toJson(verdicts, runUrl, baseline, extra = {}, telemetry = []) {
990
1002
  const notAccepted = new Map(telemetry.map((entry) => [entry.caseId, entry.issues]));
@@ -1055,7 +1067,7 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
1055
1067
  // The wait returns its partial state on abort rather than throwing, so Ctrl+C here must not
1056
1068
  // fall through to a baseline read and a verdict table that nobody asked to finish.
1057
1069
  if (signal.aborted) {
1058
- process.stderr.write("Interrupted.\n");
1070
+ process.stderr.write(`Interrupted. The cases already finished and Hue is still checking them; open ${run.runUrl} instead of rerunning.\n`);
1059
1071
  return 130;
1060
1072
  }
1061
1073
  let baseline;
@@ -1086,7 +1098,7 @@ async function reportVerdicts(client, values, run, baselineId, output, signal) {
1086
1098
  function parseBaseline(value) {
1087
1099
  if (!value)
1088
1100
  return undefined;
1089
- const parsed = parseScenarioSelector(value, ["experiments"]);
1101
+ const parsed = parseScenarioSelector(value, ["runs", "experiments"]);
1090
1102
  if (parsed.kind !== "id")
1091
1103
  throw new UsageError("--baseline must be an experiment ID or its Hue URL");
1092
1104
  return parsed.id;
@@ -1096,7 +1108,15 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
1096
1108
  const concurrency = integer("concurrency", values.concurrency, 1, 1, 100);
1097
1109
  const baselineId = parseBaseline(values.baseline);
1098
1110
  const mode = parseMode(values.mode);
1111
+ integer("wait", values.wait, 300, 0, 86_400);
1099
1112
  const pins = await resolveSelection(client, values);
1113
+ const selection = await inspectSelection(client, pins, mode);
1114
+ if (values.check)
1115
+ return checkRun(client, values, pins, selection, agent, output);
1116
+ if (selection.refusal) {
1117
+ output.error(selection.refusal);
1118
+ return 1;
1119
+ }
1100
1120
  if (!pins.saved) {
1101
1121
  if (!values["save-version"]) {
1102
1122
  output.error(`The ${pins.scenarioId ? "Scenario" : "eval set"} "${pins.name}" points at an unsaved version. Choose "Save eval-set version" in Hue, or pass --save-version to freeze it now, then rerun.`);
@@ -1107,7 +1127,7 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
1107
1127
  }
1108
1128
  const runName = values.name ?? defaultRunName(agent);
1109
1129
  const project = await client.checkConnection();
1110
- if (await detectDirect(client, pins, mode))
1130
+ if (selection.direct)
1111
1131
  return runDirect({
1112
1132
  client,
1113
1133
  values,
@@ -1190,6 +1210,62 @@ async function runOnce(values, connection, agents, agent, hue, output, signal) {
1190
1210
  telemetryNotAccepted: report.telemetryNotAccepted,
1191
1211
  }, baselineId, output, signal);
1192
1212
  }
1213
+ /**
1214
+ * `--check`: everything a one-shot run resolves before it creates anything (the key's project,
1215
+ * the selection, the case kind and the agent), printed with the command that would start it.
1216
+ * Nothing is created, frozen or checkpointed.
1217
+ */
1218
+ async function checkRun(client, values, pins, selection, agent, output) {
1219
+ const project = await client.checkConnection();
1220
+ for (const id of pins.scorerVersionIds) {
1221
+ const found = await client.getScorerVersion(id).then(() => true, (error) => {
1222
+ if (error instanceof HueApiError && (error.status === 404 || error.status === 403))
1223
+ return false;
1224
+ throw error;
1225
+ });
1226
+ if (!found) {
1227
+ output.error(`Evaluator version ${id} was not found in project "${project.name}".`);
1228
+ return 1;
1229
+ }
1230
+ }
1231
+ const { direct, cases, worldCases, refusal } = selection;
1232
+ const unsaved = !pins.saved && !values["save-version"];
1233
+ const check = {
1234
+ ok: !unsaved && !refusal,
1235
+ project: { id: project.id, name: project.name },
1236
+ selection: {
1237
+ kind: pins.scenarioId ? "case" : "eval_set",
1238
+ name: pins.name,
1239
+ datasetVersionId: pins.datasetVersionId,
1240
+ saved: pins.saved,
1241
+ cases,
1242
+ evaluators: pins.scorerVersionIds.length,
1243
+ },
1244
+ mode: direct ? "direct" : "simulation",
1245
+ worldCases: direct ? 0 : worldCases,
1246
+ agent: { key: agent.key, revision: agent.revision, concurrency: values.concurrency ?? "1" },
1247
+ };
1248
+ if (values.json)
1249
+ process.stdout.write(`${JSON.stringify({ check })}\n`);
1250
+ else {
1251
+ output.log(`Project: ${project.name} (${project.id})`);
1252
+ output.log(`Selection: ${check.selection.kind === "case" ? "case" : "eval set"} "${pins.name}", ${cases} ${cases === 1 ? "case" : "cases"}, ${pins.scorerVersionIds.length} ${pins.scorerVersionIds.length === 1 ? "evaluator" : "evaluators"}`);
1253
+ output.log(direct
1254
+ ? "Mode: direct; the agent answers without a simulated world or app mirrors"
1255
+ : `Mode: simulation; ${worldCases} ${worldCases === 1 ? "case gets" : "cases get"} a fresh world, reached through the HUE_SIM_*_URL mirrors and HUE_WORLD_TOKEN`);
1256
+ output.log(`Agent: ${agent.key} @ ${agent.revision}`);
1257
+ }
1258
+ if (refusal) {
1259
+ output.error(refusal);
1260
+ return 1;
1261
+ }
1262
+ if (unsaved) {
1263
+ output.error(`The ${pins.scenarioId ? "Scenario" : "eval set"} "${pins.name}" points at an unsaved version; add --save-version to freeze it when the run starts.`);
1264
+ return 1;
1265
+ }
1266
+ output.log("Check passed. Nothing was created; run the same command without --check to start one run.");
1267
+ return 0;
1268
+ }
1193
1269
  /**
1194
1270
  * Direct cases: one ordinary experiment through `runExperiment`. The agent receives the case's
1195
1271
  * pinned files and returns generated documents; code evaluators pinned to the run stay deferred
@@ -1218,7 +1294,7 @@ async function runDirect(run, connection) {
1218
1294
  });
1219
1295
  let attempt = await store.read("active-attempt");
1220
1296
  if (attempt && attempt.stage !== "completed" && attempt.selectionDigest !== selectionDigest)
1221
- throw new Error("Recover the unfinished direct run before running a changed selection");
1297
+ throw new Error("Recover the unfinished direct run before running a changed selection: run the unchanged command again to finish it, or pass another --checkpoint-dir");
1222
1298
  if (!attempt || attempt.stage === "completed") {
1223
1299
  attempt = { selectionDigest, idempotencyKey: randomUUID(), stage: "preparing" };
1224
1300
  await store.write("active-attempt", attempt);
@@ -1236,7 +1312,7 @@ async function runDirect(run, connection) {
1236
1312
  await store.write("active-attempt", attempt);
1237
1313
  }
1238
1314
  experimentId = attempt.experimentId;
1239
- runUrl = new URL(`/experiments/${experimentId}`, connection.baseUrl).toString();
1315
+ runUrl = new URL(`/runs/${experimentId}`, connection.baseUrl).toString();
1240
1316
  output.log(`Run: ${runUrl}`);
1241
1317
  output.log(`Experiment: ${experimentId}`);
1242
1318
  report = await runExperiment({
@@ -1251,6 +1327,7 @@ async function runDirect(run, connection) {
1251
1327
  traceNotAccepted: traceNotAcceptedPolicy(values["trace-not-accepted"]),
1252
1328
  onTelemetryNotAccepted: telemetry.report,
1253
1329
  concurrency: run.concurrency,
1330
+ signal,
1254
1331
  scorers: [],
1255
1332
  deferUnboundLocalScorers: true,
1256
1333
  environmentEvidence: "when_pinned",
@@ -1308,7 +1385,7 @@ async function runWorker(values, connection, agents, agent, hue, output, signal)
1308
1385
  };
1309
1386
  client.onClaimed = (claim) => {
1310
1387
  current = claim;
1311
- output.log(`Claimed run ${claim.runId}: ${new URL(`/experiments/${claim.experimentId}`, connection.baseUrl).toString()}`);
1388
+ output.log(`Claimed run ${claim.runId}: ${new URL(`/runs/${claim.experimentId}`, connection.baseUrl).toString()}`);
1312
1389
  };
1313
1390
  const telemetry = telemetryReporter(output);
1314
1391
  // A command can use the same stdin contract for both case kinds. Existing adapter files
@@ -1396,7 +1473,7 @@ async function runWorker(values, connection, agents, agent, hue, output, signal)
1396
1473
  if (signal.aborted)
1397
1474
  return;
1398
1475
  if (values.json)
1399
- process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/experiments/${current.experimentId}`, connection.baseUrl).toString(), undefined, {}, report.telemetryNotAccepted))}\n`);
1476
+ process.stdout.write(`${JSON.stringify(toJson(verdicts, new URL(`/runs/${current.experimentId}`, connection.baseUrl).toString(), undefined, {}, report.telemetryNotAccepted))}\n`);
1400
1477
  else
1401
1478
  renderTable(verdicts, output);
1402
1479
  }
@@ -1446,8 +1523,10 @@ export async function runEvalCommand(argv) {
1446
1523
  process.on("SIGTERM", interrupt);
1447
1524
  let hue;
1448
1525
  let json = false;
1526
+ let worker = false;
1449
1527
  try {
1450
1528
  const { values, positionals } = parse(argv);
1529
+ worker = values.worker;
1451
1530
  if (values.content && values["no-content"])
1452
1531
  throw new UsageError("Choose either --content or --no-content");
1453
1532
  // A usage error before anything is prepared or created: no run is left to recover from it.
@@ -1478,6 +1557,10 @@ export async function runEvalCommand(argv) {
1478
1557
  catch (error) {
1479
1558
  throw new UsageError(error.message);
1480
1559
  }
1560
+ // A key already in the environment keeps its own origin: the checkout's .env.hue is
1561
+ // consulted only when it has to supply the key, so it cannot point an inherited key elsewhere.
1562
+ if (envFile === undefined && !process.env.HUE_API_KEY && existsSync(DEFAULT_ENV_FILE))
1563
+ envFile = DEFAULT_ENV_FILE;
1481
1564
  if (envFile) {
1482
1565
  try {
1483
1566
  process.loadEnvFile(resolve(envFile));
@@ -1488,7 +1571,7 @@ export async function runEvalCommand(argv) {
1488
1571
  }
1489
1572
  const apiKey = process.env.HUE_API_KEY?.trim();
1490
1573
  if (!apiKey)
1491
- throw new UsageError('HUE_API_KEY is required: a "Read and write" project key, set in the environment or an ignored --env-file');
1574
+ throw new UsageError('HUE_API_KEY is required: a "Read and write" project key, set in the environment, ./.env.hue or an ignored --env-file');
1492
1575
  secrets.push(apiKey);
1493
1576
  const baseUrl = values.origin ?? process.env.HUE_BASE_URL?.trim() ?? "https://app.hue.run";
1494
1577
  const connection = { apiKey, baseUrl };
@@ -1503,6 +1586,8 @@ export async function runEvalCommand(argv) {
1503
1586
  throw new UsageError("--worker takes no selection; Hue chooses the run to execute");
1504
1587
  if (values.worker && values["no-output"])
1505
1588
  throw new UsageError("--no-output applies to one-shot runs; a run launched from Hue always stores its outputs");
1589
+ if (values.worker && values.check)
1590
+ throw new UsageError("--check applies to one-shot runs");
1506
1591
  if (!values.worker && values.capability?.length)
1507
1592
  throw new UsageError("--capability applies to --worker only");
1508
1593
  const loaded = adapterFile ? await loadAdapter(adapterFile) : undefined;
@@ -1535,7 +1620,9 @@ export async function runEvalCommand(argv) {
1535
1620
  }
1536
1621
  catch (error) {
1537
1622
  if (controller.signal.aborted || error instanceof TargetCancelledError) {
1538
- process.stderr.write("Interrupted.\n");
1623
+ process.stderr.write(worker
1624
+ ? "Interrupted.\n"
1625
+ : "Interrupted. The run stays open; run the same command again to resume it.\n");
1539
1626
  return 130;
1540
1627
  }
1541
1628
  if (error instanceof UsageError) {
package/dist/cli/login.js CHANGED
@@ -7,9 +7,8 @@ import { createInterface } from "node:readline";
7
7
  import { Writable } from "node:stream";
8
8
  import { parseArgs } from "node:util";
9
9
  import { isLoopbackHost } from "../config.js";
10
- import { envFileArgument, envFileOptions } from "./env-file.js";
10
+ import { DEFAULT_ENV_FILE, envFileArgument, envFileOptions } from "./env-file.js";
11
11
  const DEFAULT_ORIGIN = "https://app.hue.run";
12
- const DEFAULT_ENV_FILE = ".env.hue";
13
12
  /** Settings section that lists and creates project service keys. */
14
13
  const KEY_SETTINGS_PATH = "/settings/integrations";
15
14
  const REQUEST_TIMEOUT_MILLIS = 10_000;
@@ -745,6 +744,6 @@ export async function runLoginCommand(argv, io = {}) {
745
744
  // Without --auth it configures sign-in unless HUE_MCP_KEY is exported, so name the key stored.
746
745
  out(` hue mcp install --client claude-code --auth key${mcpUrl === mcpUrlForOrigin(DEFAULT_ORIGIN) ? "" : ` --url ${mcpUrl}`}`);
747
746
  if (stored.includes("evaluations"))
748
- out(` hue eval --case "<name>" ./hue-agent.ts --env-file ${envDisplay}`);
747
+ out(` hue eval --case "<name>" ./hue-agent.ts${envDisplay === DEFAULT_ENV_FILE ? "" : ` --env-file ${envDisplay}`}`);
749
748
  return 0;
750
749
  }
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
  /**
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { createHash } from "node:crypto";
3
+ import { isMediaName } from "./inline-files.js";
3
4
  import { context, isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
4
5
  import { defaultTextMapGetter, defaultTextMapSetter } from "@opentelemetry/api";
5
6
  import { SeverityNumber } from "@opentelemetry/api-logs";
@@ -10,7 +11,7 @@ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resource
10
11
  import { HUE_SCOPE } from "./config.js";
11
12
  import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, providerErrorDescription, } from "./provider-tools.js";
12
13
  import { toolCatalogSummary } from "./tool-definitions.js";
13
- import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, } from "./safety.js";
14
+ import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
14
15
  import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
15
16
  import { MAX_BLOB_BYTES } from "./blobs.js";
16
17
  import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
@@ -662,7 +663,7 @@ export class HueClient {
662
663
  * Adds a `hue.file` event to the active (or given) span for a file the work read, received or
663
664
  * produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
664
665
  * and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
665
- * never exported. The event is metadata, so it is recorded in both capture modes. An invalid
666
+ * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
666
667
  * record, or one without an active span, is omitted and counted, never thrown; an invalid name
667
668
  * alone is omitted and counted while the rest is recorded.
668
669
  */
@@ -680,26 +681,30 @@ export class HueClient {
680
681
  throw new TypeError("Invalid media type");
681
682
  let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
682
683
  let byteSize = file.byteSize;
684
+ let bytes;
683
685
  if (data !== undefined) {
684
- const bytes = typeof data === "string"
685
- ? (() => {
686
- // Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
687
- // otherwise require. Reject before Buffer.from/createHash can retain large input.
688
- if (data.length > MAX_FILE_DATA_BYTES ||
689
- Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
690
- throw new RangeError("File data exceeds Hue's 25 MiB limit");
691
- return Buffer.from(data, "utf8");
692
- })()
693
- : data instanceof Uint8Array
694
- ? data.byteLength <= MAX_FILE_DATA_BYTES
695
- ? data
696
- : (() => {
686
+ bytes =
687
+ typeof data === "string"
688
+ ? (() => {
689
+ // Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
690
+ // otherwise require. Reject before Buffer.from/createHash can retain large input.
691
+ if (data.length > MAX_FILE_DATA_BYTES ||
692
+ Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
697
693
  throw new RangeError("File data exceeds Hue's 25 MiB limit");
698
- })()
699
- : undefined;
694
+ return Buffer.from(data, "utf8");
695
+ })()
696
+ : data instanceof Uint8Array
697
+ ? data.byteLength <= MAX_FILE_DATA_BYTES
698
+ ? data
699
+ : (() => {
700
+ throw new RangeError("File data exceeds Hue's 25 MiB limit");
701
+ })()
702
+ : undefined;
700
703
  if (!bytes)
701
704
  throw new TypeError("File data must be bytes or a string");
702
- const digest = createHash("sha256").update(bytes).digest("hex");
705
+ const digest = bytes.byteLength > 0 && file.upload === true && this.transport.holdsValues()
706
+ ? this.transport.digestMedia(bytes, span.spanContext().traceId).sha256
707
+ : createHash("sha256").update(bytes).digest("hex");
703
708
  // A caller-supplied digest or size must describe the same bytes.
704
709
  if ((sha256 !== undefined && sha256 !== digest) ||
705
710
  (byteSize !== undefined && byteSize !== bytes.byteLength))
@@ -719,12 +724,55 @@ export class HueClient {
719
724
  if (byteSize !== undefined)
720
725
  attributes["hue.file.size"] = byteSize;
721
726
  if (this.captureContent && name !== undefined) {
722
- if (isLabel(name))
727
+ if (isMediaName(name))
723
728
  attributes["hue.file.name"] = name;
724
729
  else
725
730
  this.instrumentationFailed();
726
731
  }
727
- span.addEvent("hue.file", attributes);
732
+ // Let OpenTelemetry use the span's wall-clock anchor, then keep this event's exact
733
+ // timestamp for upload admission. A dropped event cannot authorize an upload.
734
+ const source = spanSource(span);
735
+ const previous = this.fileEventTimes.get(source);
736
+ const after = (time) => {
737
+ if (previous &&
738
+ (time[0] < previous[0] || (time[0] === previous[0] && time[1] <= previous[1]))) {
739
+ time[0] = previous[0];
740
+ time[1] = previous[1] + 1;
741
+ if (time[1] === 1_000_000_000) {
742
+ time[0]++;
743
+ time[1] = 0;
744
+ }
745
+ }
746
+ return time;
747
+ };
748
+ let eventTime;
749
+ if (Array.isArray(source.events)) {
750
+ const previousEvent = source.events.at(-1);
751
+ span.addEvent("hue.file", attributes);
752
+ const event = source.events.at(-1);
753
+ eventTime = event && event !== previousEvent ? after(event.time) : undefined;
754
+ }
755
+ else {
756
+ // A span that does not expose its events (an API-only wrapper in attach mode): stamp the
757
+ // event with the wall clock and keep that exact time for upload admission.
758
+ const now = Date.now();
759
+ eventTime = after([Math.floor(now / 1000), (now % 1000) * 1_000_000]);
760
+ span.addEvent("hue.file", attributes, [eventTime[0], eventTime[1]]);
761
+ }
762
+ if (eventTime)
763
+ this.fileEventTimes.set(source, eventTime);
764
+ if (file.upload === true && this.transport.offloadsValues()) {
765
+ if (!this.transport.stageMedia(span, bytes && eventTime
766
+ ? {
767
+ bytes,
768
+ digest: { sha256, size: bytes.byteLength },
769
+ contentType: mediaType,
770
+ eventTime,
771
+ ...(isMediaName(name) ? { name } : {}),
772
+ }
773
+ : undefined))
774
+ this.instrumentationFailed();
775
+ }
728
776
  }
729
777
  catch {
730
778
  this.instrumentationFailed();
@@ -732,6 +780,8 @@ export class HueClient {
732
780
  }
733
781
  /** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
734
782
  truncatedKeys = new WeakMap();
783
+ /** Distinguishes requests whose helper events otherwise have identical metadata. */
784
+ fileEventTimes = new WeakMap();
735
785
  /** A content value's bounds: Hue's value cap as the receiver last advertised it. */
736
786
  contentLimits() {
737
787
  return contentLimitsOf(this.transport.receiverLimits().valueBytes);
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import { z } from "zod/v4";
2
2
  import type { CoverageGap } from "../environment/types.js";
3
3
  import type { SimulationMcpCapability } from "./types.js";
4
4
  /** Canonical caller-observed agent configuration after missing evidence is made explicit. */
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { z } from "zod";
2
+ import { z } from "zod/v4";
3
3
  import { json, valueBounds } from "./json.js";
4
4
  const canonicalUuid = z.uuid().transform((value) => value.toLowerCase());
5
5
  const sha256Digest = z.string().regex(/^sha256:[0-9a-f]{64}$/);
@@ -15,11 +15,15 @@ export declare class CheckpointIdentityError extends Error {
15
15
  captureContent?: unknown;
16
16
  } | undefined);
17
17
  }
18
- /** One owner per directory. A crash leaves .lock for explicit operator recovery; a forced exit of
19
- * `hue eval`, which stops its agents first, releases it. */
18
+ /** One owner per directory. A forced exit of `hue eval`, which stops its agents first, releases
19
+ * it; a crash leaves .lock, which the next owner reclaims once the process that held it on this
20
+ * machine has exited. Any other lock is left for explicit operator recovery. */
20
21
  export declare class CheckpointStore {
21
22
  readonly directory: string;
22
23
  private untrack;
24
+ /** Whether this store took over the lock of a process that died here, so whatever that process
25
+ * was running never finished. */
26
+ reclaimed: boolean;
23
27
  private constructor();
24
28
  static acquire(directory: string, identity: unknown): Promise<CheckpointStore>;
25
29
  read<T>(key: string): Promise<T | undefined>;
@@ -1,7 +1,8 @@
1
1
  import { constants, rmSync } from "node:fs";
2
- import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
2
+ import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
3
+ import { hostname } from "node:os";
3
4
  import { join, resolve, sep } from "node:path";
4
- import { randomUUID } from "node:crypto";
5
+ import { createHash, randomUUID } from "node:crypto";
5
6
  import { onForcedExit } from "./exit-cleanup.js";
6
7
  import { digest } from "./json.js";
7
8
  const CONTENT_POLICY = ["persistResultContent", "captureContent"];
@@ -46,11 +47,74 @@ function contentPolicyOnly(prior, identity) {
46
47
  return undefined;
47
48
  return Object.fromEntries(CONTENT_POLICY.map((key) => [key, before[key]]));
48
49
  }
49
- /** One owner per directory. A crash leaves .lock for explicit operator recovery; a forced exit of
50
- * `hue eval`, which stops its agents first, releases it. */
50
+ async function createLock(lock) {
51
+ try {
52
+ await mkdir(lock, { mode: 0o700 });
53
+ return true;
54
+ }
55
+ catch (error) {
56
+ if (error.code === "EEXIST")
57
+ return false;
58
+ throw error;
59
+ }
60
+ }
61
+ /** Whether a process with this ID is running; one owned by another user still counts. */
62
+ function running(pid) {
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ }
67
+ catch (error) {
68
+ return error.code === "EPERM";
69
+ }
70
+ }
71
+ /** Takes over a lock whose owner ran on this machine and has exited. The lock moves to a name
72
+ * derived from its unique owner record, which a rename can claim only once, so of the processes
73
+ * that saw the same dead owner one takes the lock and no live lock is ever moved; that name stays
74
+ * behind as the record. A lock without a readable owner, from another machine or from an SDK that
75
+ * did not record the machine is never reclaimed. */
76
+ async function reclaimLock(root, lock) {
77
+ const owner = await readFile(join(lock, "owner.json"), "utf8").catch(() => undefined);
78
+ if (owner === undefined)
79
+ return false;
80
+ let pid;
81
+ let host;
82
+ try {
83
+ ({ pid, host } =
84
+ JSON.parse(owner).value ?? {});
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ if (typeof pid !== "number" ||
90
+ !Number.isInteger(pid) ||
91
+ pid <= 0 ||
92
+ pid === process.pid ||
93
+ host !== hostname() ||
94
+ running(pid))
95
+ return false;
96
+ const dead = join(root, `.lock-dead-${createHash("sha256").update(owner).digest("hex")}`);
97
+ try {
98
+ await rename(lock, dead);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ if ((await readFile(join(dead, "owner.json"), "utf8").catch(() => undefined)) !== owner) {
104
+ await rename(dead, lock).catch(() => { });
105
+ return false;
106
+ }
107
+ return createLock(lock);
108
+ }
109
+ /** One owner per directory. A forced exit of `hue eval`, which stops its agents first, releases
110
+ * it; a crash leaves .lock, which the next owner reclaims once the process that held it on this
111
+ * machine has exited. Any other lock is left for explicit operator recovery. */
51
112
  export class CheckpointStore {
52
113
  directory;
53
114
  untrack = () => { };
115
+ /** Whether this store took over the lock of a process that died here, so whatever that process
116
+ * was running never finished. */
117
+ reclaimed = false;
54
118
  constructor(directory) {
55
119
  this.directory = directory;
56
120
  }
@@ -61,15 +125,14 @@ export class CheckpointStore {
61
125
  if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)
62
126
  throw new Error("Use a private checkpoint directory (mode 0700, no symlink)");
63
127
  const store = new CheckpointStore(root);
128
+ const lock = join(root, ".lock");
129
+ const created = await createLock(lock);
130
+ if (!created && !(await reclaimLock(root, lock)))
131
+ throw new Error("Checkpoint directory is locked by a process that may still be running; confirm it stopped before explicitly removing .lock");
132
+ store.reclaimed = !created;
133
+ store.untrack = onForcedExit(() => rmSync(lock, { recursive: true, force: true }));
64
134
  try {
65
- await mkdir(join(root, ".lock"), { mode: 0o700 });
66
- store.untrack = onForcedExit(() => rmSync(join(root, ".lock"), { recursive: true, force: true }));
67
- }
68
- catch {
69
- throw new Error("Checkpoint directory is locked; confirm its owner stopped before explicitly removing .lock");
70
- }
71
- try {
72
- await store.write(".lock/owner", { pid: process.pid });
135
+ await store.write(".lock/owner", { pid: process.pid, host: hostname(), id: randomUUID() });
73
136
  const expected = { format: 1, identity, digest: digest(identity) };
74
137
  const prior = await store.read("manifest");
75
138
  if (prior && (prior.format !== 1 || prior.digest !== expected.digest))
@@ -30,6 +30,18 @@ export const outputContentTypes = [
30
30
  "image/png",
31
31
  "image/jpeg",
32
32
  "image/webp",
33
+ "image/gif",
34
+ "image/avif",
35
+ "audio/mpeg",
36
+ "audio/wav",
37
+ "audio/ogg",
38
+ "audio/webm",
39
+ "audio/mp4",
40
+ "audio/flac",
41
+ "audio/opus",
42
+ "audio/aac",
43
+ "video/mp4",
44
+ "video/webm",
33
45
  ];
34
46
  /** Thrown when the target's declared files cannot be used; recorded as the target's error. */
35
47
  export class OutputFileError extends Error {
@@ -105,6 +105,15 @@ export interface RunExperimentTargetContext {
105
105
  export interface RunExperimentOptions extends RunnerOptions {
106
106
  /** Hue tracing client; each case runs inside a `hue.experiment.case` span. */
107
107
  hue: HueClient;
108
+ /** Caller cancellation. Once it aborts, no further case starts, a case whose target stops with
109
+ * `TargetCancelledError` is saved as interrupted rather than completed, and the experiment is
110
+ * left unfinished: the call rejects with `TargetCancelledError`, and calling it again with the
111
+ * same checkpoint directory keeps the finished cases and runs the interrupted ones anew. */
112
+ signal?: AbortSignal;
113
+ /** Rerun, as a new attempt, a case that a process which died on this machine left running.
114
+ * Only for targets that give every attempt a fresh world: elsewhere the dead run's agent may
115
+ * still be acting, so such a case stays uncertain. */
116
+ rerunCrashedCases?: boolean;
108
117
  /** Experiment to run; its dataset version must be frozen. */
109
118
  experimentId: string;
110
119
  /** Whether each case waits for acknowledged trace export or explicitly omits evidence. Required. */