@hue-run/sdk 0.15.2 → 0.16.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/CLI.md CHANGED
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
42
42
  The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
43
43
  `capture_content=False`. For a supported application, setup installs the dependency and adds the
44
44
  managed import and middleware registration to the existing entrypoint; an unreferenced helper is
45
- not a completed integration. TypeScript uses `@hue-run/sdk@0.15.2`, `@opentelemetry/api@1.9.1` and
45
+ not a completed integration. TypeScript uses `@hue-run/sdk@0.16.0`, `@opentelemetry/api@1.9.1` and
46
46
  `@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
47
47
  Content capture requires an ordinary account-managed key and a later explicit application decision.
48
48
 
@@ -469,7 +469,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
469
469
  # Set project to an existing supported fixture; use the same directory on resume.
470
470
  project=/absolute/path/to/supported-fixture
471
471
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
472
- --archive .artifacts/typescript/hue-run-sdk-0.15.2.tgz \
472
+ --archive .artifacts/typescript/hue-run-sdk-0.16.0.tgz \
473
473
  --origin https://STAGING_ORIGIN \
474
474
  --project "$project" --command setup \
475
475
  --evidence .context/setup-staging-before-claim.json
@@ -480,7 +480,7 @@ the private local handoff and finish the real browser claim, then reconcile the
480
480
 
481
481
  ```sh
482
482
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
483
- --archive .artifacts/typescript/hue-run-sdk-0.15.2.tgz \
483
+ --archive .artifacts/typescript/hue-run-sdk-0.16.0.tgz \
484
484
  --origin https://STAGING_ORIGIN \
485
485
  --project "$project" --command claim \
486
486
  --evidence .context/setup-staging-after-claim.json
@@ -510,7 +510,7 @@ and prints Hue's verdicts. It is the command-line form of `runSimulation()` (one
510
510
  process, Hue creates one isolated simulated world per case, and Hue-owned outcome checks grade
511
511
  the sealed world. Hue never executes the agent. `HUE_API_KEY` must be a **Read and
512
512
  write** project key (a **Read** or **Tracing only** key cannot read cases or create runs);
513
- the CLI never prints it. The optional `zod` peer of `@hue-run/sdk/evals` must be installed.
513
+ the CLI never prints it.
514
514
 
515
515
  Run the agent's own start command with `--command`. `hue eval` spawns it once per case with the
516
516
  world's environment (below) and `{"inputs", "config"}` on stdin, and stores its stdout as the
@@ -519,19 +519,20 @@ committed helper described in [Make your agent eval-ready](https://docs.hue.run/
519
519
  reads `HUE_SIM_<SURFACE ID>_URL` and `HUE_WORLD_TOKEN` when `HUE_WORLD_TOKEN` is set, fails
520
520
  closed when a needed variable is missing, and returns the production URL and credential
521
521
  otherwise. Never hand the agent Hue-native tools (`context.tools`, `context.mcp`) or a tool its
522
- production agent lacks. Pin the SDK and bring its `zod` peer along, so the agent's repository
523
- gains no dependency:
522
+ production agent lacks. Pin the SDK, so the agent's repository gains no dependency:
524
523
 
525
524
  ```sh
526
- npx --yes --package @hue-run/sdk@0.15.2 --package "zod@^4.6.5" hue eval --case "Refund an eligible charge" --command "node agent.js" --env-file .env.hue
527
- npx --yes --package @hue-run/sdk@0.15.2 --package "zod@^4.6.5" hue eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <experiment id>
528
- npx --yes --package @hue-run/sdk@0.15.2 --package "zod@^4.6.5" hue eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
529
- npx --yes --package @hue-run/sdk@0.15.2 --package "zod@^4.6.5" hue eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision> --env-file .env.hue
525
+ npx -y @hue-run/sdk@0.16.0 eval --case "Refund an eligible charge" --command "node agent.js"
526
+ npx -y @hue-run/sdk@0.16.0 eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <run id>
527
+ npx -y @hue-run/sdk@0.16.0 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
528
+ npx -y @hue-run/sdk@0.16.0 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
530
529
  ```
531
530
 
532
- Without the second `--package`, `hue eval` exits with `hue eval needs zod`, even for `--help`;
533
- installing zod in the project does not help when the SDK itself is not installed there. An
534
- unpinned `--package @hue-run/sdk` runs a local, possibly older, copy in a project that already
531
+ One invocation creates one run holding every selected case; it prints the run URL
532
+ (`https://app.hue.run/runs/<id>`) once the run exists. Add `--check` to confirm the key, the
533
+ selection, the case kind and the agent first: it prints what the run would do and exits without
534
+ creating a run, a world or a checkpoint. An
535
+ unpinned `npx @hue-run/sdk` runs a local, possibly older, copy in a project that already
535
536
  depends on it.
536
537
 
537
538
  An adapter module is the in-process alternative for a TypeScript agent. It exports `default` or
@@ -562,7 +563,7 @@ export default function runMyAgent(inputs: JsonValue, context: SimulationTargetC
562
563
  ```
563
564
 
564
565
  ```sh
565
- npx --yes --package @hue-run/sdk@0.15.2 --package "zod@^4.6.5" hue eval --case "Refund an eligible charge" ./hue-agent.ts --content --env-file .env.hue
566
+ npx -y @hue-run/sdk@0.16.0 eval --case "Refund an eligible charge" ./hue-agent.ts --content
566
567
  ```
567
568
 
568
569
  `--scenario` remains an alias for `--case` for existing scripts. Pass one selection flag.
@@ -593,7 +594,7 @@ required judge to decide answer-only cases. Any
593
594
  other evaluator's result, an error, or a metric that carries `passed` is never advisory, whatever
594
595
  its evidence says. A metric name that
595
596
  several evaluators report, such as two judges' `verdict`, gets a column per evaluator, labelled
596
- with the version's first eight characters. `--baseline <experiment id|url>` adds improvement,
597
+ with the version's first eight characters. `--baseline <run id|url>` adds improvement,
597
598
  regression and unchanged counts with per-case deltas. `--json` prints one JSON document
598
599
  (`experimentId`, `runId`, `runUrl`, `complete`, `cases`, `totals`, optional `baseline`) on stdout
599
600
  and sends progress to stderr; each case lists its not-applicable evaluator versions in
@@ -670,7 +671,8 @@ the sealed world at once and attaches the trace once its export is complete. The
670
671
  A Scenario or eval set whose dataset version is not saved cannot back an experiment: the command
671
672
  exits 1 and asks for **Save eval-set version** in Hue or `--save-version`, which freezes that
672
673
  version at its current revision. Connection settings are `HUE_API_KEY` and `HUE_BASE_URL`
673
- (default `https://app.hue.run`), loaded from `--env-file <path>` (or its alias `--env-path`) first when given; `--origin`
674
+ (default `https://app.hue.run`), loaded from `--env-file <path>` (or its alias `--env-path`) first when given, otherwise from
675
+ `./.env.hue` when the shell has no `HUE_API_KEY` (a key in the shell keeps its own origin); `--origin`
674
676
  overrides the origin. Since TypeScript 0.14.0, telemetry content capture defaults on; `--no-content` opts out.
675
677
  Earlier releases require `--content`; the examples include it for compatibility. Model and tool spans inside the agent come
676
678
  only from the agent's own instrumentation. Case outputs, error messages and explanations are
@@ -734,8 +736,8 @@ published version, beside or instead of explicit `--scorer-version` IDs.
734
736
  ```sh
735
737
  hue eval --set gia-d1-citation --scorer gia-d1-citation \
736
738
  --command "pnpm --filter @august/frontend run hue:gia-agent" \
737
- --revision prompt-v10 --wait 1800 --content --json --env-file .env.hue
738
- hue eval --set gia-d1-citation --set-version 1 --scorer gia-d1-citation ./hue-agent.ts --content --baseline <experiment id>
739
+ --revision prompt-v10 --wait 1800 --content --json
740
+ hue eval --set gia-d1-citation --set-version 1 --scorer gia-d1-citation ./hue-agent.ts --content --baseline <run id>
739
741
  ```
740
742
 
741
743
  For each case the command is spawned once **inside a private case directory** with
package/EVALUATIONS.md CHANGED
@@ -7,7 +7,7 @@ Install the optional runtime-contract peer with the SDK before importing
7
7
  npm install @hue-run/sdk zod
8
8
  ```
9
9
 
10
- The SDK executes targets and scorers on your machine. Hue stores pinned definitions, experiment progress and results. It does not execute uploaded source code. Follow the [installation guide](https://docs.hue.run/installation) to add `@hue-run/sdk` to your application.
10
+ The SDK executes targets and scorers on your machine. Hue stores pinned definitions, experiment progress and results. It does not execute uploaded source code. Follow [Install the SDK](https://docs.hue.run/quickstart#2-install-the-sdk) to add `@hue-run/sdk` to your application.
11
11
 
12
12
  Create a **Read and write** project service key under **Settings → Integrations & API keys** and expose it to this server-side process as `HUE_API_KEY`. A **Tracing only** key cannot author datasets or evaluation runs.
13
13
 
package/README.md CHANGED
@@ -677,15 +677,12 @@ conditions only. Use Hue's UI to inspect captured values and redaction.
677
677
 
678
678
  ## Dependencies
679
679
 
680
- The tracing core depends only on official `@opentelemetry/*` packages. The optional
681
- `@hue-run/sdk/evals` entry point uses `zod` for its bounded runtime contracts; install that peer
682
- when you use evaluations or simulations. JSON Schema scoring also uses `ajv`, an optional peer
683
- loaded inside a worker only when `builtins.jsonSchema` scores a case; without it that scorer
680
+ The tracing core depends only on official `@opentelemetry/*` packages and `zod`, which the
681
+ `@hue-run/sdk/evals` entry point and `hue eval` use for their bounded runtime contracts and which
682
+ installs with the SDK. JSON Schema scoring also uses `ajv`, an optional peer loaded inside a worker only when `builtins.jsonSchema` scores a case; without it that scorer
684
683
  reports `SchemaValidatorUnavailable`. Install it when you use that scorer:
685
684
 
686
685
  ```bash
687
- npm install zod
688
- # Add ajv too when using builtins.jsonSchema.
689
686
  npm install ajv
690
687
  ```
691
688
 
@@ -795,6 +792,6 @@ existing-provider flush callbacks and recovery. Local/CI runners remain availabl
795
792
 
796
793
  Use `createHueSafe(options)` for best-effort startup. Invalid initialization returns a disabled client that keeps your `onExportIssue` hook and records the reason as an instrumentation failure. Pass `enabled: false` to disable Hue without a key or a `captureContent` choice; disabled helpers still execute the application callback, and `inject()` keeps propagating the application's own trace context. `flushSafe({ timeoutMillis: 1000 })` and `shutdownSafe({ timeoutMillis: 1000 })` return `{ ok, timedOut, report }` without rejecting. Strict initialization, connection checks and `flush()` remain available for diagnostics; do not gate application readiness or responses on them.
797
794
 
798
- Capture/serialization/redaction/provider failures omit unsafe telemetry, record failures, and preserve the original business result/error. Async diagnostic rejections are contained; diagnostics are rate-limited. The default `maxQueueBytes` is 8 MiB across traces/logs including in-flight work, alongside the existing record cap. `pendingBytes` is a current queue gauge; `droppedSpans`, `droppedLogs` and `instrumentationFailures` are cumulative failure counters; `uploadedValues` and `uploadFallbacks` count the values over the inline limit uploaded apart from their spans and exported cut instead. This is a telemetry budget, not a total process memory ceiling. A timeout bounds the caller and does not cancel a borrowed provider. Never retry the business operation to recover telemetry. See [production safety](https://docs.hue.run/guides/production-safety).
795
+ Capture/serialization/redaction/provider failures omit unsafe telemetry, record failures, and preserve the original business result/error. Async diagnostic rejections are contained; diagnostics are rate-limited. The default `maxQueueBytes` is 8 MiB across traces/logs including in-flight work, alongside the existing record cap. `pendingBytes` is a current queue gauge; `droppedSpans`, `droppedLogs` and `instrumentationFailures` are cumulative failure counters; `uploadedValues` and `uploadFallbacks` count the values over the inline limit uploaded apart from their spans and exported cut instead. This is a telemetry budget, not a total process memory ceiling. A timeout bounds the caller and does not cancel a borrowed provider. Never retry the business operation to recover telemetry. See [production setup](https://docs.hue.run/sdks/typescript#production-setup).
799
796
 
800
797
  Queued records snapshot supported telemetry values when a span ends or a log is emitted; later caller mutations cannot change queued data. Resource attributes still awaiting detection are omitted with a sanitized warning. Later records include them after detection finishes; await resource detection before instrumentation when those attributes are required.
@@ -1,3 +1,5 @@
1
+ /** The key file `hue login` writes; `hue eval` loads it when no env file is named and the shell has no HUE_API_KEY. */
2
+ export declare const DEFAULT_ENV_FILE = ".env.hue";
1
3
  /**
2
4
  * Node 22 and 24 scan the whole command line for `--env-file`, including arguments meant for the
3
5
  * script, and exit with "node: <path>: not found" before the CLI runs when that file does not
@@ -1,4 +1,6 @@
1
1
  import { resolve } from "node:path";
2
+ /** The key file `hue login` writes; `hue eval` loads it when no env file is named and the shell has no HUE_API_KEY. */
3
+ export const DEFAULT_ENV_FILE = ".env.hue";
2
4
  /**
3
5
  * Node 22 and 24 scan the whole command line for `--env-file`, including arguments meant for the
4
6
  * script, and exit with "node: <path>: not found" before the CLI runs when that file does not
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
  }
@@ -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))
@@ -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. */
@@ -213,6 +213,10 @@ async function allPages(page, maximum = 5000) {
213
213
  * world that cannot be sealed), telemetry not accepted by the one transport the cases share, or
214
214
  * a target outcome left uncertain because its world could not be sealed or read back. Starting
215
215
  * more cases into it only piles up the same failure; the run is resumed later instead. */
216
+ /** A new attempt replacing an interrupted one, which Hue still holds as started. */
217
+ function retry(previousExecutionId) {
218
+ return previousExecutionId ? { previousExecutionId, allowUncertainRetry: true } : {};
219
+ }
216
220
  function systemic(error) {
217
221
  // A saved outcome still waiting for its trace (`TraceExportUnacknowledgedError`) is not
218
222
  // systemic: the flush failed on an earlier attempt, telemetry may have recovered since, and
@@ -230,6 +234,7 @@ async function pool(items, concurrency, execute) {
230
234
  let position = 0;
231
235
  const failures = [];
232
236
  let stop = false;
237
+ let cancelled;
233
238
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
234
239
  while (position < items.length && !stop) {
235
240
  const item = items[position++];
@@ -237,12 +242,20 @@ async function pool(items, concurrency, execute) {
237
242
  await execute(item);
238
243
  }
239
244
  catch (error) {
245
+ // The caller's stop: start nothing more, and report the stop once the cases in flight settle.
246
+ if (error instanceof TargetCancelledError) {
247
+ cancelled = error;
248
+ stop = true;
249
+ continue;
250
+ }
240
251
  failures.push(error);
241
252
  if (systemic(error))
242
253
  stop = true;
243
254
  }
244
255
  }
245
256
  }));
257
+ if (cancelled)
258
+ throw cancelled;
246
259
  if (failures.length === 1)
247
260
  throw failures[0];
248
261
  if (failures.length) {
@@ -498,17 +511,19 @@ export async function runExperiment(options) {
498
511
  scores,
499
512
  exportState: "pending",
500
513
  ...(saved.trace ? { trace: saved.trace } : {}),
514
+ ...(saved.attempt ? { attempt: saved.attempt } : {}),
501
515
  };
502
516
  await store.write(file, prepared);
503
517
  return prepared;
504
518
  }
505
519
  // A case pinned to a world keeps its files apart and removes them when it ends: the agent's
506
520
  // copies of its inputs, its work, the evaluator's downloads and the staged outputs.
507
- const worldDirectoryOf = (itemId) => join(filesRoot, `world-case-${uuid(itemId)}`);
521
+ // A retry gets its own, so an agent a dead run left behind cannot write into it.
522
+ const worldDirectoryOf = (itemId, attempt) => join(filesRoot, `world-case-${uuid(itemId)}${attempt ? `-${uuid(attempt)}` : ""}`);
508
523
  const runCase = async (item, files) => {
509
524
  const file = `case-${uuid(item.id)}`;
510
- const worldDirectory = worldDirectoryOf(item.id);
511
- const caseDirectoryOf = (frozen) => frozen.environmentVersionId ? worldDirectory : join(filesRoot, `case-${uuid(item.id)}`);
525
+ const directDirectory = join(filesRoot, `case-${uuid(item.id)}`);
526
+ const caseDirectoryOf = (frozen) => frozen.environmentVersionId ? files.directory : directDirectory;
512
527
  let checkpoint;
513
528
  try {
514
529
  checkpoint = await store.read(file);
@@ -521,6 +536,24 @@ export async function runExperiment(options) {
521
536
  throw error;
522
537
  }
523
538
  files.keepOutputs = checkpoint?.stage === "uploading";
539
+ if (checkpoint && "attempt" in checkpoint)
540
+ files.directory = worldDirectoryOf(item.id, checkpoint.attempt);
541
+ let retryOf;
542
+ // A case left running by a process that died here is as interrupted as one the caller
543
+ // stopped; a fresh world isolates it from anything the dead run's agent still does.
544
+ if (checkpoint?.stage === "running" && store.reclaimed && options.rerunCrashedCases) {
545
+ checkpoint = { ...checkpoint, stage: "interrupted" };
546
+ await store.write(file, checkpoint);
547
+ }
548
+ if (checkpoint?.stage === "interrupted") {
549
+ retryOf = checkpoint.executionId;
550
+ checkpoint = undefined;
551
+ // A direct case's directory goes too, so the retry finds no input copy or output of the
552
+ // stopped attempt.
553
+ await removeCaseFiles(files.directory);
554
+ await removeCaseFiles(directDirectory);
555
+ files.directory = worldDirectoryOf(item.id, retryOf);
556
+ }
524
557
  if (checkpoint && checkpoint.stage !== "prepared" && checkpoint.stage !== "uploading") {
525
558
  if (checkpoint.stage === "serialization_failed")
526
559
  throw new OutcomeSerializationError(checkpoint.executionId);
@@ -528,6 +561,7 @@ export async function runExperiment(options) {
528
561
  ? await options.client.startExecution(experiment.id, item.id, {
529
562
  idempotencyKey: checkpoint.startKey,
530
563
  traceExternalId: checkpoint.traceExternalId,
564
+ ...retry(checkpoint.previousExecutionId),
531
565
  })
532
566
  : await options.client.getExecution(checkpoint.executionId);
533
567
  throw new UncertainExecutionError(item.id, execution.id);
@@ -542,7 +576,9 @@ export async function runExperiment(options) {
542
576
  files.keepOutputs = false;
543
577
  }
544
578
  if (!checkpoint) {
545
- if (item.execution)
579
+ if (options.signal?.aborted)
580
+ throw new TargetCancelledError();
581
+ if (item.execution && item.execution.id !== retryOf)
546
582
  throw new UncertainExecutionError(item.id, item.execution.id);
547
583
  const frozenCase = await options.client.getExperimentCase(experiment.id, item.id);
548
584
  if (frozenCase.datasetVersionId !== version.id)
@@ -574,13 +610,16 @@ export async function runExperiment(options) {
574
610
  stage: "starting",
575
611
  startKey: randomUUID(),
576
612
  traceExternalId: span.traceId,
613
+ ...(retryOf ? { previousExecutionId: retryOf } : {}),
577
614
  };
578
615
  await store.write(file, start);
579
616
  const execution = await options.client.startExecution(experiment.id, item.id, {
580
617
  idempotencyKey: start.startKey,
581
618
  traceExternalId: span.traceId,
619
+ ...retry(retryOf),
582
620
  });
583
- await store.write(file, { stage: "running", executionId: execution.id });
621
+ const attempt = retryOf ? { attempt: retryOf } : {};
622
+ await store.write(file, { stage: "running", executionId: execution.id, ...attempt });
584
623
  let state = "succeeded";
585
624
  let output;
586
625
  let generated;
@@ -604,6 +643,17 @@ export async function runExperiment(options) {
604
643
  catch (error) {
605
644
  if (error instanceof TargetOutcomeUncertainError)
606
645
  throw error;
646
+ // A stop the caller asked for is no outcome: the execution stays open for the
647
+ // resumed run to replace instead of being scored as cancelled.
648
+ if (error instanceof TargetCancelledError && options.signal?.aborted) {
649
+ options.hue.recordError(span.span, error);
650
+ await store.write(file, {
651
+ stage: "interrupted",
652
+ executionId: execution.id,
653
+ ...attempt,
654
+ });
655
+ throw error;
656
+ }
607
657
  state = error instanceof TargetCancelledError ? "cancelled" : "error";
608
658
  targetError = error;
609
659
  options.hue.recordError(span.span, error);
@@ -659,6 +709,7 @@ export async function runExperiment(options) {
659
709
  ...(options.persistResultContent && output !== undefined ? { output } : {}),
660
710
  ...(state === "error" ? { error: errorPayload(targetError) } : {}),
661
711
  files: staged,
712
+ ...attempt,
662
713
  };
663
714
  // Without persisted result content a restart cannot reconstruct the outcome; the
664
715
  // saved stage then reports the execution as uncertain instead of guessing.
@@ -838,21 +889,23 @@ export async function runExperiment(options) {
838
889
  // A world case's directory goes however the case ends, a forced exit included. An
839
890
  // interrupted upload keeps only the staged outputs it resumes from; inputs are downloaded
840
891
  // anew for scoring. A direct case has no such directory, so nothing is removed for it.
841
- const worldDirectory = worldDirectoryOf(item.id);
842
- const files = { keepOutputs: true };
843
- const untrack = onForcedExit(() => releaseWorldFilesSync(worldDirectory, files.keepOutputs));
892
+ const files = { keepOutputs: true, directory: worldDirectoryOf(item.id) };
893
+ const untrack = onForcedExit(() => releaseWorldFilesSync(files.directory, files.keepOutputs));
844
894
  try {
845
895
  await runCase(item, files);
846
- await releaseWorldFiles(worldDirectory, false);
896
+ await releaseWorldFiles(files.directory, false);
847
897
  }
848
898
  catch (error) {
849
- await releaseWorldFiles(worldDirectory, files.keepOutputs);
899
+ await releaseWorldFiles(files.directory, files.keepOutputs);
850
900
  throw error;
851
901
  }
852
902
  finally {
853
903
  untrack();
854
904
  }
855
905
  });
906
+ // A stop that came while the last cases saved their outcomes still leaves the run open.
907
+ if (options.signal?.aborted)
908
+ throw new TargetCancelledError();
856
909
  let finish = await store.read("finish");
857
910
  if (!finish) {
858
911
  finish = { key: randomUUID() };
@@ -1,4 +1,4 @@
1
- import { z } from "zod";
1
+ import { z } from "zod/v4";
2
2
  import { json, digest } from "./json.js";
3
3
  const metricName = z
4
4
  .string()
@@ -391,7 +391,7 @@ export async function runSimulation(options) {
391
391
  attempt.stage !== "completed" &&
392
392
  attempt.scenarioDigest !== scenarioDigest &&
393
393
  attempt.scenarioDigest !== previousDigest)
394
- throw new Error("Recover the unfinished simulation before running a changed scenario or agent revision");
394
+ throw new Error("Recover the unfinished simulation before running a changed scenario or agent revision: run the unchanged one again to finish it, or use another checkpoint directory");
395
395
  if (!attempt || attempt.stage === "completed") {
396
396
  attempt = {
397
397
  scenarioDigest,
@@ -417,12 +417,14 @@ export async function runSimulation(options) {
417
417
  ];
418
418
  }
419
419
  const experimentId = checkpointSegment(attempt.experimentId, "experiment id");
420
- const runUrl = new URL(`/experiments/${experimentId}`, options.client.baseUrl).toString();
420
+ const runUrl = new URL(`/runs/${experimentId}`, options.client.baseUrl).toString();
421
421
  await options.onProgress?.({ type: "run_created", experimentId, runUrl });
422
422
  const requested = requestedConfiguration
423
423
  ? pinRequestedAttemptV2(requestedConfiguration, (await options.client.getExperiment(experimentId)).config)
424
424
  : undefined;
425
425
  const report = await runExperiment({
426
+ signal: options.signal,
427
+ rerunCrashedCases: true,
426
428
  client: options.client,
427
429
  hue: options.hue,
428
430
  experimentId,
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Package version shared by the instrumentation scope and the export User-Agent. */
2
- export declare const sdkVersion = "0.15.2";
2
+ export declare const sdkVersion = "0.16.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/write-version.mjs from package.json; do not edit by hand.
2
2
  /** Package version shared by the instrumentation scope and the export User-Agent. */
3
- export const sdkVersion = "0.15.2";
3
+ export const sdkVersion = "0.16.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hue-run/sdk",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -91,7 +91,8 @@
91
91
  "@opentelemetry/otlp-transformer": "0.222.0",
92
92
  "@opentelemetry/resources": "2.11.0",
93
93
  "@opentelemetry/sdk-logs": "0.222.0",
94
- "@opentelemetry/sdk-trace": "2.11.0"
94
+ "@opentelemetry/sdk-trace": "2.11.0",
95
+ "zod": "^3.25.76 || ^4.6.5"
95
96
  },
96
97
  "devDependencies": {
97
98
  "@ai-sdk/otel": "1.0.99",
@@ -102,15 +103,13 @@
102
103
  "ai": "7.0.99",
103
104
  "ajv": "8.20.0",
104
105
  "protobufjs": "8.8.0",
105
- "typescript": "7.0.2",
106
- "zod": "4.6.5"
106
+ "typescript": "7.0.2"
107
107
  },
108
108
  "peerDependencies": {
109
109
  "@ai-sdk/otel": "^1.0.99",
110
110
  "@opentelemetry/api": "^1.9.1",
111
111
  "ai": "^6.0.0 || ^7.0.99",
112
- "ajv": "^8.17.0",
113
- "zod": "^4.6.5"
112
+ "ajv": "^8.17.0"
114
113
  },
115
114
  "peerDependenciesMeta": {
116
115
  "@ai-sdk/otel": {
@@ -121,9 +120,6 @@
121
120
  },
122
121
  "ajv": {
123
122
  "optional": true
124
- },
125
- "zod": {
126
- "optional": true
127
123
  }
128
124
  },
129
125
  "engines": {