humanish 0.122.0 → 0.123.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +33 -6
  4. package/dist/analysis/automatic.js +2 -1
  5. package/dist/analysis/evidence.js +41 -21
  6. package/dist/cli/commands/study-run.js +9 -3
  7. package/dist/feedback/derivative-text.d.ts +12 -0
  8. package/dist/feedback/derivative-text.js +130 -0
  9. package/dist/feedback/export-bundle.js +38 -131
  10. package/dist/feedback/export.js +9 -1
  11. package/dist/observer/run-routes.js +61 -18
  12. package/dist/run/bundle-shape.js +2 -2
  13. package/dist/run/bundle.d.ts +5 -2
  14. package/dist/run/bundle.js +4 -1
  15. package/dist/run/detail.d.ts +6 -0
  16. package/dist/run/detail.js +14 -1
  17. package/dist/run/locate.js +3 -1
  18. package/dist/run/projection.d.ts +7 -0
  19. package/dist/run/projection.js +10 -0
  20. package/dist/run/run.js +7 -1
  21. package/dist/run/stored-runs.js +10 -4
  22. package/dist/run/stream-shape.d.ts +11 -1
  23. package/dist/run/stream-shape.js +26 -2
  24. package/dist/run/stream-trace.d.ts +68 -0
  25. package/dist/run/stream-trace.js +260 -0
  26. package/dist/run/streams.d.ts +20 -0
  27. package/dist/study/bundle-estimate.d.ts +48 -0
  28. package/dist/study/bundle-estimate.js +159 -0
  29. package/dist/study/doctor.js +4 -0
  30. package/dist/study/plan-types.d.ts +3 -0
  31. package/dist/study/plan.js +22 -2
  32. package/dist/study/preflight.js +8 -0
  33. package/dist/tui-app.js +87 -87
  34. package/dist/verify/artifacts.d.ts +17 -10
  35. package/dist/verify/artifacts.js +22 -13
  36. package/dist/verify/scan-limit.d.ts +6 -0
  37. package/dist/verify/scan-limit.js +25 -0
  38. package/dist/verify/verify.d.ts +1 -1
  39. package/dist/verify/verify.js +68 -18
  40. package/docs/architecture/github-feedback-loop.md +1 -1
  41. package/docs/contracts/README.md +1 -1
  42. package/docs/contracts/run-bundle.md +87 -6
  43. package/docs/contracts/schemas.md +14 -7
  44. package/package.json +6 -3
@@ -0,0 +1,260 @@
1
+ // Where a participant's trace lives. A v1 run.json holds every stream's trace items and screenshot
2
+ // entries inline. From v2 the run writer moves them into one file per stream and leaves an index
3
+ // in run.json, so run.json stays a summary whatever the length of the sessions. Every reader that
4
+ // needs the items loads them here, which accepts both versions and checks a file against its index.
5
+ import { createHash } from "node:crypto";
6
+ import { RUN_BUNDLE_SCHEMA } from "./bundle.js";
7
+ import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
8
+ import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
9
+ import { isRecord } from "./type-guards.js";
10
+ /** A trace that does not match its index, or cannot be read. The message names the stream. */
11
+ export class StreamTraceRefusedError extends Error {
12
+ streamId;
13
+ constructor(streamId, detail) {
14
+ super(`Participant ${streamId}'s trace was not read: ${detail}. A trace file must stay as the run wrote it; restore the run folder from a copy.`);
15
+ this.streamId = streamId;
16
+ this.name = "StreamTraceRefusedError";
17
+ }
18
+ }
19
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
20
+ const isScreenshot = (artifact) => artifact.kind === "screenshot";
21
+ /**
22
+ * The file a trace path names on any file system. Trace file names are ASCII (isStreamTracePath),
23
+ * so lower case folds them as a case-insensitive file system does: actors/Case.json and
24
+ * actors/case.json are one file there.
25
+ */
26
+ const traceFileKey = (tracePath) => tracePath.toLowerCase();
27
+ /** Names of the form actors/<n>.json, which the splitter gives by position and never reuses. */
28
+ const POSITION_TRACE_FILE = /^actors\/\d+\.json$/;
29
+ /**
30
+ * The stream's actor trace artifact (actor.json, actors/<stream>.json, actor-<surface>.json) when
31
+ * no earlier stream claimed it (compared by traceFileKey), else actors/<position>.json. The
32
+ * position names are a namespace of their own: an artifact named that way is not reused, so two
33
+ * streams never get one file. Another trace artifact (the scripted route's traces/<surface>.json
34
+ * holds its native step trace) keeps its own file.
35
+ */
36
+ function tracePath(stream, index, claimed) {
37
+ const own = stream.artifacts.find((artifact) => artifact.kind === "trace" &&
38
+ isStreamTracePath(artifact.path) &&
39
+ !POSITION_TRACE_FILE.test(artifact.path))?.path;
40
+ return own !== undefined && !claimed.has(traceFileKey(own)) ? own : `actors/${index + 1}.json`;
41
+ }
42
+ /**
43
+ * The run.json the run writes, and the trace files it writes first. Each stream with a finished
44
+ * actor keeps that actor without `items`, its artifacts other than screenshots, and an index of its
45
+ * trace file; the file is the actor trace with the stream's screenshot entries as `artifacts`. A
46
+ * stream already split (its actor read back from a v2 run.json without its items) keeps its index,
47
+ * since its file is on disk.
48
+ */
49
+ export function splitStreamTraces(bundle) {
50
+ const files = [];
51
+ const claimed = new Set();
52
+ for (const stream of bundle.streams)
53
+ if (stream.trace)
54
+ claimed.add(traceFileKey(stream.trace.path));
55
+ const streams = bundle.streams.map((stream, index) => {
56
+ const actor = stream.actor;
57
+ if (actor === undefined || !Array.isArray(actor.items))
58
+ return stream;
59
+ const { trace: _previous, ...rest } = stream;
60
+ const filePath = tracePath(rest, index, claimed);
61
+ // Only a stream already split that moved position can hold this stream's position name.
62
+ if (claimed.has(traceFileKey(filePath)))
63
+ throw new Error(`Two participants' traces would both be written to ${filePath}.`);
64
+ claimed.add(traceFileKey(filePath));
65
+ const screenshots = stream.artifacts.filter(isScreenshot);
66
+ const bytes = Buffer.from(`${JSON.stringify({ ...actor, artifacts: screenshots }, null, 2)}\n`);
67
+ files.push({ path: filePath, bytes });
68
+ const { items, ...summary } = actor;
69
+ return {
70
+ ...rest,
71
+ // run.json's actor is the summary: every field of the trace except its items.
72
+ actor: summary,
73
+ artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
74
+ trace: {
75
+ path: filePath,
76
+ bytes: bytes.length,
77
+ sha256: sha256(bytes),
78
+ items: items.length,
79
+ screenshots: screenshots.length,
80
+ },
81
+ };
82
+ });
83
+ return { bundle: { ...bundle, schema: RUN_BUNDLE_SCHEMA, streams }, files };
84
+ }
85
+ function containedReader(runPaths) {
86
+ return async (relativePath, maxBytes) => {
87
+ const read = await readContainedRegularFile(runPaths, relativePath, maxBytes);
88
+ if (read.status === "missing")
89
+ return { refused: `${relativePath} is missing` };
90
+ if (read.status === "refused")
91
+ return { refused: refusalText(relativePath, read) };
92
+ return { bytes: read.bytes };
93
+ };
94
+ }
95
+ /**
96
+ * The stream from the bytes of its trace file: its actor with the file's items, and its artifacts
97
+ * followed by the file's screenshot entries, without the index.
98
+ */
99
+ function joinTrace(stream, bytes) {
100
+ const index = stream.trace;
101
+ const refuse = (detail) => {
102
+ throw new StreamTraceRefusedError(stream.id, detail);
103
+ };
104
+ if (bytes.length !== index.bytes)
105
+ refuse(`${index.path} holds ${bytes.length} bytes and run.json records ${index.bytes} bytes`);
106
+ if (sha256(bytes) !== index.sha256)
107
+ refuse(`${index.path} does not match the sha256 run.json records for it`);
108
+ let value;
109
+ try {
110
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
111
+ }
112
+ catch {
113
+ refuse(`${index.path} is not JSON`);
114
+ }
115
+ if (!isRecord(value) || !Array.isArray(value.items) || !Array.isArray(value.artifacts))
116
+ return refuse(`${index.path} has no items or artifacts list`);
117
+ const artifacts = value.artifacts;
118
+ if (!artifacts.every((artifact) => isRunStreamArtifact(artifact) && isScreenshot(artifact)))
119
+ refuse(`${index.path} lists an artifact that is not a screenshot entry`);
120
+ if (value.items.length !== index.items || artifacts.length !== index.screenshots)
121
+ refuse(`${index.path} holds ${value.items.length} items and ${artifacts.length} screenshots and run.json records ${index.items} and ${index.screenshots}`);
122
+ const { trace: _index, ...rest } = stream;
123
+ return {
124
+ ...rest,
125
+ actor: { ...stream.actor, items: value.items },
126
+ artifacts: [...stream.artifacts, ...artifacts],
127
+ };
128
+ }
129
+ /** Refuses an index the shape check would refuse, naming a file that is not a trace file name. */
130
+ function assertIndex(stream) {
131
+ const index = stream.trace;
132
+ if (isRecord(index) && typeof index.path === "string" && !isStreamTracePath(index.path))
133
+ throw new StreamTraceRefusedError(stream.id, `run.json indexes ${index.path}, which is not a trace file name (actor.json, actors/<stream>.json or actor-<surface>.json)`);
134
+ if (!isStreamTraceIndex(index) || stream.actor === undefined)
135
+ throw new StreamTraceRefusedError(stream.id, "run.json records a malformed trace index");
136
+ if (stream.actor.items !== undefined)
137
+ throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
138
+ }
139
+ /** The stream with its trace joined, and the bytes it was joined from when it has an index. */
140
+ async function readStreamTrace(read, stream, maxBytes) {
141
+ if (stream.trace === undefined)
142
+ return { stream };
143
+ assertIndex(stream);
144
+ const file = await read(stream.trace.path, maxBytes);
145
+ if ("refused" in file)
146
+ throw new StreamTraceRefusedError(stream.id, file.refused);
147
+ return { stream: joinTrace(stream, file.bytes), bytes: file.bytes };
148
+ }
149
+ /**
150
+ * The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
151
+ * stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
152
+ * stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
153
+ * checked against the index's byte count, sha256, item count and screenshot count, and any
154
+ * difference throws StreamTraceRefusedError. The rules that need the whole bundle (an unindexed v2
155
+ * actor, two participants indexing one file) are loadStreamTraces'.
156
+ */
157
+ export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_MAX_BYTES) {
158
+ return (await readStreamTrace(containedReader(runPaths), stream, maxBytes)).stream;
159
+ }
160
+ /**
161
+ * Why a stream of this bundle cannot be read, before any file is. A trace file belongs to one
162
+ * participant, so every stream indexing a path another stream indexes, compared as a
163
+ * case-insensitive file system compares them, is refused. In v2 the writer
164
+ * indexes every actor on every write, mid-run flushes included, so a v2 actor without an index lost
165
+ * its trace file, and inline items never come from the writer. A run still writing carries its
166
+ * participants' items so far as `liveActor` (humanish.live-actor.v1), which no index binds; a
167
+ * run.json whose outcome says the run finished never does, since the final write replaces it.
168
+ */
169
+ function bundleRefusal(bundle, stream, owners) {
170
+ const shared = isRecord(stream.trace)
171
+ ? owners.get(traceFileKey(String(stream.trace.path)))
172
+ : undefined;
173
+ if (shared !== undefined && shared.length > 1)
174
+ return `run.json indexes ${String(stream.trace.path)} for participants ${shared.join(", ")} (paths that differ only in case name one file on some file systems), and a trace file belongs to one participant`;
175
+ if (bundle.schema !== RUN_BUNDLE_SCHEMA)
176
+ return undefined;
177
+ if (stream.actor !== undefined && stream.trace === undefined)
178
+ return "run.json holds its actor and names no trace file";
179
+ if (stream.liveActor !== undefined && bundle.outcome?.state === "finished")
180
+ return "run.json records a finished run and still holds in-progress items";
181
+ return undefined;
182
+ }
183
+ /**
184
+ * Every stream of the bundle loaded through loadStreamTrace, one file at a time, and the refusal
185
+ * of each stream that could not be (see bundleRefusal for the rules that need the whole bundle).
186
+ * Each refused stream is kept as run.json holds it; a reader that shows the run passes the result
187
+ * to displayedTraces. `source` is the run's prepared paths, or a TraceFileReader. `onTrace`
188
+ * receives each trace file's path and the bytes the stream was joined from, after they matched
189
+ * the index, so a caller scans exactly what it reads.
190
+ */
191
+ export async function loadStreamTraces(source, bundle, options = {}) {
192
+ const read = typeof source === "function" ? source : containedReader(source);
193
+ const owners = new Map();
194
+ for (const stream of bundle.streams)
195
+ if (isRecord(stream.trace)) {
196
+ const key = traceFileKey(String(stream.trace.path));
197
+ owners.set(key, [...(owners.get(key) ?? []), stream.id]);
198
+ }
199
+ const streams = [];
200
+ const refused = [];
201
+ for (const stream of bundle.streams) {
202
+ try {
203
+ const reason = bundleRefusal(bundle, stream, owners);
204
+ if (reason !== undefined)
205
+ throw new StreamTraceRefusedError(stream.id, reason);
206
+ const loaded = await readStreamTrace(read, stream, options.maxBytes ?? RUN_ARTIFACT_MAX_BYTES);
207
+ if (loaded.bytes !== undefined)
208
+ options.onTrace?.(stream.trace.path, loaded.bytes);
209
+ streams.push(loaded.stream);
210
+ }
211
+ catch (error) {
212
+ if (!(error instanceof StreamTraceRefusedError))
213
+ throw error;
214
+ refused.push(error);
215
+ streams.push(stream);
216
+ }
217
+ }
218
+ return { bundle: { ...bundle, streams }, refused };
219
+ }
220
+ /**
221
+ * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
222
+ * summary, shows none of the items run.json or a file supplied for it (its actor's items, its
223
+ * in-progress items, its screenshot entries), and carries `traceUnavailable` with the reason; a
224
+ * warn event on its timeline says the same.
225
+ */
226
+ export function displayedTraces(loaded) {
227
+ const reasons = new Map(loaded.refused.map((error) => [error.streamId, error.message]));
228
+ if (reasons.size === 0)
229
+ return loaded.bundle;
230
+ const streams = loaded.bundle.streams.map((stream) => {
231
+ const reason = reasons.get(stream.id);
232
+ if (reason === undefined)
233
+ return stream;
234
+ const { liveActor: _live, trace: _index, actor, ...rest } = stream;
235
+ return {
236
+ ...rest,
237
+ ...(actor === undefined ? {} : { actor: { ...actor, items: [] } }),
238
+ artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
239
+ traceUnavailable: reason,
240
+ };
241
+ });
242
+ const events = loaded.bundle.streams
243
+ .filter((stream) => reasons.has(stream.id))
244
+ .map((stream) => ({
245
+ id: `event-trace-unavailable-${stream.id}`,
246
+ at: stream.updatedAt,
247
+ level: "warn",
248
+ type: "trace.unavailable",
249
+ message: reasons.get(stream.id),
250
+ streamId: stream.id,
251
+ }));
252
+ return { ...loaded.bundle, streams, events: [...loaded.bundle.events, ...events] };
253
+ }
254
+ /** The bundle with every stream loaded; the first refusal throws. */
255
+ export async function loadRunTraces(source, bundle, maxBytes) {
256
+ const loaded = await loadStreamTraces(source, bundle, maxBytes === undefined ? {} : { maxBytes });
257
+ if (loaded.refused[0])
258
+ throw loaded.refused[0];
259
+ return loaded.bundle;
260
+ }
@@ -142,6 +142,18 @@ export interface RunDesktopGeometry {
142
142
  /** Public-safe geometry measurement/fill warnings retained with the stream evidence. */
143
143
  warnings?: string[];
144
144
  }
145
+ /**
146
+ * A stream's trace file in a v2 run.json: the actor trace with its items and the stream's
147
+ * screenshot entries as `artifacts`. `bytes` and `sha256` bind the file to run.json; `items` and
148
+ * `screenshots` are what it holds, so a reader can list them without opening it.
149
+ */
150
+ export interface StreamTraceIndex {
151
+ path: string;
152
+ bytes: number;
153
+ sha256: string;
154
+ items: number;
155
+ screenshots: number;
156
+ }
145
157
  /** The original participant-facing assignment, before runtime access/coordination details. */
146
158
  export interface RunParticipantAssignment {
147
159
  mission: string;
@@ -189,6 +201,12 @@ export interface RunStream {
189
201
  * and never persisted into bundles; declared here because the served observer-data carries it
190
202
  * and the client is typed against this contract. */
191
203
  liveEnded?: boolean;
204
+ /**
205
+ * Set by a reader that shows the run (displayedTraces in src/run/stream-trace.ts) when the loader
206
+ * refused this participant's trace: the reason. Runtime-only and never persisted; the stream then
207
+ * carries none of the refused items.
208
+ */
209
+ traceUnavailable?: string;
192
210
  /**
193
211
  * Browser CSS layout viewport. Deterministic browser adapters may declare and render this
194
212
  * exactly; hosted-desktop CUA producers set it only from a runtime measurement. Historical
@@ -234,6 +252,8 @@ export interface RunStream {
234
252
  turnId?: string;
235
253
  };
236
254
  actor?: ActorTrace;
255
+ /** Present in a v2 run.json on each stream with an actor: where its items and screenshots are. */
256
+ trace?: StreamTraceIndex;
237
257
  /**
238
258
  * Mid-run partial actor evidence: the redacted trace items recorded so far,
239
259
  * flushed while a live participant is still running so the attached Observer's timeline can
@@ -0,0 +1,48 @@
1
+ import type { DwellWindow } from "../actors/stop-conditions.js";
2
+ /** One participant as the plan has it: when it starts and ends, and its dwell window. */
3
+ export interface EstimatedParticipant {
4
+ readonly startAfterMs?: number;
5
+ readonly endAfterMs?: number;
6
+ readonly limits: {
7
+ readonly dwell?: DwellWindow;
8
+ };
9
+ }
10
+ /** A run's estimated bundle, and the scan's limit for its participants. */
11
+ export interface BundleEstimate {
12
+ /** run.json, which analysis reads whole. */
13
+ readonly runFileBytes: number;
14
+ /** The largest file: observer/observer-data.json, or the Observer page for a small run. */
15
+ readonly largestFileBytes: number;
16
+ /** Every file, screenshots included. */
17
+ readonly totalBytes: number;
18
+ /** Files and folders in the run folder, as the public-safety scan counts them. */
19
+ readonly entries: number;
20
+ /** The most entries the scan reads in a run of these participants (runEntryLimit). */
21
+ readonly entryLimit: number;
22
+ /** What the estimate assumed, for its messages. */
23
+ readonly basis: {
24
+ readonly participants: number;
25
+ /** Participants with a declared end, counted for their whole window, and the longest window. */
26
+ readonly scheduled: number;
27
+ readonly longestWindowMs: number;
28
+ /** Participants without one, and how long each is counted at most. */
29
+ readonly open: number;
30
+ readonly openMs: number;
31
+ readonly dwell: boolean;
32
+ };
33
+ }
34
+ /**
35
+ * The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
36
+ * declares an end. A participant with an end works its whole window; one without is counted for
37
+ * the measured open session above, or its budget when that is shorter. Each takes a model turn at
38
+ * the measured pace, and a dwell window adds a frame every `everyMs` for its length, cut to the
39
+ * session, and the frame after it.
40
+ */
41
+ export declare function estimateBundle(participants: readonly EstimatedParticipant[], sessionMs: number): BundleEstimate;
42
+ /**
43
+ * The warning for a run whose estimate passes a limit, or undefined: each limit it passes, what the
44
+ * estimate counted, how high it runs, and what to change.
45
+ */
46
+ export declare function bundleLimitReason(estimate: BundleEstimate): string | undefined;
47
+ /** The estimate beside the limits, for `study check`. */
48
+ export declare function describeBundle(estimate: BundleEstimate): string;
@@ -0,0 +1,159 @@
1
+ // How large a computer-use run's bundle grows, estimated from its plan before anything runs, and
2
+ // whether it would pass a limit a later command holds it to: analysis reads a run.json of at most
3
+ // EVIDENCE_LIMITS.sourceBytes, verify reads no file larger than RUN_ARTIFACT_MAX_BYTES, and the
4
+ // public-safety scan visits at most runEntryLimit(participants) files and folders.
5
+ import { EVIDENCE_LIMITS } from "../analysis/analysis-limits.js";
6
+ import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
7
+ import { formatDuration } from "../run/projection.js";
8
+ import { plural } from "../run/text.js";
9
+ import { runEntryLimit } from "../verify/scan-limit.js";
10
+ // Measured on 2026-10-12. Live: the hosted computer-use bundles kept on the maintainer's machine,
11
+ // 219 runs since 2026-10-01 for run.json's size (a least-squares fit gave 372 bytes a run, 14,368
12
+ // a participant and 2,423 a turn), and for the pace the 42 participant sessions of 5 minutes or
13
+ // more among every kept run since August. Synthetic: runStudyWith on fake desktops, with the live median
14
+ // of about 270 characters of reasoning and message a turn, from 10 to 2,000 turns and from 1 to
15
+ // 16 participants. An adopter's 39-minute run grew run.json about 7 KB a minute for a participant
16
+ // working the whole window; these rates give 12.6 KB, so the estimate errs high, and a limit it
17
+ // passes is a warning.
18
+ const RATES = Object.freeze({
19
+ /** run.json bytes a minute of the one long live participant measured: the adopter's run. */
20
+ measuredLongRunBytesPerMinute: 7_000,
21
+ /** run.json bytes for the run itself: its header, study and scenario. */
22
+ runBytes: 400,
23
+ /** run.json bytes for each participant before its turns: its records, events and cost lines. */
24
+ participantBytes: 14_400,
25
+ /** run.json bytes a model turn adds. Synthetic: 2,094 to 2,125 a turn over 10 to 2,000 turns. */
26
+ turnBytes: 2_100,
27
+ /** run.json bytes a dwell frame adds. Synthetic: 6,247 for 10 frames and the frame after. */
28
+ frameBytes: 570,
29
+ /** Seconds between model turns: the median of the 42 long live sessions was 10.3. */
30
+ secondsPerTurn: 10,
31
+ /**
32
+ * How long a participant with no declared end is counted, at most its session budget. It ends
33
+ * when it finishes its mission: 90 % of 974 live sessions ended within 184 s.
34
+ */
35
+ openSessionMs: 3 * 60_000,
36
+ /** observer-data.json bytes beyond run.json's for each participant: 0.9 to 2.6 KB live. */
37
+ observerDataParticipantBytes: 2_600,
38
+ /** observer/index.html: the page, and a share of run.json it embeds (synthetic, 10 to 2,000 turns). */
39
+ observerPageBytes: 1_190_000,
40
+ observerPageShare: 0.62,
41
+ /** A desktop screenshot: the median of 249 live PNG frames was 108,565 bytes. */
42
+ screenshotBytes: 110_000,
43
+ /** Run files and folders besides the participants': run.json, status, review, events, receipts, the Observer, analysis. */
44
+ runEntries: 20,
45
+ /** A participant's entries besides its turn screenshots: its trace file, its screenshot folder, its first frame. */
46
+ participantEntries: 3,
47
+ });
48
+ const MIB = 1024 * 1024;
49
+ /**
50
+ * The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
51
+ * declares an end. A participant with an end works its whole window; one without is counted for
52
+ * the measured open session above, or its budget when that is shorter. Each takes a model turn at
53
+ * the measured pace, and a dwell window adds a frame every `everyMs` for its length, cut to the
54
+ * session, and the frame after it.
55
+ */
56
+ export function estimateBundle(participants, sessionMs) {
57
+ let turns = 0;
58
+ let frames = 0;
59
+ let longestWindowMs = 0;
60
+ const openMs = Math.min(sessionMs, RATES.openSessionMs);
61
+ for (const participant of participants) {
62
+ const windowMs = participant.endAfterMs === undefined
63
+ ? undefined
64
+ : participant.endAfterMs - (participant.startAfterMs ?? 0);
65
+ if (windowMs !== undefined)
66
+ longestWindowMs = Math.max(longestWindowMs, windowMs);
67
+ turns += Math.ceil((windowMs ?? openMs) / (RATES.secondsPerTurn * 1000));
68
+ const dwell = participant.limits.dwell;
69
+ if (dwell !== undefined)
70
+ frames += Math.floor(Math.min(dwell.ms, windowMs ?? sessionMs) / dwell.everyMs) + 1;
71
+ }
72
+ const count = participants.length;
73
+ const runFileBytes = RATES.runBytes +
74
+ count * RATES.participantBytes +
75
+ turns * RATES.turnBytes +
76
+ frames * RATES.frameBytes;
77
+ const observerDataBytes = runFileBytes + count * RATES.observerDataParticipantBytes;
78
+ const pageBytes = RATES.observerPageBytes + Math.round(RATES.observerPageShare * runFileBytes);
79
+ const screenshots = count + turns + frames;
80
+ const scheduled = participants.filter((participant) => participant.endAfterMs !== undefined);
81
+ return {
82
+ runFileBytes,
83
+ largestFileBytes: Math.max(observerDataBytes, pageBytes),
84
+ // Each participant's trace file holds about what its stream holds in run.json.
85
+ totalBytes: 2 * runFileBytes + observerDataBytes + pageBytes + screenshots * RATES.screenshotBytes,
86
+ entries: RATES.runEntries + count * RATES.participantEntries + turns + frames,
87
+ entryLimit: runEntryLimit(count),
88
+ basis: {
89
+ participants: count,
90
+ scheduled: scheduled.length,
91
+ longestWindowMs,
92
+ open: count - scheduled.length,
93
+ openMs,
94
+ dwell: frames > 0,
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * The warning for a run whose estimate passes a limit, or undefined: each limit it passes, what the
100
+ * estimate counted, how high it runs, and what to change.
101
+ */
102
+ export function bundleLimitReason(estimate) {
103
+ const passed = [
104
+ ...(estimate.runFileBytes > EVIDENCE_LIMITS.sourceBytes
105
+ ? [
106
+ `This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads.`,
107
+ ]
108
+ : []),
109
+ ...(estimate.largestFileBytes > RUN_ARTIFACT_MAX_BYTES
110
+ ? [
111
+ `Its largest file, observer/observer-data.json, may grow to about ${mib(estimate.largestFileBytes)}, past the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads in one file.`,
112
+ ]
113
+ : []),
114
+ ...(estimate.entries > estimate.entryLimit
115
+ ? [
116
+ `It may hold about ${count(estimate.entries)} files and folders, past the ${count(estimate.entryLimit)} the public-safety scan reads in a run of ${plural(estimate.basis.participants, "participant")}.`,
117
+ ]
118
+ : []),
119
+ ];
120
+ if (passed.length === 0)
121
+ return undefined;
122
+ const { scheduled, longestWindowMs, open, openMs, dwell } = estimate.basis;
123
+ const counted = [
124
+ ...(scheduled > 0
125
+ ? [
126
+ `${plural(scheduled, "participant")} working their declared windows of up to ${formatDuration(longestWindowMs)}`,
127
+ ]
128
+ : []),
129
+ ...(open > 0
130
+ ? [
131
+ `${scheduled > 0 ? open : plural(open, "participant")} for up to ${formatDuration(openMs)} each`,
132
+ ]
133
+ : []),
134
+ ];
135
+ const changes = [
136
+ ...(scheduled > 0 ? ["shorten the longest windows (participants[].endAfterMs)"] : []),
137
+ ...(dwell ? ["shorten the dwell window (dwell.ms) or capture less often (dwell.everyMs)"] : []),
138
+ ];
139
+ const split = "split the participants across studies";
140
+ const advice = changes.length === 0 ? split : `${changes.join(", ")}, or ${split}`;
141
+ const perMinute = (RATES.turnBytes * 60) / RATES.secondsPerTurn;
142
+ return [
143
+ ...passed,
144
+ `The estimate counts ${counted.join(" and ")}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)} that adds about ${(RATES.turnBytes / 1000).toFixed(1)} KB.`,
145
+ `It runs high, about ${(perMinute / RATES.measuredLongRunBytesPerMinute).toFixed(1)} times the one long live run measured, so the run goes ahead.`,
146
+ `To stay under the limit, ${advice}.`,
147
+ ].join(" ");
148
+ }
149
+ /** The estimate beside the limits, for `study check`. */
150
+ export function describeBundle(estimate) {
151
+ return `run.json about ${mib(estimate.runFileBytes)} of the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads; largest file about ${mib(estimate.largestFileBytes)} of the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads; about ${count(estimate.entries)} files and folders of the ${count(estimate.entryLimit)} the public-safety scan reads; about ${mib(estimate.totalBytes, 0)} in all. Participants with a declared end are counted for their whole window and the others for up to ${formatDuration(estimate.basis.openMs)}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)}.`;
152
+ }
153
+ function mib(bytes, digits = 1) {
154
+ const value = bytes / MIB;
155
+ return `${Number.isInteger(value) ? value : value.toFixed(digits)} MiB`;
156
+ }
157
+ function count(value) {
158
+ return value.toLocaleString("en-US");
159
+ }
@@ -172,6 +172,10 @@ export async function studySetupChecks(args) {
172
172
  keys: [],
173
173
  checks: [...checks, { name: "live route", ok: false, message: planned.refusal.message }],
174
174
  };
175
+ // A plan warning, such as a bundle estimated past a limit, is a note: the run still goes ahead.
176
+ for (const warning of planned.planned.plan.warnings ?? [])
177
+ if (!resolved.warnings.includes(warning))
178
+ checks.push({ name: "plan", ok: true, status: "note", message: warning });
175
179
  const requirements = planned.planned.plan.requirements;
176
180
  const keys = requiredKeys(requirements, args.keyPresent);
177
181
  // This flag controls the hosted desktop SDK check as well as its API key.
@@ -6,6 +6,7 @@ import type { ScriptedRefusal } from "../routes/scripted/plan.js";
6
6
  import type { TerminalRefusal } from "../routes/terminal/plan.js";
7
7
  import type { RunStudyProvenance } from "../run/study-provenance.js";
8
8
  import type { ArrivalPlan } from "./arrivals.js";
9
+ import type { BundleEstimate } from "./bundle-estimate.js";
9
10
  import type { ComputerUseRefusal } from "../routes/computer-use/plan.js";
10
11
  import type { SharedWorldRefusal } from "../routes/shared-world/plan.js";
11
12
  import type { ComputerUseParticipant, ExternalPublicParticipant, ProvisionedParticipant } from "./plan-participants.js";
@@ -275,6 +276,8 @@ export interface ScriptedPlan extends PlanBase {
275
276
  export type StudyPlan = PreviewPlan | ComputerUsePlan | SharedWorldPlan | TerminalPlan | ScriptedPlan;
276
277
  interface PlannedStudy {
277
278
  readonly plan: StudyPlan;
279
+ /** A computer-use plan's estimated bundle, which planStudy checked against the bundle limits. */
280
+ readonly bundle?: BundleEstimate;
278
281
  }
279
282
  /**
280
283
  * The error a route returns before a run starts: the route, its error code and a message. Each
@@ -9,6 +9,7 @@ import { planTerminalStudy } from "../routes/terminal/plan.js";
9
9
  import { concurrentSandboxes, sandboxCeiling, } from "../substrates/e2b/lifetime.js";
10
10
  import { localBrowserDefaults } from "../substrates/local/runtime-config.js";
11
11
  import { THIS_REPO_DRY_RUN_ONLY } from "./composition-rules.js";
12
+ import { bundleLimitReason, estimateBundle } from "./bundle-estimate.js";
12
13
  import { planBase } from "./plan-base.js";
13
14
  import { routeOf } from "./routing.js";
14
15
  import { automaticAnalysisRouteReason, taskProtocolValidationReason } from "./validation.js";
@@ -81,15 +82,34 @@ export function planStudy(config, options, deps = {}) {
81
82
  const result = planRoute(routeOf(config), study, options, input, deps, e2b);
82
83
  if (!result.ok)
83
84
  return result;
85
+ const bundle = plannedBundle(result.plan);
84
86
  // The manifest the CLI resolved enters the plan here and nowhere else; the routes read
85
87
  // plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
86
- const warnings = [...recordedStudyWarnings(study), ...(result.plan.warnings ?? [])];
88
+ const warnings = [
89
+ ...recordedStudyWarnings(study),
90
+ ...(result.plan.warnings ?? []),
91
+ ...(bundle.warning === undefined ? [] : [bundle.warning]),
92
+ ];
87
93
  const plan = {
88
94
  ...result.plan,
89
95
  ...(options.study === undefined ? {} : { study: options.study }),
90
96
  ...(warnings.length === 0 ? {} : { warnings }),
91
97
  };
92
- return { ok: true, planned: { plan } };
98
+ return {
99
+ ok: true,
100
+ planned: { plan, ...(bundle.estimate === undefined ? {} : { bundle: bundle.estimate }) },
101
+ };
102
+ }
103
+ /**
104
+ * The bundle a computer-use plan's participants leave, each session at the plan's budget, and the
105
+ * warning for a live plan whose estimate passes a bundle limit. A dry run records no turns.
106
+ */
107
+ function plannedBundle(plan) {
108
+ if (plan.route !== "computer-use")
109
+ return {};
110
+ const estimate = estimateBundle(plan.runner.participants, plan.sessionBudgetMs);
111
+ const warning = plan.dryRun ? undefined : bundleLimitReason(estimate);
112
+ return warning === undefined ? { estimate } : { estimate, warning };
93
113
  }
94
114
  function planRoute(route, study, options, input, deps, e2b) {
95
115
  const { sandboxCeiling } = e2b;
@@ -7,6 +7,7 @@ import { runPublicPreviewPreflight, runSandboxLoopbackPreflight } from "./prefli
7
7
  import { digest, fail, finalize, STUDY_CHECK_SCHEMA } from "./preflight-result.js";
8
8
  import { planStudy, routeOf } from "./plan.js";
9
9
  import { describeArrivals, describeWindows } from "./arrivals.js";
10
+ import { describeBundle } from "./bundle-estimate.js";
10
11
  import { resolveStudyManifest } from "./discover.js";
11
12
  import { participantList } from "./study-fields.js";
12
13
  import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
@@ -81,6 +82,13 @@ export async function runStudyPreflight(options) {
81
82
  const windows = windowsCheck(planned.planned.plan);
82
83
  if (windows !== undefined)
83
84
  ctx.checks.push(windows);
85
+ const bundle = planned.planned.bundle;
86
+ if (bundle !== undefined)
87
+ ctx.checks.push({ name: "bundle size", ok: true, message: describeBundle(bundle) });
88
+ // The plan's warnings, which the run records; the study file's own are already listed.
89
+ for (const warning of planned.planned.plan.warnings ?? [])
90
+ if (!ctx.warnings.includes(warning))
91
+ ctx.warnings.push(warning);
84
92
  const machine = machineCheck(ctx.config);
85
93
  // A local study's app and desktops run on this machine, so the hosted probes do not apply.
86
94
  if (reachability !== "metadata" && isLocalBrowserStudy(ctx.config))