humanish 0.133.0 → 0.134.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 (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/actors/computer-use/loop/actions.d.ts +2 -0
  3. package/dist/actors/computer-use/loop/actions.js +11 -5
  4. package/dist/actors/computer-use/loop/backstop.d.ts +23 -2
  5. package/dist/actors/computer-use/loop/backstop.js +40 -7
  6. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  7. package/dist/actors/computer-use/loop/session.js +3 -0
  8. package/dist/actors/computer-use/loop/streak.d.ts +18 -0
  9. package/dist/actors/computer-use/loop/streak.js +47 -0
  10. package/dist/actors/computer-use/loop/types.d.ts +5 -1
  11. package/dist/actors/computer-use/loop/watch.d.ts +22 -4
  12. package/dist/actors/computer-use/loop/watch.js +83 -27
  13. package/dist/actors/computer-use/loop.js +18 -6
  14. package/dist/actors/contract.d.ts +3 -1
  15. package/dist/analysis/service.js +3 -2
  16. package/dist/analysis/source.d.ts +6 -0
  17. package/dist/analysis/source.js +15 -13
  18. package/dist/cli/commands/run-signals.js +6 -2
  19. package/dist/evidence/json-split.d.ts +21 -0
  20. package/dist/evidence/json-split.js +57 -0
  21. package/dist/evidence/sensitive-json.js +5 -28
  22. package/dist/observer/render.js +5 -3
  23. package/dist/routes/computer-use/bundle-parts.d.ts +9 -0
  24. package/dist/routes/computer-use/bundle-parts.js +24 -4
  25. package/dist/routes/computer-use/fanout-bundle.js +5 -3
  26. package/dist/routes/computer-use/single-bundle.js +2 -2
  27. package/dist/run/locate.d.ts +8 -2
  28. package/dist/run/locate.js +10 -4
  29. package/dist/run/run.js +18 -2
  30. package/dist/run/sandbox-ids.d.ts +10 -4
  31. package/dist/run/sandbox-ids.js +73 -11
  32. package/dist/run/stream-trace.d.ts +8 -2
  33. package/dist/run/stream-trace.js +13 -7
  34. package/dist/study/bundle-estimate.d.ts +1 -4
  35. package/dist/study/bundle-estimate.js +29 -23
  36. package/dist/verify/artifacts.d.ts +7 -1
  37. package/dist/verify/artifacts.js +15 -4
  38. package/dist/verify/verify.js +29 -8
  39. package/docs/architecture/actor-contract.md +3 -1
  40. package/docs/architecture/state-driven-executor.md +7 -4
  41. package/docs/contracts/run-bundle.md +13 -2
  42. package/docs/contracts/study-analysis.md +4 -2
  43. package/package.json +1 -1
@@ -88,7 +88,9 @@ export interface ActorTraceItem {
88
88
  * the observation read no URL, on other producers and in older bundles. */
89
89
  page?: TracePage;
90
90
  /** On the computer-use `ui_action` of the wait during which a watch ended: the watch the loop
91
- * ran in place of a turn that only waited. Additive: absent on plain waits, on other items and
91
+ * ran in place of a turn that only waited. A watch no wait carries, which a participant held to
92
+ * its scheduled end gets for a turn of only screenshots or after a repeated action, is on a
93
+ * `notice` titled `watched the screen`. Additive: absent on plain waits, on other items and
92
94
  * producers, and in older bundles. */
93
95
  watch?: ActorWatch;
94
96
  /** On a computer-use wait's `ui_action`: the length in milliseconds the participant named for
@@ -11,7 +11,7 @@ import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
11
11
  import { physicalCwdOf, runIdOf, resolvePhysicalCwd, validatePreparedRunRootIdentity, } from "../run/paths.js";
12
12
  import { isRunStatusRecord, RUN_STATUS_FILE } from "../run/status.js";
13
13
  import { captureEvidence, EVIDENCE_LIMITS } from "./evidence.js";
14
- import { AnalysisSourceRefusedError, runFileTooLarge } from "./source.js";
14
+ import { AnalysisSourceRefusedError, runFileTooLarge, throwAsAnalysisRefusal } from "./source.js";
15
15
  import { pathMissing, readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
16
16
  import { estimateAnalysisAdmission, preferLargerAnalysisOutput, runAnalysis, ANALYSIS_PROMPT_VERSION, } from "./execute.js";
17
17
  import { appendAnalysisCorrection, assertAnalysisPublicationCapacity, listAnalyses, writeAnalysis, } from "./store.js";
@@ -155,7 +155,8 @@ export async function readCompletedAnalysisSource(cwd, prepared) {
155
155
  const verified = await verifyRunPrepared(cwd, runIdOf(prepared), prepared);
156
156
  if (!verified.ok && verified.recordingOk !== true)
157
157
  throw new Error("ANALYSIS_VERIFY_FAILED");
158
- const loaded = await loadRunBundlePrepared(cwd, prepared);
158
+ // Verify passes a run whose trace is past the read limit, which analysis does not read.
159
+ const loaded = await loadRunBundlePrepared(cwd, prepared).catch(throwAsAnalysisRefusal);
159
160
  if (!loaded || loaded.bundle.streams.length === 0)
160
161
  throw new Error("ANALYSIS_NO_PARTICIPANTS");
161
162
  if (loaded.bundle.mode !== "live")
@@ -26,3 +26,9 @@ export declare function parseAnalysisSource(prepared: PreparedRunArtifactPaths,
26
26
  * ANALYSIS_SOURCE_TOO_LARGE, and any other trace the loader refuses as ANALYSIS_SOURCE_INVALID.
27
27
  */
28
28
  export declare function readParticipantTraces<T>(prepared: PreparedRunArtifactPaths, bundle: StoredRunBundle, keep: (stream: RunStream, index: number) => T): Promise<T[]>;
29
+ /**
30
+ * The trace loader's refusal as analysis gives it, ANALYSIS_SOURCE_TOO_LARGE for a trace past the
31
+ * read limit and ANALYSIS_SOURCE_INVALID for any other; another error as it is. The loader's
32
+ * message names the participant, the file, why, and what to do.
33
+ */
34
+ export declare function throwAsAnalysisRefusal(error: unknown): never;
@@ -78,20 +78,22 @@ export function parseAnalysisSource(prepared, bytes) {
78
78
  * ANALYSIS_SOURCE_TOO_LARGE, and any other trace the loader refuses as ANALYSIS_SOURCE_INVALID.
79
79
  */
80
80
  export async function readParticipantTraces(prepared, bundle, keep) {
81
- try {
82
- return await reduceRunTraces(prepared, bundle, (stream, index) => {
83
- validateTraceItems(stream);
84
- return keep(stream, index);
85
- });
86
- }
87
- catch (error) {
88
- // The loader's refusal names the participant, the file, why, and what to do.
89
- if (error instanceof StreamTraceRefusedError)
90
- throw new AnalysisSourceRefusedError(error instanceof StreamTraceTooLargeError
91
- ? "ANALYSIS_SOURCE_TOO_LARGE"
92
- : "ANALYSIS_SOURCE_INVALID", error.message);
81
+ return reduceRunTraces(prepared, bundle, (stream, index) => {
82
+ validateTraceItems(stream);
83
+ return keep(stream, index);
84
+ }).catch(throwAsAnalysisRefusal);
85
+ }
86
+ /**
87
+ * The trace loader's refusal as analysis gives it, ANALYSIS_SOURCE_TOO_LARGE for a trace past the
88
+ * read limit and ANALYSIS_SOURCE_INVALID for any other; another error as it is. The loader's
89
+ * message names the participant, the file, why, and what to do.
90
+ */
91
+ export function throwAsAnalysisRefusal(error) {
92
+ if (!(error instanceof StreamTraceRefusedError))
93
93
  throw error;
94
- }
94
+ throw new AnalysisSourceRefusedError(error instanceof StreamTraceTooLargeError
95
+ ? "ANALYSIS_SOURCE_TOO_LARGE"
96
+ : "ANALYSIS_SOURCE_INVALID", error.message);
95
97
  }
96
98
  function validateTraceItems(stream) {
97
99
  const actor = stream.actor;
@@ -134,8 +134,12 @@ async function stopActiveRuns(io, signal, stopping, options) {
134
134
  // not swept; verify grades a file that names a receipt's id local_only.
135
135
  Promise.all(stopping
136
136
  .filter((stop) => stop.report !== null)
137
- .map((stop) => scrubRunSandboxIds(stop.run.paths).catch((error) => {
138
- // A file the sweep refused to read may still name a sandbox; say which.
137
+ .map((stop) => scrubRunSandboxIds(stop.run.paths).then(
138
+ // A file the sweep refused to read may still name a sandbox; say which.
139
+ (unread) => {
140
+ if (unread !== undefined)
141
+ say(io, `humanish: ${signal}: ${unread}`);
142
+ }, (error) => {
139
143
  if (error instanceof Error)
140
144
  say(io, `humanish: ${signal}: ${error.message}`);
141
145
  }))));
@@ -0,0 +1,21 @@
1
+ /** The JSON text of a value cut around the elements of one of its arrays. */
2
+ export interface ArraySplit {
3
+ /** The array's elements, read as JSON.stringify reads them. */
4
+ readonly elements: readonly Record<string, unknown>[];
5
+ /** The value's JSON text up to and including the array's `[`. */
6
+ before(): string;
7
+ /** The value's JSON text from the array's `]` on. */
8
+ after(): string;
9
+ }
10
+ /**
11
+ * The cut of JSON.stringify(value) around the elements of its array `value[key]`, when that text
12
+ * holds each element's text as JSON.stringify(element) writes it: `value` is a plain object that
13
+ * has `key` among its own enumerable keys, `value[key]` is a plain array with no holes, and each
14
+ * element is a plain object. A plain object has Object.prototype or null as its prototype, a plain
15
+ * array Array.prototype, and neither has a toJSON. Otherwise undefined: the caller needs the whole
16
+ * text. Getters, proxies and a toJSON that changes `value` while it is serialized are not detected:
17
+ * the elements are read before anything is serialized. before() and after() serialize the members
18
+ * on their side of the array when called, so a caller that calls before(), serializes each element
19
+ * and then calls after() runs every toJSON in the order JSON.stringify(value) runs them.
20
+ */
21
+ export declare function splitAtArray(value: unknown, key: string): ArraySplit | undefined;
@@ -0,0 +1,57 @@
1
+ // When the JSON text of a value can be worked on one element of an array at a time. A run bundle
2
+ // holds every participant's trace in its `streams` array, and the bundle's whole text can pass
3
+ // V8's longest string, 536,870,888 characters on Node 24.
4
+ //
5
+ // JSON.stringify calls a toJSON with the value's key in its parent, and an element serialized
6
+ // alone gets "" in place of its index. It writes a boxed primitive as the primitive. A plain object
7
+ // or array with no toJSON is written as its own keys or items wherever it sits, and every value
8
+ // inside an element gets the same key alone as inside the whole value.
9
+ import { isRecord } from "../run/type-guards.js";
10
+ /**
11
+ * The cut of JSON.stringify(value) around the elements of its array `value[key]`, when that text
12
+ * holds each element's text as JSON.stringify(element) writes it: `value` is a plain object that
13
+ * has `key` among its own enumerable keys, `value[key]` is a plain array with no holes, and each
14
+ * element is a plain object. A plain object has Object.prototype or null as its prototype, a plain
15
+ * array Array.prototype, and neither has a toJSON. Otherwise undefined: the caller needs the whole
16
+ * text. Getters, proxies and a toJSON that changes `value` while it is serialized are not detected:
17
+ * the elements are read before anything is serialized. before() and after() serialize the members
18
+ * on their side of the array when called, so a caller that calls before(), serializes each element
19
+ * and then calls after() runs every toJSON in the order JSON.stringify(value) runs them.
20
+ */
21
+ export function splitAtArray(value, key) {
22
+ // JSON.stringify writes only own enumerable keys.
23
+ if (!isPlainRecord(value))
24
+ return undefined;
25
+ const keys = Object.keys(value);
26
+ const at = keys.indexOf(key);
27
+ if (at === -1)
28
+ return undefined;
29
+ const array = value[key];
30
+ if (!isPlainArray(array))
31
+ return undefined;
32
+ // JSON.stringify reads the length once and then each index, so a hole reads as undefined and an
33
+ // own iterator is never called. Array.from would call that iterator, and every skips holes.
34
+ const elements = [];
35
+ for (let index = 0, length = array.length; index < length; index += 1)
36
+ elements.push(array[index]);
37
+ if (!elements.every(isPlainRecord))
38
+ return undefined;
39
+ // fromEntries keeps a key such as __proto__ an own property.
40
+ const members = (from, to) => Object.fromEntries(keys.slice(from, to).map((name) => [name, value[name]]));
41
+ return {
42
+ elements,
43
+ before: () => JSON.stringify({ ...members(0, at), [key]: [] }).slice(0, -"]}".length),
44
+ after: () => {
45
+ const text = JSON.stringify(members(at + 1));
46
+ return text === "{}" ? "]}" : `],${text.slice(1)}`;
47
+ },
48
+ };
49
+ }
50
+ const hasToJson = (value) => typeof value.toJSON === "function";
51
+ function isPlainRecord(value) {
52
+ if (!isRecord(value) || hasToJson(value))
53
+ return false;
54
+ const prototype = Object.getPrototypeOf(value);
55
+ return prototype === Object.prototype || prototype === null;
56
+ }
57
+ const isPlainArray = (value) => Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype && !hasToJson(value);
@@ -21,7 +21,7 @@
21
21
  // piece up to its first slash or whitespace. A public E2B page after the scheme would need a space
22
22
  // or the text's end before the cut, so neither text exempts the URL. The scan holds one piece at a
23
23
  // time.
24
- import { isRecord } from "../run/type-guards.js";
24
+ import { splitAtArray } from "./json-split.js";
25
25
  import { containsSandboxUrl, containsSensitive } from "./redaction.js";
26
26
  // The sandbox URL's scheme, `https://` or `https:\/\/` as JSON may escape it, at a text's end.
27
27
  const SCHEME_AT_END = /https?:\\?\/\\?\/$/i;
@@ -35,14 +35,11 @@ const SLASH_OR_SPACE = /[/\s]/;
35
35
  * them.
36
36
  */
37
37
  export function containsSensitiveJson(value, key) {
38
- // JSON.stringify writes only own enumerable keys, and `around` cuts the text at this one.
39
- const elements = isPlainRecord(value) && Object.keys(value).includes(key) ? value[key] : undefined;
40
- // Array.from reads a hole as undefined, where every would skip it.
41
- if (!isPlainRecord(value) ||
42
- !isPlainArray(elements) ||
43
- !Array.from(elements).every(isPlainRecord))
38
+ const split = splitAtArray(value, key);
39
+ if (split === undefined)
44
40
  return wholeTextSensitive(value);
45
- const [prefix, suffix] = around(value, key);
41
+ const { elements } = split;
42
+ const [prefix, suffix] = [split.before(), split.after()];
46
43
  if (elements.length === 0)
47
44
  return containsSensitive(prefix + suffix);
48
45
  // The scheme of a URL no slash or whitespace has followed, from an earlier piece.
@@ -59,26 +56,6 @@ export function containsSensitiveJson(value, key) {
59
56
  return false;
60
57
  }
61
58
  const wholeTextSensitive = (value) => containsSensitive(JSON.stringify(value) ?? "");
62
- // JSON.stringify calls a toJSON with the value's key in its parent, and an element serialized
63
- // alone gets "" in place of its index. It writes a boxed primitive as the primitive. A plain object
64
- // or array with no toJSON is written as its own keys or items wherever it sits.
65
- const hasToJson = (value) => typeof value.toJSON === "function";
66
- function isPlainRecord(value) {
67
- if (!isRecord(value) || hasToJson(value))
68
- return false;
69
- const prototype = Object.getPrototypeOf(value);
70
- return prototype === Object.prototype || prototype === null;
71
- }
72
- const isPlainArray = (value) => Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype && !hasToJson(value);
73
- /** The document's text before the first element of `value[key]` and after its last. */
74
- function around(value, key) {
75
- const keys = Object.keys(value);
76
- const at = keys.indexOf(key);
77
- const entries = (from, to) => Object.fromEntries(keys.slice(from, to).map((name) => [name, value[name]]));
78
- const before = JSON.stringify({ ...entries(0, at), [key]: [] });
79
- const after = JSON.stringify(entries(at + 1));
80
- return [before.slice(0, -"]}".length), after === "{}" ? "]}" : `],${after.slice(1)}`];
81
- }
82
59
  /** `piece` up to and including its first slash or whitespace, or all of it when it has none. */
83
60
  function head(piece) {
84
61
  const at = piece.search(SLASH_OR_SPACE);
@@ -90,11 +90,13 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
90
90
  ? "HUMANISH_RUN_NOT_FOUND"
91
91
  : "HUMANISH_INVALID_RUN_BUNDLE", verified.error?.message ?? "Run bundle failed verification.");
92
92
  }
93
- const loaded = await loadRunBundlePrepared(selectedPhysicalCwd, preparedRunPaths);
93
+ // Verify passes a trace past the read limit, so its participant shows without its items.
94
+ const loaded = await loadRunBundlePrepared(selectedPhysicalCwd, preparedRunPaths, {
95
+ display: true,
96
+ });
94
97
  await validatePreparedRunArtifactPaths(preparedRunPaths);
95
- if (!loaded) {
98
+ if (!loaded)
96
99
  return runNotFound(cwd, runInput);
97
- }
98
100
  if (loaded.bundle.runId !== selection.runId ||
99
101
  (await realpath(loaded.runDir)) !== preparedRunPaths.physicalRunRoot) {
100
102
  throw new Error("Observer output directory does not match the selected run.");
@@ -1,6 +1,15 @@
1
+ import type { CuaLoopResult } from "../../actors/computer-use/loop.js";
1
2
  import { type RunProviderResource, type RunSubjectProvenance } from "../../run/bundle.js";
2
3
  import { type ParticipantIds } from "../../run/participant-records.js";
3
4
  import { type ParticipantRunOutcome } from "./types.js";
5
+ /**
6
+ * The review's gap for the streaks a participant's trace records: turns that repeated a recent
7
+ * action on an unchanged screen past noProgressSteps, which did not end the session of a
8
+ * participant held to its scheduled end. Its session passes, so no other gap names them. One line
9
+ * names the first streak and counts the rest: a page that changes every minute, watched 1 s at a
10
+ * time, records a streak a minute, and a fan-out would list each.
11
+ */
12
+ export declare function streakGaps(participantId: string, session: CuaLoopResult | undefined): string[];
4
13
  /** The human-readable state story appended to the provenance event (and review.md via it). */
5
14
  export declare function describeSubjectState(state: RunSubjectProvenance["state"], dryRun: boolean): string;
6
15
  /** Human-readable provenance line for the single-participant subject.provenance event of a clone
@@ -1,14 +1,34 @@
1
1
  // The parts both computer-use bundle shapes share: the subject-state story and provenance line,
2
- // provider-resource records, and the public-safe app URL label, the URL digest and the
3
- // subject-phase event id suffix. The builders are bundle.ts (the dispatcher and the judge),
4
- // single-bundle.ts and fanout-bundle.ts. Feedback candidates from participant reports are in
5
- // participant-feedback.ts, which the shared-world bundle reads too.
2
+ // provider-resource records, the review's gap for a held participant's streak, and the public-safe
3
+ // app URL label, the URL digest and the subject-phase event id suffix. The builders are bundle.ts
4
+ // (the dispatcher and the judge), single-bundle.ts and fanout-bundle.ts. Feedback candidates from
5
+ // participant reports are in participant-feedback.ts, which the shared-world bundle reads too.
6
+ import { STREAK_TITLE } from "../../actors/computer-use/loop/streak.js";
6
7
  import { scanEncodedText } from "../../evidence/encoded-text.js";
7
8
  import { digestText } from "../../evidence/redaction.js";
8
9
  import {} from "../../run/bundle.js";
9
10
  import { participantResourceIds } from "../../run/participant-records.js";
10
11
  import {} from "./types.js";
11
12
  import { plural } from "../../run/text.js";
13
+ /**
14
+ * The review's gap for the streaks a participant's trace records: turns that repeated a recent
15
+ * action on an unchanged screen past noProgressSteps, which did not end the session of a
16
+ * participant held to its scheduled end. Its session passes, so no other gap names them. One line
17
+ * names the first streak and counts the rest: a page that changes every minute, watched 1 s at a
18
+ * time, records a streak a minute, and a fan-out would list each.
19
+ */
20
+ export function streakGaps(participantId, session) {
21
+ const texts = (session?.trace.items ?? []).flatMap((item) => item.kind === "notice" && item.title === STREAK_TITLE && item.text !== undefined
22
+ ? [item.text]
23
+ : []);
24
+ const [first] = texts;
25
+ if (first === undefined)
26
+ return [];
27
+ const more = texts.length > 1
28
+ ? ` It had ${plural(texts.length - 1, "more such streak")} after that; its trace records each.`
29
+ : "";
30
+ return [`${participantId}: ${first}${more}`];
31
+ }
12
32
  /** The human-readable state story appended to the provenance event (and review.md via it). */
13
33
  export function describeSubjectState(state, dryRun) {
14
34
  switch (state.provenance) {
@@ -5,7 +5,7 @@ import { cuaGoalSource } from "../../actors/goal-source.js";
5
5
  import { redactText } from "../../evidence/redaction.js";
6
6
  import { REVIEW_SCHEMA, bundleArtifacts, bundleHead, } from "../../run/bundle.js";
7
7
  import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, gapsListClause, skippedSentence, withCuaReviewProvenance, } from "../../run/outcomes.js";
8
- import { providerResourcesForOutcome, publicSafeAppUrlLabel } from "./bundle-parts.js";
8
+ import { providerResourcesForOutcome, publicSafeAppUrlLabel, streakGaps } from "./bundle-parts.js";
9
9
  import { subjectName } from "../../run/subject-name.js";
10
10
  import { participantFeedbackCandidates } from "./participant-feedback.js";
11
11
  import { participantFactsOf } from "./participant-facts.js";
@@ -70,18 +70,20 @@ function fanoutSummary(args, facts) {
70
70
  }
71
71
  /**
72
72
  * The review's gaps: one line for the participants a host suspension likely failed, then a line
73
- * for each other participant that did not pass.
73
+ * for each other participant that did not pass, then a line for each held participant's streaks.
74
74
  */
75
75
  function fanoutGaps(args, judgment) {
76
76
  const suspended = new Set(args.hostSuspension?.participantIds ?? []);
77
77
  const others = judgment.participants.flatMap((participant, index) => participant.gapLine === undefined || suspended.has(args.specs[index].planned.id)
78
78
  ? []
79
79
  : [participant.gapLine]);
80
+ const streaks = args.specs.flatMap((spec, index) => streakGaps(spec.planned.id, args.outcomes?.[index]?.session));
80
81
  if (suspended.size === 0)
81
- return others;
82
+ return [...others, ...streaks];
82
83
  return [
83
84
  `${[...suspended].join(", ")}: failed after the host was suspended, likely its effect. Each participant's record keeps its own error.`,
84
85
  ...others,
86
+ ...streaks,
85
87
  ];
86
88
  }
87
89
  function fanoutReview(args, streams, judgment) {
@@ -6,7 +6,7 @@ import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, p
6
6
  import { participantCaption } from "../../run/participant-caption.js";
7
7
  import { actorEnding } from "../../actors/stop-cause.js";
8
8
  import { cuaGoalSource, ranUntilClockEnd } from "../../actors/goal-source.js";
9
- import { describeSubjectState, phaseEventIdSuffix, publicSafeAppUrlLabel, subjectProvenanceMessage, } from "./bundle-parts.js";
9
+ import { describeSubjectState, phaseEventIdSuffix, publicSafeAppUrlLabel, streakGaps, subjectProvenanceMessage, } from "./bundle-parts.js";
10
10
  import { participantFeedbackCandidates } from "./participant-feedback.js";
11
11
  import { judgeParticipantRecords } from "../../run/judge.js";
12
12
  import { buildRunCostSummary } from "../../run/cost-summary.js";
@@ -348,7 +348,7 @@ function singleReview(args, view, stream) {
348
348
  ...(credibilityNote === undefined ? {} : { credibilityNote }),
349
349
  })}`,
350
350
  gaps: args.session || args.sessionError !== undefined
351
- ? []
351
+ ? streakGaps(args.participantId ?? "lane-01", args.session)
352
352
  : args.inProgress === true
353
353
  ? ["Live desktop session is still running."]
354
354
  : ["Live desktop session not yet run (dry run only)."],
@@ -55,8 +55,14 @@ export declare function loadRunBundle(cwdInput: string, runInput: string): Promi
55
55
  bundlePath: string;
56
56
  runDir: string;
57
57
  } | null>;
58
- /** loadRunBundle for a caller that already holds prepared paths. Revalidates them first. */
59
- export declare function loadRunBundlePrepared(cwdInput: string, runPaths: PreparedRunArtifactPaths): Promise<{
58
+ /**
59
+ * loadRunBundle for a caller that already holds prepared paths. Revalidates them first. With
60
+ * `display`, a participant whose trace the loader refuses, such as one past the read limit, is
61
+ * kept as displayedTraces shows it, for a page that shows the run.
62
+ */
63
+ export declare function loadRunBundlePrepared(cwdInput: string, runPaths: PreparedRunArtifactPaths, options?: {
64
+ display?: boolean;
65
+ }): Promise<{
60
66
  bundle: RunBundle;
61
67
  bundlePath: string;
62
68
  runDir: string;
@@ -3,7 +3,7 @@ import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveExistingRunDir
3
3
  import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
4
4
  import { RUN_BUNDLE_FILE } from "./bundle.js";
5
5
  import { isRunBundle, isRunPointer } from "./bundle-shape.js";
6
- import { loadRunTraces } from "./stream-trace.js";
6
+ import { displayedTraces, loadRunTraces, loadStreamTraces } from "./stream-trace.js";
7
7
  import { readUnchangedFile } from "./evidence-files.js";
8
8
  /** The run input that resolves through latest.json rather than naming a run directory. */
9
9
  export const LATEST_RUN_ALIAS = "latest";
@@ -119,8 +119,12 @@ export async function loadRunBundle(cwdInput, runInput) {
119
119
  }
120
120
  return loadRunBundlePrepared(cwd, runPaths);
121
121
  }
122
- /** loadRunBundle for a caller that already holds prepared paths. Revalidates them first. */
123
- export async function loadRunBundlePrepared(cwdInput, runPaths) {
122
+ /**
123
+ * loadRunBundle for a caller that already holds prepared paths. Revalidates them first. With
124
+ * `display`, a participant whose trace the loader refuses, such as one past the read limit, is
125
+ * kept as displayedTraces shows it, for a page that shows the run.
126
+ */
127
+ export async function loadRunBundlePrepared(cwdInput, runPaths, options = {}) {
124
128
  const cwd = path.resolve(cwdInput);
125
129
  await validatePreparedRunArtifactPaths(runPaths);
126
130
  const bundlePath = path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE);
@@ -134,7 +138,9 @@ export async function loadRunBundlePrepared(cwdInput, runPaths) {
134
138
  }
135
139
  return {
136
140
  // A trace that does not match its index throws StreamTraceRefusedError, naming the participant.
137
- bundle: await loadRunTraces(runPaths, bundle),
141
+ bundle: options.display === true
142
+ ? displayedTraces(await loadStreamTraces(runPaths, bundle))
143
+ : await loadRunTraces(runPaths, bundle),
138
144
  bundlePath: path.relative(cwd, bundlePath),
139
145
  runDir: runPaths.absoluteRunRoot,
140
146
  };
package/dist/run/run.js CHANGED
@@ -148,6 +148,14 @@ async function writeRunJson(paths, liveItems, bundle) {
148
148
  });
149
149
  return { ...publicBundle, streams };
150
150
  }
151
+ /** The warn event that names the files the final sandbox id sweep could not read. */
152
+ const unsweptEvent = (message, at) => ({
153
+ id: "event-sandbox-ids-unresolved",
154
+ at: new Date(at).toISOString(),
155
+ level: "warn",
156
+ type: "sandbox-ids.unresolved",
157
+ message,
158
+ });
151
159
  function runPublisher(args) {
152
160
  const { options, runId, createdAt, paths, runStatus, now, admit, participantsRan } = args;
153
161
  const observer = observerTarget(options);
@@ -161,11 +169,14 @@ function runPublisher(args) {
161
169
  type: "study.warning",
162
170
  message: redactText(message),
163
171
  }));
164
- // So do the host suspensions the heartbeat has seen by the time of the write.
172
+ // So do the host suspensions the heartbeat has seen by the time of the write, and the files the
173
+ // final sandbox id sweep could not read.
174
+ let unswept = [];
165
175
  const withRunEvents = (events) => {
166
176
  const owned = [
167
177
  ...studyWarnings,
168
178
  ...hostSuspensionEvents(runStatus.suspensions(), Date.parse(createdAt)),
179
+ ...unswept,
169
180
  ];
170
181
  return [...events.filter((event) => !owned.some((mine) => mine.id === event.id)), ...owned];
171
182
  };
@@ -231,7 +242,12 @@ function runPublisher(args) {
231
242
  await writeBundleFiles(bundle, finishedOutcome(outcome), (publicBundle) => runStatus.finish(runStatusOutcome(publicBundle)));
232
243
  // The final run.json indexes each finished trace, so the live files are journal no reader needs.
233
244
  await liveItems.remove();
234
- await scrubRunSandboxIds(paths);
245
+ const unread = await scrubRunSandboxIds(paths);
246
+ if (unread !== undefined) {
247
+ unswept = [unsweptEvent(unread, now())];
248
+ // The sweep reads the files the final write left, so run.json is written again to record it.
249
+ await writeBundleFiles(bundle, finishedOutcome(outcome), async () => { });
250
+ }
235
251
  await writePointer();
236
252
  let current = { ok: outcome.ok, execution: outcome.execution };
237
253
  // An Observer that did not render, judged under the route's policy and written to run.json
@@ -4,7 +4,12 @@ import { type PreparedRunArtifactPaths } from "./paths.js";
4
4
  export declare function scrubSandboxIds(text: string, ids: Iterable<string>, label?: (id: string) => string): string;
5
5
  /**
6
6
  * `value` as it may be written or returned: ids at the sandbox-id keys become the marker and a
7
- * digest, and any raw id left in free text or a URL becomes its label.
7
+ * digest, and any raw id left in free text, a key or a URL becomes its label. When no label goes
8
+ * in, the result is the key-redacted value. Otherwise it is what JSON.parse returns for the
9
+ * scrubbed JSON text of that value. A run bundle is scrubbed one stream at a time, so no string
10
+ * holds two streams unless a member outside `streams` holds them too. An id that holds one of
11
+ * {}[],:"\ makes the scrub work on the whole text, which throws RangeError for a bundle past V8's
12
+ * longest string. No E2B id holds one.
8
13
  */
9
14
  export declare function publicSandboxView<T>(value: T, ids: Iterable<string>): T;
10
15
  /**
@@ -40,10 +45,11 @@ export declare function scrubSandboxIdBytes(file: string, bytes: Buffer, ids: re
40
45
  * receipts, with the label in place of the id. Writers such as a participant's actor.json can
41
46
  * quote an SDK error that names the sandbox. Run.finish calls it before analysis reads the run.
42
47
  * Best effort for writes and listings: verify grades a file this misses. A text file it refuses
43
- * to read, or a journal it refuses, throws once the sweep ends, naming each, since such a file
44
- * may still hold an id.
48
+ * to read, such as a trace past RUN_ARTIFACT_MAX_BYTES, may still hold an id, so the sweep
49
+ * returns a warning that names each such file and why, for the caller to record; the run goes
50
+ * on. A journal it refuses throws, since without its ids no file can be swept.
45
51
  */
46
- export declare function scrubRunSandboxIds(paths: PreparedRunArtifactPaths): Promise<void>;
52
+ export declare function scrubRunSandboxIds(paths: PreparedRunArtifactPaths): Promise<string | undefined>;
47
53
  /**
48
54
  * A run's result as it may be returned or printed: the same object when it names no sandbox,
49
55
  * otherwise its public view, scrubbed of the ids the run's receipts journal.
@@ -4,6 +4,7 @@
4
4
  // so output an agent pastes into an issue never carries an id.
5
5
  import { opendir, realpath } from "node:fs/promises";
6
6
  import path from "node:path";
7
+ import { splitAtArray } from "../evidence/json-split.js";
7
8
  import { readPlainText } from "../evidence/plain-text.js";
8
9
  import { collectSandboxIds, redactSandboxIds, sandboxIdDigest, REDACTED_SANDBOX_ID, } from "../evidence/redaction.js";
9
10
  import { ContainedReadRefusedError, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
@@ -26,20 +27,79 @@ export function scrubSandboxIds(text, ids, label = sandboxIdLabel) {
26
27
  unique.sort((left, right) => right.length - left.length);
27
28
  return unique.reduce((current, id) => current.split(id).join(label(id)), text);
28
29
  }
30
+ // A run bundle holds every finished participant's trace, and its whole JSON text can pass V8's
31
+ // longest string, 536,870,888 characters on Node 24. publicSandboxView scrubs such a bundle in
32
+ // pieces: the shell, which is the bundle's JSON text with `streams` written as `[]`, and the JSON
33
+ // text of each stream on its own. The pieces give what the whole text gives:
34
+ // - An id that holds none of {}[],:"\ lies inside one token of the whole text. Each cut between
35
+ // pieces falls between two of the characters {}[], and the placeholder adds only [ and ], so no
36
+ // occurrence crosses a cut. A replacement leaves those characters in place, so the next id's
37
+ // occurrences cannot cross one either.
38
+ // - A label holds no quote or backslash, so it never moves the end of a string. A label inside an
39
+ // escape, a number, true, false or null makes its piece fail to parse, and the whole text fails
40
+ // the same way.
41
+ // - JSON.stringify writes a stream's members alone as it writes them inside the bundle, since each
42
+ // gets the same key for its toJSON. Only the stream itself would get "" in place of its index,
43
+ // and splitAtArray takes no stream with a toJSON. The members before `streams`, each stream and
44
+ // the members after are serialized in that order, which is the order the whole text runs each
45
+ // toJSON in.
46
+ // - The parsed shell keeps its keys in the order JSON.parse gives for the whole text, and the
47
+ // streams go in at the place of `streams`. A key a label went into holds the label's `[`, so no
48
+ // other key becomes `streams`. An id inside the key `streams` itself takes the whole text.
49
+ // - Once a label goes in, the view is parsed from the scrubbed pieces, as it was from the whole
50
+ // text. A piece kept as given could run a toJSON or getter again when the view is written.
51
+ // - All of this holds for a bundle that serializing leaves unchanged, as every bundle JSON.parse
52
+ // returns is. splitAtArray reads the streams before any toJSON runs.
53
+ /** The characters that let an id reach past one token of a JSON text or move where a string ends. */
54
+ const CROSSES_TOKENS = /[{}[\],:"\\]/;
55
+ /** Whether every occurrence of `id` in a bundle's JSON text stays inside one piece. */
56
+ const staysInOnePiece = (id) => id.length === 0 || (!CROSSES_TOKENS.test(id) && !"streams".includes(id));
29
57
  /**
30
58
  * `value` as it may be written or returned: ids at the sandbox-id keys become the marker and a
31
- * digest, and any raw id left in free text or a URL becomes its label.
59
+ * digest, and any raw id left in free text, a key or a URL becomes its label. When no label goes
60
+ * in, the result is the key-redacted value. Otherwise it is what JSON.parse returns for the
61
+ * scrubbed JSON text of that value. A run bundle is scrubbed one stream at a time, so no string
62
+ * holds two streams unless a member outside `streams` holds them too. An id that holds one of
63
+ * {}[],:"\ makes the scrub work on the whole text, which throws RangeError for a bundle past V8's
64
+ * longest string. No E2B id holds one.
32
65
  */
33
66
  export function publicSandboxView(value, ids) {
34
67
  const redacted = redactSandboxIds(value);
35
68
  const known = [...ids];
36
69
  if (known.length === 0)
37
70
  return redacted;
38
- const text = JSON.stringify(redacted);
71
+ const split = known.every(staysInOnePiece) ? splitAtArray(redacted, "streams") : undefined;
72
+ return (split === undefined ? wholeTextView(redacted, known) : streamwiseView(redacted, split, known));
73
+ }
74
+ /** The scrub of `value`'s whole JSON text, parsed back when a label went in. */
75
+ function wholeTextView(value, ids) {
76
+ const text = JSON.stringify(value);
39
77
  if (text === undefined)
40
- return redacted;
41
- const scrubbed = scrubSandboxIds(text, known);
42
- return (scrubbed === text ? redacted : JSON.parse(scrubbed));
78
+ return value;
79
+ const scrubbed = scrubSandboxIds(text, ids);
80
+ return scrubbed === text ? value : JSON.parse(scrubbed);
81
+ }
82
+ /** The bundle's whole-text scrub, built from its shell and each stream scrubbed on its own. */
83
+ function streamwiseView(bundle, split, ids) {
84
+ const before = split.before();
85
+ let changed = false;
86
+ // Each stream is parsed at its turn, even before any label goes in, since a later piece can still
87
+ // take one and by then the earlier streams' text is gone.
88
+ const streams = split.elements.map((stream) => {
89
+ const text = JSON.stringify(stream);
90
+ const scrubbed = scrubSandboxIds(text, ids);
91
+ if (scrubbed !== text)
92
+ changed = true;
93
+ return JSON.parse(scrubbed);
94
+ });
95
+ const shellText = before + split.after();
96
+ const shell = scrubSandboxIds(shellText, ids);
97
+ if (!changed && shell === shellText)
98
+ return bundle;
99
+ // Assigning to `streams` keeps its place among the keys.
100
+ const view = JSON.parse(shell);
101
+ view.streams = streams;
102
+ return view;
43
103
  }
44
104
  /** The raw sandbox ids a receipts journal names. */
45
105
  function receiptSandboxIds(journal) {
@@ -137,16 +197,17 @@ const MAX_SWEPT_ENTRIES = 10_000;
137
197
  * receipts, with the label in place of the id. Writers such as a participant's actor.json can
138
198
  * quote an SDK error that names the sandbox. Run.finish calls it before analysis reads the run.
139
199
  * Best effort for writes and listings: verify grades a file this misses. A text file it refuses
140
- * to read, or a journal it refuses, throws once the sweep ends, naming each, since such a file
141
- * may still hold an id.
200
+ * to read, such as a trace past RUN_ARTIFACT_MAX_BYTES, may still hold an id, so the sweep
201
+ * returns a warning that names each such file and why, for the caller to record; the run goes
202
+ * on. A journal it refuses throws, since without its ids no file can be swept.
142
203
  */
143
204
  export async function scrubRunSandboxIds(paths) {
144
205
  const ids = await readRunSandboxIds(paths);
145
206
  if (ids.length === 0)
146
- return;
207
+ return undefined;
147
208
  const valid = await validatePreparedRunRootIdentity(paths).then(() => true, () => false);
148
209
  if (!valid)
149
- return;
210
+ return undefined;
150
211
  const refused = [];
151
212
  const scrub = async (file) => {
152
213
  const read = await readContainedRegularFile(paths, file, RUN_ARTIFACT_MAX_BYTES);
@@ -182,8 +243,9 @@ export async function scrubRunSandboxIds(paths) {
182
243
  await walk("").catch(() => {
183
244
  // A directory that changed while it was listed ends the sweep; verify names what it missed.
184
245
  });
185
- if (refused.length > 0)
186
- throw new Error(`The sandbox id sweep could not read every run file, so these may still name a sandbox: ${refused.join("; ")}.`);
246
+ return refused.length === 0
247
+ ? undefined
248
+ : `The sandbox id sweep could not read every run file, so these may still name a sandbox: ${refused.join("; ")}.`;
187
249
  }
188
250
  /**
189
251
  * A run's result as it may be returned or printed: the same object when it names no sandbox,