humanish 0.125.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 (104) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actors/computer-use/actor.d.ts +2 -0
  3. package/dist/actors/computer-use/actor.js +1 -0
  4. package/dist/actors/computer-use/loop/clock.d.ts +9 -8
  5. package/dist/actors/computer-use/loop/clock.js +27 -17
  6. package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
  7. package/dist/actors/computer-use/loop/debrief.js +15 -5
  8. package/dist/actors/computer-use/loop/ending.d.ts +8 -5
  9. package/dist/actors/computer-use/loop/ending.js +8 -5
  10. package/dist/actors/computer-use/loop/observation.d.ts +6 -0
  11. package/dist/actors/computer-use/loop/observation.js +19 -1
  12. package/dist/actors/computer-use/loop/session.d.ts +4 -2
  13. package/dist/actors/computer-use/loop/session.js +4 -1
  14. package/dist/actors/computer-use/loop/trace.js +4 -2
  15. package/dist/actors/computer-use/loop/types.d.ts +22 -8
  16. package/dist/actors/computer-use/loop.js +5 -5
  17. package/dist/actors/contract.d.ts +31 -8
  18. package/dist/actors/contract.js +3 -0
  19. package/dist/actors/stop-cause.js +1 -0
  20. package/dist/cli/commands/study.js +8 -0
  21. package/dist/evidence/redaction.d.ts +1 -0
  22. package/dist/evidence/redaction.js +1 -0
  23. package/dist/evidence/trace-page.d.ts +6 -0
  24. package/dist/evidence/trace-page.js +90 -0
  25. package/dist/observer/feed-cursors.d.ts +14 -0
  26. package/dist/observer/feed-cursors.js +61 -0
  27. package/dist/observer/participant-items.d.ts +7 -0
  28. package/dist/observer/pinned-files.d.ts +2 -1
  29. package/dist/observer/pinned-files.js +2 -2
  30. package/dist/observer/run-routes.d.ts +1 -1
  31. package/dist/observer/run-routes.js +38 -17
  32. package/dist/observer-app.html +7 -7
  33. package/dist/routes/computer-use/bundle.js +4 -0
  34. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  35. package/dist/routes/computer-use/diagnostics.js +3 -3
  36. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  37. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  38. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  39. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  40. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  41. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  42. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  43. package/dist/routes/computer-use/fanout-records.js +4 -0
  44. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  45. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  46. package/dist/routes/computer-use/participant-model.js +16 -6
  47. package/dist/routes/computer-use/participant-runs.d.ts +2 -2
  48. package/dist/routes/computer-use/participant-runs.js +11 -7
  49. package/dist/routes/computer-use/plan.js +39 -1
  50. package/dist/routes/computer-use/setup.d.ts +1 -0
  51. package/dist/routes/computer-use/setup.js +28 -0
  52. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  53. package/dist/routes/computer-use/single-bundle.js +2 -0
  54. package/dist/routes/computer-use/types.d.ts +16 -10
  55. package/dist/routes/shared-world/bundle-records.js +3 -0
  56. package/dist/routes/shared-world/participant-specs.js +3 -2
  57. package/dist/routes/shared-world/plan.js +5 -0
  58. package/dist/run/contained-output.d.ts +7 -3
  59. package/dist/run/contained-output.js +11 -7
  60. package/dist/run/judge.d.ts +7 -4
  61. package/dist/run/judge.js +13 -8
  62. package/dist/run/live-items.d.ts +19 -0
  63. package/dist/run/live-items.js +28 -8
  64. package/dist/run/stream-trace.d.ts +25 -4
  65. package/dist/run/stream-trace.js +55 -17
  66. package/dist/run/streams.d.ts +16 -0
  67. package/dist/study/arrivals.d.ts +32 -14
  68. package/dist/study/arrivals.js +47 -21
  69. package/dist/study/bundle-estimate.d.ts +6 -5
  70. package/dist/study/bundle-estimate.js +12 -7
  71. package/dist/study/desktop-files.d.ts +37 -0
  72. package/dist/study/desktop-files.js +174 -0
  73. package/dist/study/keys.d.ts +8 -0
  74. package/dist/study/keys.js +3 -0
  75. package/dist/study/migrate/v2.js +12 -3
  76. package/dist/study/parse/actors.d.ts +1 -1
  77. package/dist/study/parse/actors.js +28 -20
  78. package/dist/study/parse/clock.d.ts +18 -0
  79. package/dist/study/parse/clock.js +36 -0
  80. package/dist/study/plan-participants.d.ts +19 -5
  81. package/dist/study/plan-participants.js +17 -1
  82. package/dist/study/plan.js +24 -13
  83. package/dist/study/preflight.js +4 -0
  84. package/dist/study/types.d.ts +34 -5
  85. package/dist/study/validation.js +3 -0
  86. package/dist/study/warnings.d.ts +2 -2
  87. package/dist/study/warnings.js +7 -0
  88. package/dist/subject/local-tree-archive.d.ts +7 -0
  89. package/dist/subject/local-tree-archive.js +12 -0
  90. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  91. package/dist/substrates/e2b/desktop-browser.js +6 -2
  92. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  93. package/dist/substrates/e2b/desktop-clock.js +29 -0
  94. package/dist/substrates/e2b/sdk.d.ts +6 -0
  95. package/dist/verify/pages.d.ts +7 -0
  96. package/dist/verify/pages.js +50 -0
  97. package/dist/verify/verify.js +10 -3
  98. package/docs/architecture/actor-contract.md +2 -0
  99. package/docs/architecture/external-public-shared-world.md +4 -1
  100. package/docs/architecture/observer.md +10 -1
  101. package/docs/contracts/policy.md +7 -0
  102. package/docs/contracts/run-bundle.md +31 -1
  103. package/docs/contracts/schemas.md +72 -3
  104. package/package.json +2 -2
@@ -13,7 +13,7 @@ import { type CuaDiagnostics } from "./diagnostics.js";
13
13
  import type { ActorCompletionReason, ActorStatus, ActorStopCause, ActorTokenUsage, ActorTraceItem } from "../../actors/contract.js";
14
14
  import { type CuaActorDescriptor } from "../../actors/registry.js";
15
15
  import type { StudyDeps } from "../../study/study-deps.js";
16
- import type { ArrivalPlan, ParticipantArrival } from "../../study/arrivals.js";
16
+ import type { ArrivalPlan, ParticipantArrival, ParticipantEnd } from "../../study/arrivals.js";
17
17
  import type { StudyEvent, ParticipantRef } from "../../study/run-study-events.js";
18
18
  import type { InProcessDriver, ProviderFactory, RunStudyHomes } from "../../study/run-study-homes.js";
19
19
  import { type BrowserScorer } from "../../study/adapter-extension.js";
@@ -23,9 +23,10 @@ import { type DesktopResourceObservation } from "../../substrates/e2b/desktop-re
23
23
  import { type DetachedTimers } from "../../substrates/detached.js";
24
24
  import type { Brain, ComputerUsePlan, ComputerUseRunner, ResidualConfig } from "../../study/plan-types.js";
25
25
  import { type StudyCommsEmail } from "../../study/types.js";
26
+ import type { DesktopFile } from "../../study/desktop-files.js";
26
27
  import { type ObserverResult } from "../../observer/render.js";
27
28
  import { type BundleRun, type RunBundle, type RunRerunLineage, type RunScorerProvenance, type RunSubjectProvenance, type RunSubjectStateStepRecord } from "../../run/bundle.js";
28
- import { type RunDesktopGeometry } from "../../run/streams.js";
29
+ import { type RunDesktopFile, type RunDesktopGeometry } from "../../run/streams.js";
29
30
  import { type PreparedOutputRoot } from "../../run/contained-output.js";
30
31
  import type { ComputerUseParticipant, SharedWorldParticipant } from "../../study/plan-participants.js";
31
32
  import type { ResolvedParticipant } from "../../run/participant.js";
@@ -114,6 +115,8 @@ export interface CuaParticipantPlanEntry {
114
115
  startAfterMs?: number;
115
116
  /** The participant's declared end, on the same clock. */
116
117
  endAfterMs?: number;
118
+ /** The participant's session cap, from the start of its session. */
119
+ maxSessionMs?: number;
117
120
  }
118
121
  /** The pre-flight spend and participant plan (pure; printed to stderr + recorded as a bundle event before
119
122
  * any sandbox or provider call; identical in dry-run, marked $0). */
@@ -195,7 +198,7 @@ export interface CuaParticipantSummary {
195
198
  concurrency: number;
196
199
  waves: number;
197
200
  }
198
- export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED" | "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_SUBJECT_BUILD_FAILED" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
201
+ export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED" | "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY" | "HUMANISH_COMPUTER_USE_DESKTOP_FILES_INVALID" | "HUMANISH_COMPUTER_USE_TIME_ZONE_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_SUBJECT_BUILD_FAILED" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
199
202
  export interface CuaActorStudyResult extends AutomaticAnalysisResult, StudyResultIdentity<"computer-use"> {
200
203
  /** True when the Observer verified the bundle, all live participants passed credibility checks
201
204
  * (or this is a dry-run), and no declared adapter/scorer verdict failed. */
@@ -316,6 +319,9 @@ export interface E2BDesktopDeps {
316
319
  /** Local-tree route only: the once-per-run packed archive bytes, shared byte-identically
317
320
  * across every fan-out participant's upload step. Absent on dry-run and every other route. */
318
321
  localTreeArchiveBuffer?: ArrayBuffer;
322
+ /** Computer use on a hosted desktop: each participant's files (participants[].files) by id,
323
+ * read on this machine before any sandbox. A participant without an entry receives none. */
324
+ desktopFiles?: ReadonlyMap<string, readonly DesktopFile[]>;
319
325
  env: Record<string, string | undefined>;
320
326
  e2bApiKey: string;
321
327
  requestTimeoutMs: number;
@@ -367,14 +373,12 @@ export interface ParticipantModelDeps {
367
373
  openaiApiKey: string;
368
374
  timeoutMs: number;
369
375
  /**
370
- * The participant's scheduled end on `now`'s clock, when it declares participants[].endAfterMs
371
- * (scheduledParticipantDeps). Its session ends there with stop cause `scheduled_end`.
376
+ * How the participant's session ends, when it declares participants[].endAfterMs or maxSessionMs
377
+ * (scheduledParticipantDeps): at its scheduled end on `now`'s clock, with stop cause
378
+ * `scheduled_end`, or at its cap after the session starts, with stop cause `session_cap`.
372
379
  */
373
- scheduledEnd?: {
374
- readonly atMs: number;
375
- readonly windDownMs: number;
376
- };
377
- /** The run's clock. A participant with a scheduled end runs its session on it. */
380
+ end?: ParticipantEnd;
381
+ /** The run's clock. A participant with an end or a cap runs its session on it. */
378
382
  now: () => number;
379
383
  participantCount: number;
380
384
  artifactRoot: PreparedOutputRoot;
@@ -449,6 +453,8 @@ export interface ParticipantRunOutcome {
449
453
  /** Requested + measured desktop/browser geometry. Viewport is absent when measurement failed. */
450
454
  desktopGeometry?: RunDesktopGeometry;
451
455
  recording?: RunDesktopRecording;
456
+ /** participants[].files confirmed on the participant's desktop. */
457
+ desktopFiles?: RunDesktopFile[];
452
458
  stateStepRecords: RunSubjectStateStepRecord[];
453
459
  /** Completed subject-phase records (clone/upload/extract/install/build/ready/state groups),
454
460
  * folded into bundle.events at build time. Empty on the in-process route (no provisioning). */
@@ -122,6 +122,9 @@ function sharedWorldStream(ctx, spec, index, view) {
122
122
  },
123
123
  }),
124
124
  desktopGeometry,
125
+ ...(spec.planned.limits.timeZone === undefined
126
+ ? {}
127
+ : { timeZone: spec.planned.limits.timeZone }),
125
128
  ui: {
126
129
  route: view.route,
127
130
  intent: `Watch ${name} use the shared app at the same time as the others.`,
@@ -140,10 +140,11 @@ export function participantRunDeps(ctx, live, scrubKnownValues) {
140
140
  studyCwd: ctx.cwd,
141
141
  redactScreenshots: ctx.redactScreenshots,
142
142
  scrubKnownValues,
143
- // A session that throws returns no trace; the run still records that it started.
143
+ // A session that throws returns no trace; the run still records that it started. Observed
144
+ // URLs land in a shared-world bundle only as digests, so no session records its pages.
144
145
  runSession: (options) => {
145
146
  ctx.run.participantStarted();
146
- return ctx.runSession(options);
147
+ return ctx.runSession({ ...options, recordPages: false });
147
148
  },
148
149
  liveFlush: () => live.flush?.flush,
149
150
  onStream: participantStreams(ctx.input.onStream, live),
@@ -8,6 +8,8 @@ import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
8
8
  import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
9
9
  import { brainOf, planCaps, desktopRequirements, isNonEmpty, planBase, } from "../../study/plan-base.js";
10
10
  import { sharedWorldParticipants } from "../../study/plan-participants.js";
11
+ import { DESKTOP_FILES_ROUTE_REASON } from "../../study/desktop-files.js";
12
+ import { participantList } from "../../study/study-fields.js";
11
13
  import { PUBLIC_TARGET_OWNER_PATTERN } from "../../study/parse/subject.js";
12
14
  import { REPO_SLUG_PATTERN } from "../../study/parse/values.js";
13
15
  import { concurrentSharedWorldValidationReason, desktopMediaValidationReason, externalPublicSharedWorldValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, taskProtocolValidationReason, waitLimitValidationReason, } from "../../study/validation.js";
@@ -41,6 +43,9 @@ export function planSharedWorldStudy(config, input) {
41
43
  const tasksReason = taskProtocolValidationReason(config, false);
42
44
  if (tasksReason)
43
45
  return refuse("HUMANISH_STUDY_TASKS_UNSUPPORTED", tasksReason);
46
+ // The parser refuses these; a library caller's config skips it.
47
+ if (participantList(config)?.some((entry) => entry.files !== undefined))
48
+ return refuse(invalid, `participants[].files: ${DESKTOP_FILES_ROUTE_REASON}. Remove them.`);
44
49
  const actorType = config.actor?.type ?? "";
45
50
  const descriptor = actorRegistry[actorType];
46
51
  if (!descriptor || !isCuaActorDescriptor(descriptor))
@@ -117,10 +117,14 @@ export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number):
117
117
  /**
118
118
  * The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
119
119
  * or all of a file that holds fewer. Bytes past `length`, such as a later append, are not read.
120
+ * With `start`, the bytes from `start` to `length`.
120
121
  */
121
- export declare function readContainedPrefix(rootInput: PreparedOutputRoot, relativePath: string, length: number): Promise<ContainedRead>;
122
- /** The first `length` bytes of an opened file, read by position, or all of a shorter one. */
123
- export declare function readOpenedPrefix(handle: FileHandle, length: number): Promise<Buffer>;
122
+ export declare function readContainedPrefix(rootInput: PreparedOutputRoot, relativePath: string, length: number, start?: number): Promise<ContainedRead>;
123
+ /**
124
+ * The first `length` bytes of an opened file, read by position, or all of a shorter one. With
125
+ * `start`, the bytes from `start` to `length`.
126
+ */
127
+ export declare function readOpenedPrefix(handle: FileHandle, length: number, start?: number): Promise<Buffer>;
124
128
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
125
129
  export declare function openContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<FileHandle | null>;
126
130
  export declare function assertSafeOutputPathSegment(value: string, label?: string): void;
@@ -307,15 +307,16 @@ export async function readOpenedAtMost(handle, maxBytes) {
307
307
  /**
308
308
  * The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
309
309
  * or all of a file that holds fewer. Bytes past `length`, such as a later append, are not read.
310
+ * With `start`, the bytes from `start` to `length`.
310
311
  */
311
- export async function readContainedPrefix(rootInput, relativePath, length) {
312
+ export async function readContainedPrefix(rootInput, relativePath, length, start = 0) {
312
313
  const opened = await openContained(rootInput, relativePath);
313
314
  if (opened.status === "missing")
314
315
  return opened;
315
316
  if (opened.status === "refused")
316
317
  return { status: "refused", reason: opened.reason, limit: length };
317
318
  try {
318
- return { status: "read", bytes: await readOpenedPrefix(opened.handle, length) };
319
+ return { status: "read", bytes: await readOpenedPrefix(opened.handle, length, start) };
319
320
  }
320
321
  catch {
321
322
  return { status: "refused", reason: "unreadable", limit: length };
@@ -324,12 +325,15 @@ export async function readContainedPrefix(rootInput, relativePath, length) {
324
325
  await opened.handle.close().catch(() => { });
325
326
  }
326
327
  }
327
- /** The first `length` bytes of an opened file, read by position, or all of a shorter one. */
328
- export async function readOpenedPrefix(handle, length) {
329
- const bytes = Buffer.allocUnsafe(length);
328
+ /**
329
+ * The first `length` bytes of an opened file, read by position, or all of a shorter one. With
330
+ * `start`, the bytes from `start` to `length`.
331
+ */
332
+ export async function readOpenedPrefix(handle, length, start = 0) {
333
+ const bytes = Buffer.allocUnsafe(Math.max(0, length - start));
330
334
  let total = 0;
331
- while (total < length) {
332
- const { bytesRead } = await handle.read(bytes, total, length - total, total);
335
+ while (total < bytes.length) {
336
+ const { bytesRead } = await handle.read(bytes, total, bytes.length - total, start + total);
333
337
  if (bytesRead === 0)
334
338
  break;
335
339
  total += bytesRead;
@@ -6,7 +6,7 @@ export interface BlockerFacts {
6
6
  completionReason: ActorCompletionReason;
7
7
  /** A harness-owned stop condition ended the session: a matched stopWhen or a completed dwell window. */
8
8
  stopConditionMatched: boolean;
9
- /** What the participant declared in a field, when its provider has one. */
9
+ /** What the participant declared, when it declared an outcome (ActorTrace.declaredOutcome). */
10
10
  declaredOutcome?: ParticipantDeclaredOutcome;
11
11
  /** The route's reading of the closing report: it describes a blocker or asks for instructions. */
12
12
  closingReportReadsBlocked: boolean;
@@ -22,9 +22,12 @@ export interface SessionEnding extends BlockerFacts {
22
22
  */
23
23
  export declare function hollowCompletion(ending: SessionEnding): boolean;
24
24
  /**
25
- * The participant said it reached the goal but reported a blocker. A declared outcome is its own
26
- * word and wins. Without one, the closing report is read, unless a stop condition ended the
27
- * session: a matched stopWhen is structured completion evidence and overrides the text.
25
+ * The session ended goal_satisfied but the participant reported a blocker. A declared `reached`
26
+ * or `blocked` is its own word and wins. Otherwise the closing report is read, unless a stop
27
+ * condition ended the session: a matched stopWhen is structured completion evidence and overrides
28
+ * the text. A declared `not_reached` is read the same way: it ends the session as gave_up when
29
+ * the participant ends it, so here it comes from a closing report at a scheduled end or a session
30
+ * cap, and it does not say whether something stopped the participant.
28
31
  */
29
32
  export declare function selfReportedBlocker(ending: BlockerFacts): boolean;
30
33
  /** What a participant's pass reads: its session's end, or why it has none, and the two judgments above. */
package/dist/run/judge.js CHANGED
@@ -25,16 +25,21 @@ export function hollowCompletion(ending) {
25
25
  !ending.stopConditionMatched);
26
26
  }
27
27
  /**
28
- * The participant said it reached the goal but reported a blocker. A declared outcome is its own
29
- * word and wins. Without one, the closing report is read, unless a stop condition ended the
30
- * session: a matched stopWhen is structured completion evidence and overrides the text.
28
+ * The session ended goal_satisfied but the participant reported a blocker. A declared `reached`
29
+ * or `blocked` is its own word and wins. Otherwise the closing report is read, unless a stop
30
+ * condition ended the session: a matched stopWhen is structured completion evidence and overrides
31
+ * the text. A declared `not_reached` is read the same way: it ends the session as gave_up when
32
+ * the participant ends it, so here it comes from a closing report at a scheduled end or a session
33
+ * cap, and it does not say whether something stopped the participant.
31
34
  */
32
35
  export function selfReportedBlocker(ending) {
33
- if (ending.declaredOutcome !== undefined)
34
- return ending.declaredOutcome === "blocked" && ending.completionReason === "goal_satisfied";
35
- return (ending.completionReason === "goal_satisfied" &&
36
- ending.closingReportReadsBlocked &&
37
- !ending.stopConditionMatched);
36
+ if (ending.completionReason !== "goal_satisfied")
37
+ return false;
38
+ if (ending.declaredOutcome === "blocked")
39
+ return true;
40
+ if (ending.declaredOutcome === "reached")
41
+ return false;
42
+ return ending.closingReportReadsBlocked && !ending.stopConditionMatched;
38
43
  }
39
44
  /** Judge participant evidence for the bundle and the result in the same order. */
40
45
  export function judgeParticipantRecords(participants, wording = {}) {
@@ -8,6 +8,11 @@ export declare const LIVE_ACTOR_SCHEMA = "humanish.live-actor.v2";
8
8
  export declare const LIVE_ACTOR_V1_SCHEMA = "humanish.live-actor.v1";
9
9
  /** Whether `value` is a live items index naming a live file name and its own generation. */
10
10
  export declare function isLiveItemsIndex(value: unknown): value is LiveItemsIndex;
11
+ /** Where a reader's copy of a live file ends: its first `items` lines, `bytes` bytes. */
12
+ export interface LiveItemsCursor {
13
+ items: number;
14
+ bytes: number;
15
+ }
11
16
  /**
12
17
  * The items the indexed prefix of a live file holds, or why the bytes do not match the index:
13
18
  * `bytes` must be exactly the indexed byte count, hash to the indexed sha256, and hold the indexed
@@ -18,6 +23,20 @@ export declare function liveItemsFromBytes(bytes: Buffer, index: LiveItemsIndex)
18
23
  } | {
19
24
  refused: string;
20
25
  };
26
+ /**
27
+ * The items past `after` in the indexed prefix of a live file, from the file's bytes between
28
+ * `after.bytes - 1` and the indexed end, or why they do not fit: the first byte must end the line
29
+ * before the cursor, and the rest must hold exactly the lines between the cursor's count and the
30
+ * indexed count, each a JSON object. A line end at `after.bytes - 1` fixes how many lines precede
31
+ * it, so a cursor whose `items` is not that count fails on the line count. The sha256 run.json
32
+ * records covers the whole prefix and is not checked here: what precedes the cursor is the
33
+ * reader's, and a generation's bytes never change once written.
34
+ */
35
+ export declare function liveItemsAfter(bytes: Buffer, index: LiveItemsIndex, after: LiveItemsCursor): {
36
+ items: ActorTraceItem[];
37
+ } | {
38
+ refused: string;
39
+ };
21
40
  /** One run's live files: what each stream position's current generation holds. */
22
41
  export interface LiveItemsWriter {
23
42
  /**
@@ -41,20 +41,40 @@ export function liveItemsFromBytes(bytes, index) {
41
41
  };
42
42
  if (createHash("sha256").update(bytes).digest("hex") !== index.sha256)
43
43
  return { refused: `${index.path} does not match the sha256 run.json records for it` };
44
+ return itemLinesOf(bytes, index.items, index.path);
45
+ }
46
+ /**
47
+ * The items past `after` in the indexed prefix of a live file, from the file's bytes between
48
+ * `after.bytes - 1` and the indexed end, or why they do not fit: the first byte must end the line
49
+ * before the cursor, and the rest must hold exactly the lines between the cursor's count and the
50
+ * indexed count, each a JSON object. A line end at `after.bytes - 1` fixes how many lines precede
51
+ * it, so a cursor whose `items` is not that count fails on the line count. The sha256 run.json
52
+ * records covers the whole prefix and is not checked here: what precedes the cursor is the
53
+ * reader's, and a generation's bytes never change once written.
54
+ */
55
+ export function liveItemsAfter(bytes, index, after) {
56
+ if (bytes.length !== index.bytes - after.bytes + 1)
57
+ return {
58
+ refused: `${index.path} holds ${bytes.length - 1} bytes past byte ${after.bytes} and run.json records ${index.bytes - after.bytes}`,
59
+ };
60
+ if (bytes[0] !== 0x0a)
61
+ return { refused: `${index.path} has no line end before byte ${after.bytes}` };
62
+ return itemLinesOf(bytes.subarray(1), index.items - after.items, index.path);
63
+ }
64
+ /** The JSON object on each of `count` lines in `bytes`, which must be UTF-8 and end a line. */
65
+ function itemLinesOf(bytes, count, file) {
44
66
  let text;
45
67
  try {
46
68
  text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
47
69
  }
48
70
  catch {
49
- return { refused: `${index.path} is not UTF-8 text` };
71
+ return { refused: `${file} is not UTF-8 text` };
50
72
  }
51
73
  const lines = text === "" ? [] : text.split("\n");
52
74
  if (lines.length > 0 && lines.pop() !== "")
53
- return { refused: `${index.path} does not end its indexed bytes at a line end` };
54
- if (lines.length !== index.items)
55
- return {
56
- refused: `${index.path} holds ${lines.length} items and run.json records ${index.items}`,
57
- };
75
+ return { refused: `${file} does not end its indexed bytes at a line end` };
76
+ if (lines.length !== count)
77
+ return { refused: `${file} holds ${lines.length} items and run.json records ${count}` };
58
78
  const items = [];
59
79
  for (const line of lines) {
60
80
  let item;
@@ -62,10 +82,10 @@ export function liveItemsFromBytes(bytes, index) {
62
82
  item = JSON.parse(line);
63
83
  }
64
84
  catch {
65
- return { refused: `${index.path} holds a line that is not JSON` };
85
+ return { refused: `${file} holds a line that is not JSON` };
66
86
  }
67
87
  if (!isRecord(item))
68
- return { refused: `${index.path} holds a line that is not an item` };
88
+ return { refused: `${file} holds a line that is not an item` };
69
89
  items.push(item);
70
90
  }
71
91
  return { items };
@@ -1,6 +1,7 @@
1
1
  import { type RunBundle } from "./bundle.js";
2
2
  import { type PreparedOutputRoot } from "./contained-output.js";
3
- import type { RunStream } from "./streams.js";
3
+ import { type LiveItemsCursor } from "./live-items.js";
4
+ import type { LiveItemsIndex, RunStream } from "./streams.js";
4
5
  /** A trace that does not match its index, or cannot be read. The message names the stream. */
5
6
  export declare class StreamTraceRefusedError extends Error {
6
7
  readonly streamId: string;
@@ -23,15 +24,27 @@ export declare function splitStreamTraces(bundle: RunBundle): {
23
24
  /**
24
25
  * Where a loader reads trace files from: the bytes at a run-relative path, at most `maxBytes`, or
25
26
  * why it could not. With `length`, the first `length` bytes, or all of a shorter file: a live file
26
- * grows past what run.json indexes, and the loader reads only the indexed prefix. Prepared run
27
- * paths read through the contained reader; the Observer server passes a reader over its pinned run
27
+ * grows past what run.json indexes, and the loader reads only the indexed prefix. With `start` as
28
+ * well, the bytes from `start` to `length`: the items past a reader's cursor. Prepared run paths
29
+ * read through the contained reader; the Observer server passes a reader over its pinned run
28
30
  * folder.
29
31
  */
30
- export type TraceFileReader = (relativePath: string, maxBytes: number, length?: number) => Promise<{
32
+ export type TraceFileReader = (relativePath: string, maxBytes: number, length?: number, start?: number) => Promise<{
31
33
  bytes: Buffer;
32
34
  } | {
33
35
  refused: string;
34
36
  }>;
37
+ /**
38
+ * A live file the loader joined into stream `streamId`: the index it read the items by, and the
39
+ * cursor it read past when the stream holds only the items after one.
40
+ */
41
+ export interface LiveItemsRead {
42
+ streamId: string;
43
+ index: LiveItemsIndex;
44
+ after?: LiveItemsCursor;
45
+ }
46
+ /** For a live file's index, the prefix of that file the caller holds already, if any. */
47
+ type LiveAfter = (index: LiveItemsIndex) => LiveItemsCursor | undefined;
35
48
  /**
36
49
  * The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
37
50
  * stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
@@ -44,6 +57,8 @@ export declare function loadStreamTrace(runPaths: PreparedOutputRoot, stream: Ru
44
57
  interface LoadedTraces {
45
58
  bundle: RunBundle;
46
59
  refused: StreamTraceRefusedError[];
60
+ /** Each live file joined into a stream that loaded, in stream order. */
61
+ live: LiveItemsRead[];
47
62
  }
48
63
  /**
49
64
  * Every stream of the bundle loaded through loadStreamTrace, one file at a time, and the refusal
@@ -53,6 +68,11 @@ interface LoadedTraces {
53
68
  * receives each trace and live file's path and the bytes the stream was joined from, after they
54
69
  * matched the index, so a caller scans exactly what it reads.
55
70
  *
71
+ * `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
72
+ * feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
73
+ * without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
74
+ * file's lines gets the whole prefix, checked as always.
75
+ *
56
76
  * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
57
77
  * removes a superseded generation once run.json names the next one, and every live file once the
58
78
  * run finished. The loader then reads run.json again, once, through the same reader, and loads
@@ -61,6 +81,7 @@ interface LoadedTraces {
61
81
  export declare function loadStreamTraces(source: PreparedOutputRoot | TraceFileReader, bundle: RunBundle, options?: {
62
82
  maxBytes?: number;
63
83
  onTrace?: (path: string, bytes: Buffer) => void;
84
+ liveAfter?: LiveAfter;
64
85
  }): Promise<LoadedTraces>;
65
86
  /**
66
87
  * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
@@ -8,7 +8,7 @@ import { createHash } from "node:crypto";
8
8
  import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "./bundle.js";
9
9
  import { isRunBundle } from "./bundle-shape.js";
10
10
  import { readContainedPrefix, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
11
- import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsFromBytes, } from "./live-items.js";
11
+ import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsAfter, liveItemsFromBytes, } from "./live-items.js";
12
12
  import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
13
13
  import { isRecord } from "./type-guards.js";
14
14
  /** A trace that does not match its index, or cannot be read. The message names the stream. */
@@ -87,10 +87,10 @@ export function splitStreamTraces(bundle) {
87
87
  return { bundle: { ...bundle, schema: RUN_BUNDLE_SCHEMA, streams }, files };
88
88
  }
89
89
  function containedReader(runPaths) {
90
- return async (relativePath, maxBytes, length) => {
90
+ return async (relativePath, maxBytes, length, start) => {
91
91
  const read = length === undefined
92
92
  ? await readContainedRegularFile(runPaths, relativePath, maxBytes)
93
- : await readContainedPrefix(runPaths, relativePath, length);
93
+ : await readContainedPrefix(runPaths, relativePath, length, start);
94
94
  if (read.status === "missing")
95
95
  return { refused: `${relativePath} is missing` };
96
96
  if (read.status === "refused")
@@ -152,7 +152,7 @@ class LiveFileUnreadableError extends StreamTraceRefusedError {
152
152
  * file's path and those bytes. A live actor with inline items (humanish.live-actor.v1) is returned
153
153
  * as it is; bundleRefusal refuses one in a v3 run.json.
154
154
  */
155
- async function readLiveItems(read, stream, maxBytes) {
155
+ async function readLiveItems(read, stream, maxBytes, liveAfter) {
156
156
  const live = stream.liveActor;
157
157
  if (live === undefined || isInlineLiveActor(live))
158
158
  return { stream };
@@ -168,6 +168,15 @@ async function readLiveItems(read, stream, maxBytes) {
168
168
  refuse("run.json holds in-progress items beside their live file");
169
169
  if (index.bytes > maxBytes)
170
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
+ };
171
180
  const file = await read(index.path, maxBytes, index.bytes);
172
181
  if ("refused" in file)
173
182
  throw new LiveFileUnreadableError(stream.id, file.refused);
@@ -175,17 +184,38 @@ async function readLiveItems(read, stream, maxBytes) {
175
184
  const joined = liveItemsFromBytes(file.bytes.subarray(0, index.bytes), index);
176
185
  if ("refused" in joined)
177
186
  return refuse(joined.refused);
178
- const { itemsFile: _index, ...summary } = live;
179
187
  return {
180
188
  stream: { ...stream, liveActor: { ...summary, items: joined.items } },
181
- live: { path: index.path, bytes: file.bytes.subarray(0, index.bytes) },
189
+ file: { path: index.path, bytes: file.bytes.subarray(0, index.bytes) },
190
+ read: { streamId: stream.id, index },
182
191
  };
183
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
+ }
184
214
  /**
185
215
  * The stream with its trace and live items joined, and each file's path and the bytes it was
186
216
  * joined from.
187
217
  */
188
- async function readStreamTrace(read, stream, maxBytes) {
218
+ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
189
219
  const files = [];
190
220
  let joined = stream;
191
221
  if (stream.trace !== undefined) {
@@ -196,10 +226,10 @@ async function readStreamTrace(read, stream, maxBytes) {
196
226
  joined = joinTrace(stream, file.bytes);
197
227
  files.push({ path: stream.trace.path, bytes: file.bytes });
198
228
  }
199
- const live = await readLiveItems(read, joined, maxBytes);
200
- if (live.live !== undefined)
201
- files.push(live.live);
202
- return { stream: live.stream, files };
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 }) };
203
233
  }
204
234
  /**
205
235
  * The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
@@ -249,6 +279,11 @@ function bundleRefusal(bundle, stream, owners) {
249
279
  * receives each trace and live file's path and the bytes the stream was joined from, after they
250
280
  * matched the index, so a caller scans exactly what it reads.
251
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
+ *
252
287
  * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
253
288
  * removes a superseded generation once run.json names the next one, and every live file once the
254
289
  * run finished. The loader then reads run.json again, once, through the same reader, and loads
@@ -257,15 +292,15 @@ function bundleRefusal(bundle, stream, owners) {
257
292
  export async function loadStreamTraces(source, bundle, options = {}) {
258
293
  const read = typeof source === "function" ? source : containedReader(source);
259
294
  const maxBytes = options.maxBytes ?? RUN_ARTIFACT_MAX_BYTES;
260
- let loaded = await loadEveryStream(read, bundle, maxBytes);
295
+ let loaded = await loadEveryStream(read, bundle, maxBytes, options.liveAfter);
261
296
  if (loaded.refused.some((error) => error instanceof LiveFileUnreadableError)) {
262
297
  const current = await rereadBundle(read, bundle.runId);
263
298
  if (current !== undefined)
264
- loaded = await loadEveryStream(read, current, maxBytes);
299
+ loaded = await loadEveryStream(read, current, maxBytes, options.liveAfter);
265
300
  }
266
301
  for (const file of loaded.files)
267
302
  options.onTrace?.(file.path, file.bytes);
268
- return { bundle: loaded.bundle, refused: loaded.refused };
303
+ return { bundle: loaded.bundle, refused: loaded.refused, live: loaded.live };
269
304
  }
270
305
  /** run.json as it is now, when it is a run bundle of run `runId`. */
271
306
  async function rereadBundle(read, runId) {
@@ -280,7 +315,7 @@ async function rereadBundle(read, runId) {
280
315
  return undefined;
281
316
  }
282
317
  }
283
- async function loadEveryStream(read, bundle, maxBytes) {
318
+ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
284
319
  const owners = new Map();
285
320
  for (const stream of bundle.streams)
286
321
  if (isRecord(stream.trace)) {
@@ -290,13 +325,16 @@ async function loadEveryStream(read, bundle, maxBytes) {
290
325
  const streams = [];
291
326
  const refused = [];
292
327
  const files = [];
328
+ const live = [];
293
329
  for (const stream of bundle.streams) {
294
330
  try {
295
331
  const reason = bundleRefusal(bundle, stream, owners);
296
332
  if (reason !== undefined)
297
333
  throw new StreamTraceRefusedError(stream.id, reason);
298
- const loaded = await readStreamTrace(read, stream, maxBytes);
334
+ const loaded = await readStreamTrace(read, stream, maxBytes, liveAfter);
299
335
  files.push(...loaded.files);
336
+ if (loaded.live !== undefined)
337
+ live.push(loaded.live);
300
338
  streams.push(loaded.stream);
301
339
  }
302
340
  catch (error) {
@@ -306,7 +344,7 @@ async function loadEveryStream(read, bundle, maxBytes) {
306
344
  streams.push(stream);
307
345
  }
308
346
  }
309
- return { bundle: { ...bundle, streams }, refused, files };
347
+ return { bundle: { ...bundle, streams }, refused, files, live };
310
348
  }
311
349
  /**
312
350
  * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
@@ -175,6 +175,15 @@ export interface RunParticipantAssignment {
175
175
  goal: string;
176
176
  }>;
177
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
+ }
178
187
  export interface RunStream {
179
188
  id: string;
180
189
  simId: string;
@@ -232,6 +241,13 @@ export interface RunStream {
232
241
  };
233
242
  /** Truthful screen/window/viewport evidence for hosted desktop browser participants. */
234
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;
235
251
  terminal?: {
236
252
  title: string;
237
253
  format: "ansi" | "plain";