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.
- package/CHANGELOG.md +24 -0
- package/dist/actors/stop-cause.d.ts +1 -1
- package/dist/analysis/automatic.js +0 -1
- package/dist/analysis/evidence-sources.d.ts +44 -7
- package/dist/analysis/evidence-sources.js +40 -6
- package/dist/analysis/evidence.js +96 -125
- package/dist/analysis/service.js +12 -6
- package/dist/analysis/source.d.ts +28 -0
- package/dist/analysis/source.js +118 -0
- package/dist/analysis/validation.js +1 -1
- package/dist/cli/commands/study-format.js +2 -1
- package/dist/feedback/export.js +0 -1
- package/dist/observer/data.d.ts +2 -2
- package/dist/observer/feed-cursors.d.ts +3 -1
- package/dist/observer/feed-cursors.js +5 -2
- package/dist/observer/run-routes.js +40 -7
- package/dist/observer-app.html +1 -1
- package/dist/routes/computer-use/fanout-bundle.js +4 -7
- package/dist/routes/shared-world/bundle-records.js +6 -1
- package/dist/routes/shared-world/bundle.d.ts +8 -0
- package/dist/routes/shared-world/bundle.js +33 -13
- package/dist/routes/shared-world/participant-specs.d.ts +2 -1
- package/dist/routes/shared-world/participant-specs.js +5 -2
- package/dist/routes/shared-world/result.js +13 -7
- package/dist/routes/shared-world/types.d.ts +2 -0
- package/dist/run/bundle-shape.d.ts +2 -2
- package/dist/run/bundle.d.ts +8 -1
- package/dist/run/detail.js +4 -1
- package/dist/run/outcomes.d.ts +7 -0
- package/dist/run/outcomes.js +14 -0
- package/dist/run/participant-records.d.ts +1 -1
- package/dist/run/stream-shape.d.ts +2 -2
- package/dist/run/stream-trace.d.ts +46 -15
- package/dist/run/stream-trace.js +101 -20
- package/dist/run/streams.d.ts +18 -0
- package/dist/study/bundle-estimate.d.ts +4 -1
- package/dist/study/bundle-estimate.js +40 -15
- package/dist/verify/verify.js +28 -21
- package/docs/architecture/observer.md +13 -0
- package/docs/contracts/run-bundle.md +11 -5
- package/docs/contracts/study-analysis.md +13 -1
- 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(
|
|
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
|
-
|
|
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"}`,
|
package/dist/feedback/export.js
CHANGED
|
@@ -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";
|
package/dist/observer/data.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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");
|