humanish 0.124.0 → 0.126.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 (129) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +2 -2
  4. package/dist/actors/computer-use/actor.d.ts +2 -0
  5. package/dist/actors/computer-use/actor.js +1 -0
  6. package/dist/actors/computer-use/loop/clock.d.ts +9 -8
  7. package/dist/actors/computer-use/loop/clock.js +27 -17
  8. package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
  9. package/dist/actors/computer-use/loop/debrief.js +15 -5
  10. package/dist/actors/computer-use/loop/ending.d.ts +8 -5
  11. package/dist/actors/computer-use/loop/ending.js +8 -5
  12. package/dist/actors/computer-use/loop/observation.d.ts +6 -0
  13. package/dist/actors/computer-use/loop/observation.js +19 -1
  14. package/dist/actors/computer-use/loop/session.d.ts +4 -2
  15. package/dist/actors/computer-use/loop/session.js +4 -1
  16. package/dist/actors/computer-use/loop/trace.js +4 -2
  17. package/dist/actors/computer-use/loop/types.d.ts +22 -8
  18. package/dist/actors/computer-use/loop.js +5 -5
  19. package/dist/actors/contract.d.ts +31 -8
  20. package/dist/actors/contract.js +3 -0
  21. package/dist/actors/stop-cause.js +1 -0
  22. package/dist/analysis/evidence.js +2 -2
  23. package/dist/cli/commands/study.js +8 -0
  24. package/dist/evidence/redaction.d.ts +1 -0
  25. package/dist/evidence/redaction.js +1 -0
  26. package/dist/evidence/trace-page.d.ts +6 -0
  27. package/dist/evidence/trace-page.js +90 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +1 -0
  30. package/dist/observer/feed-cursors.d.ts +14 -0
  31. package/dist/observer/feed-cursors.js +61 -0
  32. package/dist/observer/participant-items.d.ts +7 -0
  33. package/dist/observer/pinned-files.d.ts +4 -1
  34. package/dist/observer/pinned-files.js +10 -7
  35. package/dist/observer/run-routes.d.ts +1 -1
  36. package/dist/observer/run-routes.js +39 -17
  37. package/dist/observer-app.html +7 -7
  38. package/dist/routes/computer-use/bundle.js +4 -0
  39. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  40. package/dist/routes/computer-use/diagnostics.js +3 -3
  41. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  42. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  43. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  44. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  45. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  46. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  47. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  48. package/dist/routes/computer-use/fanout-records.js +4 -0
  49. package/dist/routes/computer-use/live-flush.d.ts +9 -4
  50. package/dist/routes/computer-use/live-flush.js +16 -3
  51. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  52. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  53. package/dist/routes/computer-use/participant-model.js +16 -6
  54. package/dist/routes/computer-use/participant-runs.d.ts +2 -2
  55. package/dist/routes/computer-use/participant-runs.js +11 -7
  56. package/dist/routes/computer-use/plan.js +39 -1
  57. package/dist/routes/computer-use/setup.d.ts +1 -0
  58. package/dist/routes/computer-use/setup.js +28 -0
  59. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  60. package/dist/routes/computer-use/single-bundle.js +2 -0
  61. package/dist/routes/computer-use/types.d.ts +16 -10
  62. package/dist/routes/shared-world/bundle-records.js +3 -0
  63. package/dist/routes/shared-world/participant-specs.js +3 -2
  64. package/dist/routes/shared-world/plan.js +5 -0
  65. package/dist/run/bundle-shape.js +2 -2
  66. package/dist/run/bundle.d.ts +10 -3
  67. package/dist/run/bundle.js +11 -2
  68. package/dist/run/contained-output.d.ts +26 -0
  69. package/dist/run/contained-output.js +101 -1
  70. package/dist/run/evidence-files.d.ts +12 -1
  71. package/dist/run/evidence-files.js +70 -40
  72. package/dist/run/judge.d.ts +7 -4
  73. package/dist/run/judge.js +13 -8
  74. package/dist/run/live-items.d.ts +67 -0
  75. package/dist/run/live-items.js +262 -0
  76. package/dist/run/locate.d.ts +10 -0
  77. package/dist/run/locate.js +10 -0
  78. package/dist/run/run.d.ts +3 -2
  79. package/dist/run/run.js +29 -8
  80. package/dist/run/sandbox-ids.d.ts +0 -5
  81. package/dist/run/sandbox-ids.js +0 -7
  82. package/dist/run/stream-trace.d.ts +40 -10
  83. package/dist/run/stream-trace.js +151 -22
  84. package/dist/run/streams.d.ts +33 -2
  85. package/dist/study/arrivals.d.ts +32 -14
  86. package/dist/study/arrivals.js +47 -21
  87. package/dist/study/bundle-estimate.d.ts +6 -5
  88. package/dist/study/bundle-estimate.js +12 -7
  89. package/dist/study/desktop-files.d.ts +37 -0
  90. package/dist/study/desktop-files.js +174 -0
  91. package/dist/study/keys.d.ts +8 -0
  92. package/dist/study/keys.js +3 -0
  93. package/dist/study/migrate/v2.js +12 -3
  94. package/dist/study/parse/actors.d.ts +1 -1
  95. package/dist/study/parse/actors.js +51 -26
  96. package/dist/study/parse/clock.d.ts +18 -0
  97. package/dist/study/parse/clock.js +36 -0
  98. package/dist/study/parse/execution.js +6 -0
  99. package/dist/study/plan-base.js +4 -1
  100. package/dist/study/plan-participants.d.ts +19 -5
  101. package/dist/study/plan-participants.js +17 -1
  102. package/dist/study/plan.js +24 -13
  103. package/dist/study/preflight.js +4 -0
  104. package/dist/study/types.d.ts +34 -5
  105. package/dist/study/validation.js +3 -0
  106. package/dist/study/warnings.d.ts +2 -2
  107. package/dist/study/warnings.js +7 -0
  108. package/dist/subject/local-tree-archive.d.ts +7 -0
  109. package/dist/subject/local-tree-archive.js +12 -0
  110. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  111. package/dist/substrates/e2b/desktop-browser.js +6 -2
  112. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  113. package/dist/substrates/e2b/desktop-clock.js +29 -0
  114. package/dist/substrates/e2b/sdk.d.ts +6 -0
  115. package/dist/verify/artifacts.js +39 -51
  116. package/dist/verify/pages.d.ts +7 -0
  117. package/dist/verify/pages.js +50 -0
  118. package/dist/verify/screenshot-reads.d.ts +21 -0
  119. package/dist/verify/screenshot-reads.js +46 -0
  120. package/dist/verify/verify.js +17 -9
  121. package/docs/architecture/actor-contract.md +2 -0
  122. package/docs/architecture/external-public-shared-world.md +4 -1
  123. package/docs/architecture/github-feedback-loop.md +1 -1
  124. package/docs/architecture/observer.md +10 -1
  125. package/docs/contracts/README.md +1 -1
  126. package/docs/contracts/policy.md +7 -0
  127. package/docs/contracts/run-bundle.md +106 -14
  128. package/docs/contracts/schemas.md +110 -40
  129. package/package.json +4 -2
@@ -1,10 +1,14 @@
1
1
  // Where a participant's trace lives. A v1 run.json holds every stream's trace items and screenshot
2
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.
3
+ // in run.json, so run.json stays a summary whatever the length of the sessions. From v3 a running
4
+ // participant's items so far are in a live file run.json indexes too (src/run/live-items.ts).
5
+ // Every reader that needs the items loads them here, which accepts every version and checks each
6
+ // file against its index.
5
7
  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 { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "./bundle.js";
9
+ import { isRunBundle } from "./bundle-shape.js";
10
+ import { readContainedPrefix, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
11
+ import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsAfter, liveItemsFromBytes, } from "./live-items.js";
8
12
  import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
9
13
  import { isRecord } from "./type-guards.js";
10
14
  /** A trace that does not match its index, or cannot be read. The message names the stream. */
@@ -83,8 +87,10 @@ export function splitStreamTraces(bundle) {
83
87
  return { bundle: { ...bundle, schema: RUN_BUNDLE_SCHEMA, streams }, files };
84
88
  }
85
89
  function containedReader(runPaths) {
86
- return async (relativePath, maxBytes) => {
87
- const read = await readContainedRegularFile(runPaths, relativePath, maxBytes);
90
+ return async (relativePath, maxBytes, length, start) => {
91
+ const read = length === undefined
92
+ ? await readContainedRegularFile(runPaths, relativePath, maxBytes)
93
+ : await readContainedPrefix(runPaths, relativePath, length, start);
88
94
  if (read.status === "missing")
89
95
  return { refused: `${relativePath} is missing` };
90
96
  if (read.status === "refused")
@@ -136,15 +142,94 @@ function assertIndex(stream) {
136
142
  if (stream.actor.items !== undefined)
137
143
  throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
138
144
  }
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)
145
+ /** A live actor whose items run.json holds inline: humanish.live-actor.v1, or one with no schema. */
146
+ const isInlineLiveActor = (live) => live.schema === LIVE_ACTOR_V1_SCHEMA || live.schema === undefined;
147
+ /** A live file that could not be read, which a writer that moved on may have removed. */
148
+ class LiveFileUnreadableError extends StreamTraceRefusedError {
149
+ }
150
+ /**
151
+ * The stream with its live actor's items joined from the indexed prefix of its live file, and the
152
+ * file's path and those bytes. A live actor with inline items (humanish.live-actor.v1) is returned
153
+ * as it is; bundleRefusal refuses one in a v3 run.json.
154
+ */
155
+ async function readLiveItems(read, stream, maxBytes, liveAfter) {
156
+ const live = stream.liveActor;
157
+ if (live === undefined || isInlineLiveActor(live))
142
158
  return { stream };
143
- assertIndex(stream);
144
- const file = await read(stream.trace.path, maxBytes);
159
+ const refuse = (detail) => {
160
+ throw new StreamTraceRefusedError(stream.id, detail);
161
+ };
162
+ if (live.schema !== LIVE_ACTOR_SCHEMA)
163
+ refuse(`run.json records live actor schema ${String(live.schema)}, which this version does not read`);
164
+ const index = live.itemsFile;
165
+ if (!isLiveItemsIndex(index))
166
+ return refuse("run.json records a malformed live items index");
167
+ if (live.items !== undefined)
168
+ refuse("run.json holds in-progress items beside their live file");
169
+ if (index.bytes > maxBytes)
170
+ refuse(`${index.path} is indexed at ${index.bytes} bytes, more than the ${maxBytes} read`);
171
+ const { itemsFile: _index, ...summary } = live;
172
+ const after = liveAfter?.(index);
173
+ const tail = after === undefined ? undefined : await readLiveTail(read, stream, index, after);
174
+ if (tail !== undefined)
175
+ return {
176
+ stream: { ...stream, liveActor: { ...summary, items: tail.items } },
177
+ file: { path: index.path, bytes: tail.bytes },
178
+ read: { streamId: stream.id, index, after: after },
179
+ };
180
+ const file = await read(index.path, maxBytes, index.bytes);
145
181
  if ("refused" in file)
146
- throw new StreamTraceRefusedError(stream.id, file.refused);
147
- return { stream: joinTrace(stream, file.bytes), bytes: file.bytes };
182
+ throw new LiveFileUnreadableError(stream.id, file.refused);
183
+ // Only the indexed prefix: a reader that ignores `length` hands over the bytes appended since.
184
+ const joined = liveItemsFromBytes(file.bytes.subarray(0, index.bytes), index);
185
+ if ("refused" in joined)
186
+ return refuse(joined.refused);
187
+ return {
188
+ stream: { ...stream, liveActor: { ...summary, items: joined.items } },
189
+ file: { path: index.path, bytes: file.bytes.subarray(0, index.bytes) },
190
+ read: { streamId: stream.id, index },
191
+ };
192
+ }
193
+ /**
194
+ * The items of the live file `index` describes past `after`, and the bytes they were read from;
195
+ * undefined when the cursor does not fit the index or the file's lines, so the caller reads the
196
+ * whole prefix. A cursor at the indexed end reads nothing. A file the reader cannot read throws
197
+ * LiveFileUnreadableError, as a whole read does.
198
+ */
199
+ async function readLiveTail(read, stream, index, after) {
200
+ if (after.bytes === 0 ||
201
+ after.items > index.items ||
202
+ after.bytes > index.bytes ||
203
+ (after.items === index.items) !== (after.bytes === index.bytes))
204
+ return undefined;
205
+ if (after.bytes === index.bytes)
206
+ return { items: [], bytes: Buffer.alloc(0) };
207
+ const file = await read(index.path, index.bytes - after.bytes + 1, index.bytes, after.bytes - 1);
208
+ if ("refused" in file)
209
+ throw new LiveFileUnreadableError(stream.id, file.refused);
210
+ const tail = file.bytes.subarray(0, index.bytes - after.bytes + 1);
211
+ const joined = liveItemsAfter(tail, index, after);
212
+ return "refused" in joined ? undefined : { items: joined.items, bytes: tail.subarray(1) };
213
+ }
214
+ /**
215
+ * The stream with its trace and live items joined, and each file's path and the bytes it was
216
+ * joined from.
217
+ */
218
+ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
219
+ const files = [];
220
+ let joined = stream;
221
+ if (stream.trace !== undefined) {
222
+ assertIndex(stream);
223
+ const file = await read(stream.trace.path, maxBytes);
224
+ if ("refused" in file)
225
+ throw new StreamTraceRefusedError(stream.id, file.refused);
226
+ joined = joinTrace(stream, file.bytes);
227
+ files.push({ path: stream.trace.path, bytes: file.bytes });
228
+ }
229
+ const live = await readLiveItems(read, joined, maxBytes, liveAfter);
230
+ if (live.file !== undefined)
231
+ files.push(live.file);
232
+ return { stream: live.stream, files, ...(live.read === undefined ? {} : { live: live.read }) };
148
233
  }
149
234
  /**
150
235
  * The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
@@ -163,8 +248,10 @@ export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_
163
248
  * case-insensitive file system compares them, is refused. In v2 the writer
164
249
  * indexes every actor on every write, mid-run flushes included, so a v2 actor without an index lost
165
250
  * 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.
251
+ * participants' items so far as `liveActor`: inline in v2 (humanish.live-actor.v1), in a live file
252
+ * from v3 (humanish.live-actor.v2), so a v3 run.json with inline live items did not come from the
253
+ * writer. A run.json whose outcome says the run finished never carries `liveActor`, since the
254
+ * final write replaces it.
168
255
  */
169
256
  function bundleRefusal(bundle, stream, owners) {
170
257
  const shared = isRecord(stream.trace)
@@ -172,12 +259,16 @@ function bundleRefusal(bundle, stream, owners) {
172
259
  : undefined;
173
260
  if (shared !== undefined && shared.length > 1)
174
261
  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)
262
+ if (bundle.schema !== RUN_BUNDLE_SCHEMA && bundle.schema !== RUN_BUNDLE_V2_SCHEMA)
176
263
  return undefined;
177
264
  if (stream.actor !== undefined && stream.trace === undefined)
178
265
  return "run.json holds its actor and names no trace file";
179
266
  if (stream.liveActor !== undefined && bundle.outcome?.state === "finished")
180
267
  return "run.json records a finished run and still holds in-progress items";
268
+ if (bundle.schema === RUN_BUNDLE_SCHEMA &&
269
+ stream.liveActor !== undefined &&
270
+ isInlineLiveActor(stream.liveActor))
271
+ return "run.json v3 holds in-progress items inline, which the writer keeps in a live file";
181
272
  return undefined;
182
273
  }
183
274
  /**
@@ -185,11 +276,46 @@ function bundleRefusal(bundle, stream, owners) {
185
276
  * of each stream that could not be (see bundleRefusal for the rules that need the whole bundle).
186
277
  * Each refused stream is kept as run.json holds it; a reader that shows the run passes the result
187
278
  * 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.
279
+ * receives each trace and live file's path and the bytes the stream was joined from, after they
280
+ * matched the index, so a caller scans exactly what it reads.
281
+ *
282
+ * `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
283
+ * feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
284
+ * without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
285
+ * file's lines gets the whole prefix, checked as always.
286
+ *
287
+ * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
288
+ * removes a superseded generation once run.json names the next one, and every live file once the
289
+ * run finished. The loader then reads run.json again, once, through the same reader, and loads
290
+ * that bundle instead when it is a run bundle of the same run.
190
291
  */
191
292
  export async function loadStreamTraces(source, bundle, options = {}) {
192
293
  const read = typeof source === "function" ? source : containedReader(source);
294
+ const maxBytes = options.maxBytes ?? RUN_ARTIFACT_MAX_BYTES;
295
+ let loaded = await loadEveryStream(read, bundle, maxBytes, options.liveAfter);
296
+ if (loaded.refused.some((error) => error instanceof LiveFileUnreadableError)) {
297
+ const current = await rereadBundle(read, bundle.runId);
298
+ if (current !== undefined)
299
+ loaded = await loadEveryStream(read, current, maxBytes, options.liveAfter);
300
+ }
301
+ for (const file of loaded.files)
302
+ options.onTrace?.(file.path, file.bytes);
303
+ return { bundle: loaded.bundle, refused: loaded.refused, live: loaded.live };
304
+ }
305
+ /** run.json as it is now, when it is a run bundle of run `runId`. */
306
+ async function rereadBundle(read, runId) {
307
+ const file = await read(RUN_BUNDLE_FILE, RUN_ARTIFACT_MAX_BYTES);
308
+ if ("refused" in file)
309
+ return undefined;
310
+ try {
311
+ const value = JSON.parse(file.bytes.toString("utf8"));
312
+ return isRunBundle(value) && value.runId === runId ? value : undefined;
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ }
318
+ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
193
319
  const owners = new Map();
194
320
  for (const stream of bundle.streams)
195
321
  if (isRecord(stream.trace)) {
@@ -198,14 +324,17 @@ export async function loadStreamTraces(source, bundle, options = {}) {
198
324
  }
199
325
  const streams = [];
200
326
  const refused = [];
327
+ const files = [];
328
+ const live = [];
201
329
  for (const stream of bundle.streams) {
202
330
  try {
203
331
  const reason = bundleRefusal(bundle, stream, owners);
204
332
  if (reason !== undefined)
205
333
  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);
334
+ const loaded = await readStreamTrace(read, stream, maxBytes, liveAfter);
335
+ files.push(...loaded.files);
336
+ if (loaded.live !== undefined)
337
+ live.push(loaded.live);
209
338
  streams.push(loaded.stream);
210
339
  }
211
340
  catch (error) {
@@ -215,7 +344,7 @@ export async function loadStreamTraces(source, bundle, options = {}) {
215
344
  streams.push(stream);
216
345
  }
217
346
  }
218
- return { bundle: { ...bundle, streams }, refused };
347
+ return { bundle: { ...bundle, streams }, refused, files, live };
219
348
  }
220
349
  /**
221
350
  * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
@@ -154,6 +154,18 @@ export interface StreamTraceIndex {
154
154
  items: number;
155
155
  screenshots: number;
156
156
  }
157
+ /**
158
+ * A v3 run.json's index of a running participant's live file (src/run/live-items.ts): its path
159
+ * `live/<position>.<generation>.ndjson`, the generation, and the prefix written so far, as its item
160
+ * (line) count, byte count and sha256. The file only grows, so the prefix stays as indexed.
161
+ */
162
+ export interface LiveItemsIndex {
163
+ path: string;
164
+ generation: number;
165
+ items: number;
166
+ bytes: number;
167
+ sha256: string;
168
+ }
157
169
  /** The original participant-facing assignment, before runtime access/coordination details. */
158
170
  export interface RunParticipantAssignment {
159
171
  mission: string;
@@ -163,6 +175,15 @@ export interface RunParticipantAssignment {
163
175
  goal: string;
164
176
  }>;
165
177
  }
178
+ /** One file humanish copied onto a participant's desktop before it started (participants[].files). */
179
+ export interface RunDesktopFile {
180
+ /** Where it landed, relative to the desktop user's home folder. */
181
+ as: string;
182
+ /** Its size in bytes, confirmed on the desktop. */
183
+ size: number;
184
+ /** The sha256 of the bytes humanish copied, 64 hex characters. */
185
+ sha256: string;
186
+ }
166
187
  export interface RunStream {
167
188
  id: string;
168
189
  simId: string;
@@ -220,6 +241,13 @@ export interface RunStream {
220
241
  };
221
242
  /** Truthful screen/window/viewport evidence for hosted desktop browser participants. */
222
243
  desktopGeometry?: RunDesktopGeometry;
244
+ /** The files copied onto this participant's desktop before it started. Never their contents. */
245
+ desktopFiles?: RunDesktopFile[];
246
+ /**
247
+ * The participant's time zone (participants[].clock, else actor.clock): its turns gave the time
248
+ * in it, and its hosted browser ran in it. Absent when it had none.
249
+ */
250
+ timeZone?: string;
223
251
  terminal?: {
224
252
  title: string;
225
253
  format: "ansi" | "plain";
@@ -260,10 +288,11 @@ export interface RunStream {
260
288
  * grow. It is deliberately not an ActorTrace: a running participant has no final status,
261
289
  * completionReason, or completedAt, and this shape cannot claim them. Present only on
262
290
  * `inProgress` bundles; the final write replaces it with the real `actor` and never
263
- * carries it.
291
+ * carries it. A v3 run.json holds `humanish.live-actor.v2` with `itemsFile` in place of `items`;
292
+ * the trace loader (src/run/stream-trace.ts) reads the file and puts the items back.
264
293
  */
265
294
  liveActor?: {
266
- schema: "humanish.live-actor.v1";
295
+ schema: "humanish.live-actor.v1" | "humanish.live-actor.v2";
267
296
  executionProfile?: ActorTrace["executionProfile"];
268
297
  providerRequests?: ActorTrace["providerRequests"];
269
298
  historyTurnsOmitted?: number;
@@ -273,6 +302,8 @@ export interface RunStream {
273
302
  /** When this flush was written (ISO-8601). */
274
303
  updatedAt: string;
275
304
  items: ActorTraceItem[];
305
+ /** In a v3 run.json, in place of `items`: the live file that holds them. */
306
+ itemsFile?: LiveItemsIndex;
276
307
  };
277
308
  completion?: RunStreamCompletion;
278
309
  recording?: RunDesktopRecording;
@@ -8,41 +8,58 @@ export declare const LATEST_START_AFTER_MS: number;
8
8
  export declare function isStartAfterMs(value: unknown): value is number;
9
9
  /** How long before its end a participant is told the time left, unless it declares `windDownMs`. */
10
10
  export declare const DEFAULT_WIND_DOWN_MS: number;
11
- /** A participant's declared window: when it starts and when it ends, on the run's clock. */
11
+ /**
12
+ * A participant's declared window: when it starts, and when it ends on the run's clock
13
+ * (`endAfterMs`) or at the latest after its own session started (`maxSessionMs`).
14
+ */
12
15
  export interface ParticipantWindow {
13
16
  readonly id?: string;
14
17
  readonly startAfterMs?: number;
15
18
  readonly endAfterMs?: number;
19
+ readonly maxSessionMs?: number;
16
20
  readonly windDownMs?: number;
17
21
  }
18
22
  /**
19
23
  * Why a participant's declared end cannot run, or undefined: an end that is not a whole number of
20
- * milliseconds up to 24 hours, one at or before its start, or a wind-down that is not a whole
21
- * number of milliseconds or has no end. `path` names the entry, as in "participants[1]".
24
+ * milliseconds up to 24 hours, one at or before its start, a cap that is not a whole number of
25
+ * milliseconds from 1 to 24 hours, a cap beside an end, or a wind-down that is not a whole number
26
+ * of milliseconds or has neither. `path` names the entry, as in "participants[1]".
22
27
  */
23
28
  export declare function windowReason(entry: {
24
29
  readonly id?: string;
25
30
  readonly startAfterMs?: number;
26
31
  readonly endAfterMs?: unknown;
32
+ readonly maxSessionMs?: unknown;
27
33
  readonly windDownMs?: unknown;
28
34
  }, path: string): string | undefined;
29
35
  /**
30
- * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS in
31
- * place of the study's `sessionMs` (execution.timeoutMs or its default); else `sessionMs`.
36
+ * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS, and
37
+ * with a cap, the cap plus the same room, in place of the study's `sessionMs` (execution.timeoutMs
38
+ * or its default); else `sessionMs`.
32
39
  */
33
40
  export declare function participantSessionMs(participant: ParticipantWindow, sessionMs: number): number;
34
41
  /**
35
- * When a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, on the
36
- * same clock, or undefined when it declares no end. A participant that waited for a slot still ends
37
- * at its declared time.
42
+ * When a participant's session ends: at a time on the run's clock, or at the latest a time after
43
+ * its own session starts, which the route reads when the session starts.
38
44
  */
39
- export declare function scheduledEndAt(participant: ParticipantWindow, scheduledAt: number): {
45
+ export type ParticipantEnd = {
46
+ readonly kind: "scheduled_end";
40
47
  readonly atMs: number;
41
48
  readonly windDownMs: number;
42
- } | undefined;
49
+ } | {
50
+ readonly kind: "session_cap";
51
+ readonly afterMs: number;
52
+ readonly windDownMs: number;
53
+ };
54
+ /**
55
+ * How a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, or
56
+ * undefined when it declares neither an end nor a cap. A participant that waited for a slot still
57
+ * ends at its declared time, and still gets its whole cap.
58
+ */
59
+ export declare function participantEnd(participant: ParticipantWindow, scheduledAt: number): ParticipantEnd | undefined;
43
60
  /**
44
- * Why the sandbox of the first participant whose window passes `ceilingMs` cannot live that long,
45
- * or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
61
+ * Why the sandbox of the first participant whose window or cap passes `ceilingMs` cannot live that
62
+ * long, or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
46
63
  * route adds to a session for setup and teardown (sandboxHeadroomMs).
47
64
  */
48
65
  export declare function windowDeadlineRefusal(participants: readonly ParticipantWindow[], sandbox: {
@@ -50,8 +67,9 @@ export declare function windowDeadlineRefusal(participants: readonly Participant
50
67
  readonly headroomMs: number;
51
68
  }, ceilingMs: number): string | undefined;
52
69
  /**
53
- * Each participant's window for `study check`, when any participant declares an end: when it starts
54
- * and ends and when it is first told the time left, or when it starts and its session budget.
70
+ * Each participant's window for `study check`, when any participant declares an end or a cap: when
71
+ * it starts and ends and when it is first told the time left, when it starts and its cap and how
72
+ * far into its session it is first told the time left, or when it starts and its session budget.
55
73
  */
56
74
  export declare function describeWindows(participants: readonly ParticipantWindow[], sessionMs: number): string | undefined;
57
75
  /** The refusal of a start offset that is not one, naming its `field`. */
@@ -32,13 +32,18 @@ export const DEFAULT_WIND_DOWN_MS = 10 * 60_000;
32
32
  const SCHEDULED_END_ROOM_MS = 5 * 60_000;
33
33
  /**
34
34
  * Why a participant's declared end cannot run, or undefined: an end that is not a whole number of
35
- * milliseconds up to 24 hours, one at or before its start, or a wind-down that is not a whole
36
- * number of milliseconds or has no end. `path` names the entry, as in "participants[1]".
35
+ * milliseconds up to 24 hours, one at or before its start, a cap that is not a whole number of
36
+ * milliseconds from 1 to 24 hours, a cap beside an end, or a wind-down that is not a whole number
37
+ * of milliseconds or has neither. `path` names the entry, as in "participants[1]".
37
38
  */
38
39
  export function windowReason(entry, path) {
39
- const { endAfterMs, windDownMs } = entry;
40
- if (windDownMs !== undefined && endAfterMs === undefined)
41
- return `${path}.windDownMs is how long before its endAfterMs a participant is told the time left, so it needs ${path}.endAfterMs. Add the end, or remove windDownMs.`;
40
+ const { endAfterMs, maxSessionMs, windDownMs } = entry;
41
+ if (maxSessionMs !== undefined && endAfterMs !== undefined)
42
+ return `${path} sets both endAfterMs and maxSessionMs. endAfterMs holds the participant until a set time, and maxSessionMs lets it end its session sooner, up to a cap, so a participant takes one of them. Keep endAfterMs for one that stays until a set time, or maxSessionMs for one that may leave when it is done.`;
43
+ if (maxSessionMs !== undefined && !(isStartAfterMs(maxSessionMs) && maxSessionMs > 0))
44
+ return `${path}.maxSessionMs must be a whole number of milliseconds from 1 to ${LATEST_START_AFTER_MS} (24 hours): the longest the participant's session lasts, counted from its start.`;
45
+ if (windDownMs !== undefined && endAfterMs === undefined && maxSessionMs === undefined)
46
+ return `${path}.windDownMs is how long before its endAfterMs or maxSessionMs a participant is told the time left, so it needs one of them. Add an end, or remove windDownMs.`;
42
47
  if (windDownMs !== undefined && !isStartAfterMs(windDownMs))
43
48
  return `${path}.windDownMs must be a whole number of milliseconds from 0 to ${LATEST_START_AFTER_MS} (24 hours): how long before its end the participant is told the time left.`;
44
49
  if (endAfterMs === undefined)
@@ -53,54 +58,75 @@ export function windowReason(entry, path) {
53
58
  return undefined;
54
59
  }
55
60
  /**
56
- * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS in
57
- * place of the study's `sessionMs` (execution.timeoutMs or its default); else `sessionMs`.
61
+ * A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS, and
62
+ * with a cap, the cap plus the same room, in place of the study's `sessionMs` (execution.timeoutMs
63
+ * or its default); else `sessionMs`.
58
64
  */
59
65
  export function participantSessionMs(participant, sessionMs) {
66
+ const windowMs = declaredWindowMs(participant);
67
+ return windowMs === undefined ? sessionMs : windowMs + SCHEDULED_END_ROOM_MS;
68
+ }
69
+ /** How long a participant with a declared end or cap may work, or undefined without either. */
70
+ function declaredWindowMs(participant) {
71
+ if (participant.maxSessionMs !== undefined)
72
+ return participant.maxSessionMs;
60
73
  if (participant.endAfterMs === undefined)
61
- return sessionMs;
62
- return participant.endAfterMs - (participant.startAfterMs ?? 0) + SCHEDULED_END_ROOM_MS;
74
+ return undefined;
75
+ return participant.endAfterMs - (participant.startAfterMs ?? 0);
63
76
  }
64
77
  /**
65
- * When a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, on the
66
- * same clock, or undefined when it declares no end. A participant that waited for a slot still ends
67
- * at its declared time.
78
+ * How a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, or
79
+ * undefined when it declares neither an end nor a cap. A participant that waited for a slot still
80
+ * ends at its declared time, and still gets its whole cap.
68
81
  */
69
- export function scheduledEndAt(participant, scheduledAt) {
82
+ export function participantEnd(participant, scheduledAt) {
83
+ const windDownMs = participant.windDownMs ?? DEFAULT_WIND_DOWN_MS;
84
+ if (participant.maxSessionMs !== undefined)
85
+ return { kind: "session_cap", afterMs: participant.maxSessionMs, windDownMs };
70
86
  if (participant.endAfterMs === undefined)
71
87
  return undefined;
72
88
  return {
89
+ kind: "scheduled_end",
73
90
  atMs: scheduledAt - (participant.startAfterMs ?? 0) + participant.endAfterMs,
74
- windDownMs: participant.windDownMs ?? DEFAULT_WIND_DOWN_MS,
91
+ windDownMs,
75
92
  };
76
93
  }
77
94
  /**
78
- * Why the sandbox of the first participant whose window passes `ceilingMs` cannot live that long,
79
- * or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
95
+ * Why the sandbox of the first participant whose window or cap passes `ceilingMs` cannot live that
96
+ * long, or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
80
97
  * route adds to a session for setup and teardown (sandboxHeadroomMs).
81
98
  */
82
99
  export function windowDeadlineRefusal(participants, sandbox, ceilingMs) {
83
100
  const inMinutes = (ms) => Math.round(ms / 60_000);
84
101
  for (const participant of participants) {
85
- if (participant.endAfterMs === undefined)
102
+ if (declaredWindowMs(participant) === undefined)
86
103
  continue;
87
104
  const deadlineMs = participantSessionMs(participant, sandbox.sessionMs) + sandbox.headroomMs;
88
105
  if (deadlineMs <= ceilingMs)
89
106
  continue;
90
107
  const name = participant.id === undefined ? "A participant" : `Participant "${participant.id}"`;
91
- return `${name} works from +${formatDuration(participant.startAfterMs ?? 0)} to +${formatDuration(participant.endAfterMs)}, so its sandbox needs a ${inMinutes(deadlineMs)}m deadline: the window, ${inMinutes(SCHEDULED_END_ROOM_MS)}m for its closing report and ${inMinutes(sandbox.headroomMs)}m to set up and tear down. A sandbox may not live longer than ${ceilingMs / 60_000}m. Shorten its window.${ceilingAdvice(deadlineMs)}`;
108
+ const works = participant.maxSessionMs === undefined
109
+ ? `works from +${formatDuration(participant.startAfterMs ?? 0)} to +${formatDuration(participant.endAfterMs ?? 0)}`
110
+ : `may work for up to ${formatDuration(participant.maxSessionMs)}`;
111
+ const window = participant.maxSessionMs === undefined ? "window" : "cap";
112
+ return `${name} ${works}, so its sandbox needs a ${inMinutes(deadlineMs)}m deadline: the ${window}, ${inMinutes(SCHEDULED_END_ROOM_MS)}m for its closing report and ${inMinutes(sandbox.headroomMs)}m to set up and tear down. A sandbox may not live longer than ${ceilingMs / 60_000}m. Shorten its ${window}.${ceilingAdvice(deadlineMs)}`;
92
113
  }
93
114
  return undefined;
94
115
  }
95
116
  /**
96
- * Each participant's window for `study check`, when any participant declares an end: when it starts
97
- * and ends and when it is first told the time left, or when it starts and its session budget.
117
+ * Each participant's window for `study check`, when any participant declares an end or a cap: when
118
+ * it starts and ends and when it is first told the time left, when it starts and its cap and how
119
+ * far into its session it is first told the time left, or when it starts and its session budget.
98
120
  */
99
121
  export function describeWindows(participants, sessionMs) {
100
- if (!participants.some((participant) => participant.endAfterMs !== undefined))
122
+ if (!participants.some((participant) => declaredWindowMs(participant) !== undefined))
101
123
  return undefined;
102
124
  const windows = participants.map((participant) => {
103
125
  const start = `${participant.id ?? "participant"} from +${formatDuration(participant.startAfterMs ?? 0)}`;
126
+ if (participant.maxSessionMs !== undefined) {
127
+ const told = Math.max(0, participant.maxSessionMs - (participant.windDownMs ?? DEFAULT_WIND_DOWN_MS));
128
+ return `${start} until it ends itself or for up to ${formatDuration(participant.maxSessionMs)}, told the time left from ${formatDuration(told)} into its session`;
129
+ }
104
130
  if (participant.endAfterMs === undefined)
105
131
  return `${start} for up to ${formatDuration(sessionMs)}`;
106
132
  const told = Math.max(participant.startAfterMs ?? 0, participant.endAfterMs - (participant.windDownMs ?? DEFAULT_WIND_DOWN_MS));
@@ -1,8 +1,9 @@
1
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. */
2
+ /** One participant as the plan has it: when it starts and ends, its cap, and its dwell window. */
3
3
  export interface EstimatedParticipant {
4
4
  readonly startAfterMs?: number;
5
5
  readonly endAfterMs?: number;
6
+ readonly maxSessionMs?: number;
6
7
  readonly limits: {
7
8
  readonly dwell?: DwellWindow;
8
9
  };
@@ -33,10 +34,10 @@ export interface BundleEstimate {
33
34
  }
34
35
  /**
35
36
  * 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.
37
+ * declares an end or a cap. A participant with an end works its whole window; one without ends
38
+ * itself, so it is counted for the measured open session above, or its budget (its cap, else
39
+ * `sessionMs`) when that is shorter. Each takes a model turn at the measured pace, and a dwell
40
+ * window adds a frame every `everyMs` for its length, cut to the session, and the frame after it.
40
41
  */
41
42
  export declare function estimateBundle(participants: readonly EstimatedParticipant[], sessionMs: number): BundleEstimate;
42
43
  /**
@@ -48,26 +48,31 @@ const RATES = Object.freeze({
48
48
  const MIB = 1024 * 1024;
49
49
  /**
50
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.
51
+ * declares an end or a cap. A participant with an end works its whole window; one without ends
52
+ * itself, so it is counted for the measured open session above, or its budget (its cap, else
53
+ * `sessionMs`) when that is shorter. Each takes a model turn at the measured pace, and a dwell
54
+ * window adds a frame every `everyMs` for its length, cut to the session, and the frame after it.
55
55
  */
56
56
  export function estimateBundle(participants, sessionMs) {
57
57
  let turns = 0;
58
58
  let frames = 0;
59
59
  let longestWindowMs = 0;
60
- const openMs = Math.min(sessionMs, RATES.openSessionMs);
60
+ // The longest any participant without an end is counted, for the messages.
61
+ let openMs = Math.min(sessionMs, RATES.openSessionMs);
61
62
  for (const participant of participants) {
62
63
  const windowMs = participant.endAfterMs === undefined
63
64
  ? undefined
64
65
  : participant.endAfterMs - (participant.startAfterMs ?? 0);
66
+ const budgetMs = windowMs ?? participant.maxSessionMs ?? sessionMs;
67
+ const countedMs = windowMs ?? Math.min(budgetMs, RATES.openSessionMs);
65
68
  if (windowMs !== undefined)
66
69
  longestWindowMs = Math.max(longestWindowMs, windowMs);
67
- turns += Math.ceil((windowMs ?? openMs) / (RATES.secondsPerTurn * 1000));
70
+ else
71
+ openMs = Math.max(openMs, countedMs);
72
+ turns += Math.ceil(countedMs / (RATES.secondsPerTurn * 1000));
68
73
  const dwell = participant.limits.dwell;
69
74
  if (dwell !== undefined)
70
- frames += Math.floor(Math.min(dwell.ms, windowMs ?? sessionMs) / dwell.everyMs) + 1;
75
+ frames += Math.floor(Math.min(dwell.ms, budgetMs) / dwell.everyMs) + 1;
71
76
  }
72
77
  const count = participants.length;
73
78
  const runFileBytes = RATES.runBytes +
@@ -0,0 +1,37 @@
1
+ import type { StudyParseFailure, StudyParticipantFile } from "./types.js";
2
+ /**
3
+ * Where `participants[].files` act: the hosted desktop of an independent computer-use participant.
4
+ * The parser's unread-field check and the route planners give this reason.
5
+ */
6
+ export declare const DESKTOP_FILES_ROUTE_REASON = "files are copied onto a hosted computer-use desktop; needs route: computer-use with execution.target: e2b-desktop";
7
+ /** A participant's `files` list: each entry's `from` and `as`. */
8
+ export declare function parseParticipantFiles(raw: unknown, field: string): {
9
+ ok: true;
10
+ value: StudyParticipantFile[] | undefined;
11
+ } | StudyParseFailure;
12
+ /** One file read on this machine for a participant's desktop. */
13
+ export interface DesktopFile {
14
+ /** Where it lands, relative to the desktop user's home folder. */
15
+ readonly as: string;
16
+ /** The file's bytes, shared by every participant that receives it and uploaded as they are. */
17
+ readonly data: ArrayBuffer;
18
+ readonly size: number;
19
+ /** The sha256 of `data`, 64 hex characters. */
20
+ readonly sha256: string;
21
+ }
22
+ type DesktopFilesRead = {
23
+ readonly ok: true;
24
+ readonly files: ReadonlyMap<string, readonly DesktopFile[]>;
25
+ } | {
26
+ readonly ok: false;
27
+ readonly message: string;
28
+ };
29
+ /**
30
+ * Read every participant's declared files from `folder`, the folder that holds the study file, and
31
+ * return them by participant id. Runs before any sandbox exists, so a refusal costs nothing.
32
+ */
33
+ export declare function readDesktopFiles(folder: string, participants: readonly {
34
+ readonly id: string;
35
+ readonly files?: readonly StudyParticipantFile[];
36
+ }[]): Promise<DesktopFilesRead>;
37
+ export {};