humanish 0.125.0 → 0.127.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 (128) hide show
  1. package/CHANGELOG.md +29 -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 +10 -5
  9. package/dist/actors/computer-use/loop/ending.js +12 -7
  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/computer-use/openai-provider.js +1 -1
  18. package/dist/actors/contract.d.ts +31 -8
  19. package/dist/actors/contract.js +3 -0
  20. package/dist/actors/goal-source.d.ts +32 -2
  21. package/dist/actors/goal-source.js +41 -2
  22. package/dist/actors/stop-cause.js +1 -0
  23. package/dist/analysis/evidence-sources.js +2 -2
  24. package/dist/analysis/types.d.ts +2 -2
  25. package/dist/analysis/validation.js +2 -1
  26. package/dist/cli/commands/study.js +8 -0
  27. package/dist/evidence/redaction.d.ts +1 -0
  28. package/dist/evidence/redaction.js +1 -0
  29. package/dist/evidence/trace-page.d.ts +6 -0
  30. package/dist/evidence/trace-page.js +90 -0
  31. package/dist/observer/data.js +8 -5
  32. package/dist/observer/feed-cursors.d.ts +14 -0
  33. package/dist/observer/feed-cursors.js +61 -0
  34. package/dist/observer/participant-items.d.ts +7 -0
  35. package/dist/observer/pinned-files.d.ts +2 -1
  36. package/dist/observer/pinned-files.js +2 -2
  37. package/dist/observer/run-routes.d.ts +1 -1
  38. package/dist/observer/run-routes.js +38 -17
  39. package/dist/observer-app.html +7 -7
  40. package/dist/routes/computer-use/bundle.js +4 -0
  41. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  42. package/dist/routes/computer-use/diagnostics.js +3 -3
  43. package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
  44. package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
  45. package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
  46. package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
  47. package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
  48. package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
  49. package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
  50. package/dist/routes/computer-use/fanout-bundle.js +3 -0
  51. package/dist/routes/computer-use/fanout-records.js +5 -1
  52. package/dist/routes/computer-use/participant-deps.d.ts +1 -1
  53. package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
  54. package/dist/routes/computer-use/participant-execution.js +6 -2
  55. package/dist/routes/computer-use/participant-facts.js +4 -1
  56. package/dist/routes/computer-use/participant-model.js +16 -6
  57. package/dist/routes/computer-use/participant-runs.d.ts +10 -10
  58. package/dist/routes/computer-use/participant-runs.js +54 -84
  59. package/dist/routes/computer-use/plan.d.ts +2 -0
  60. package/dist/routes/computer-use/plan.js +65 -17
  61. package/dist/routes/computer-use/rerun-selection.js +8 -5
  62. package/dist/routes/computer-use/setup.d.ts +1 -0
  63. package/dist/routes/computer-use/setup.js +29 -1
  64. package/dist/routes/computer-use/single-bundle.d.ts +5 -1
  65. package/dist/routes/computer-use/single-bundle.js +11 -1
  66. package/dist/routes/computer-use/types.d.ts +18 -13
  67. package/dist/routes/computer-use/types.js +0 -3
  68. package/dist/routes/shared-world/bundle-records.js +4 -1
  69. package/dist/routes/shared-world/external-public.js +1 -1
  70. package/dist/routes/shared-world/participant-specs.js +3 -2
  71. package/dist/routes/shared-world/plan.d.ts +2 -0
  72. package/dist/routes/shared-world/plan.js +8 -3
  73. package/dist/routes/shared-world/provisioned.js +1 -1
  74. package/dist/run/bundle.d.ts +11 -1
  75. package/dist/run/contained-output.d.ts +7 -3
  76. package/dist/run/contained-output.js +11 -7
  77. package/dist/run/judge.d.ts +14 -4
  78. package/dist/run/judge.js +17 -12
  79. package/dist/run/live-items.d.ts +19 -0
  80. package/dist/run/live-items.js +28 -8
  81. package/dist/run/notes.js +1 -1
  82. package/dist/run/outcomes.d.ts +8 -2
  83. package/dist/run/outcomes.js +56 -14
  84. package/dist/run/stream-trace.d.ts +25 -4
  85. package/dist/run/stream-trace.js +55 -17
  86. package/dist/run/streams.d.ts +16 -0
  87. package/dist/study/arrivals.d.ts +105 -34
  88. package/dist/study/arrivals.js +179 -74
  89. package/dist/study/bundle-estimate.d.ts +12 -10
  90. package/dist/study/bundle-estimate.js +138 -72
  91. package/dist/study/desktop-files.d.ts +37 -0
  92. package/dist/study/desktop-files.js +174 -0
  93. package/dist/study/keys.d.ts +8 -0
  94. package/dist/study/keys.js +3 -0
  95. package/dist/study/migrate/v2.js +12 -3
  96. package/dist/study/parse/actors.d.ts +1 -1
  97. package/dist/study/parse/actors.js +28 -20
  98. package/dist/study/parse/clock.d.ts +18 -0
  99. package/dist/study/parse/clock.js +36 -0
  100. package/dist/study/plan-participants.d.ts +19 -10
  101. package/dist/study/plan-participants.js +13 -9
  102. package/dist/study/plan-types.d.ts +6 -1
  103. package/dist/study/plan.d.ts +2 -1
  104. package/dist/study/plan.js +28 -15
  105. package/dist/study/preflight.js +4 -0
  106. package/dist/study/types.d.ts +35 -5
  107. package/dist/study/validation.js +3 -0
  108. package/dist/study/warnings.d.ts +2 -2
  109. package/dist/study/warnings.js +7 -0
  110. package/dist/subject/local-tree-archive.d.ts +7 -0
  111. package/dist/subject/local-tree-archive.js +12 -0
  112. package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
  113. package/dist/substrates/e2b/desktop-browser.js +6 -2
  114. package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
  115. package/dist/substrates/e2b/desktop-clock.js +29 -0
  116. package/dist/substrates/e2b/lifetime.d.ts +22 -8
  117. package/dist/substrates/e2b/lifetime.js +54 -10
  118. package/dist/substrates/e2b/sdk.d.ts +6 -0
  119. package/dist/verify/pages.d.ts +7 -0
  120. package/dist/verify/pages.js +50 -0
  121. package/dist/verify/verify.js +10 -3
  122. package/docs/architecture/actor-contract.md +2 -0
  123. package/docs/architecture/external-public-shared-world.md +4 -1
  124. package/docs/architecture/observer.md +10 -1
  125. package/docs/contracts/policy.md +7 -0
  126. package/docs/contracts/run-bundle.md +31 -1
  127. package/docs/contracts/schemas.md +93 -11
  128. package/package.json +2 -2
@@ -72,7 +72,7 @@ function sharedWorldSimulation(ctx, spec, index, view) {
72
72
  updatedAt: args.run.createdAt,
73
73
  ...(args.plan.arrivals.declared
74
74
  ? {
75
- arrival: arrivalRecord(spec.planned.startAfterMs, actorArrival(args.actorResults[index])),
75
+ arrival: arrivalRecord(spec.planned.schedule.startAfterMs, actorArrival(args.actorResults[index])),
76
76
  }
77
77
  : {}),
78
78
  });
@@ -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.`,
@@ -104,7 +104,7 @@ export async function runExternalPublicPlane(ctx, live, inbox) {
104
104
  }))
105
105
  : undefined;
106
106
  const followerResultsPromise = runOnSchedule(followerEntries, {
107
- startAfterMs: ({ spec }) => spec.planned.startAfterMs,
107
+ startAfterMs: ({ spec }) => spec.planned.schedule.startAfterMs,
108
108
  slots: Math.max(1, concurrency - 1),
109
109
  now: ctx.now,
110
110
  }, async ({ spec, index }, _position, { scheduledAt }) => ({
@@ -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),
@@ -23,6 +23,8 @@ export declare function planSharedWorldStudy(config: StudyConfig, input: {
23
23
  readonly sandboxCeiling: SandboxCeiling;
24
24
  /** How many sandboxes the operator's E2B plan runs at once (concurrentSandboxes). */
25
25
  readonly concurrentSandboxes: ConcurrentSandboxes;
26
+ /** HUMANISH_CUA_MAX_CONCURRENCY (concurrencyCap), which may only lower the participants at once. */
27
+ readonly concurrencyCap?: number | undefined;
26
28
  }): SharedWorldPlanResult;
27
29
  /**
28
30
  * The schedule lines a shared-world run prints before it creates anything, when the study declares
@@ -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))
@@ -98,7 +103,7 @@ export function planSharedWorldStudy(config, input) {
98
103
  if (plane === undefined)
99
104
  throw new Error("shared-world validation admitted a plane it cannot plan");
100
105
  const sessionTimeoutMs = config.execution?.timeoutMs ?? defaultSessionTimeoutMs(plane, ceiling.ms);
101
- const starts = startsOf(plane, config.execution?.concurrency, input.concurrentSandboxes, sessionTimeoutMs);
106
+ const starts = startsOf(plane, config.execution?.concurrency, { sandboxes: input.concurrentSandboxes, cap: input.concurrencyCap }, sessionTimeoutMs);
102
107
  if (!starts.ok)
103
108
  return refuse(invalid, starts.message, actor);
104
109
  const { arrivals, warnings } = starts;
@@ -149,7 +154,7 @@ export function planSharedWorldStudy(config, input) {
149
154
  * shared world needs two participants live together. On the external-public plane the host runs
150
155
  * on a slot of its own and the others queue for the rest (external-public.ts).
151
156
  */
152
- function startsOf(plane, declared, limit, sessionMs) {
157
+ function startsOf(plane, declared, limits, sessionMs) {
153
158
  const n = plane.participants.length;
154
159
  const atOnce = participantsAtOnce({
155
160
  concurrency: boundedConcurrency(declared, n),
@@ -157,7 +162,7 @@ function startsOf(plane, declared, limit, sessionMs) {
157
162
  participants: n,
158
163
  alongside: plane.kind === "provisioned" ? 1 : 0,
159
164
  minimum: 2,
160
- }, limit);
165
+ }, limits);
161
166
  if (!atOnce.ok)
162
167
  return atOnce;
163
168
  const hostIndex = plane.participants.findIndex((participant) => participant.host === true);
@@ -391,7 +391,7 @@ function runParticipants(plane, ctx, live, setup) {
391
391
  const { commsEmail } = setup;
392
392
  const baseActorDeps = participantRunDeps(ctx, live, ctx.scrubKnownValues);
393
393
  const schedule = {
394
- startAfterMs: (spec) => spec.planned.startAfterMs,
394
+ startAfterMs: (spec) => spec.planned.schedule.startAfterMs,
395
395
  slots: Math.max(1, ctx.concurrency),
396
396
  now,
397
397
  };
@@ -511,8 +511,18 @@ export interface RunRerunLineage {
511
511
  export interface ParticipantOutcomes {
512
512
  /** Participants whose sessions reached a terminal state: the denominator for every count below. */
513
513
  total: number;
514
- /** Recorded successful sessions; completion provenance depends on the actor and its evidence. */
514
+ /**
515
+ * Recorded successful sessions; completion provenance depends on the actor and its evidence. A
516
+ * computer-use session that passed at its scheduled end or session cap counts here only when
517
+ * its participant declared that it reached the goal; otherwise it counts in `ranUntilEnd`.
518
+ */
515
519
  reachedGoal: number;
520
+ /**
521
+ * Passed at a scheduled end or a session cap without declaring that they reached the goal: the
522
+ * closing report said `DID NOT REACH THE GOAL.` or declared nothing. Present when above 0.
523
+ * Bundles written before this count existed tallied them under `reachedGoal`.
524
+ */
525
+ ranUntilEnd?: number;
516
526
  /** Stopped trying. A finding about the product. */
517
527
  abandoned: number;
518
528
  /** Interrupted before reaching the goal, including session, spend and provider limits. */
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import type { ActorCompletionReason, ActorStatus, ParticipantDeclaredOutcome } from "../actors/contract.js";
2
+ import type { ClockEnd } from "../actors/goal-source.js";
2
3
  import type { ReviewSummary } from "./bundle.js";
3
4
  export type Verdict = ReviewSummary["verdict"];
4
5
  /** How a finished session ended, as the blocker rule reads it. */
@@ -6,7 +7,7 @@ export interface BlockerFacts {
6
7
  completionReason: ActorCompletionReason;
7
8
  /** A harness-owned stop condition ended the session: a matched stopWhen or a completed dwell window. */
8
9
  stopConditionMatched: boolean;
9
- /** What the participant declared in a field, when its provider has one. */
10
+ /** What the participant declared, when it declared an outcome (ActorTrace.declaredOutcome). */
10
11
  declaredOutcome?: ParticipantDeclaredOutcome;
11
12
  /** The route's reading of the closing report: it describes a blocker or asks for instructions. */
12
13
  closingReportReadsBlocked: boolean;
@@ -22,9 +23,12 @@ export interface SessionEnding extends BlockerFacts {
22
23
  */
23
24
  export declare function hollowCompletion(ending: SessionEnding): boolean;
24
25
  /**
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.
26
+ * The session ended goal_satisfied but the participant reported a blocker. A declared `reached`
27
+ * or `blocked` is its own word and wins. Otherwise the closing report is read, unless a stop
28
+ * condition ended the session: a matched stopWhen is structured completion evidence and overrides
29
+ * the text. A declared `not_reached` is read the same way: it ends the session as gave_up when
30
+ * the participant ends it, so here it comes from a closing report at a scheduled end or a session
31
+ * cap, and it does not say whether something stopped the participant.
28
32
  */
29
33
  export declare function selfReportedBlocker(ending: BlockerFacts): boolean;
30
34
  /** What a participant's pass reads: its session's end, or why it has none, and the two judgments above. */
@@ -40,6 +44,12 @@ export interface ParticipantFacts {
40
44
  noEngagement: boolean;
41
45
  /** selfReportedBlocker held for its session. */
42
46
  selfReportedBlocker: boolean;
47
+ /**
48
+ * Its session ran until its scheduled end or session cap without the participant declaring it
49
+ * reached the goal (ranUntilClockEnd). A pass counts under `ranUntilEnd` in place of
50
+ * `reachedGoal`.
51
+ */
52
+ ranUntil?: ClockEnd;
43
53
  }
44
54
  /** The participant facts its bundle and result can name. */
45
55
  export interface ParticipantRecordFacts extends ParticipantFacts {
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 = {}) {
@@ -63,12 +68,12 @@ export function judgeParticipantRecords(participants, wording = {}) {
63
68
  : `${participant.id ?? "Participant"}: ${participant.skippedReason ?? participant.sessionError ?? participant.reason ?? "did not pass"}`,
64
69
  };
65
70
  });
66
- const terminal = records.filter((_, index) => participants[index].status !== undefined && !participants[index].inProgress);
71
+ const ended = (participant) => participant.status !== undefined && !participant.inProgress;
72
+ const terminal = records.filter((_, index) => ended(participants[index]));
73
+ const terminalFacts = participants.filter(ended);
67
74
  return {
68
75
  participants: records,
69
- tally: tallyParticipantOutcomes(terminal.map((participant) => participant.judgedStatus), participants
70
- .filter((participant) => participant.status !== undefined && !participant.inProgress)
71
- .map((participant) => participant.reportedFriction === true)),
76
+ tally: tallyParticipantOutcomes(terminal.map((participant) => participant.judgedStatus), terminalFacts.map((participant) => participant.reportedFriction === true), terminalFacts.map((participant) => participant.ranUntil !== undefined)),
72
77
  };
73
78
  }
74
79
  function participantNotPassedMessage(participant, 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 };
package/dist/run/notes.js CHANGED
@@ -21,7 +21,7 @@ const refuse = (code, message) => ({
21
21
  });
22
22
  /**
23
23
  * The stream id a note's participant names: a stream id, or the participant's own id from the study
24
- * (`charge-nurse`). Both kinds of id are matched, so a name that is one stream's id and another
24
+ * (`support-lead`). Both kinds of id are matched, so a name that is one stream's id and another
25
25
  * stream's study id is refused, naming both, as is a study id that two streams record. Otherwise
26
26
  * the refusal names the run's participants by caption, with the id to type for each.
27
27
  */
@@ -6,7 +6,13 @@ import type { ParticipantOutcomes, ReviewSummary, RunTaskFunnel } from "./bundle
6
6
  * `total` but nowhere else, so the parts can never exceed the whole. */
7
7
  export declare function tallyParticipantOutcomes(statuses: readonly ActorStatus[],
8
8
  /** Per-participant: did this one report friction or a defect? Same order as `statuses`. */
9
- reportedFriction?: readonly boolean[]): ParticipantOutcomes;
9
+ reportedFriction?: readonly boolean[],
10
+ /**
11
+ * Per-participant: did its session run until its scheduled end or session cap without it
12
+ * declaring that it reached the goal (ranUntilClockEnd)? Same order as `statuses`. Such a pass
13
+ * counts under `ranUntilEnd` in place of `reachedGoal`.
14
+ */
15
+ ranUntilEnd?: readonly boolean[]): ParticipantOutcomes;
10
16
  /** Roll per-participant funnels up into the study funnel. Undefined when nothing measured one. */
11
17
  export declare function aggregateTaskFunnels(funnels: readonly TaskFunnel[]): RunTaskFunnel | undefined;
12
18
  /** The funnel as one line, denominator on every number: `signup 2/2 · verify-email 1/2`. */
@@ -53,7 +59,7 @@ export declare function formatParticipantOutcomes(outcomes: ParticipantOutcomes,
53
59
  export declare function participantOutcomeDetails(streams: readonly {
54
60
  actor?: unknown;
55
61
  status?: unknown;
56
- }[]): ParticipantOutcomeDetail[];
62
+ }[], goalSourceOf?: (actor: unknown, recordedStatus: unknown) => CuaGoalSource | undefined): ParticipantOutcomeDetail[];
57
63
  /** Refresh a CUA completion claim from recorded traces, leaving original evidence and enums intact. */
58
64
  export declare function withCuaReviewProvenance(review: ReviewSummary, streams: readonly {
59
65
  actor?: unknown;
@@ -1,13 +1,19 @@
1
1
  import { ACTOR_STATUSES } from "../actors/contract.js";
2
- import { cuaGoalSource, isCuaTrace, CUA_COMPLETION_NOTE, } from "../actors/goal-source.js";
3
- import { actorEnding } from "../actors/stop-cause.js";
2
+ import { cuaEndpointSource, cuaGoalSource, isClockEnd, isCuaTrace, CUA_COMPLETION_NOTE, } from "../actors/goal-source.js";
3
+ import { actorEnding, actorStopCauseLabel } from "../actors/stop-cause.js";
4
4
  import { plural } from "./text.js";
5
5
  import { isNonNegativeSafeInteger, isRecord } from "./type-guards.js";
6
6
  /** Tally participant outcomes from actor statuses. Statuses this does not recognise are counted in
7
7
  * `total` but nowhere else, so the parts can never exceed the whole. */
8
8
  export function tallyParticipantOutcomes(statuses,
9
9
  /** Per-participant: did this one report friction or a defect? Same order as `statuses`. */
10
- reportedFriction = []) {
10
+ reportedFriction = [],
11
+ /**
12
+ * Per-participant: did its session run until its scheduled end or session cap without it
13
+ * declaring that it reached the goal (ranUntilClockEnd)? Same order as `statuses`. Such a pass
14
+ * counts under `ranUntilEnd` in place of `reachedGoal`.
15
+ */
16
+ ranUntilEnd = []) {
11
17
  const tally = {
12
18
  total: statuses.length,
13
19
  reachedGoal: 0,
@@ -17,8 +23,11 @@ reportedFriction = []) {
17
23
  harnessFailed: 0,
18
24
  reportedFriction: reportedFriction.filter(Boolean).length,
19
25
  };
20
- for (const status of statuses) {
21
- if (status === "passed")
26
+ let clockEnds = 0;
27
+ statuses.forEach((status, index) => {
28
+ if (status === "passed" && ranUntilEnd[index] === true)
29
+ clockEnds += 1;
30
+ else if (status === "passed")
22
31
  tally.reachedGoal += 1;
23
32
  else if (status === "abandoned")
24
33
  tally.abandoned += 1;
@@ -28,8 +37,8 @@ reportedFriction = []) {
28
37
  tally.blocked += 1;
29
38
  else if (status === "failed")
30
39
  tally.harnessFailed += 1;
31
- }
32
- return tally;
40
+ });
41
+ return clockEnds === 0 ? tally : { ...tally, ranUntilEnd: clockEnds };
33
42
  }
34
43
  /** Roll per-participant funnels up into the study funnel. Undefined when nothing measured one. */
35
44
  export function aggregateTaskFunnels(funnels) {
@@ -86,15 +95,38 @@ export function formatRunTaskFunnel(funnel) {
86
95
  })
87
96
  .join(" · ");
88
97
  }
98
+ /**
99
+ * "1 ran until its session cap", "2 ran until their scheduled end", grouped by clock end, most
100
+ * first. Clock ends the details cannot account for get one line that names both.
101
+ */
102
+ function ranUntilPhrases(count, details) {
103
+ const words = (n, end) => n === 1 ? `1 ran until its ${end}` : `${n} ran until their ${end}`;
104
+ const ends = details.flatMap((entry) => (isClockEnd(entry.goalSource) ? [entry.goalSource] : []));
105
+ if (ends.length !== count)
106
+ return [words(count, "scheduled end or session cap")];
107
+ return ["session_cap", "scheduled_end"]
108
+ .map((end) => [end, ends.filter((entry) => entry === end).length])
109
+ .filter(([, n]) => n > 0)
110
+ .sort(([, m], [, n]) => n - m)
111
+ .map(([end, n]) => words(n, actorStopCauseLabel(end)));
112
+ }
89
113
  function participantCompletionLine(outcomes, terminalCauses) {
90
- const completions = terminalCauses.filter((entry) => entry.status === "passed");
114
+ const passed = terminalCauses.filter((entry) => entry.status === "passed");
115
+ // A bundle without `ranUntilEnd` either has no such clock end or predates the count and tallied
116
+ // its clock ends under reachedGoal. Those stay among its completions, and the breakdown names
117
+ // them, so a re-read never changes a recorded count.
118
+ const completions = outcomes.ranUntilEnd === undefined
119
+ ? passed
120
+ : passed.filter((entry) => !isClockEnd(entry.goalSource));
91
121
  const reported = completions.filter((entry) => entry.goalSource === "participant_report").length;
92
122
  const matched = completions.filter((entry) => entry.goalSource === "condition_matched").length;
123
+ const clockEnds = completions.filter((entry) => isClockEnd(entry.goalSource));
93
124
  let goalLine = outcomes.reachedGoal === 0
94
125
  ? `0/${outcomes.total} recorded completions`
95
126
  : `${outcomes.reachedGoal}/${outcomes.total} reached the goal`;
96
127
  if (outcomes.reachedGoal > 0 && terminalCauses.some((entry) => entry.goalSource !== undefined)) {
97
128
  const count = `${outcomes.reachedGoal}/${outcomes.total}`;
129
+ const other = outcomes.reachedGoal - reported - matched - clockEnds.length;
98
130
  goalLine =
99
131
  completions.length !== outcomes.reachedGoal
100
132
  ? `${count} recorded completions (completion source unavailable)`
@@ -105,9 +137,8 @@ function participantCompletionLine(outcomes, terminalCauses) {
105
137
  : `${count} recorded completions (${[
106
138
  reported > 0 ? `${reported} participant-reported` : undefined,
107
139
  matched > 0 ? `${matched} condition-matched` : undefined,
108
- outcomes.reachedGoal - reported - matched > 0
109
- ? `${outcomes.reachedGoal - reported - matched} other or unavailable source`
110
- : undefined,
140
+ ...(clockEnds.length > 0 ? ranUntilPhrases(clockEnds.length, clockEnds) : []),
141
+ other > 0 ? `${other} other or unavailable source` : undefined,
111
142
  ]
112
143
  .filter(Boolean)
113
144
  .join(", ")})`;
@@ -124,6 +155,8 @@ export function sessionEndingInWords(trace) {
124
155
  const source = cuaGoalSource(trace);
125
156
  if (source === "participant_report")
126
157
  return "reported reaching the goal";
158
+ if (isClockEnd(source))
159
+ return `ran until its ${actorStopCauseLabel(source)}`;
127
160
  return source === "condition_matched" ? "met its completion condition" : "reached the goal";
128
161
  }
129
162
  case "turn_completed":
@@ -178,6 +211,10 @@ export function formatParticipantOutcomes(outcomes, terminalCauses = []) {
178
211
  if (outcomes.total === 0)
179
212
  return "no participants reached a terminal state";
180
213
  const parts = [participantCompletionLine(outcomes, terminalCauses)];
214
+ // Passed at the clock without declaring that they reached the goal: next to the completions,
215
+ // and never counted among them.
216
+ if (outcomes.ranUntilEnd !== undefined && outcomes.ranUntilEnd > 0)
217
+ parts.push(...ranUntilPhrases(outcomes.ranUntilEnd, terminalCauses.filter((entry) => entry.status === "passed")));
181
218
  // Detail may explain a recorded outcome, but must never change its count or invent a match
182
219
  // between a tally and an incomplete set of traces.
183
220
  const append = (count, statuses, fallback) => {
@@ -212,12 +249,12 @@ export function formatParticipantOutcomes(outcomes, terminalCauses = []) {
212
249
  return parts.join(", ");
213
250
  }
214
251
  /** Presentation details tolerate optional legacy actor payloads without changing their tallies. */
215
- export function participantOutcomeDetails(streams) {
252
+ export function participantOutcomeDetails(streams, goalSourceOf = cuaGoalSource) {
216
253
  return streams.flatMap((stream) => {
217
254
  if (!isRecord(stream.actor))
218
255
  return [];
219
256
  const actor = stream.actor;
220
- const goalSource = cuaGoalSource(actor, stream.status);
257
+ const goalSource = goalSourceOf(actor, stream.status);
221
258
  if (!ACTOR_STATUSES.includes(String(actor.status))) {
222
259
  return goalSource === "unavailable" ? [{ status: "passed", goalSource }] : [];
223
260
  }
@@ -257,6 +294,9 @@ export function withCuaReviewProvenance(review, streams) {
257
294
  const { reachedGoal, total } = review.participants;
258
295
  const bareLine = `${reachedGoal}/${total} reached the goal`;
259
296
  const qualifiedLine = participantCompletionLine(review.participants, details);
297
+ // Earlier releases qualified the count with the endpoint source (cuaEndpointSource), which
298
+ // reads a clock end as a participant report.
299
+ const endpointLine = participantCompletionLine(review.participants, participantOutcomeDetails(streams, cuaEndpointSource));
260
300
  // A summary with no count gets it in a header. Earlier releases wrote a header in other words.
261
301
  const header = `${plural(total, "participant")} took part: ${outcomes}.${tasks} `;
262
302
  const olderHeader = `Run gate: ${review.verdict}. Participants: ${outcomes}.${tasks} Recorded summary: `;
@@ -266,7 +306,9 @@ export function withCuaReviewProvenance(review, streams) {
266
306
  ? review.summary.split(bareLine).join(qualifiedLine)
267
307
  : review.summary.includes(qualifiedLine)
268
308
  ? review.summary
269
- : `${header}${review.summary}`;
309
+ : review.summary.includes(endpointLine)
310
+ ? review.summary.split(endpointLine).join(qualifiedLine)
311
+ : `${header}${review.summary}`;
270
312
  return {
271
313
  ...review,
272
314
  summary,
@@ -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