humanish 0.128.0 → 0.130.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 (42) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/actors/stop-cause.d.ts +1 -1
  3. package/dist/analysis/automatic.js +0 -1
  4. package/dist/analysis/evidence-sources.d.ts +44 -7
  5. package/dist/analysis/evidence-sources.js +40 -6
  6. package/dist/analysis/evidence.js +96 -125
  7. package/dist/analysis/service.js +12 -6
  8. package/dist/analysis/source.d.ts +28 -0
  9. package/dist/analysis/source.js +118 -0
  10. package/dist/analysis/validation.js +1 -1
  11. package/dist/cli/commands/study-format.js +2 -1
  12. package/dist/feedback/export.js +0 -1
  13. package/dist/observer/data.d.ts +2 -2
  14. package/dist/observer/feed-cursors.d.ts +3 -1
  15. package/dist/observer/feed-cursors.js +5 -2
  16. package/dist/observer/run-routes.js +40 -7
  17. package/dist/observer-app.html +1 -1
  18. package/dist/routes/computer-use/fanout-bundle.js +4 -7
  19. package/dist/routes/shared-world/bundle-records.js +6 -1
  20. package/dist/routes/shared-world/bundle.d.ts +8 -0
  21. package/dist/routes/shared-world/bundle.js +33 -13
  22. package/dist/routes/shared-world/participant-specs.d.ts +2 -1
  23. package/dist/routes/shared-world/participant-specs.js +5 -2
  24. package/dist/routes/shared-world/result.js +13 -7
  25. package/dist/routes/shared-world/types.d.ts +2 -0
  26. package/dist/run/bundle-shape.d.ts +2 -2
  27. package/dist/run/bundle.d.ts +8 -1
  28. package/dist/run/detail.js +4 -1
  29. package/dist/run/outcomes.d.ts +7 -0
  30. package/dist/run/outcomes.js +14 -0
  31. package/dist/run/participant-records.d.ts +1 -1
  32. package/dist/run/stream-shape.d.ts +2 -2
  33. package/dist/run/stream-trace.d.ts +46 -15
  34. package/dist/run/stream-trace.js +101 -20
  35. package/dist/run/streams.d.ts +18 -0
  36. package/dist/study/bundle-estimate.d.ts +4 -1
  37. package/dist/study/bundle-estimate.js +40 -15
  38. package/dist/verify/verify.js +28 -21
  39. package/docs/architecture/observer.md +13 -0
  40. package/docs/contracts/run-bundle.md +11 -5
  41. package/docs/contracts/study-analysis.md +13 -1
  42. package/package.json +1 -1
@@ -0,0 +1,28 @@
1
+ import { type StoredRunBundle } from "../run/bundle.js";
2
+ import { type PreparedRunArtifactPaths } from "../run/paths.js";
3
+ import type { RunStream } from "../run/streams.js";
4
+ /**
5
+ * A refusal of the run's recorded source. Its message is the code, as every analysis error's is,
6
+ * and `reason` says which file analysis did not read and why, for the person who asked.
7
+ */
8
+ export declare class AnalysisSourceRefusedError extends Error {
9
+ readonly reason: string;
10
+ constructor(code: "ANALYSIS_SOURCE_TOO_LARGE" | "ANALYSIS_SOURCE_INVALID", reason: string);
11
+ }
12
+ /** The refusal of a run.json of `size` bytes, more than the `max` analysis reads of it. */
13
+ export declare function runFileTooLarge(size: number | bigint, max: number): AnalysisSourceRefusedError;
14
+ /**
15
+ * run.json's bundle, when it is a run bundle of the prepared run that analysis reads: at most
16
+ * EVIDENCE_LIMITS.sourceBytes, at most EVIDENCE_LIMITS.participants participants, and each
17
+ * participant and event with the fields analysis reads.
18
+ */
19
+ export declare function parseAnalysisSource(prepared: PreparedRunArtifactPaths, bytes: Buffer): StoredRunBundle;
20
+ /**
21
+ * What `keep` takes from each stream of run.json's bundle with the stream's trace loaded, in stream
22
+ * order. Each trace file is read up to RUN_ARTIFACT_MAX_BYTES, as the loader, verify and the
23
+ * Observer read it, and is dropped once `keep` returns, so analysis holds one trace at a time
24
+ * whatever the number of participants. run.json's digest, which the analysis record binds, binds
25
+ * each trace's sha256. A trace whose index records more than the limit is refused as
26
+ * ANALYSIS_SOURCE_TOO_LARGE, and any other trace the loader refuses as ANALYSIS_SOURCE_INVALID.
27
+ */
28
+ export declare function readParticipantTraces<T>(prepared: PreparedRunArtifactPaths, bundle: StoredRunBundle, keep: (stream: RunStream, index: number) => T): Promise<T[]>;
@@ -0,0 +1,118 @@
1
+ // How analysis reads a run's recorded source: run.json, then each participant's trace one at a time,
2
+ // and the refusals it gives for either.
3
+ import { isCommsReceivingEvidence } from "../comms/receiving-evidence.js";
4
+ import { isReadRunBundleSchema } from "../run/bundle.js";
5
+ import { runIdOf } from "../run/paths.js";
6
+ import { reduceRunTraces, StreamTraceRefusedError, StreamTraceTooLargeError, } from "../run/stream-trace.js";
7
+ import { isRecord } from "../run/type-guards.js";
8
+ import { EVIDENCE_LIMITS } from "./analysis-limits.js";
9
+ /**
10
+ * A refusal of the run's recorded source. Its message is the code, as every analysis error's is,
11
+ * and `reason` says which file analysis did not read and why, for the person who asked.
12
+ */
13
+ export class AnalysisSourceRefusedError extends Error {
14
+ reason;
15
+ constructor(code, reason) {
16
+ super(code);
17
+ this.reason = reason;
18
+ this.name = "AnalysisSourceRefusedError";
19
+ }
20
+ }
21
+ const bytes = (count) => `${count.toLocaleString("en-US")} bytes`;
22
+ const limit = (max) => `${bytes(max)} (${(max / (1024 * 1024)).toLocaleString("en-US", { maximumFractionDigits: 1 })} MiB)`;
23
+ const runFileInvalid = (reason) => new AnalysisSourceRefusedError("ANALYSIS_SOURCE_INVALID", `Analysis did not read run.json: ${reason}`);
24
+ const traceInvalid = (streamId, reason) => new AnalysisSourceRefusedError("ANALYSIS_SOURCE_INVALID", `Analysis did not read participant ${streamId}'s trace: ${reason}`);
25
+ /** The refusal of a run.json of `size` bytes, more than the `max` analysis reads of it. */
26
+ export function runFileTooLarge(size, max) {
27
+ return new AnalysisSourceRefusedError("ANALYSIS_SOURCE_TOO_LARGE", `Analysis did not read run.json: it holds ${bytes(size)}, more than the ${limit(max)} analysis reads of it. run.json grows with each participant and the study's mission; split the participants across studies to keep it under the limit.`);
28
+ }
29
+ /**
30
+ * run.json's bundle, when it is a run bundle of the prepared run that analysis reads: at most
31
+ * EVIDENCE_LIMITS.sourceBytes, at most EVIDENCE_LIMITS.participants participants, and each
32
+ * participant and event with the fields analysis reads.
33
+ */
34
+ export function parseAnalysisSource(prepared, bytes) {
35
+ if (bytes.length > EVIDENCE_LIMITS.sourceBytes)
36
+ throw runFileTooLarge(bytes.length, EVIDENCE_LIMITS.sourceBytes);
37
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
38
+ if (!isRecord(value) || !isReadRunBundleSchema(value.schema))
39
+ throw runFileInvalid("it is not a run bundle of a schema this version of humanish reads.");
40
+ if (value.runId !== runIdOf(prepared))
41
+ throw runFileInvalid(`it records another run than ${runIdOf(prepared)}, the run in its folder.`);
42
+ if (!Array.isArray(value.streams) || !Array.isArray(value.events))
43
+ throw runFileInvalid("it has no participants or events list.");
44
+ if (value.streams.length > EVIDENCE_LIMITS.participants)
45
+ throw runFileInvalid(`it lists ${value.streams.length} participants, more than the ${EVIDENCE_LIMITS.participants} analysis reads; split the participants across studies.`);
46
+ if (value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving))
47
+ throw runFileInvalid("its email receiving evidence holds a field or value analysis does not read.");
48
+ const ids = new Set();
49
+ for (const [index, stream] of value.streams.entries()) {
50
+ if (!isRecord(stream) ||
51
+ typeof stream.id !== "string" ||
52
+ stream.id.length === 0 ||
53
+ stream.id.length > 256 ||
54
+ ids.has(stream.id) ||
55
+ typeof stream.label !== "string" ||
56
+ typeof stream.status !== "string") {
57
+ throw runFileInvalid(`participant ${index + 1} needs an id of 1 to 256 characters no other participant has, a label and a status.`);
58
+ }
59
+ ids.add(stream.id);
60
+ if (stream.actor !== undefined && !isRecord(stream.actor))
61
+ throw runFileInvalid(`participant ${stream.id}'s actor is not an object.`);
62
+ }
63
+ for (const [index, event] of value.events.entries()) {
64
+ if (!isRecord(event) ||
65
+ typeof event.id !== "string" ||
66
+ typeof event.message !== "string" ||
67
+ typeof event.type !== "string")
68
+ throw runFileInvalid(`event ${index + 1} needs an id, a type and a message.`);
69
+ }
70
+ return value;
71
+ }
72
+ /**
73
+ * What `keep` takes from each stream of run.json's bundle with the stream's trace loaded, in stream
74
+ * order. Each trace file is read up to RUN_ARTIFACT_MAX_BYTES, as the loader, verify and the
75
+ * Observer read it, and is dropped once `keep` returns, so analysis holds one trace at a time
76
+ * whatever the number of participants. run.json's digest, which the analysis record binds, binds
77
+ * each trace's sha256. A trace whose index records more than the limit is refused as
78
+ * ANALYSIS_SOURCE_TOO_LARGE, and any other trace the loader refuses as ANALYSIS_SOURCE_INVALID.
79
+ */
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);
93
+ throw error;
94
+ }
95
+ }
96
+ function validateTraceItems(stream) {
97
+ const actor = stream.actor;
98
+ if (actor === undefined)
99
+ return;
100
+ if (!isRecord(actor) || !Array.isArray(actor.items))
101
+ throw traceInvalid(stream.id, "it has no items list.");
102
+ // The place each id was first seen. A refusal names places, since an id is trace text.
103
+ const firstPlace = new Map();
104
+ for (const [index, item] of actor.items.entries()) {
105
+ if (!isRecord(item) ||
106
+ typeof item.id !== "string" ||
107
+ item.id.length === 0 ||
108
+ item.id.length > 256 ||
109
+ typeof item.kind !== "string" ||
110
+ typeof item.title !== "string" ||
111
+ (item.text !== undefined && typeof item.text !== "string"))
112
+ throw traceInvalid(stream.id, `item ${index + 1} needs an id of 1 to 256 characters, a kind and a title, and a text that is a string if it has one.`);
113
+ const first = firstPlace.get(item.id);
114
+ if (first !== undefined)
115
+ throw traceInvalid(stream.id, `items ${first + 1} and ${index + 1} share an id, and analysis cites each item by its id.`);
116
+ firstPlace.set(item.id, index);
117
+ }
118
+ }
@@ -150,7 +150,7 @@ const analysisEvidenceSchema = z
150
150
  quoteEligible: z.boolean(),
151
151
  at: timestamp.nullable(),
152
152
  elapsedMs: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
153
- frame: z.number().int().min(0).max(100000).nullable(),
153
+ frame: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
154
154
  capture: z
155
155
  .object({
156
156
  eventId: sourceId,
@@ -138,7 +138,8 @@ export function formatConcurrentSharedWorldStudyHuman(result) {
138
138
  ? "Participants used the app at the same time (observed in this run only)."
139
139
  : "Participants were not observed using the app at the same time.",
140
140
  ]),
141
- ...result.roles.map((participant) => `${participantCaption({ id: participant.id, personaId: participant.persona })}: ${participantStatus(participant.status)}${participant.ok ? "" : ", not ok"}`),
141
+ // A skipped participant's line ends on the reason the run recorded, as on computer use.
142
+ ...result.roles.map((participant) => `${participantCaption({ id: participant.id, personaId: participant.persona })}: ${participantStatus(participant.status)}${participant.ok ? "" : ", not ok"}${participant.skippedReason === undefined ? "" : ` · ${participant.skippedReason}`}`),
142
143
  ...(result.subjectSandbox
143
144
  ? [
144
145
  `subject sandbox: ${sandboxName(result.subjectSandbox)} killed=${result.subjectSandbox.killed ? "yes" : "no"}`,
@@ -17,7 +17,6 @@ import { buildObserverData, OBSERVER_DATA_SCHEMA, observerDataFromSaved, } from
17
17
  import { resolveRunPath } from "../run/locate.js";
18
18
  import { loadRunTraces, StreamTraceRefusedError } from "../run/stream-trace.js";
19
19
  import { verifyRun } from "../verify/verify.js";
20
- import {} from "../run/bundle.js";
21
20
  import { exportRedactedBundle } from "./export-bundle.js";
22
21
  import { loadAnalysis } from "../analysis/load.js";
23
22
  import { readNotesForSharing } from "../run/note-files.js";
@@ -1,7 +1,7 @@
1
1
  import { type ActorEnding } from "../actors/stop-cause.js";
2
2
  import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
3
3
  import { type RunDisplay } from "../run/display.js";
4
- import type { RunStream, RunStreamKind } from "../run/streams.js";
4
+ import type { RunStream, RunStreamKind, StoredRunStream } from "../run/streams.js";
5
5
  /**
6
6
  * The Observer's data. From v2 observer-data.json leaves out each participant's items, and from v3
7
7
  * each participant's per-request lists (REQUEST_LISTS in src/run/stream-trace.ts), which no part
@@ -96,7 +96,7 @@ export interface ObserverData {
96
96
  artifactRoot: string;
97
97
  };
98
98
  }
99
- export interface ObserverStream extends RunStream {
99
+ export interface ObserverStream extends StoredRunStream {
100
100
  /** Only the attached server may grant provider-origin access for a live desktop iframe. */
101
101
  embed?: NonNullable<RunStream["embed"]> & {
102
102
  runtimeDesktop?: true;
@@ -9,6 +9,8 @@ import type { ObserverItems } from "./participant-items.js";
9
9
  export declare function parseFeedCursors(after: string | null): ReadonlyMap<string, LiveItemsCursor>;
10
10
  /**
11
11
  * `items` with each live file's cursor on the entry of the stream it was joined into, and
12
- * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it.
12
+ * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it. A
13
+ * live file the loader stopped reading (LiveItemsRead's `stopped`) keeps the page's cursor as its
14
+ * own, so the page holds its items and sends that cursor again.
13
15
  */
14
16
  export declare function withFeedCursors(items: ObserverItems, live: readonly LiveItemsRead[]): ObserverItems;
@@ -43,7 +43,9 @@ function cursorText(path, position) {
43
43
  }
44
44
  /**
45
45
  * `items` with each live file's cursor on the entry of the stream it was joined into, and
46
- * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it.
46
+ * `liveAfter`, the page's cursor, where the entry's `liveActor` holds only the items past it. A
47
+ * live file the loader stopped reading (LiveItemsRead's `stopped`) keeps the page's cursor as its
48
+ * own, so the page holds its items and sends that cursor again.
47
49
  */
48
50
  export function withFeedCursors(items, live) {
49
51
  const streams = Object.assign(Object.create(null), items.streams);
@@ -51,9 +53,10 @@ export function withFeedCursors(items, live) {
51
53
  const entry = Object.hasOwn(streams, read.streamId) ? streams[read.streamId] : undefined;
52
54
  if (entry === undefined || !Array.isArray(entry.liveActor))
53
55
  continue;
56
+ const end = read.stopped === undefined ? read.index : read.after;
54
57
  streams[read.streamId] = {
55
58
  ...entry,
56
- live: cursorText(read.index.path, read.index),
59
+ live: cursorText(read.index.path, end),
57
60
  ...(read.after === undefined ? {} : { liveAfter: cursorText(read.index.path, read.after) }),
58
61
  };
59
62
  }
@@ -10,6 +10,7 @@ import { listRuns } from "../run/stored-runs.js";
10
10
  import { readAnalysisAccounting } from "../run/costs.js";
11
11
  import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
12
12
  import { isRunOutcome } from "../run/bundle-shape.js";
13
+ import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
13
14
  import { displayedTraces, loadStreamTraces, } from "../run/stream-trace.js";
14
15
  import { isNodeError, isRecord } from "../run/type-guards.js";
15
16
  import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-files.js";
@@ -150,10 +151,17 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
150
151
  writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
151
152
  return;
152
153
  }
154
+ // run.json is read under the limit every other reader of it uses, by name as through the feed.
155
+ const runJson = cleanedRelativePath === "run.json";
153
156
  try {
154
- const body = await readContainedFile(runRoot, filePath, notePath ? { maxBytes: MAX_NOTE_FILE_BYTES } : {});
157
+ const body = await readContainedFile(runRoot, filePath, notePath
158
+ ? { maxBytes: MAX_NOTE_FILE_BYTES }
159
+ : runJson
160
+ ? { maxBytes: RUN_ARTIFACT_MAX_BYTES }
161
+ : {});
155
162
  if (!body) {
156
- writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
163
+ const tooLarge = runJson ? await pastReadLimit(runRoot, "run.json") : undefined;
164
+ writeResponse(response, 404, tooLarge === undefined ? "Not found" : `Not found: ${tooLarge}`, "text/plain; charset=utf-8");
157
165
  return;
158
166
  }
159
167
  response.writeHead(200, {
@@ -198,12 +206,15 @@ async function readObserverData(runRoot, runtimeStreamUrls = []) {
198
206
  * fallback. The saved observer-data.json is read only while the run has no run.json at all, or in
199
207
  * safe serving, where only its admitted bytes can be read. Otherwise a run.json that is there and
200
208
  * cannot be read, parsed or used as a bundle (which a browser poll can meet mid-write) gives the
201
- * reason, a 404 the poller retries, never the saved projection.
209
+ * reason, a 404 the poller retries, never the saved projection. Both files are read under
210
+ * RUN_ARTIFACT_MAX_BYTES, as every other reader of a run file reads them.
202
211
  */
203
212
  async function observerDataOrReason(runRoot, runtimeStreamUrls, cursors) {
204
213
  const record = await readLocalRunStatus(runRoot);
205
214
  const runJson = path.join(runRoot.physicalPath, "run.json");
206
- const bundleBytes = await readContainedFile(runRoot, runJson).catch(() => null);
215
+ const bundleBytes = await readContainedFile(runRoot, runJson, {
216
+ maxBytes: RUN_ARTIFACT_MAX_BYTES,
217
+ }).catch(() => null);
207
218
  const absent = bundleBytes === null &&
208
219
  (await lstat(runJson).then(() => false, (error) => isNodeError(error) && error.code === "ENOENT"));
209
220
  // A root admitted for safe serving checks every read against the identity and digest admitted
@@ -214,7 +225,10 @@ async function observerDataOrReason(runRoot, runtimeStreamUrls, cursors) {
214
225
  return "unavailable" in saved ? saved : { data: saved, live: [] };
215
226
  }
216
227
  if (bundleBytes === null)
217
- return { unavailable: "run.json is there but could not be read: it is a link or changed." };
228
+ return {
229
+ unavailable: (await pastReadLimit(runRoot, "run.json")) ??
230
+ "run.json is there but could not be read: it is a link or changed.",
231
+ };
218
232
  let stored;
219
233
  try {
220
234
  const parsed = JSON.parse(bundleBytes.toString("utf8"));
@@ -237,12 +251,31 @@ async function observerDataOrReason(runRoot, runtimeStreamUrls, cursors) {
237
251
  return { unavailable: "run.json could not be shown as a run." };
238
252
  }
239
253
  }
254
+ /**
255
+ * Why run.json or a saved observer-data.json was not read, when the file at `relativePath` passes
256
+ * the root's checks and is larger than RUN_ARTIFACT_MAX_BYTES, the most the server reads of either;
257
+ * otherwise undefined. The size comes from the opened file, so no link or file outside the run
258
+ * folder is measured.
259
+ */
260
+ async function pastReadLimit(runRoot, relativePath) {
261
+ const opened = await openContainedFile(runRoot, path.join(runRoot.physicalPath, relativePath));
262
+ if (opened === null)
263
+ return undefined;
264
+ await opened.handle.close().catch(() => undefined);
265
+ return opened.size > RUN_ARTIFACT_MAX_BYTES
266
+ ? `${relativePath} is larger than ${RUN_ARTIFACT_MAX_BYTES} bytes, the most humanish reads of it.`
267
+ : undefined;
268
+ }
240
269
  /** The saved observer-data.json of a run that has written no run.json yet. */
241
270
  async function savedObserverData(runRoot, record, runtimeStreamUrls) {
242
271
  try {
243
- const observerBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "observer", "observer-data.json"));
244
- if (!observerBytes)
272
+ const observerBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "observer", "observer-data.json"), { maxBytes: RUN_ARTIFACT_MAX_BYTES });
273
+ if (!observerBytes) {
274
+ const tooLarge = await pastReadLimit(runRoot, "observer/observer-data.json");
275
+ if (tooLarge !== undefined)
276
+ return { unavailable: tooLarge };
245
277
  throw new Error("observer-data.json unavailable");
278
+ }
246
279
  const saved = observerDataFromSaved(JSON.parse(observerBytes.toString("utf8")));
247
280
  if (saved === null)
248
281
  throw new Error("observer-data.json holds no participant's items");