humanish 0.110.3 → 0.110.4

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/actors/scripted-browser/actor.js +17 -8
  3. package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
  4. package/dist/actors/scripted-browser/launch-env.js +50 -0
  5. package/dist/analysis/automatic-completion.d.ts +2 -1
  6. package/dist/analysis/automatic-completion.js +7 -2
  7. package/dist/cli/commands/migrate.js +1 -1
  8. package/dist/cli/commands/runs.js +5 -2
  9. package/dist/cli/commands/study-route-computer-use.js +3 -2
  10. package/dist/cli/commands/study-route-preview.js +2 -1
  11. package/dist/cli/commands/study-route-shared-world.js +3 -3
  12. package/dist/cli/commands/study.js +4 -1
  13. package/dist/routes/computer-use/live-phase.js +8 -1
  14. package/dist/routes/computer-use/local-vm.js +2 -1
  15. package/dist/routes/computer-use/plan.js +6 -5
  16. package/dist/routes/computer-use/route.js +2 -1
  17. package/dist/routes/computer-use/setup.js +2 -1
  18. package/dist/routes/scripted/plan.js +5 -4
  19. package/dist/routes/scripted/route.js +5 -1
  20. package/dist/routes/scripted/surface-sessions.d.ts +2 -0
  21. package/dist/routes/scripted/surface-sessions.js +2 -1
  22. package/dist/routes/shared-world/participant-specs.js +5 -1
  23. package/dist/routes/shared-world/plan.js +7 -6
  24. package/dist/routes/shared-world/route.js +3 -3
  25. package/dist/routes/terminal/plan.js +4 -3
  26. package/dist/routes/terminal/route.js +2 -1
  27. package/dist/routes/terminal/session.js +1 -0
  28. package/dist/run/display.d.ts +10 -0
  29. package/dist/run/display.js +22 -0
  30. package/dist/run/reclaim.d.ts +4 -2
  31. package/dist/run/reclaim.js +61 -18
  32. package/dist/run/run.d.ts +11 -1
  33. package/dist/run/run.js +24 -5
  34. package/dist/run/status.d.ts +6 -0
  35. package/dist/run/status.js +1 -0
  36. package/dist/run/stored-runs.js +26 -11
  37. package/dist/study/composition-rules.js +32 -32
  38. package/dist/study/config.d.ts +9 -2
  39. package/dist/study/config.js +14 -5
  40. package/dist/study/doctor.js +5 -4
  41. package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
  42. package/dist/study/{convert.js → migrate/convert.js} +29 -31
  43. package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
  44. package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
  45. package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
  46. package/dist/study/migrate/v2.d.ts +68 -0
  47. package/dist/study/migrate/v2.js +480 -0
  48. package/dist/study/parse/actors.d.ts +46 -1
  49. package/dist/study/parse/actors.js +116 -69
  50. package/dist/study/parse/execution.js +2 -2
  51. package/dist/study/parse/study-v3.d.ts +26 -0
  52. package/dist/study/parse/study-v3.js +53 -35
  53. package/dist/study/parse/study.d.ts +38 -0
  54. package/dist/study/parse/study.js +225 -0
  55. package/dist/study/persona-resolve.d.ts +2 -4
  56. package/dist/study/persona-resolve.js +2 -1
  57. package/dist/study/plan-base.d.ts +1 -1
  58. package/dist/study/plan-base.js +5 -4
  59. package/dist/study/plan-participants.js +12 -11
  60. package/dist/study/plan.js +4 -3
  61. package/dist/study/preflight-probes.js +2 -2
  62. package/dist/study/preflight.js +2 -2
  63. package/dist/study/routing.js +12 -15
  64. package/dist/study/run-study-options.js +3 -3
  65. package/dist/study/study-fields.d.ts +28 -0
  66. package/dist/study/study-fields.js +51 -0
  67. package/dist/study/summary.js +13 -17
  68. package/dist/study/types.d.ts +40 -0
  69. package/dist/study/url-credentials.js +2 -1
  70. package/dist/study/validation.d.ts +2 -0
  71. package/dist/study/validation.js +46 -41
  72. package/dist/study/warnings.js +5 -4
  73. package/dist/substrates/local/runtime-config.js +9 -9
  74. package/dist/tui-app.js +3 -3
  75. package/docs/contracts/schemas.md +13 -4
  76. package/package.json +1 -1
  77. /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
@@ -111,6 +111,7 @@ export async function runLiveTerminalSession(args) {
111
111
  if ((await session.bootstrap()) &&
112
112
  (await session.verifyRuntimeVersion()) &&
113
113
  (await session.prepareProduct())) {
114
+ run.participantStarted();
114
115
  await session.execCodex();
115
116
  }
116
117
  }
@@ -57,3 +57,13 @@ export declare function bundleDisplayFacts(bundle: DisplayedBundle, status?: unk
57
57
  * execution failed never reads as a pass beside its participants' verdict.
58
58
  */
59
59
  export declare function reviewOutcome(bundle: DisplayedBundle): string;
60
+ /**
61
+ * The summary and gaps a review shows beside its run's display. A run stopped while a participant
62
+ * was still running keeps the review its last live flush wrote, which describes that session as
63
+ * running. Once runDisplay reads the run as interrupted, the review says how many participants the
64
+ * stop cut off instead.
65
+ */
66
+ export declare function displayedReview<T extends {
67
+ summary: string;
68
+ gaps: string[];
69
+ }>(review: T, bundle: DisplayedBundle, display: RunDisplay): T;
@@ -3,6 +3,7 @@
3
3
  // same facts, so a run whose own ok is false shows as a pass on none of them, and two surfaces
4
4
  // cannot disagree about one run.
5
5
  import { classifyRunStatus, isRunStatusRecord } from "./status.js";
6
+ import { plural } from "./text.js";
6
7
  const LABELS = {
7
8
  running: "running",
8
9
  interrupted: "interrupted",
@@ -104,3 +105,24 @@ export function bundleDisplayFacts(bundle, status, nowMs = Date.now()) {
104
105
  export function reviewOutcome(bundle) {
105
106
  return runDisplayLine(runDisplay(bundleDisplayFacts(bundle)));
106
107
  }
108
+ /**
109
+ * The summary and gaps a review shows beside its run's display. A run stopped while a participant
110
+ * was still running keeps the review its last live flush wrote, which describes that session as
111
+ * running. Once runDisplay reads the run as interrupted, the review says how many participants the
112
+ * stop cut off instead.
113
+ */
114
+ export function displayedReview(review, bundle, display) {
115
+ const records = bundle.simulations ?? [];
116
+ const running = records.filter((record) => record?.status === "running").length;
117
+ if (display.state !== "interrupted" || running === 0)
118
+ return review;
119
+ const signal = bundle.outcome?.state === "interrupted" ? ` by ${bundle.outcome.signal}` : "";
120
+ const one = running === 1;
121
+ return {
122
+ ...review,
123
+ summary: `Interrupted${signal} while ${running} of ${plural(records.length, "participant")} ${one ? "was" : "were"} still running.`,
124
+ gaps: [
125
+ `${plural(running, "participant")} ${one ? "has" : "have"} no verdict: the run stopped before ${one ? "its session" : "their sessions"} ended.`,
126
+ ],
127
+ };
128
+ }
@@ -20,9 +20,10 @@ export interface ReclaimResult {
20
20
  createsInFlight: number;
21
21
  /**
22
22
  * Why there was nothing to look for: `dry-run` when run.json or status.json records the run as
23
- * a dry run, which creates no sandboxes. Reclaim then contacts E2B for nothing.
23
+ * a dry run, which creates no sandboxes, and `no-sandbox` when status.json records that the
24
+ * route created none for this run. Reclaim then contacts E2B for nothing.
24
25
  */
25
- reason?: "dry-run";
26
+ reason?: NoSandboxReason;
26
27
  warnings: string[];
27
28
  error?: {
28
29
  code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_E2B_DEBUG" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
@@ -47,6 +48,7 @@ export declare function reclaimRunSandboxes(cwd: string, runInput: string, hooks
47
48
  * also an alias (a run named `latest`) cannot reach another run's receipts.
48
49
  */
49
50
  export declare function reclaimPinnedRunSandboxes(cwd: string, runPaths: PreparedRunArtifactPaths, hooks?: ReclaimHooks): Promise<ReclaimResult>;
51
+ type NoSandboxReason = "dry-run" | "no-sandbox";
50
52
  /**
51
53
  * Kill the sandboxes left by interrupted `humanish study check` probes. A probe journals under
52
54
  * .humanish/preflight/<probe-id>/ and removes the journal after a confirmed kill, so what is left
@@ -15,6 +15,7 @@ import { readRunJsonIfExists, resolveRunPath } from "./locate.js";
15
15
  import { RUN_BUNDLE_FILE } from "./bundle.js";
16
16
  import { RUN_STATUS_FILE } from "./status.js";
17
17
  import { isRecord } from "./type-guards.js";
18
+ import { isRunOutcome } from "./bundle-shape.js";
18
19
  import { parseSandboxOwners, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
19
20
  import { sandboxOwnerTags, } from "./sandbox-creates.js";
20
21
  import { earlierOutcomes, GONE, RECLAIM_RECEIPT_ARTIFACT, RECLAIM_RESULT_SCHEMA, receiptWriter, rootState, searchTagSets, settledWithin, targetSet, } from "./reclaim-outcomes.js";
@@ -64,8 +65,9 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
64
65
  }
65
66
  const runId = runIdOf(runPaths);
66
67
  // The signal handler passes the creates it stopped and decides from what they reported.
67
- if (hooks.creates === undefined && (await recordedDryRun(runPaths)))
68
- return { ...base, ok: true, state: "clean", runId, reason: "dry-run" };
68
+ const reason = hooks.creates === undefined ? await recordedNoSandbox(runPaths) : undefined;
69
+ if (reason !== undefined)
70
+ return { ...base, ok: true, state: "clean", runId, reason };
69
71
  const reclaimed = await reclaimRoot(runPaths, runId, hooks, warnings);
70
72
  if (reclaimed.kind === "module-unavailable")
71
73
  return {
@@ -93,29 +95,70 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
93
95
  };
94
96
  }
95
97
  /**
96
- * Whether the run records that it was a dry run: run.json and status.json, each one present, say
97
- * `dry-run`. A run with neither record is not taken for one. A journal with any line in it, or an
98
- * earlier reclaim receipt that names a sandbox, means a create ran, so that run is reclaimed as a
99
- * live one whatever its records say.
98
+ * Why the run's own records say it created no sandbox. `dry-run`: run.json and status.json, each
99
+ * one present, say `dry-run`. `no-sandbox`: status.json, written at start, names this run and
100
+ * records `sandboxes: none`, which the route sets only when it has no way to create one (a
101
+ * scripted run against an app-url subject), and run.json agrees (scriptedAppUrlBundle). A status
102
+ * record alone can be stale or copied from another run, so it never decides on its own, and an
103
+ * empty journal alone never makes a run clean. A journal with any line in it, or an earlier
104
+ * reclaim receipt that names a sandbox, means a create ran, so that run is reclaimed as a live one
105
+ * whatever its records say.
100
106
  */
101
- async function recordedDryRun(runPaths) {
102
- const modes = [];
103
- for (const file of [RUN_BUNDLE_FILE, RUN_STATUS_FILE]) {
104
- const record = await readRunJsonIfExists(runPaths, file);
105
- if (isRecord(record))
106
- modes.push(record.mode);
107
- }
108
- if (modes.length === 0 || modes.some((mode) => mode !== "dry-run"))
109
- return false;
107
+ async function recordedNoSandbox(runPaths) {
108
+ const bundle = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
109
+ const status = await readRunJsonIfExists(runPaths, RUN_STATUS_FILE);
110
+ const records = [bundle, status].filter(isRecord);
111
+ const reason = records.length > 0 && records.every((record) => record.mode === "dry-run")
112
+ ? "dry-run"
113
+ : isRecord(status) &&
114
+ status.runId === runIdOf(runPaths) &&
115
+ status.sandboxes === "none" &&
116
+ scriptedAppUrlBundle(bundle, runIdOf(runPaths))
117
+ ? "no-sandbox"
118
+ : undefined;
119
+ if (reason === undefined)
120
+ return undefined;
110
121
  if (!(await containedPathAbsent(runPaths, RECLAIM_RECEIPT_ARTIFACT))) {
111
122
  const earlier = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
112
123
  if (!isRecord(earlier) || !Array.isArray(earlier.outcomes) || earlier.outcomes.length > 0)
113
- return false;
124
+ return undefined;
114
125
  }
115
126
  if (await containedPathAbsent(runPaths, SANDBOX_RECEIPTS_ARTIFACT))
116
- return true;
127
+ return reason;
117
128
  const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT);
118
- return journal !== null && journal.toString("utf8").trim() === "";
129
+ return journal !== null && journal.toString("utf8").trim() === "" ? reason : undefined;
130
+ }
131
+ /** The execution failure kinds that leave a sandbox or a provider's resources unconfirmed. */
132
+ const CLEANUP_KINDS = new Set(["sandbox-cleanup", "provider-cleanup"]);
133
+ /**
134
+ * run.json for `runId` agrees that its run could create no sandbox: the scripted route wrote it
135
+ * (each lifecycle event is a `scripted-lab.` one), its subject is not a provisioned clone (a clone
136
+ * records `subject`), it lists no provider resource and no desktop minutes, and its outcome marks
137
+ * no sandbox or provider cleanup unconfirmed. A run.json that is missing, unreadable or says
138
+ * otherwise does not agree.
139
+ */
140
+ function scriptedAppUrlBundle(bundle, runId) {
141
+ if (!isRecord(bundle) || bundle.runId !== runId || bundle.subject !== undefined)
142
+ return false;
143
+ const { lifecycle, providerResources, cost, outcome } = bundle;
144
+ const scripted = Array.isArray(lifecycle) &&
145
+ lifecycle.length > 0 &&
146
+ lifecycle.every((entry) => isRecord(entry) &&
147
+ typeof entry.event === "string" &&
148
+ entry.event.startsWith("scripted-lab."));
149
+ if (!scripted)
150
+ return false;
151
+ if (providerResources !== undefined &&
152
+ !(Array.isArray(providerResources) && providerResources.length === 0))
153
+ return false;
154
+ if (cost !== undefined && !(isRecord(cost) && cost.desktopMinutes === null))
155
+ return false;
156
+ if (!isRunOutcome(outcome))
157
+ return false;
158
+ if (outcome.state !== "finished")
159
+ return true;
160
+ const { failures, warnings = [] } = outcome.execution;
161
+ return ![...failures, ...warnings].some((failure) => CLEANUP_KINDS.has(failure.kind));
119
162
  }
120
163
  /**
121
164
  * Kill the sandboxes left by interrupted `humanish study check` probes. A probe journals under
package/dist/run/run.d.ts CHANGED
@@ -11,6 +11,8 @@ interface StartRunOptions {
11
11
  mintRunId: () => string;
12
12
  mode: "dry-run" | "live";
13
13
  study?: RunStudyProvenance | undefined;
14
+ /** `none` when the route creates no sandbox for this run; status.json records it for reclaim. */
15
+ sandboxes?: "none" | undefined;
14
16
  /** review.md for the published bundle. */
15
17
  renderReview: (bundle: RunBundle) => string;
16
18
  /** Used by `FinishedRun.renderObserver`; `render` is the `StudyDeps.renderObserver` seam. */
@@ -52,6 +54,12 @@ interface Run {
52
54
  * can never overwrite the final bundle. Routes that throttle snapshots own their timers.
53
55
  */
54
56
  writeSnapshot(bundle: RunBundle): Promise<void>;
57
+ /**
58
+ * A participant's session started: its actor began to act on the subject. FinishedRun's
59
+ * participantsRan reads this, so a session that throws before it returns a trace still counts,
60
+ * and a run that failed before any session (a refused E2B key) does not.
61
+ */
62
+ participantStarted(): void;
55
63
  /**
56
64
  * The one final publication: run.json with `outcome` set from `outcome`, then the status
57
65
  * outcome copied from it, then review.json, review.md, events.ndjson,
@@ -92,12 +100,14 @@ export declare class FinishedRun {
92
100
  readonly runId: string;
93
101
  /** The paths created by startRun and validated at the start of `finish`. */
94
102
  readonly paths: PreparedRunArtifactPaths;
95
- constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>, interrupted: () => boolean);
103
+ constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>, interrupted: () => boolean, participantsRan: () => boolean);
96
104
  /**
97
105
  * A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
98
106
  * when asked, since the signal can arrive after the bundle was published.
99
107
  */
100
108
  get interrupted(): boolean;
109
+ /** The route reported a participant's session started (Run.participantStarted). */
110
+ get participantsRan(): boolean;
101
111
  static isIssued(value: unknown): value is FinishedRun;
102
112
  /**
103
113
  * The run's ok and execution outcome as run.json records them: what the route passed to
package/dist/run/run.js CHANGED
@@ -32,11 +32,13 @@ export class FinishedRun {
32
32
  runId;
33
33
  /** The paths created by startRun and validated at the start of `finish`. */
34
34
  paths;
35
- constructor(key, runId, paths, observer, outcome, recordFailure, interrupted) {
35
+ #participantsRan;
36
+ constructor(key, runId, paths, observer, outcome, recordFailure, interrupted, participantsRan) {
36
37
  if (key !== issueKey)
37
38
  throw new Error("Only Run.finish issues a FinishedRun.");
38
39
  this.runId = runId;
39
40
  this.paths = paths;
41
+ this.#participantsRan = participantsRan;
40
42
  this.#observer = observer;
41
43
  this.#outcome = outcome;
42
44
  this.#recordFailure = recordFailure;
@@ -49,6 +51,10 @@ export class FinishedRun {
49
51
  get interrupted() {
50
52
  return this.#interrupted();
51
53
  }
54
+ /** The route reported a participant's session started (Run.participantStarted). */
55
+ get participantsRan() {
56
+ return this.#participantsRan();
57
+ }
52
58
  static isIssued(value) {
53
59
  return typeof value === "object" && value !== null && #observer in value;
54
60
  }
@@ -106,7 +112,7 @@ function observerTarget(options) {
106
112
  };
107
113
  }
108
114
  function runPublisher(args) {
109
- const { options, runId, paths, runStatus, now, admit } = args;
115
+ const { options, runId, paths, runStatus, now, admit, participantsRan } = args;
110
116
  const observer = observerTarget(options);
111
117
  let pointerWritten = false;
112
118
  // The bundle of the last write asked for, and the outcome every later write carries once a
@@ -176,7 +182,7 @@ function runPublisher(args) {
176
182
  await writeBundleFiles(bundle, finishedOutcome(current), (publicBundle) => runStatus.restate(runStatusOutcome(publicBundle))).catch(() => undefined);
177
183
  return current;
178
184
  }));
179
- return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, () => runStatus.interrupted);
185
+ return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, () => runStatus.interrupted, participantsRan);
180
186
  };
181
187
  const interrupt = async (signal) => {
182
188
  interrupted = { state: "interrupted", ok: false, signal, at: new Date(now()).toISOString() };
@@ -231,7 +237,8 @@ export async function runScope(fn) {
231
237
  const created = await createRunArtifactPaths(options.cwd, runId);
232
238
  if (!created.ok)
233
239
  return created;
234
- // beginRunStatus reads only the mode and the study provenance from the run's options.
240
+ // beginRunStatus reads only the mode, the study provenance and the sandboxes field from
241
+ // the run's options.
235
242
  const runStatus = beginRunStatus(created.paths, { ...options, runId });
236
243
  status = runStatus;
237
244
  let interruptBundle;
@@ -260,7 +267,16 @@ export async function runScope(fn) {
260
267
  const openRun = (options, runId, paths, runStatus) => {
261
268
  const now = options.now ?? Date.now;
262
269
  const createdAt = new Date(now()).toISOString();
263
- const publisher = runPublisher({ options, runId, paths, runStatus, now, admit });
270
+ let participantsRan = false;
271
+ const publisher = runPublisher({
272
+ options,
273
+ runId,
274
+ paths,
275
+ runStatus,
276
+ now,
277
+ admit,
278
+ participantsRan: () => participantsRan,
279
+ });
264
280
  let finishCalled = false;
265
281
  const checkIdentity = (bundle) => bundle.runId !== runId || bundle.mode !== options.mode
266
282
  ? "The bundle names another run or mode than this run."
@@ -271,6 +287,9 @@ export async function runScope(fn) {
271
287
  mode: options.mode,
272
288
  ...(options.study === undefined ? {} : { study: options.study }),
273
289
  paths,
290
+ participantStarted() {
291
+ participantsRan = true;
292
+ },
274
293
  writeSnapshot(bundle) {
275
294
  if (closed)
276
295
  return refuse("The run scope has closed.");
@@ -53,6 +53,11 @@ export interface RunStatusRecord {
53
53
  * Read either through studyProvenanceOf.
54
54
  */
55
55
  lab?: RunStudyProvenance;
56
+ /**
57
+ * `none` when the route recorded at start that this run creates no sandbox, as a scripted run
58
+ * against an app-url subject does. Absent when the run may create one. Reclaim reads it.
59
+ */
60
+ sandboxes?: "none";
56
61
  /** The pid that owns the run, for local liveness checks. */
57
62
  pid: number;
58
63
  startedAt: string;
@@ -87,6 +92,7 @@ export interface BeginRunStatusOptions {
87
92
  runId: string;
88
93
  mode: "dry-run" | "live";
89
94
  study?: RunStudyProvenance | undefined;
95
+ sandboxes?: "none" | undefined;
90
96
  }
91
97
  /**
92
98
  * Start a run's status record and keep it fresh. Fire-and-forget by design: a status write that
@@ -64,6 +64,7 @@ export function beginRunStatus(runPaths, options) {
64
64
  state: "running",
65
65
  mode: options.mode,
66
66
  ...studyFields(options.study),
67
+ ...(options.sandboxes === undefined ? {} : { sandboxes: options.sandboxes }),
67
68
  pid: process.pid,
68
69
  startedAt,
69
70
  updatedAt: startedAt,
@@ -5,7 +5,7 @@ import { bindExistingRunArtifactPaths, RUNS_RELATIVE_ROOT, isSafeRunIdSegment, r
5
5
  import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, } from "./contained-output.js";
6
6
  import { RUN_BUNDLE_FILE } from "./bundle.js";
7
7
  import { isReviewSummary, isRunOutcome } from "./bundle-shape.js";
8
- import { bundleDisplayFacts, runDisplay } from "./display.js";
8
+ import { bundleDisplayFacts, displayedReview, runDisplay, } from "./display.js";
9
9
  import { RUN_STATUS_FILE } from "./status.js";
10
10
  import { readLatest, readRunJsonIfExists, resolveRunPath } from "./locate.js";
11
11
  import { withCuaReviewProvenance } from "./outcomes.js";
@@ -101,22 +101,33 @@ export async function listRuns(cwdInput) {
101
101
  * that is not an object naming a run gets none.
102
102
  */
103
103
  async function listedRunDisplay(runPaths, bundle) {
104
- if (!isRecord(bundle) || typeof bundle.runId !== "string")
104
+ const displayed = displayedBundle(bundle);
105
+ if (displayed === undefined)
105
106
  return {};
106
- const outcome = isRunOutcome(bundle.outcome) ? bundle.outcome : undefined;
107
- const status = outcome === undefined ? await readRunJsonIfExists(runPaths, RUN_STATUS_FILE) : undefined;
108
- const facts = bundleDisplayFacts({
107
+ const status = displayed.outcome === undefined
108
+ ? await readRunJsonIfExists(runPaths, RUN_STATUS_FILE)
109
+ : undefined;
110
+ return { display: runDisplay(bundleDisplayFacts(displayed, status)) };
111
+ }
112
+ /** The fields runDisplay reads from an unchecked run.json; none when it does not name a run. */
113
+ function displayedBundle(bundle) {
114
+ if (!isRecord(bundle) || typeof bundle.runId !== "string")
115
+ return undefined;
116
+ return {
109
117
  runId: bundle.runId,
110
118
  ...(typeof bundle.mode === "string" ? { mode: bundle.mode } : {}),
111
119
  ...(isRecord(bundle.review) && typeof bundle.review.verdict === "string"
112
120
  ? { review: { verdict: bundle.review.verdict } }
113
121
  : {}),
114
122
  ...(Array.isArray(bundle.simulations)
115
- ? { simulations: bundle.simulations.filter(isRecord) }
123
+ ? {
124
+ simulations: bundle.simulations
125
+ .filter(isRecord)
126
+ .map((record) => (typeof record.status === "string" ? { status: record.status } : {})),
127
+ }
116
128
  : {}),
117
- ...(outcome === undefined ? {} : { outcome }),
118
- }, status);
119
- return { display: runDisplay(facts) };
129
+ ...(isRunOutcome(bundle.outcome) ? { outcome: bundle.outcome } : {}),
130
+ };
120
131
  }
121
132
  function runsUnavailableResult(cwd, error) {
122
133
  return {
@@ -159,10 +170,14 @@ export async function readReview(cwdInput, runInput) {
159
170
  const projected = isRecord(bundle) && Array.isArray(bundle.streams)
160
171
  ? withCuaReviewProvenance(review, bundle.streams.filter(isRecord))
161
172
  : review;
173
+ const displayed = displayedBundle(bundle);
174
+ const { display } = runPaths ? await listedRunDisplay(runPaths, bundle) : {};
162
175
  return {
163
- ...projected,
176
+ ...(displayed === undefined || display === undefined
177
+ ? projected
178
+ : displayedReview(projected, displayed, display)),
164
179
  path: path.relative(cwd, path.join(runPaths.absoluteRunRoot, "review.json")),
165
180
  runId: path.basename(runPaths.absoluteRunRoot),
166
- ...(runPaths ? await listedRunDisplay(runPaths, bundle) : {}),
181
+ ...(display === undefined ? {} : { display }),
167
182
  };
168
183
  }
@@ -10,7 +10,7 @@ import { REPO_SLUG_PATTERN } from "./parse/values.js";
10
10
  import { declaredTargets } from "./plan-participants.js";
11
11
  import { actorResolvesToComputerUse, actorResolvesToScriptedBrowser, actorResolvesToTerminal, computerUseParticipantCount, registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, isComputerUseComposition, } from "./routing.js";
12
12
  import { cloneTargetValidationReason, computerUseValidationReason, sharedWorldValidationReason, } from "./validation.js";
13
- import { rosterOf } from "./parse/actors.js";
13
+ import { actorOf, participantList, declaredParticipantCount, surfaceCount, modeOf, scenarioRefOf, declaresSharedWorld, } from "./study-fields.js";
14
14
  /** The first composition rule a config breaks, in the parser's order, or null. */
15
15
  export function compositionReason(config) {
16
16
  return (thisRepoValidationReason(config) ??
@@ -25,7 +25,7 @@ export function compositionReason(config) {
25
25
  (isComputerUseComposition(config) ? computerUseValidationReason(config) : null) ??
26
26
  // Whenever shared world is declared, not only when it routes, so a half-declared shared world
27
27
  // fails with a precise reason instead of running as independent participants.
28
- (config.subject.topology === "shared-world" ? sharedWorldValidationReason(config) : null) ??
28
+ (declaresSharedWorld(config) ? sharedWorldValidationReason(config) : null) ??
29
29
  desktopCliValidationReason(config) ??
30
30
  terminalValidationReason(config) ??
31
31
  cloneActorValidationReason(config));
@@ -39,7 +39,7 @@ function thisRepoValidationReason(config) {
39
39
  if (config.execution?.target) {
40
40
  return "`execution.target` applies only to clone/app-url/local-app subjects; this-repo studies run locally.";
41
41
  }
42
- if (config.scenario?.mode === "live") {
42
+ if (modeOf(config) === "live") {
43
43
  return THIS_REPO_DRY_RUN_ONLY;
44
44
  }
45
45
  }
@@ -54,18 +54,18 @@ function thisRepoValidationReason(config) {
54
54
  // planComputerUseStudy refuses it with HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR.
55
55
  function localAppValidationReason(config) {
56
56
  if (config.subject.source === "local-app") {
57
- const type = config.actors[0]?.type ?? "";
57
+ const type = actorOf(config)?.type ?? "";
58
58
  if (config.execution?.target !== undefined && config.execution.target !== "local") {
59
59
  return "A local-app subject drives a local dev server in this process, with no E2B desktop. Set `execution.target: local` or omit it. To run on a hosted desktop, use an app-url subject with `execution.target: e2b-desktop`.";
60
60
  }
61
61
  if (!actorResolvesToComputerUse(type)) {
62
- return `actors[0].type must be a registered computer-use actor for local-app subjects (one of: ${registeredComputerUseActors().join(", ")}); the caller's custom executor runs the computer-use loop. Got "${type}".`;
62
+ return `actor.type must be a registered computer-use actor for local-app subjects (one of: ${registeredComputerUseActors().join(", ")}); the caller's custom executor runs the computer-use loop. Got "${type}".`;
63
63
  }
64
64
  if (computerUseParticipantCount(config) > 1) {
65
- return "Fan-out to more than one participant is not supported on the in-process/local-app route: fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Set actors[0].count to 1 and drop actors[0].lanes (use an app-url or clone subject on execution.target: e2b-desktop for fan-out).";
65
+ return "Fan-out to more than one participant is not supported on the in-process/local-app route: fan-out provisions one independent E2B desktop per participant, which the in-process route deliberately skips. Set participants to 1 and drop participants (use an app-url or clone subject on execution.target: e2b-desktop for fan-out).";
66
66
  }
67
- if (rosterOf(config.actors[0]) !== undefined) {
68
- return "`actors[0].lanes` (fan-out roster) is not supported on the in-process/local-app route: it provisions one E2B desktop per participant, which this route skips. Use an app-url or clone subject with execution.target: e2b-desktop.";
67
+ if (participantList(config) !== undefined) {
68
+ return "`participants` (fan-out roster) is not supported on the in-process/local-app route: it provisions one E2B desktop per participant, which this route skips. Use an app-url or clone subject with execution.target: e2b-desktop.";
69
69
  }
70
70
  if (config.policies?.allowPublicTargets === true) {
71
71
  return "`policies.allowPublicTargets` does not apply to a local-app subject: it is always a loopback dev server, so there is no public target to allow. Remove the setting.";
@@ -78,18 +78,18 @@ function localAppValidationReason(config) {
78
78
  // app; a computer-use actor drives a hosted desktop browser. Fail closed on mis-configs.
79
79
  function appUrlValidationReason(config) {
80
80
  if (config.subject.source === "app-url") {
81
- const type = config.actors[0]?.type ?? "";
81
+ const type = actorOf(config)?.type ?? "";
82
82
  if (actorResolvesToScriptedBrowser(type)) {
83
83
  // Scripted-browser route (all fail-closed, so claims match mechanism: a field that cannot act on
84
84
  // this route is rejected, never silently ignored).
85
85
  if (config.execution?.target !== undefined && config.execution.target !== "local") {
86
86
  return "A scripted-browser actor on an app-url subject runs on this machine. Set `execution.target: local` or omit it. To run scripted steps on a hosted desktop, use a clone subject.";
87
87
  }
88
- if (!config.scenario?.ref) {
89
- return "A scripted-browser study needs `scenario.ref`: the actor runs the browser steps in that scenario file.";
88
+ if (!scenarioRefOf(config)) {
89
+ return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
90
90
  }
91
- if ((config.actors[0]?.count ?? 1) > 2) {
92
- return "A scripted-browser study takes `actors[0].count` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
91
+ if ((surfaceCount(config) ?? 1) > 2) {
92
+ return "A scripted-browser study takes `surfaces` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
93
93
  }
94
94
  if (config.policies?.redactScreenshots === true) {
95
95
  return "`policies.redactScreenshots: true` is not supported on the scripted-browser route yet, so its screenshots would be stored unredacted in .humanish/. Remove the setting, or use a computer-use actor, which blurs screenshots as it takes them.";
@@ -106,7 +106,7 @@ function appUrlValidationReason(config) {
106
106
  return "app-url computer-use subjects require `execution.target: local` or `e2b-desktop`.";
107
107
  }
108
108
  if (!actorResolvesToComputerUse(type)) {
109
- return `actors[0].type must be a registered computer-use actor for app-url × e2b-desktop studies (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`;
109
+ return `actor.type must be a registered computer-use actor for app-url × e2b-desktop studies (one of: ${registeredComputerUseActors().join(", ")}); for local scripted execution use a registered scripted-browser actor (${registeredScriptedBrowserActors().join(", ")}). Got "${type}".`;
110
110
  }
111
111
  // Multi-participant fan-out is consumed on this route (`per-lane-worlds`; the shared cua
112
112
  // cross-validation below enforces `lanes`/`count` XOR rules, the 16 cap, and the
@@ -116,7 +116,7 @@ function appUrlValidationReason(config) {
116
116
  const targets = [config.subject.appUrl ?? "", ...declaredTargets(config)];
117
117
  const unsafeTarget = targets.find((target) => !config.policies?.allowPublicTargets && !isLoopbackUrl(target));
118
118
  if (unsafeTarget !== undefined) {
119
- return "`subject.appUrl` and `actors[0].lanes[].target` must be loopback URLs (127.0.0.1 or localhost). To drive a deployed or preview URL you own, set `policies.allowPublicTargets: true`.";
119
+ return "`subject.appUrl` and `participants[].target` must be loopback URLs (127.0.0.1 or localhost). To drive a deployed or preview URL you own, set `policies.allowPublicTargets: true`.";
120
120
  }
121
121
  }
122
122
  }
@@ -125,7 +125,7 @@ function appUrlValidationReason(config) {
125
125
  // Scripted-browser actors on any other subject: only a provisioned clone on e2b-desktop.
126
126
  function scriptedBrowserValidationReason(config) {
127
127
  if (config.subject.source !== "app-url" &&
128
- actorResolvesToScriptedBrowser(config.actors[0]?.type)) {
128
+ actorResolvesToScriptedBrowser(actorOf(config)?.type)) {
129
129
  if (config.subject.source !== "clone") {
130
130
  return "scripted-browser actors require `subject.source: app-url` (a running app at a loopback URL) or `subject.source: clone` with `execution.target: e2b-desktop` (a provisioned synthetic subject).";
131
131
  }
@@ -148,14 +148,14 @@ function scriptedBrowserValidationReason(config) {
148
148
  if (config.subject.clone?.fanout !== undefined || config.subject.clone?.keep === true) {
149
149
  return "A clone scripted-browser study does not support `subject.clone.fanout` or `subject.clone.keep` yet: its subject is always one sandbox, removed after the run.";
150
150
  }
151
- if (!config.scenario?.ref) {
152
- return "A scripted-browser study needs `scenario.ref`: the actor runs the browser steps in that scenario file.";
151
+ if (!scenarioRefOf(config)) {
152
+ return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
153
153
  }
154
- if ((config.actors[0]?.count ?? 1) > 2) {
155
- return "A scripted-browser study takes `actors[0].count` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
154
+ if ((surfaceCount(config) ?? 1) > 2) {
155
+ return "A scripted-browser study takes `surfaces` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
156
156
  }
157
- if (rosterOf(config.actors[0]) !== undefined) {
158
- return "`actors[0].lanes` is not supported on the scripted-browser route yet. Use `actors[0].count` to choose the desktop and mobile surfaces.";
157
+ if (participantList(config) !== undefined) {
158
+ return "`participants` is not supported on the scripted-browser route yet. Use `surfaces` to choose the desktop and mobile surfaces.";
159
159
  }
160
160
  if (config.policies?.redactScreenshots === true) {
161
161
  return "`policies.redactScreenshots: true` is not supported on the scripted-browser route yet, so its screenshots would be stored unredacted in .humanish/. Remove the setting, or use a computer-use actor, which blurs screenshots as it takes them.";
@@ -183,7 +183,7 @@ function scriptedBrowserValidationReason(config) {
183
183
  function cloneComputerUseValidationReason(config) {
184
184
  if (config.subject.source === "clone" &&
185
185
  config.execution?.target === "e2b-desktop" &&
186
- actorResolvesToComputerUse(config.actors[0]?.type)) {
186
+ actorResolvesToComputerUse(actorOf(config)?.type)) {
187
187
  if (!config.subject.serve) {
188
188
  return "A clone subject on the computer-use route needs `subject.serve` (start and url): humanish starts the app in the sandbox before the participant opens it.";
189
189
  }
@@ -210,8 +210,8 @@ function localTreeValidationReason(config) {
210
210
  if (config.execution?.target !== "e2b-desktop") {
211
211
  return "local-tree subjects require `execution.target: e2b-desktop`: the packed working tree is provisioned and served inside a hosted desktop sandbox; there is no local route for a local-tree subject.";
212
212
  }
213
- if (!actorResolvesToComputerUse(config.actors[0]?.type)) {
214
- return `actors[0].type must be a registered computer-use actor for local-tree subjects (one of: ${registeredComputerUseActors().join(", ")}); the actor drives the hosted desktop that serves the packed working tree. Got "${config.actors[0]?.type ?? ""}".`;
213
+ if (!actorResolvesToComputerUse(actorOf(config)?.type)) {
214
+ return `actor.type must be a registered computer-use actor for local-tree subjects (one of: ${registeredComputerUseActors().join(", ")}); the actor drives the hosted desktop that serves the packed working tree. Got "${actorOf(config)?.type ?? ""}".`;
215
215
  }
216
216
  }
217
217
  return null;
@@ -238,7 +238,7 @@ function desktopCliValidationReason(config) {
238
238
  if (config.execution?.target !== undefined && config.execution.target !== "e2b-desktop") {
239
239
  return "A desktop-cli subject runs on a hosted desktop. Set `execution.target: e2b-desktop` or omit it.";
240
240
  }
241
- if (!actorResolvesToComputerUse(config.actors[0]?.type ?? "")) {
241
+ if (!actorResolvesToComputerUse(actorOf(config)?.type ?? "")) {
242
242
  return "desktop-cli subjects need a registered computer-use actor: the participant reads the screen and types, which is what makes an interactive surface studiable at all.";
243
243
  }
244
244
  const install = config.subject.product.install;
@@ -255,15 +255,15 @@ function desktopCliValidationReason(config) {
255
255
  // fan-out lands.
256
256
  function terminalValidationReason(config) {
257
257
  if (config.subject.source === "terminal-product") {
258
- const type = config.actors[0]?.type ?? "";
258
+ const type = actorOf(config)?.type ?? "";
259
259
  if (config.execution?.target !== undefined && config.execution.target !== "e2b-terminal") {
260
260
  return "A terminal-product subject runs its agent in an E2B shell. Set `execution.target: e2b-terminal` or omit it; `local` and `e2b-desktop` are not supported.";
261
261
  }
262
262
  if (!actorResolvesToTerminal(type)) {
263
- return `actors[0].type must be a registered terminal actor for terminal-product subjects (one of: ${registeredTerminalActors().join(", ")}). Got "${type}".`;
263
+ return `actor.type must be a registered terminal actor for terminal-product subjects (one of: ${registeredTerminalActors().join(", ")}). Got "${type}".`;
264
264
  }
265
- if ((config.actors[0]?.count ?? 1) > 1) {
266
- return "Terminal fan-out to more than one participant is not supported yet; set actors[0].count to 1.";
265
+ if ((declaredParticipantCount(config) ?? 1) > 1) {
266
+ return "Terminal fan-out to more than one participant is not supported yet; set participants to 1.";
267
267
  }
268
268
  }
269
269
  else if (config.execution?.target === "e2b-terminal") {
@@ -271,7 +271,7 @@ function terminalValidationReason(config) {
271
271
  // mis-config: reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
272
272
  return "`execution.target: e2b-terminal` requires `subject.source: terminal-product` with a registered terminal actor.";
273
273
  }
274
- else if (actorResolvesToTerminal(config.actors[0]?.type)) {
274
+ else if (actorResolvesToTerminal(actorOf(config)?.type)) {
275
275
  // A registered terminal actor on a non-terminal-product subject: rejected, never ignored (the
276
276
  // terminal agent only studies a declared terminal-product from public surfaces).
277
277
  return "terminal actors require `subject.source: terminal-product` (a CLI/product the agent studies from public surfaces); other subjects are not supported on this route.";
@@ -283,7 +283,7 @@ function terminalValidationReason(config) {
283
283
  // Terminal actors were already refused above with their own message.
284
284
  function cloneActorValidationReason(config) {
285
285
  if (config.subject.source === "clone") {
286
- const type = config.actors[0]?.type ?? "";
286
+ const type = actorOf(config)?.type ?? "";
287
287
  if (!actorResolvesToComputerUse(type) && !actorResolvesToScriptedBrowser(type)) {
288
288
  return `clone subjects need a registered computer-use actor (one of: ${registeredComputerUseActors().join(", ")}) or scripted-browser actor (one of: ${registeredScriptedBrowserActors().join(", ")}); humanish clones and serves the app for that participant to drive. Got "${type}".`;
289
289
  }
@@ -1,4 +1,4 @@
1
- import { type StudyParseResult } from "./types.js";
1
+ import { type StudyConfig, type StudyParseResult } from "./types.js";
2
2
  /** The message for a humanish.lab.v2 document, which the package and the CLI no longer run. */
3
3
  export declare const V2_UNSUPPORTED_MESSAGE = "This is a humanish.lab.v2 study, which humanish no longer reads. Convert its file with humanish migrate <path>.";
4
4
  /**
@@ -10,6 +10,13 @@ export declare function parseStudy(raw: unknown): StudyParseResult;
10
10
  * Validate a parsed YAML object, humanish.study.v3 or humanish.lab.v2, into a StudyConfig. Pure:
11
11
  * the caller owns file IO. Structural validation only. Fields the engine does not yet consume are
12
12
  * accepted but reported in `warnings` so `study show` never silently swallows a setting that does
13
- * nothing. migrate's conversion (convert.ts) calls it directly, since it reads v2 files.
13
+ * nothing. The v2 parser's tests call it directly; migrate reads v2 files through migrate/v2.ts.
14
14
  */
15
15
  export declare function parseStudyDocument(raw: unknown): StudyParseResult;
16
+ /**
17
+ * The checks that read more than one section of a parsed config, and the defaults they fill:
18
+ * shared-world `execution.concurrency`, `comms.email.recipients` and the local browser's defaults.
19
+ * parseV2 runs them once every section has parsed. parse/study.ts runs them on its v2 view of a v3
20
+ * study.
21
+ */
22
+ export declare function checkStudyConfig(config: StudyConfig): StudyParseResult;