humanish 0.135.0 → 0.136.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.
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveExistingRunDirectory, resolveLatestRunDirectory, RUNS_RELATIVE_ROOT, validatePreparedRunArtifactPaths, } from "./paths.js";
3
3
  import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
4
- import { RUN_BUNDLE_FILE } from "./bundle.js";
4
+ import { RUN_BUNDLE_FILE, } from "./bundle.js";
5
5
  import { isRunBundle, isRunPointer } from "./bundle-shape.js";
6
6
  import { displayedTraces, loadRunTraces, loadStreamTraces } from "./stream-trace.js";
7
7
  import { readUnchangedFile } from "./evidence-files.js";
@@ -119,29 +119,36 @@ export async function loadRunBundle(cwdInput, runInput) {
119
119
  }
120
120
  return loadRunBundlePrepared(cwd, runPaths);
121
121
  }
122
+ /** loadRunBundle for a caller that already holds prepared paths. Revalidates them first. */
123
+ export async function loadRunBundlePrepared(cwdInput, runPaths) {
124
+ const run = await readPreparedRunJson(cwdInput, runPaths);
125
+ // A trace that does not match its index throws StreamTraceRefusedError, naming the participant.
126
+ return run === null ? null : { ...run, bundle: await loadRunTraces(runPaths, run.bundle) };
127
+ }
122
128
  /**
123
- * loadRunBundle for a caller that already holds prepared paths. Revalidates them first. With
124
- * `display`, a participant whose trace the loader refuses, such as one past the read limit, is
125
- * kept as displayedTraces shows it, for a page that shows the run.
129
+ * loadRunBundlePrepared for a page that shows the run: a participant whose trace the loader
130
+ * refuses, such as one past the read limit, is kept as displayedTraces shows it.
126
131
  */
127
- export async function loadRunBundlePrepared(cwdInput, runPaths, options = {}) {
132
+ export async function loadDisplayedRunBundle(cwdInput, runPaths) {
133
+ const run = await readPreparedRunJson(cwdInput, runPaths);
134
+ if (run === null)
135
+ return null;
136
+ return { ...run, bundle: displayedTraces(await loadStreamTraces(runPaths, run.bundle)) };
137
+ }
138
+ /** The prepared run's run.json when it is a run bundle, after revalidating the paths; else null. */
139
+ async function readPreparedRunJson(cwdInput, runPaths) {
128
140
  const cwd = path.resolve(cwdInput);
129
141
  await validatePreparedRunArtifactPaths(runPaths);
130
- const bundlePath = path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE);
131
142
  const read = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
132
143
  // A run.json it refuses is no reason to call the run absent: the caller reports why.
133
144
  if (read.status === "refused")
134
145
  throw new ContainedReadRefusedError(RUN_BUNDLE_FILE, read);
135
146
  const bundle = read.status === "read" ? read.value : null;
136
- if (!isRunBundle(bundle)) {
147
+ if (!isRunBundle(bundle))
137
148
  return null;
138
- }
139
149
  return {
140
- // A trace that does not match its index throws StreamTraceRefusedError, naming the participant.
141
- bundle: options.display === true
142
- ? displayedTraces(await loadStreamTraces(runPaths, bundle))
143
- : await loadRunTraces(runPaths, bundle),
144
- bundlePath: path.relative(cwd, bundlePath),
150
+ bundle,
151
+ bundlePath: path.relative(cwd, path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE)),
145
152
  runDir: runPaths.absoluteRunRoot,
146
153
  };
147
154
  }
package/dist/run/paths.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { lstat, mkdir, readdir, realpath, rmdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { mapWithConcurrency } from "./concurrency.js";
3
4
  import { isNodeError } from "./type-guards.js";
4
5
  export const RUNS_RELATIVE_ROOT = path.join(".humanish", "runs");
5
6
  const LATEST_RUN_RELATIVE_PATH = path.join(RUNS_RELATIVE_ROOT, "latest.json");
@@ -262,35 +263,44 @@ export async function assertRegularFileOrMissing(filePath, subject = "humanish s
262
263
  throw error;
263
264
  }
264
265
  }
265
- async function assertNoSymlinkDescendants(directory) {
266
- const entries = await readdir(directory, { withFileTypes: true });
267
- for (const entry of entries) {
268
- const child = path.join(directory, entry.name);
269
- // A run directory can be written concurrently with this walk: the atomic writer creates a
270
- // `.humanish-write-*.tmp` sibling and renames it, and a run now also refreshes its own status
271
- // record on a cadence. An entry that vanished between readdir and lstat therefore proves
272
- // nothing except that it is gone, and a path that no longer exists cannot be a symlink escape:
273
- // skip it rather than failing the containment check on a benign race.
274
- let stats;
275
- try {
276
- stats = await lstat(child);
277
- }
278
- catch (error) {
279
- if (isNodeError(error) && error.code === "ENOENT") {
280
- continue;
281
- }
282
- throw error;
283
- }
284
- if (stats.isSymbolicLink()) {
285
- throw new Error("humanish run directories must not contain symbolic links.");
286
- }
287
- if (stats.isDirectory()) {
288
- await assertNoSymlinkDescendants(child);
289
- }
290
- else if (!stats.isFile() || stats.nlink > 1) {
291
- throw new Error("humanish run leaves must be single-link regular files, not hardlinks or special files.");
292
- }
266
+ // Entries the storage check lists or lstats at once. A run folder holds one entry per screenshot,
267
+ // about 70,000 for 24 participants working 8 hours, and one call at a time left the process idle
268
+ // between calls.
269
+ const STORAGE_CHECK_CONCURRENCY = 32;
270
+ /** Every entry under `root` is a directory or a single-link regular file, checked a level at a time. */
271
+ async function assertNoSymlinkDescendants(root) {
272
+ let directories = [root];
273
+ while (directories.length > 0) {
274
+ const listed = await mapWithConcurrency(directories, STORAGE_CHECK_CONCURRENCY, async (dir) => (await readdir(dir, { withFileTypes: true })).map((entry) => path.join(dir, entry.name)));
275
+ const children = listed.flat();
276
+ const kinds = await mapWithConcurrency(children, STORAGE_CHECK_CONCURRENCY, storageEntryKind);
277
+ directories = children.filter((_, index) => kinds[index] === "directory");
278
+ }
279
+ }
280
+ async function storageEntryKind(child) {
281
+ // A run directory can be written concurrently with this walk: the atomic writer creates a
282
+ // `.humanish-write-*.tmp` sibling and renames it, and a run also refreshes its own status record
283
+ // on a cadence. An entry that vanished between readdir and lstat therefore proves nothing except
284
+ // that it is gone, and a path that no longer exists cannot be a symlink escape: skip it rather
285
+ // than failing the containment check on a benign race.
286
+ let stats;
287
+ try {
288
+ stats = await lstat(child);
289
+ }
290
+ catch (error) {
291
+ if (isNodeError(error) && error.code === "ENOENT")
292
+ return "gone";
293
+ throw error;
294
+ }
295
+ if (stats.isSymbolicLink()) {
296
+ throw new Error("humanish run directories must not contain symbolic links.");
297
+ }
298
+ if (stats.isDirectory())
299
+ return "directory";
300
+ if (!stats.isFile() || stats.nlink > 1) {
301
+ throw new Error("humanish run leaves must be single-link regular files, not hardlinks or special files.");
293
302
  }
303
+ return "file";
294
304
  }
295
305
  async function capturePreparedRunArtifactPaths(paths, runRoot) {
296
306
  const physicalRunRoot = await realpath(runRoot);
@@ -1,4 +1,4 @@
1
- import { type RunBundle, type StoredRunBundle } from "./bundle.js";
1
+ import { type DisplayedRunBundle, type RunBundle, type StoredRunBundle } from "./bundle.js";
2
2
  import { type PreparedOutputRoot } from "./contained-output.js";
3
3
  import { type LiveItemsCursor } from "./live-items.js";
4
4
  import type { LiveItemsIndex, RunStream, StoredRunStream, WithoutRequestLists } from "./streams.js";
@@ -131,7 +131,7 @@ export declare function displayedTraces(loaded: {
131
131
  bundle: RunBundle;
132
132
  refused: readonly StreamTraceRefusedError[];
133
133
  live?: readonly LiveItemsRead[];
134
- }): RunBundle;
134
+ }): DisplayedRunBundle;
135
135
  /**
136
136
  * What `reduce` returns for each stream of the bundle, loaded as loadRunTraces loads it, in stream
137
137
  * order; the first refusal throws. Streams are loaded one at a time and nothing of a stream's files
@@ -178,7 +178,7 @@ function containedReader(runPaths) {
178
178
  }
179
179
  /**
180
180
  * The stream from the bytes of its trace file: its actor with the file's items, and its artifacts
181
- * followed by the file's screenshot entries, without the index.
181
+ * followed by the file's screenshot entries, without the index. Its live actor is as it was.
182
182
  */
183
183
  function joinTrace(stream, bytes) {
184
184
  const index = stream.trace;
@@ -246,12 +246,19 @@ function assertIndex(stream) {
246
246
  throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
247
247
  }
248
248
  /**
249
- * A stream of run.json typed as a loaded one, for the streams the loader joins no trace into: one
250
- * without an index, which v1 wrote with its actor's lists, and a refused one, kept as run.json
251
- * holds it. From v4 a refused stream's actor has none of the lists, and its `conversation` lacks
252
- * the `requests` ActorTrace declares; the loader's `refused` names each such stream.
249
+ * The joined stream as a loaded one, once its actor's conversation, if any, holds the requests
250
+ * list ActorTrace types as present. The writer writes the list with every conversation, in
251
+ * run.json before v4 and in the trace file from v4, so a stream without it was edited, and a reader
252
+ * that counts the requests would throw. Items a v1 run.json or a humanish.live-actor.v1 live actor
253
+ * holds inline are taken as they are.
253
254
  */
254
- const unjoined = (stream) => stream;
255
+ function asLoaded(stream) {
256
+ const conversation = stream.actor?.conversation;
257
+ if (conversation !== undefined &&
258
+ !(isRecord(conversation) && Array.isArray(conversation.requests)))
259
+ throw new StreamTraceRefusedError(stream.id, "its actor records a conversation without the requests list");
260
+ return stream;
261
+ }
255
262
  /** A live actor whose items run.json holds inline: humanish.live-actor.v1, or one with no schema. */
256
263
  const isInlineLiveActor = (live) => live.schema === LIVE_ACTOR_V1_SCHEMA || live.schema === undefined;
257
264
  /** A live file that could not be read, which a writer that moved on may have removed. */
@@ -345,7 +352,7 @@ async function readLiveTail(read, stream, index, after) {
345
352
  */
346
353
  async function readStreamTrace(read, stream, maxBytes, liveAfter) {
347
354
  const files = [];
348
- let joined = unjoined(stream);
355
+ let joined = stream;
349
356
  if (stream.trace !== undefined) {
350
357
  assertIndex(stream);
351
358
  if (stream.trace.bytes > maxBytes)
@@ -359,7 +366,11 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
359
366
  const live = await readLiveItems(read, joined, maxBytes, liveAfter);
360
367
  if (live.file !== undefined)
361
368
  files.push(live.file);
362
- return { stream: live.stream, files, ...(live.read === undefined ? {} : { live: live.read }) };
369
+ return {
370
+ stream: asLoaded(live.stream),
371
+ files,
372
+ ...(live.read === undefined ? {} : { live: live.read }),
373
+ };
363
374
  }
364
375
  /**
365
376
  * The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
@@ -500,7 +511,9 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
500
511
  if (!(error instanceof StreamTraceRefusedError))
501
512
  throw error;
502
513
  refused.push(error);
503
- streams.push(unjoined(stream));
514
+ // Kept as run.json holds it, typed as loaded: from v2 its actor has no items, and from v4
515
+ // none of the lists. The loader's `refused` names it, and displayedTraces shows it so.
516
+ streams.push(stream);
504
517
  }
505
518
  }
506
519
  return { bundle: { ...bundle, streams }, refused, files, live };
@@ -523,12 +536,12 @@ export function displayedTraces(loaded) {
523
536
  if (reason === undefined)
524
537
  return stream;
525
538
  const { liveActor: _live, trace: _index, actor, ...rest } = stream;
526
- return unjoined({
539
+ return {
527
540
  ...rest,
528
541
  ...(actor === undefined ? {} : { actor: { ...withoutRequestLists(actor), items: [] } }),
529
542
  artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
530
543
  traceUnavailable: reason,
531
- });
544
+ };
532
545
  });
533
546
  const events = loaded.bundle.streams
534
547
  .filter((stream) => reasons.has(stream.id))
@@ -327,11 +327,26 @@ export type WithoutRequestLists<T> = Omit<T, "providerRequests" | "tokenUsage" |
327
327
  [K in "conversation" & keyof T]?: Omit<NonNullable<T[K]>, "requests"> | undefined;
328
328
  };
329
329
  /**
330
- * A stream as run.json holds it, in any version: its actor and live actor WithoutRequestLists.
331
- * loadStreamTraces joins the lists from the trace file.
330
+ * `T` WithoutRequestLists and with `items` optional: a v1 run.json holds the items inline, and from
331
+ * v2 (an actor) or v3 (a live actor) a file holds them.
332
+ */
333
+ type StoredTrace<T extends {
334
+ items: unknown;
335
+ }> = WithoutRequestLists<Omit<T, "items">> & Partial<Pick<T, "items">>;
336
+ /**
337
+ * A stream as run.json holds it, in any version: its actor and live actor without the per-request
338
+ * lists, and with items only in a v1 run.json. loadStreamTraces joins both from the files.
332
339
  */
333
340
  export type StoredRunStream = Omit<RunStream, "actor" | "liveActor"> & {
341
+ actor?: StoredTrace<ActorTrace>;
342
+ liveActor?: StoredTrace<NonNullable<RunStream["liveActor"]>>;
343
+ };
344
+ /**
345
+ * A stream as a reader that shows the run has it (displayedTraces in src/run/stream-trace.ts):
346
+ * loaded, or with `traceUnavailable` when the loader refused its trace. A refused stream's actor
347
+ * keeps run.json's summary, which from v4 has none of the per-request lists, so the type has none.
348
+ */
349
+ export type DisplayedRunStream = Omit<RunStream, "actor"> & {
334
350
  actor?: WithoutRequestLists<ActorTrace>;
335
- liveActor?: WithoutRequestLists<NonNullable<RunStream["liveActor"]>>;
336
351
  };
337
352
  export {};
@@ -1,6 +1,9 @@
1
1
  import type { DwellWindow } from "../actors/stop-conditions.js";
2
2
  import type { ParticipantSchedule } from "./arrivals.js";
3
- /** One participant as the plan has it: its schedule, its dwell window and its mission. */
3
+ /**
4
+ * One participant as the plan has it: its schedule, its dwell window, its mission and instruction,
5
+ * and its task goals.
6
+ */
4
7
  export interface EstimatedParticipant {
5
8
  readonly schedule: ParticipantSchedule;
6
9
  readonly limits: {
@@ -8,8 +11,22 @@ export interface EstimatedParticipant {
8
11
  };
9
12
  readonly assignment: {
10
13
  readonly mission?: string;
14
+ readonly focus?: string;
11
15
  };
16
+ readonly tasks?: readonly {
17
+ readonly goal: string;
18
+ }[];
12
19
  }
20
+ /**
21
+ * The study fields run.json copies into each participant's stream, and the advice to shorten each.
22
+ * The plan does not keep which form declared the participants, so the advice names both paths.
23
+ */
24
+ declare const COPIED_FIELDS: Readonly<{
25
+ mission: "shorten the mission (actor.mission)";
26
+ focus: "shorten the participants' instructions (participants.instruction or participants[].instruction)";
27
+ tasks: "shorten the task goals (actor.tasks[].goal)";
28
+ }>;
29
+ type CopiedField = keyof typeof COPIED_FIELDS;
13
30
  /** A run's estimated bundle, and the scan's limit for its participants. */
14
31
  export interface BundleEstimate {
15
32
  /** run.json, which analysis reads whole. */
@@ -34,6 +51,8 @@ export interface BundleEstimate {
34
51
  readonly open: number;
35
52
  readonly openMs: number;
36
53
  readonly dwell: boolean;
54
+ /** The copied field with the most bytes over all participants, when one has any. */
55
+ readonly largestField?: CopiedField;
37
56
  };
38
57
  }
39
58
  /**
@@ -51,3 +70,4 @@ export declare function estimateBundle(participants: readonly EstimatedParticipa
51
70
  export declare function bundleLimitReason(estimate: BundleEstimate): string | undefined;
52
71
  /** The estimate beside the limits, for `study check`. */
53
72
  export declare function describeBundle(estimate: BundleEstimate): string;
73
+ export {};
@@ -7,12 +7,16 @@
7
7
  // observer/index.html carries every participant's items gzipped, so with busy participants it
8
8
  // passes verify's 32 MiB long before run.json passes analysis's 16 MiB: at these rates, 20
9
9
  // participants working 8 hours. From run bundle v4 run.json grows by participant, not by model
10
- // request: each participant's per-request lists are only in its trace. run.json holds the mission
11
- // once for the run, once in each participant's stream and once more in the feedback candidate of
12
- // each participant that reports friction or stops. The mission is not capped, and the estimate
13
- // counts that last copy for every participant, so it warns at 100 participants with a mission of
14
- // 75,238 bytes. On 2026-10-13 the longest mission on the live computer-use runs kept on the
15
- // maintainer's machine was 807 characters.
10
+ // request: each participant's per-request lists are only in its trace. run.json holds each
11
+ // participant's text, its mission, its instruction and its task goals, in its stream and again in
12
+ // the feedback candidate of a participant that reports friction or stops, and the first
13
+ // participant's once more in the scenario goal. A synthetic live run of 3 participants that all
14
+ // reported friction held their instructions 3, 2 and 2 times, and the mission and each of 2 task
15
+ // goals 7 times. None of the three fields is capped, and the estimate counts the feedback
16
+ // candidate's copy for every participant, so it warns at 100 participants with 75,238 bytes of
17
+ // text each. On 2026-10-13 the longest mission on the live computer-use runs kept on the
18
+ // maintainer's machine was 807 characters. On 2026-10-14 the longest instruction on the 170 live
19
+ // runs kept there was 4,061 characters, and none declared task goals.
16
20
  // observer-data.json leaves the lists out too and was 0.94 to 1.12 of run.json's size on the live
17
21
  // runs measured, so it passes 32 MiB only after run.json passes 16 MiB, and the estimate does not
18
22
  // check it.
@@ -83,10 +87,11 @@ const RATES = Object.freeze({
83
87
  /**
84
88
  * The page's compact copy of observer-data.json, as a share of run.json before the mission: 0.71
85
89
  * to 0.84 on the live runs written as v4, where neither carries the per-request lists. The page
86
- * holds the mission at its full size once for the run and once for each participant: dry runs of
87
- * 1 and 4 participants grew the page by 2 and 5 copies, as run.json grew, and a 0.128.0 run of 2
88
- * participants with 2 feedback candidates held 5 copies in run.json and 3 on the page. An
89
- * analysis adds each participant's assignment, cut to 8,000 bytes.
90
+ * holds each participant's text at its full size once, and the first participant's once more for
91
+ * the run: dry runs of 1 and 4 participants grew the page by 2 and 5 copies of the mission, as
92
+ * run.json grew, and a 0.128.0 run of 2 participants with 2 feedback candidates held 5 copies in
93
+ * run.json and 3 on the page. The synthetic run of 3 held their instructions 2, 1 and 1 times on
94
+ * the page. An analysis adds each participant's assignment, cut to 8,000 bytes.
90
95
  */
91
96
  observerPageDataShare: 0.85,
92
97
  /**
@@ -107,6 +112,15 @@ const RATES = Object.freeze({
107
112
  participantEntries: 3,
108
113
  });
109
114
  const MIB = 1024 * 1024;
115
+ /**
116
+ * The study fields run.json copies into each participant's stream, and the advice to shorten each.
117
+ * The plan does not keep which form declared the participants, so the advice names both paths.
118
+ */
119
+ const COPIED_FIELDS = Object.freeze({
120
+ mission: "shorten the mission (actor.mission)",
121
+ focus: "shorten the participants' instructions (participants.instruction or participants[].instruction)",
122
+ tasks: "shorten the task goals (actor.tasks[].goal)",
123
+ });
110
124
  /**
111
125
  * The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
112
126
  * declares an end or a cap. A participant with an end works its whole window; one without ends
@@ -119,13 +133,14 @@ export function estimateBundle(participants, sessionMs) {
119
133
  let frames = 0;
120
134
  let traces = 0;
121
135
  let largestTrace = 0;
122
- let missionBytes = 0;
123
- let longestMission = 0;
136
+ // The bytes of each copied field over all participants, and the longest participant's text.
137
+ const fieldBytes = { mission: 0, focus: 0, tasks: 0 };
138
+ let longestText = 0;
124
139
  let longestWindowMs = 0;
125
140
  // The longest any participant without an end is counted, for the messages.
126
141
  let openMs = Math.min(sessionMs, RATES.openSessionMs);
127
142
  let scheduled = 0;
128
- for (const { schedule, limits, assignment } of participants) {
143
+ for (const { schedule, limits, assignment, tasks = [] } of participants) {
129
144
  const { end } = schedule;
130
145
  const budgetMs = end?.windowMs ?? sessionMs;
131
146
  let countedMs;
@@ -146,26 +161,32 @@ export function estimateBundle(participants, sessionMs) {
146
161
  frames += ownFrames;
147
162
  traces += trace;
148
163
  largestTrace = Math.max(largestTrace, trace);
149
- // The mission's bytes as run.json stores them, a JSON string without its quotes.
150
- const mission = assignment.mission === undefined
151
- ? 0
152
- : Buffer.byteLength(JSON.stringify(assignment.mission)) - 2;
153
- missionBytes += mission;
154
- longestMission = Math.max(longestMission, mission);
164
+ const mission = storedBytes(assignment.mission);
165
+ const focus = storedBytes(assignment.focus);
166
+ const goals = tasks.reduce((sum, task) => sum + storedBytes(task.goal), 0);
167
+ fieldBytes.mission += mission;
168
+ fieldBytes.focus += focus;
169
+ fieldBytes.tasks += goals;
170
+ longestText = Math.max(longestText, mission + focus + goals);
155
171
  }
172
+ const textBytes = fieldBytes.mission + fieldBytes.focus + fieldBytes.tasks;
173
+ // Sorting is stable, so of fields with equal bytes the first declared is named.
174
+ const [largestField] = Object.keys(fieldBytes)
175
+ .filter((field) => fieldBytes[field] > 0)
176
+ .sort((a, b) => fieldBytes[b] - fieldBytes[a]);
156
177
  const count = participants.length;
157
178
  const dataBytes = RATES.runBytes + count * RATES.participantBytes;
158
- // run.json holds the mission in the run's scenario goal, in each participant's stream and in the
159
- // feedback candidate of each participant that reports friction or stops, counted for every
160
- // participant. The rates were fit on runs with short missions, so a short mission is counted
161
- // twice.
162
- const runFileBytes = dataBytes + longestMission + 2 * missionBytes;
179
+ // run.json holds each participant's text in its stream and in its feedback candidate if it
180
+ // reports friction or stops, counted for every participant, and the first participant's in the
181
+ // run's scenario goal, counted at the longest. The rates were fit on runs with short texts, so a
182
+ // short text is counted twice.
183
+ const runFileBytes = dataBytes + longestText + 2 * textBytes;
163
184
  const screenshots = count + turns + frames;
164
185
  // The page holds no feedback candidate's copy.
165
186
  const observerPageBytes = Math.round(RATES.observerAppBytes +
166
187
  RATES.observerPageDataShare * dataBytes +
167
- longestMission +
168
- missionBytes +
188
+ longestText +
189
+ textBytes +
169
190
  RATES.observerPageItemsShare * traces);
170
191
  return {
171
192
  runFileBytes,
@@ -182,9 +203,14 @@ export function estimateBundle(participants, sessionMs) {
182
203
  open: count - scheduled,
183
204
  openMs,
184
205
  dwell: frames > 0,
206
+ ...(largestField === undefined ? {} : { largestField }),
185
207
  },
186
208
  };
187
209
  }
210
+ /** A text's bytes as run.json stores it, a JSON string without its quotes. */
211
+ function storedBytes(text) {
212
+ return text === undefined ? 0 : Buffer.byteLength(JSON.stringify(text)) - 2;
213
+ }
188
214
  /**
189
215
  * The warning for a run whose estimate passes a limit, or undefined: each limit it passes, what the
190
216
  * estimate counted, and what to change.
@@ -196,7 +222,7 @@ export function bundleLimitReason(estimate) {
196
222
  const passed = [
197
223
  ...(runFile
198
224
  ? [
199
- `This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads. It holds the mission once for the run, once in each participant's stream and once more for each participant that reports friction or stops, and the estimate counts the last copy for every participant.`,
225
+ `This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads. It holds the mission, the task goals and each participant's instruction in that participant's stream, and again in the feedback candidate of each participant that reports friction or stops. It holds the first participant's mission, instruction and task goals once more for the run, and the estimate counts a feedback candidate for every participant.`,
200
226
  ]
201
227
  : []),
202
228
  ...(estimate.largestTraceBytes > RUN_ARTIFACT_MAX_BYTES
@@ -217,7 +243,7 @@ export function bundleLimitReason(estimate) {
217
243
  ];
218
244
  if (passed.length === 0)
219
245
  return undefined;
220
- const { scheduled, longestWindowMs, open, openMs, dwell } = estimate.basis;
246
+ const { scheduled, longestWindowMs, open, openMs, dwell, largestField } = estimate.basis;
221
247
  const counted = [
222
248
  ...(scheduled > 0
223
249
  ? [
@@ -233,7 +259,7 @@ export function bundleLimitReason(estimate) {
233
259
  const changes = [
234
260
  ...(scheduled > 0 ? ["shorten the longest windows (participants[].endAfterMs)"] : []),
235
261
  ...(dwell ? ["shorten the dwell window (dwell.ms) or capture less often (dwell.everyMs)"] : []),
236
- ...(runFile ? ["shorten the mission (actor.mission)"] : []),
262
+ ...(runFile && largestField !== undefined ? [COPIED_FIELDS[largestField]] : []),
237
263
  ];
238
264
  // A trace is one participant's, so splitting the study helps only the run's own files.
239
265
  if (runFile || page || entries || changes.length === 0)