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
@@ -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))
@@ -1,6 +1,6 @@
1
1
  import { isCommsReceivingEvidence } from "../comms/receiving-evidence.js";
2
2
  import { isCapturedGitState } from "./git-state.js";
3
- import { PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "./bundle.js";
3
+ import { PUBLIC_TARGET_CWD, REVIEW_SCHEMA, isReadRunBundleSchema, } from "./bundle.js";
4
4
  import { CLEANUP_SCHEMA, } from "./results.js";
5
5
  import { isRunSimulationStatus, isRunStream, isRunStreamKind } from "./stream-shape.js";
6
6
  import { isLocalEvidenceArtifactPath } from "./paths.js";
@@ -10,7 +10,7 @@ import { isSharedWorldEvidence } from "./shared-world-shape.js";
10
10
  import { isNonNegativeSafeInteger, isPositiveSafeInteger, isRecord } from "./type-guards.js";
11
11
  export function isRunBundle(value) {
12
12
  return (isRecord(value) &&
13
- (value.schema === RUN_BUNDLE_SCHEMA || value.schema === RUN_BUNDLE_V1_SCHEMA) &&
13
+ isReadRunBundleSchema(value.schema) &&
14
14
  (value.commsReceiving === undefined || isCommsReceivingEvidence(value.commsReceiving)) &&
15
15
  (value.publication === undefined ||
16
16
  (isRecord(value.publication) &&
@@ -5,10 +5,17 @@ import type { SharedWorldEvidence } from "./shared-world-evidence.js";
5
5
  import { type RunStudyProvenance } from "./study-provenance.js";
6
6
  import type { RunInterruptSignal } from "./status.js";
7
7
  import type { RunSimulationStatus, RunStream, RunStreamKind } from "./streams.js";
8
- /** The run bundle version this release writes: each stream's trace is a file run.json indexes. */
9
- export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v2";
8
+ /**
9
+ * The run bundle version this release writes: each stream's trace, and each running participant's
10
+ * items so far, are files run.json indexes.
11
+ */
12
+ export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
13
+ /** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
14
+ export declare const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
10
15
  /** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
11
16
  export declare const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
17
+ /** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
18
+ export declare function isReadRunBundleSchema(value: unknown): value is RunBundle["schema"];
12
19
  /** The run bundle file, relative to the run directory. */
13
20
  export declare const RUN_BUNDLE_FILE = "run.json";
14
21
  export declare const REVIEW_SCHEMA = "humanish.review.v1";
@@ -231,7 +238,7 @@ export interface RunBundle {
231
238
  restrictions: ["real-communications"];
232
239
  };
233
240
  commsReceiving?: CommsReceivingEvidence;
234
- schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
241
+ schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V2_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
235
242
  runId: string;
236
243
  mode: "dry-run" | "live";
237
244
  simCount: number;
@@ -1,10 +1,19 @@
1
1
  import path from "node:path";
2
2
  import { captureGitState } from "./git-state.js";
3
3
  import { studyFields } from "./study-provenance.js";
4
- /** The run bundle version this release writes: each stream's trace is a file run.json indexes. */
5
- export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v2";
4
+ /**
5
+ * The run bundle version this release writes: each stream's trace, and each running participant's
6
+ * items so far, are files run.json indexes.
7
+ */
8
+ export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
9
+ /** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
10
+ export const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
6
11
  /** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
7
12
  export const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
13
+ /** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
14
+ export function isReadRunBundleSchema(value) {
15
+ return (value === RUN_BUNDLE_SCHEMA || value === RUN_BUNDLE_V2_SCHEMA || value === RUN_BUNDLE_V1_SCHEMA);
16
+ }
8
17
  /** The run bundle file, relative to the run directory. */
9
18
  export const RUN_BUNDLE_FILE = "run.json";
10
19
  export const REVIEW_SCHEMA = "humanish.review.v1";
@@ -46,6 +46,21 @@ export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot,
46
46
  * writeContainedOutputFile renames a finished file into place instead, replacing what is there.
47
47
  */
48
48
  export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
49
+ /**
50
+ * Appends `data` to an existing single-link regular file inside the root that holds exactly
51
+ * `size` bytes, the count the caller says it wrote, so an append never lands after bytes the
52
+ * caller did not write. The checks writeContainedOutputFile makes run before the open; the opened
53
+ * file must be the one checked, and the root and its folders are checked again before the write.
54
+ * The data is synced before this resolves, so a run.json written after it never indexes bytes the
55
+ * disk may not hold.
56
+ */
57
+ export declare function appendContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: Uint8Array, size: number): Promise<void>;
58
+ /**
59
+ * Removes a single-link regular file or an empty folder inside the root. Nothing at the path, or a
60
+ * folder on it missing, is not an error. A link, a hard-linked file or a folder that holds anything
61
+ * is refused and kept.
62
+ */
63
+ export declare function removeContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<void>;
49
64
  /**
50
65
  * A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
51
66
  * and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
@@ -99,6 +114,17 @@ export declare function readContainedRegularFile(rootInput: PreparedOutputRoot,
99
114
  * after it was opened is never read whole.
100
115
  */
101
116
  export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
117
+ /**
118
+ * The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
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`.
121
+ */
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>;
102
128
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
103
129
  export declare function openContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<FileHandle | null>;
104
130
  export declare function assertSafeOutputPathSegment(value: string, label?: string): void;
@@ -1,5 +1,5 @@
1
1
  import { constants } from "node:fs";
2
- import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
2
+ import { lstat, mkdir, open, realpath, rename, rmdir, unlink, } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { assertDirectoryIdentity, assertRegularFileOrMissing, isPathInside, prepareHumanishStorageDirectory, resolveExistingHumanishStorageDirectory, validatePreparedRunArtifactPaths, validatePreparedRunRootIdentity, } from "./paths.js";
@@ -150,6 +150,70 @@ export async function createContainedOutputFile(rootInput, relativePath, data) {
150
150
  }
151
151
  await handle.close();
152
152
  }
153
+ /**
154
+ * Appends `data` to an existing single-link regular file inside the root that holds exactly
155
+ * `size` bytes, the count the caller says it wrote, so an append never lands after bytes the
156
+ * caller did not write. The checks writeContainedOutputFile makes run before the open; the opened
157
+ * file must be the one checked, and the root and its folders are checked again before the write.
158
+ * The data is synced before this resolves, so a run.json written after it never indexes bytes the
159
+ * disk may not hold.
160
+ */
161
+ export async function appendContainedOutputFile(rootInput, relativePath, data, size) {
162
+ const filePath = await prepareContainedOutputFile(rootInput, relativePath);
163
+ const root = await resolveOutputRoot(rootInput);
164
+ await assertContainedDirectoryChain(root, path.dirname(filePath));
165
+ const before = await lstat(filePath, { bigint: true });
166
+ if (!before.isFile() || before.nlink > 1n)
167
+ throw new Error(`${relativePath} is not a single-link regular file.`);
168
+ // O_NONBLOCK: a file swapped for a FIFO after the lstat fails the open at once (no reader), where
169
+ // a blocking open would wait for a reader and hold up every later write in the run's queue.
170
+ // Writes to a regular file ignore it.
171
+ const handle = await open(filePath, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
172
+ try {
173
+ const after = await handle.stat({ bigint: true });
174
+ if (!after.isFile() || after.nlink > 1n || after.dev !== before.dev || after.ino !== before.ino)
175
+ throw new Error(`${relativePath} changed while it was opened.`);
176
+ if (after.size !== BigInt(size))
177
+ throw new Error(`${relativePath} holds ${after.size} bytes, not ${size}.`);
178
+ if ((await resolveOutputRoot(rootInput)) !== root)
179
+ throw new Error("Output root changed after it was prepared.");
180
+ await assertContainedDirectoryChain(root, path.dirname(filePath));
181
+ const { bytesWritten } = await handle.write(data, 0, data.length);
182
+ if (bytesWritten !== data.length)
183
+ throw new Error(`${relativePath} was not appended in full.`);
184
+ await handle.datasync();
185
+ }
186
+ finally {
187
+ await handle.close();
188
+ }
189
+ }
190
+ /**
191
+ * Removes a single-link regular file or an empty folder inside the root. Nothing at the path, or a
192
+ * folder on it missing, is not an error. A link, a hard-linked file or a folder that holds anything
193
+ * is refused and kept.
194
+ */
195
+ export async function removeContainedOutputFile(rootInput, relativePath) {
196
+ assertSafeRelativeOutputPath(relativePath, false);
197
+ const root = await resolveOutputRoot(rootInput);
198
+ const candidate = path.resolve(root, normalizeRelativeOutputPath(relativePath));
199
+ if (!isPathInside(root, candidate) || candidate === root)
200
+ throw new Error("Output file must stay inside its selected root.");
201
+ let stats;
202
+ try {
203
+ await assertContainedDirectoryChain(root, path.dirname(candidate));
204
+ stats = await lstat(candidate, { bigint: true });
205
+ }
206
+ catch (error) {
207
+ if (isNodeError(error) && error.code === "ENOENT")
208
+ return;
209
+ throw error;
210
+ }
211
+ if (stats.isDirectory())
212
+ return rmdir(candidate);
213
+ if (!stats.isFile() || stats.nlink > 1n)
214
+ throw new Error(`${relativePath} is not a single-link regular file.`);
215
+ await unlink(candidate);
216
+ }
153
217
  // The most humanish reads of one file of each kind. A larger file is refused unread.
154
218
  /**
155
219
  * A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
@@ -240,6 +304,42 @@ export async function readOpenedAtMost(handle, maxBytes) {
240
304
  chunks.push(chunk.subarray(0, bytesRead));
241
305
  }
242
306
  }
307
+ /**
308
+ * The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
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`.
311
+ */
312
+ export async function readContainedPrefix(rootInput, relativePath, length, start = 0) {
313
+ const opened = await openContained(rootInput, relativePath);
314
+ if (opened.status === "missing")
315
+ return opened;
316
+ if (opened.status === "refused")
317
+ return { status: "refused", reason: opened.reason, limit: length };
318
+ try {
319
+ return { status: "read", bytes: await readOpenedPrefix(opened.handle, length, start) };
320
+ }
321
+ catch {
322
+ return { status: "refused", reason: "unreadable", limit: length };
323
+ }
324
+ finally {
325
+ await opened.handle.close().catch(() => { });
326
+ }
327
+ }
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));
334
+ let total = 0;
335
+ while (total < bytes.length) {
336
+ const { bytesRead } = await handle.read(bytes, total, bytes.length - total, start + total);
337
+ if (bytesRead === 0)
338
+ break;
339
+ total += bytesRead;
340
+ }
341
+ return bytes.subarray(0, total);
342
+ }
243
343
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
244
344
  export async function openContainedRegularFile(rootInput, relativePath) {
245
345
  const opened = await openContained(rootInput, relativePath);
@@ -1,3 +1,4 @@
1
+ import { type BigIntStats } from "node:fs";
1
2
  import { type PreparedOutputRoot } from "./contained-output.js";
2
3
  /**
3
4
  * Analysis inputs are retained local artifacts, never URLs or caller-selected outputs. The path is
@@ -17,6 +18,8 @@ export declare function decodesToPlainRelativePath(value: string, maxLength: num
17
18
  * authority to select another file or initiate a network request.
18
19
  */
19
20
  export declare function readBoundedFile(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<Buffer | null>;
21
+ /** The fields of a file's lstat that sameFile compares: which file it is, its size and times. */
22
+ export type FileStamp = Pick<BigIntStats, "dev" | "ino" | "size" | "mtimeNs" | "ctimeNs">;
20
23
  /**
21
24
  * After a bounded read returned nothing: true when the file is absent, false when something is
22
25
  * there that could not be read. Errors other than ENOENT propagate.
@@ -25,6 +28,7 @@ export declare function pathMissing(filePath: string): Promise<boolean>;
25
28
  export type BoundedFileResult = {
26
29
  state: "read";
27
30
  bytes: Buffer;
31
+ stamp: FileStamp;
28
32
  } | {
29
33
  state: "limit";
30
34
  size: bigint;
@@ -37,6 +41,13 @@ export declare function readBoundedFileResult(root: PreparedOutputRoot, relative
37
41
  * At most `maxBytes` of a contained single-link regular file that stays the same file, with the
38
42
  * same size and times, from the first check to the last. Size refusals are distinguished only after
39
43
  * the same checks. `relativePath` uses `/`, and any name a contained read accepts
40
- * (isSafeRelativeFilePath) is read: verify and export read the names they always did.
44
+ * (isSafeRelativeFilePath) is read: verify and export read the names they always did. A read
45
+ * returns the stamp it held the file to.
41
46
  */
42
47
  export declare function readUnchangedFile(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<BoundedFileResult>;
48
+ /**
49
+ * True when the file readUnchangedFile would read at `relativePath` is still the file `stamp`
50
+ * describes: it passes the same root, parent and leaf checks, before and after its lstat matches
51
+ * the stamp. Opens nothing.
52
+ */
53
+ export declare function isUnchangedFile(root: PreparedOutputRoot, relativePath: string, stamp: FileStamp): Promise<boolean>;
@@ -99,47 +99,19 @@ export async function readBoundedFileResult(root, relativePath, maxBytes) {
99
99
  * At most `maxBytes` of a contained single-link regular file that stays the same file, with the
100
100
  * same size and times, from the first check to the last. Size refusals are distinguished only after
101
101
  * the same checks. `relativePath` uses `/`, and any name a contained read accepts
102
- * (isSafeRelativeFilePath) is read: verify and export read the names they always did.
102
+ * (isSafeRelativeFilePath) is read: verify and export read the names they always did. A read
103
+ * returns the stamp it held the file to.
103
104
  */
104
105
  export async function readUnchangedFile(root, relativePath, maxBytes) {
105
- if (!isSafeRelativeFilePath(relativePath) ||
106
- relativePath.includes("\\") ||
107
- !Number.isSafeInteger(maxBytes) ||
108
- maxBytes < 1)
106
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
109
107
  return unavailable;
110
- const validateRoot = async () => {
111
- if ("physicalRunRoot" in root) {
112
- await validatePreparedRunRootIdentity(root);
113
- return root.physicalRunRoot;
114
- }
115
- await assertPreparedSelectedOutputDirectory(root);
116
- return root.physicalPath;
117
- };
118
108
  try {
119
- const physicalRoot = await validateRoot();
120
- const candidate = path.join(physicalRoot, relativePath);
121
- if (!isPathInside(physicalRoot, candidate) || candidate === physicalRoot)
122
- return unavailable;
123
- const validateParents = async () => {
124
- let current = physicalRoot;
125
- for (const segment of relativePath.split("/").slice(0, -1)) {
126
- current = path.join(current, segment);
127
- const info = await lstat(current);
128
- if (!info.isDirectory() || info.isSymbolicLink())
129
- throw new Error("Unsafe analysis input directory.");
130
- }
131
- };
132
- await validateParents();
133
- const before = await lstat(candidate, { bigint: true });
134
- if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n)
135
- return unavailable;
136
- if ((await realpath(candidate)) !== candidate)
109
+ const located = await locateContainedFile(root, relativePath);
110
+ if (located === null)
137
111
  return unavailable;
112
+ const { candidate, before, stillSameFile } = located;
138
113
  if (before.size > BigInt(maxBytes)) {
139
- if ((await validateRoot()) !== physicalRoot)
140
- return unavailable;
141
- await validateParents();
142
- if (!sameFile(before, await lstat(candidate, { bigint: true })))
114
+ if (!(await stillSameFile(before)))
143
115
  return unavailable;
144
116
  return { state: "limit", size: before.size };
145
117
  }
@@ -153,12 +125,10 @@ export async function readUnchangedFile(root, relativePath, maxBytes) {
153
125
  !sameFile(before, await handle.stat({ bigint: true })) ||
154
126
  bytes.length !== Number(before.size))
155
127
  return unavailable;
156
- if ((await validateRoot()) !== physicalRoot)
157
- return unavailable;
158
- await validateParents();
159
- if (!sameFile(before, await lstat(candidate, { bigint: true })))
128
+ if (!(await stillSameFile(before)))
160
129
  return unavailable;
161
- return { state: "read", bytes };
130
+ const { dev, ino, size, mtimeNs, ctimeNs } = before;
131
+ return { state: "read", bytes, stamp: { dev, ino, size, mtimeNs, ctimeNs } };
162
132
  }
163
133
  finally {
164
134
  await handle.close();
@@ -168,3 +138,63 @@ export async function readUnchangedFile(root, relativePath, maxBytes) {
168
138
  return unavailable;
169
139
  }
170
140
  }
141
+ /**
142
+ * True when the file readUnchangedFile would read at `relativePath` is still the file `stamp`
143
+ * describes: it passes the same root, parent and leaf checks, before and after its lstat matches
144
+ * the stamp. Opens nothing.
145
+ */
146
+ export async function isUnchangedFile(root, relativePath, stamp) {
147
+ try {
148
+ const located = await locateContainedFile(root, relativePath);
149
+ return (located !== null && sameFile(stamp, located.before) && (await located.stillSameFile(stamp)));
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ /**
156
+ * The contained single-link regular file at `relativePath`: its path, its lstat, and a recheck that
157
+ * the root, its parents and the leaf are unchanged since. Null for a path or leaf a read refuses;
158
+ * a root or parent that fails its check throws.
159
+ */
160
+ async function locateContainedFile(root, relativePath) {
161
+ if (!isSafeRelativeFilePath(relativePath) || relativePath.includes("\\"))
162
+ return null;
163
+ const validateRoot = async () => {
164
+ if ("physicalRunRoot" in root) {
165
+ await validatePreparedRunRootIdentity(root);
166
+ return root.physicalRunRoot;
167
+ }
168
+ await assertPreparedSelectedOutputDirectory(root);
169
+ return root.physicalPath;
170
+ };
171
+ const physicalRoot = await validateRoot();
172
+ const candidate = path.join(physicalRoot, relativePath);
173
+ if (!isPathInside(physicalRoot, candidate) || candidate === physicalRoot)
174
+ return null;
175
+ const validateParents = async () => {
176
+ let current = physicalRoot;
177
+ for (const segment of relativePath.split("/").slice(0, -1)) {
178
+ current = path.join(current, segment);
179
+ const info = await lstat(current);
180
+ if (!info.isDirectory() || info.isSymbolicLink())
181
+ throw new Error("Unsafe analysis input directory.");
182
+ }
183
+ };
184
+ await validateParents();
185
+ const before = await lstat(candidate, { bigint: true });
186
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n)
187
+ return null;
188
+ if ((await realpath(candidate)) !== candidate)
189
+ return null;
190
+ return {
191
+ candidate,
192
+ before,
193
+ stillSameFile: async (stamp) => {
194
+ if ((await validateRoot()) !== physicalRoot)
195
+ return false;
196
+ await validateParents();
197
+ return sameFile(stamp, await lstat(candidate, { bigint: true }));
198
+ },
199
+ };
200
+ }
@@ -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 = {}) {