humanish 0.128.0 → 0.129.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 +13 -0
- package/dist/analysis/evidence.js +1 -4
- package/dist/analysis/validation.js +1 -1
- package/dist/cli/commands/study-format.js +2 -1
- 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/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/outcomes.d.ts +7 -0
- package/dist/run/outcomes.js +14 -0
- package/dist/run/stream-trace.d.ts +25 -8
- package/dist/run/stream-trace.js +61 -14
- package/docs/architecture/observer.md +13 -0
- package/docs/contracts/run-bundle.md +9 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,19 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.129.0: Analysis reads long runs past 100,000 items; traces past 32 MiB get their own advice; shared-world skips counted apart (2026-10-13)
|
|
12
|
+
|
|
13
|
+
humanish 0.129.0 fixes three things long studies reach. Analysis no longer refuses a run by how
|
|
14
|
+
many events, trace items or screenshot frames it holds; it still reads each file only up to 16
|
|
15
|
+
MiB. A trace or live file past 32 MiB, the most the trace loader reads, is refused with advice
|
|
16
|
+
that fits, a running participant whose live file passes 32 MiB keeps the items the Observer page
|
|
17
|
+
already shows, and the Observer server reads run.json under that limit. Shared-world runs count a
|
|
18
|
+
participant skipped before its session apart from those that used the app, and no longer count a
|
|
19
|
+
skip as overlap. This is a minor release because a shared-world `skippedReason` is now a sentence
|
|
20
|
+
where it was `handoff-timeout` or `host-ended-before-handoff`.
|
|
21
|
+
|
|
22
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.129.0)
|
|
23
|
+
|
|
11
24
|
## 0.128.0: Run bundle v4 keeps per-request lists in trace files; the study budget is checked before each request (2026-10-13)
|
|
12
25
|
|
|
13
26
|
humanish 0.128.0 keeps run.json small in long studies. Run bundles are now
|
|
@@ -35,7 +35,6 @@ function parseSource(prepared, bytes) {
|
|
|
35
35
|
!Array.isArray(value.streams) ||
|
|
36
36
|
value.streams.length > EVIDENCE_LIMITS.participants ||
|
|
37
37
|
!Array.isArray(value.events) ||
|
|
38
|
-
value.events.length > 100000 ||
|
|
39
38
|
(value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving)))
|
|
40
39
|
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
41
40
|
const ids = new Set();
|
|
@@ -79,10 +78,8 @@ async function readSource(prepared, bytes, traceBytes) {
|
|
|
79
78
|
return loaded.bundle;
|
|
80
79
|
}
|
|
81
80
|
function validateTraceItems(actor) {
|
|
82
|
-
if (actor !== undefined &&
|
|
83
|
-
(!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
|
|
81
|
+
if (actor !== undefined && (!isRecord(actor) || !Array.isArray(actor.items)))
|
|
84
82
|
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
85
|
-
}
|
|
86
83
|
const eventIds = new Set();
|
|
87
84
|
for (const item of isRecord(actor) ? actor.items : []) {
|
|
88
85
|
if (!isRecord(item) ||
|
|
@@ -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"}`,
|
|
@@ -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");
|
|
@@ -4,7 +4,7 @@ import { actorEnding } from "../../actors/stop-cause.js";
|
|
|
4
4
|
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
|
-
import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, gapsListClause, withCuaReviewProvenance, } from "../../run/outcomes.js";
|
|
7
|
+
import { aggregateTaskFunnels, formatParticipantOutcomes, formatRunTaskFunnel, gapsListClause, skippedSentence, withCuaReviewProvenance, } from "../../run/outcomes.js";
|
|
8
8
|
import { providerResourcesForOutcome, publicSafeAppUrlLabel } from "./bundle-parts.js";
|
|
9
9
|
import { subjectName } from "../../run/subject-name.js";
|
|
10
10
|
import { participantFeedbackCandidates } from "./participant-feedback.js";
|
|
@@ -63,12 +63,9 @@ function fanoutSummary(args, facts) {
|
|
|
63
63
|
// A run where every participant was skipped has no one to describe in this sentence.
|
|
64
64
|
if (tookPart > 0)
|
|
65
65
|
sentences.push(`${plural(tookPart, "participant")} took part, ${tookPart === 1 ? "in its own copy" : "each in their own copy"} of the app: ${outcomes}${gaps}.`);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
sentences.push(`1 participant was skipped before its session started${why}.`);
|
|
70
|
-
if (facts.skipped > 1)
|
|
71
|
-
sentences.push(`${facts.skipped} participants were skipped before their sessions started${why}.`);
|
|
66
|
+
const skipped = skippedSentence(facts.skipped, gaps === "");
|
|
67
|
+
if (skipped !== "")
|
|
68
|
+
sentences.push(skipped);
|
|
72
69
|
return `${host}${rerun}${sentences.join(" ")}${tasks}`;
|
|
73
70
|
}
|
|
74
71
|
/**
|
|
@@ -67,7 +67,12 @@ function sharedWorldSimulation(ctx, spec, index, view) {
|
|
|
67
67
|
mode: "browser-sim",
|
|
68
68
|
progress: inProgress ? 35 : 100,
|
|
69
69
|
currentStep: view.reason,
|
|
70
|
-
summary: participantSummary(name, { name: "the shared app" }, {
|
|
70
|
+
summary: participantSummary(name, { name: "the shared app" }, {
|
|
71
|
+
trace: session?.trace,
|
|
72
|
+
skippedReason: outcome?.skippedReason,
|
|
73
|
+
sessionError: outcome?.sessionError,
|
|
74
|
+
inProgress,
|
|
75
|
+
}),
|
|
71
76
|
startedAt: args.run.createdAt,
|
|
72
77
|
updatedAt: args.run.createdAt,
|
|
73
78
|
...(args.plan.arrivals.declared
|
|
@@ -7,6 +7,14 @@ export declare function maxSimultaneousWindows(windows: Array<{
|
|
|
7
7
|
startedAt: number;
|
|
8
8
|
endedAt: number;
|
|
9
9
|
}>): number;
|
|
10
|
+
/**
|
|
11
|
+
* The windows of the participants that used the app, for overlap and the live-at-once count. A
|
|
12
|
+
* participant the run skipped before its session started never used it, though its window sits
|
|
13
|
+
* inside another participant's session. run.json's laneWindows keep that window: verify expects
|
|
14
|
+
* one per declared participant, and reads overlap from them only on a pass, which a run with a
|
|
15
|
+
* skipped participant cannot reach.
|
|
16
|
+
*/
|
|
17
|
+
export declare function usedAppWindows(results: readonly ActorRunResult[]): ActorRunResult[];
|
|
10
18
|
/**
|
|
11
19
|
* The judgment for a shared-world bundle's inputs: how each participant ended, plus what the run
|
|
12
20
|
* observed about its world: overlap, a state change under overlap on the provisioned plane, and
|
|
@@ -16,7 +16,7 @@ import { hostOriginDigest } from "./provenance.js";
|
|
|
16
16
|
import { participantEvent, participantIds } from "../../run/participant-records.js";
|
|
17
17
|
import { sharedWorldParticipantRecords } from "./bundle-records.js";
|
|
18
18
|
import { CONCURRENT_ATTRIBUTION_LIMITS, EXTERNAL_PUBLIC_ATTRIBUTION_LIMITS, } from "./types.js";
|
|
19
|
-
import { gapsListClause } from "../../run/outcomes.js";
|
|
19
|
+
import { gapsListClause, skippedSentence } from "../../run/outcomes.js";
|
|
20
20
|
import { plural } from "../../run/text.js";
|
|
21
21
|
import { describeArrivals } from "../../study/arrivals.js";
|
|
22
22
|
/** Max windows live at the same instant (sweep over start/end points). The simultaneity
|
|
@@ -38,6 +38,16 @@ export function maxSimultaneousWindows(windows) {
|
|
|
38
38
|
}
|
|
39
39
|
return max;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The windows of the participants that used the app, for overlap and the live-at-once count. A
|
|
43
|
+
* participant the run skipped before its session started never used it, though its window sits
|
|
44
|
+
* inside another participant's session. run.json's laneWindows keep that window: verify expects
|
|
45
|
+
* one per declared participant, and reads overlap from them only on a pass, which a run with a
|
|
46
|
+
* skipped participant cannot reach.
|
|
47
|
+
*/
|
|
48
|
+
export function usedAppWindows(results) {
|
|
49
|
+
return results.filter((result) => result.outcome.skippedReason === undefined);
|
|
50
|
+
}
|
|
41
51
|
/**
|
|
42
52
|
* The judgment for a shared-world bundle's inputs: how each participant ended, plus what the run
|
|
43
53
|
* observed about its world: overlap, a state change under overlap on the provisioned plane, and
|
|
@@ -52,11 +62,7 @@ export function judgeSharedWorldRun(args) {
|
|
|
52
62
|
expected: args.actorSpecs.length,
|
|
53
63
|
participants: args.actorResults.map((result) => participantFactsOf(result.outcome)),
|
|
54
64
|
world: {
|
|
55
|
-
|
|
56
|
-
...concurrencyFacts(args.actorSpecs.map((_spec, index) => ({
|
|
57
|
-
startedAt: args.actorResults[index]?.startedAt ?? 0,
|
|
58
|
-
endedAt: args.actorResults[index]?.endedAt ?? 0,
|
|
59
|
-
})), external ? undefined : args.stateSnapshots),
|
|
65
|
+
...concurrencyFacts(usedAppWindows(args.actorResults), external ? undefined : args.stateSnapshots),
|
|
60
66
|
...(external ? { lobbyConvergence: args.lobbyConvergenceDigest !== undefined } : {}),
|
|
61
67
|
},
|
|
62
68
|
});
|
|
@@ -68,8 +74,10 @@ export function actorRunPassed(result) {
|
|
|
68
74
|
* The run's summary in plain sentences: who took part, how many reached the goal, and what the
|
|
69
75
|
* world showed. A participant reached the goal when its session passed the judge's checks and
|
|
70
76
|
* ended at the goal, so a productive `budget_reached` session does not count even when the lobby
|
|
71
|
-
* converged.
|
|
72
|
-
*
|
|
77
|
+
* converged. A participant the run skipped, such as a follower stopped at the handoff, never used
|
|
78
|
+
* the app, so it is counted in a sentence of its own. Each participant's ending stays in
|
|
79
|
+
* `sharedWorld.outcomes`, and the plane, topology and attribution limits stay in the bundle's
|
|
80
|
+
* events and `sharedWorld` block.
|
|
73
81
|
*/
|
|
74
82
|
function sharedWorldSummary(args, facts) {
|
|
75
83
|
const count = args.actorSpecs.length;
|
|
@@ -79,16 +87,20 @@ function sharedWorldSummary(args, facts) {
|
|
|
79
87
|
const served = facts.external ? ", as it is deployed" : ", served from a sandbox";
|
|
80
88
|
return `Dry run: ${people} would use ${app} at the same time${served}. No sandboxes were launched and $0 was spent.`;
|
|
81
89
|
}
|
|
90
|
+
// The live summary is written once, before any participant starts, so it names the plan.
|
|
82
91
|
if (facts.inProgress) {
|
|
83
|
-
return `${people} ${count === 1 ? "is" : "are"}
|
|
92
|
+
return `${people} ${count === 1 ? "is planned, in" : "are planned, all in"} ${app}. The run is still going, so nothing here is final.`;
|
|
84
93
|
}
|
|
85
94
|
const passed = facts.outcomes.filter((outcome) => outcome.ok);
|
|
86
95
|
const reached = passed.filter((outcome) => outcome.completionReason === "goal_satisfied").length;
|
|
87
|
-
const
|
|
96
|
+
const skipped = args.actorResults.filter((result) => result.outcome.skippedReason !== undefined).length;
|
|
97
|
+
const tookPart = count - skipped;
|
|
98
|
+
const together = tookPart === 1 ? "" : facts.overlaps ? " at the same time" : ", but never at the same time";
|
|
88
99
|
// A participant who did not pass has a gap line; one who passed but stopped short has none.
|
|
100
|
+
const gaps = gapsListClause(tookPart - passed.length, tookPart);
|
|
89
101
|
const rest = [
|
|
90
102
|
passed.length > reached ? `; ${passed.length - reached} ended without reaching it` : "",
|
|
91
|
-
|
|
103
|
+
gaps,
|
|
92
104
|
].join("");
|
|
93
105
|
const world = facts.external
|
|
94
106
|
? args.lobbyConvergenceDigest
|
|
@@ -97,7 +109,15 @@ function sharedWorldSummary(args, facts) {
|
|
|
97
109
|
: facts.deltas === 0
|
|
98
110
|
? "The app's shared state did not change during the run."
|
|
99
111
|
: `The app's shared state changed ${plural(facts.deltas, "time")} during the run.`;
|
|
100
|
-
return
|
|
112
|
+
return [
|
|
113
|
+
tookPart > 0
|
|
114
|
+
? `${plural(tookPart, "participant")} used ${app}${together}. ${reached} of ${tookPart} reached the goal${rest}.`
|
|
115
|
+
: "",
|
|
116
|
+
skippedSentence(skipped, gaps === ""),
|
|
117
|
+
world,
|
|
118
|
+
]
|
|
119
|
+
.filter((sentence) => sentence !== "")
|
|
120
|
+
.join(" ");
|
|
101
121
|
}
|
|
102
122
|
/** The run's first two events: its creation and the shared plane's provenance. */
|
|
103
123
|
function planeEvents(args, external) {
|
|
@@ -261,7 +281,7 @@ function concurrencyReview(args, external, inProgress, evidence, events, nextEve
|
|
|
261
281
|
// The count that matters is how many participants were live at once, which can be fewer than exist:
|
|
262
282
|
// a 6-participant run capped at 3 must never read as 6-wide concurrency.
|
|
263
283
|
const capForReport = plan.concurrency;
|
|
264
|
-
const maxLive = maxSimultaneousWindows(
|
|
284
|
+
const maxLive = maxSimultaneousWindows(usedAppWindows(args.actorResults));
|
|
265
285
|
events.push({
|
|
266
286
|
id: nextEventId("concurrency"),
|
|
267
287
|
at: createdAt,
|
|
@@ -12,7 +12,8 @@ export declare function resolveActorEntryUrl(baseUrl: string, entry: string | un
|
|
|
12
12
|
* follower joins through the real UI (a direct /lobby/CODE visit does not auto-join a non-member). */
|
|
13
13
|
export declare function withLobbyCodeMission(spec: DesktopParticipantRun, code: string): DesktopParticipantRun;
|
|
14
14
|
/** A follower blocked by an expired deadline or an ended host. It never opened a browser;
|
|
15
|
-
* keep the actual reason rather than turning every upstream failure into a timeout.
|
|
15
|
+
* keep the actual reason rather than turning every upstream failure into a timeout. The skip
|
|
16
|
+
* reason is a sentence in the form the computer-use skips use, since run lines and gaps print it. */
|
|
16
17
|
export declare function makeBlockedFollowerOutcome(spec: DesktopParticipantRun, reason: string, timedOut: boolean): ParticipantRunOutcome;
|
|
17
18
|
/**
|
|
18
19
|
* A live run publishes an in-progress bundle before its participants start, whether or not an
|
|
@@ -63,7 +63,8 @@ export function withLobbyCodeMission(spec, code) {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
/** A follower blocked by an expired deadline or an ended host. It never opened a browser;
|
|
66
|
-
* keep the actual reason rather than turning every upstream failure into a timeout.
|
|
66
|
+
* keep the actual reason rather than turning every upstream failure into a timeout. The skip
|
|
67
|
+
* reason is a sentence in the form the computer-use skips use, since run lines and gaps print it. */
|
|
67
68
|
export function makeBlockedFollowerOutcome(spec, reason, timedOut) {
|
|
68
69
|
return {
|
|
69
70
|
spec,
|
|
@@ -77,7 +78,9 @@ export function makeBlockedFollowerOutcome(spec, reason, timedOut) {
|
|
|
77
78
|
noEngagement: true,
|
|
78
79
|
selfReportedBlocker: false,
|
|
79
80
|
harnessError: false,
|
|
80
|
-
skippedReason: timedOut
|
|
81
|
+
skippedReason: timedOut
|
|
82
|
+
? "skipped: the host did not reach a lobby within the handoff deadline; no desktop was created"
|
|
83
|
+
: "skipped: the host ended before it reached a lobby; no desktop was created",
|
|
81
84
|
};
|
|
82
85
|
}
|
|
83
86
|
/**
|
|
@@ -8,7 +8,7 @@ import { validatePreparedRunArtifactPaths } from "../../run/paths.js";
|
|
|
8
8
|
import { participantExecutionFailures, participantFactsOf, } from "../computer-use/participant-facts.js";
|
|
9
9
|
import { participantCapWarning } from "../computer-use/participant-model.js";
|
|
10
10
|
import { resolveSubjectState } from "../computer-use/subject-projection.js";
|
|
11
|
-
import { actorRunPassed, buildConcurrentSharedWorldBundle, judgeSharedWorldRun, maxSimultaneousWindows, } from "./bundle.js";
|
|
11
|
+
import { actorRunPassed, buildConcurrentSharedWorldBundle, judgeSharedWorldRun, maxSimultaneousWindows, usedAppWindows, } from "./bundle.js";
|
|
12
12
|
import { planeStateOf } from "./plan.js";
|
|
13
13
|
import { buildSubjectProvenance, hostOriginDigest } from "./provenance.js";
|
|
14
14
|
import {} from "./types.js";
|
|
@@ -47,9 +47,16 @@ function concurrentParticipantResults(actorSpecs, actorResults, dryRun) {
|
|
|
47
47
|
}
|
|
48
48
|
const session = result.outcome.session;
|
|
49
49
|
const thisOk = participantOk(result);
|
|
50
|
+
const { skippedReason } = result.outcome;
|
|
51
|
+
// The same judged record run.json writes, so the run line and the bundle name one status.
|
|
52
|
+
const record = judgeParticipantRecords([participantFactsOf(result.outcome)], {
|
|
53
|
+
sessionLabel: "Participant",
|
|
54
|
+
missingSessionMessage: "Actor did not produce a terminal session.",
|
|
55
|
+
}).participants[0];
|
|
50
56
|
return {
|
|
51
57
|
...base,
|
|
52
|
-
status:
|
|
58
|
+
status: record.status,
|
|
59
|
+
...(skippedReason === undefined ? {} : { skippedReason }),
|
|
53
60
|
ok: thisOk,
|
|
54
61
|
window: { startedAt: result.startedAt, endedAt: result.endedAt },
|
|
55
62
|
...(session
|
|
@@ -70,10 +77,7 @@ function concurrentParticipantResults(actorSpecs, actorResults, dryRun) {
|
|
|
70
77
|
: {
|
|
71
78
|
error: {
|
|
72
79
|
code: "HUMANISH_SHARED_WORLD_FAILED",
|
|
73
|
-
message:
|
|
74
|
-
sessionLabel: "Participant",
|
|
75
|
-
missingSessionMessage: "Actor did not produce a terminal session.",
|
|
76
|
-
}).participants[0].notPassedMessage,
|
|
80
|
+
message: record.notPassedMessage,
|
|
77
81
|
},
|
|
78
82
|
}),
|
|
79
83
|
};
|
|
@@ -306,7 +310,9 @@ export async function finishConcurrentRun(ctx, live, results, plane) {
|
|
|
306
310
|
? {}
|
|
307
311
|
: { subjectSandbox: { sandboxId: subjectSandboxId, killed: subjectKilled } }),
|
|
308
312
|
...(dryRun ? {} : { overlapProven }),
|
|
309
|
-
...(dryRun
|
|
313
|
+
...(dryRun
|
|
314
|
+
? {}
|
|
315
|
+
: { maxSimultaneousLanes: maxSimultaneousWindows(usedAppWindows(actorResults)) }),
|
|
310
316
|
subject,
|
|
311
317
|
roles: participantResults,
|
|
312
318
|
observer,
|
|
@@ -94,6 +94,8 @@ export interface ConcurrentSharedWorldParticipantResult {
|
|
|
94
94
|
sandboxIdDigest?: string;
|
|
95
95
|
killed: boolean;
|
|
96
96
|
};
|
|
97
|
+
/** Set when the run skipped the participant before its session started (status `blocked`). */
|
|
98
|
+
skippedReason?: string;
|
|
97
99
|
error?: {
|
|
98
100
|
code: ConcurrentSharedWorldStudyErrorCode;
|
|
99
101
|
message: string;
|
package/dist/run/outcomes.d.ts
CHANGED
|
@@ -53,6 +53,13 @@ interface SummarySession {
|
|
|
53
53
|
export declare function participantSummary(name: string, app: SummaryApp, session: SummarySession): string;
|
|
54
54
|
/** "; the gaps list the other 2", or "all 3" when nobody passed. Empty when the gaps list nobody. */
|
|
55
55
|
export declare function gapsListClause(listed: number, total: number): string;
|
|
56
|
+
/**
|
|
57
|
+
* The sentence that counts the participants a run skipped before their sessions started, or ""
|
|
58
|
+
* when it skipped none. A skipped participant never used the app, so a summary counts it here and
|
|
59
|
+
* leaves it out of the sentence about those that took part. `pointAtGaps` is false when that
|
|
60
|
+
* sentence already points at the gaps.
|
|
61
|
+
*/
|
|
62
|
+
export declare function skippedSentence(skipped: number, pointAtGaps: boolean): string;
|
|
56
63
|
/** One line a stakeholder can read, with the denominator attached to every number. */
|
|
57
64
|
export declare function formatParticipantOutcomes(outcomes: ParticipantOutcomes, terminalCauses?: readonly ParticipantOutcomeDetail[]): string;
|
|
58
65
|
/** Presentation details tolerate optional legacy actor payloads without changing their tallies. */
|
package/dist/run/outcomes.js
CHANGED
|
@@ -206,6 +206,20 @@ export function gapsListClause(listed, total) {
|
|
|
206
206
|
return `; the gaps list the other ${listed}`;
|
|
207
207
|
return total === 1 ? "; the gaps say why" : `; the gaps list all ${total}`;
|
|
208
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* The sentence that counts the participants a run skipped before their sessions started, or ""
|
|
211
|
+
* when it skipped none. A skipped participant never used the app, so a summary counts it here and
|
|
212
|
+
* leaves it out of the sentence about those that took part. `pointAtGaps` is false when that
|
|
213
|
+
* sentence already points at the gaps.
|
|
214
|
+
*/
|
|
215
|
+
export function skippedSentence(skipped, pointAtGaps) {
|
|
216
|
+
const why = pointAtGaps ? "; the gaps say why" : "";
|
|
217
|
+
if (skipped === 1)
|
|
218
|
+
return `1 participant was skipped before its session started${why}.`;
|
|
219
|
+
if (skipped > 1)
|
|
220
|
+
return `${skipped} participants were skipped before their sessions started${why}.`;
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
209
223
|
/** One line a stakeholder can read, with the denominator attached to every number. */
|
|
210
224
|
export function formatParticipantOutcomes(outcomes, terminalCauses = []) {
|
|
211
225
|
if (outcomes.total === 0)
|
|
@@ -2,10 +2,13 @@ import { type RunBundle } from "./bundle.js";
|
|
|
2
2
|
import { type PreparedOutputRoot } from "./contained-output.js";
|
|
3
3
|
import { type LiveItemsCursor } from "./live-items.js";
|
|
4
4
|
import type { LiveItemsIndex, RunStream } from "./streams.js";
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* A trace that does not match its index, or cannot be read. The message names the stream, and ends
|
|
7
|
+
* with `advice`: by default, restore the run folder from a copy.
|
|
8
|
+
*/
|
|
6
9
|
export declare class StreamTraceRefusedError extends Error {
|
|
7
10
|
readonly streamId: string;
|
|
8
|
-
constructor(streamId: string, detail: string);
|
|
11
|
+
constructor(streamId: string, detail: string, advice?: string);
|
|
9
12
|
}
|
|
10
13
|
/** `trace` without the per-request lists; every other field as it is. */
|
|
11
14
|
export declare function withoutRequestLists<T extends object>(trace: T): T;
|
|
@@ -39,13 +42,22 @@ export type TraceFileReader = (relativePath: string, maxBytes: number, length?:
|
|
|
39
42
|
}>;
|
|
40
43
|
/**
|
|
41
44
|
* A live file the loader joined into stream `streamId`: the index it read the items by, and the
|
|
42
|
-
* cursor it read past when the stream holds only the items after one.
|
|
45
|
+
* cursor it read past when the stream holds only the items after one. A read with `stopped` has
|
|
46
|
+
* an index that records more than the loader reads of one file, so it read nothing and joined no
|
|
47
|
+
* items past `after`, which it always has; `stopped` is the warning a reader that shows the run
|
|
48
|
+
* gives.
|
|
43
49
|
*/
|
|
44
|
-
export
|
|
50
|
+
export type LiveItemsRead = {
|
|
45
51
|
streamId: string;
|
|
46
52
|
index: LiveItemsIndex;
|
|
47
53
|
after?: LiveItemsCursor;
|
|
48
|
-
|
|
54
|
+
stopped?: undefined;
|
|
55
|
+
} | {
|
|
56
|
+
streamId: string;
|
|
57
|
+
index: LiveItemsIndex;
|
|
58
|
+
after: LiveItemsCursor;
|
|
59
|
+
stopped: string;
|
|
60
|
+
};
|
|
49
61
|
/** For a live file's index, the prefix of that file the caller holds already, if any. */
|
|
50
62
|
type LiveAfter = (index: LiveItemsIndex) => LiveItemsCursor | undefined;
|
|
51
63
|
/**
|
|
@@ -53,7 +65,8 @@ type LiveAfter = (index: LiveItemsIndex) => LiveItemsCursor | undefined;
|
|
|
53
65
|
* stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
|
|
54
66
|
* stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
|
|
55
67
|
* checked against the index's byte count, sha256, item count and screenshot count, and any
|
|
56
|
-
* difference throws StreamTraceRefusedError.
|
|
68
|
+
* difference throws StreamTraceRefusedError. An index that records more than `maxBytes` throws
|
|
69
|
+
* before the file is read. The rules that need the whole bundle (an unindexed v2
|
|
57
70
|
* actor, two participants indexing one file) are loadStreamTraces'.
|
|
58
71
|
*/
|
|
59
72
|
export declare function loadStreamTrace(runPaths: PreparedOutputRoot, stream: RunStream, maxBytes?: number): Promise<RunStream>;
|
|
@@ -80,7 +93,9 @@ interface LoadedTraces {
|
|
|
80
93
|
* `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
|
|
81
94
|
* feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
|
|
82
95
|
* without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
|
|
83
|
-
* file's lines gets the whole prefix, checked as always.
|
|
96
|
+
* file's lines gets the whole prefix, checked as always. A live file whose index records more than
|
|
97
|
+
* `maxBytes` is not read: for a cursor that fits the index the stream gets no items past it and
|
|
98
|
+
* `live` records `stopped`, and without one the stream is refused.
|
|
84
99
|
*
|
|
85
100
|
* A live file that cannot be read may have been removed by the writer after `bundle` was read: it
|
|
86
101
|
* removes a superseded generation once run.json names the next one, and every live file once the
|
|
@@ -97,11 +112,13 @@ export declare function loadStreamTraces(source: PreparedOutputRoot | TraceFileR
|
|
|
97
112
|
* summary, shows none of the items or per-request lists run.json or a file supplied for it (its
|
|
98
113
|
* actor's items and lists, its in-progress items, its screenshot entries), and carries
|
|
99
114
|
* `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
|
|
100
|
-
* shows the run then never prices or checks rows the loader refused.
|
|
115
|
+
* shows the run then never prices or checks rows the loader refused. A live file the loader
|
|
116
|
+
* stopped reading (LiveItemsRead's `stopped`) gives its stream a `live-items.not-read` warn event.
|
|
101
117
|
*/
|
|
102
118
|
export declare function displayedTraces(loaded: {
|
|
103
119
|
bundle: RunBundle;
|
|
104
120
|
refused: readonly StreamTraceRefusedError[];
|
|
121
|
+
live?: readonly LiveItemsRead[];
|
|
105
122
|
}): RunBundle;
|
|
106
123
|
/** The bundle with every stream loaded; the first refusal throws. */
|
|
107
124
|
export declare function loadRunTraces(source: PreparedOutputRoot | TraceFileReader, bundle: RunBundle, maxBytes?: number): Promise<RunBundle>;
|
package/dist/run/stream-trace.js
CHANGED
|
@@ -13,15 +13,25 @@ import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsAft
|
|
|
13
13
|
import { estimateActorCostForExecution } from "./pricing.js";
|
|
14
14
|
import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
|
|
15
15
|
import { isRecord } from "./type-guards.js";
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* A trace that does not match its index, or cannot be read. The message names the stream, and ends
|
|
18
|
+
* with `advice`: by default, restore the run folder from a copy.
|
|
19
|
+
*/
|
|
17
20
|
export class StreamTraceRefusedError extends Error {
|
|
18
21
|
streamId;
|
|
19
|
-
constructor(streamId, detail) {
|
|
20
|
-
super(`Participant ${streamId}'s trace was not read: ${detail}.
|
|
22
|
+
constructor(streamId, detail, advice = "A trace file must stay as the run wrote it; restore the run folder from a copy.") {
|
|
23
|
+
super(`Participant ${streamId}'s trace was not read: ${detail}. ${advice}`);
|
|
21
24
|
this.streamId = streamId;
|
|
22
25
|
this.name = "StreamTraceRefusedError";
|
|
23
26
|
}
|
|
24
27
|
}
|
|
28
|
+
/** Why the loader reads no trace or live file whose index records more than `maxBytes`. */
|
|
29
|
+
const pastLimitDetail = (index, maxBytes) => `run.json records ${index.path} at ${index.bytes} bytes, more than the ${maxBytes} bytes humanish reads of one file`;
|
|
30
|
+
/**
|
|
31
|
+
* The refusal of a trace or live file whose index records more than `maxBytes`. The loader reads
|
|
32
|
+
* no file past the limit, so a copy of the file is refused too.
|
|
33
|
+
*/
|
|
34
|
+
const tooLarge = (streamId, index, maxBytes) => new StreamTraceRefusedError(streamId, pastLimitDetail(index, maxBytes), "Restoring a copy does not help; open the file with another tool, such as jq, to read its items.");
|
|
25
35
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
26
36
|
const isScreenshot = (artifact) => artifact.kind === "screenshot";
|
|
27
37
|
/**
|
|
@@ -250,10 +260,22 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
|
|
|
250
260
|
return refuse("run.json records a malformed live items index");
|
|
251
261
|
if (live.items !== undefined)
|
|
252
262
|
refuse("run.json holds in-progress items beside their live file");
|
|
253
|
-
if (index.bytes > maxBytes)
|
|
254
|
-
refuse(`${index.path} is indexed at ${index.bytes} bytes, more than the ${maxBytes} read`);
|
|
255
263
|
const { itemsFile: _index, ...summary } = live;
|
|
256
264
|
const after = liveAfter?.(index);
|
|
265
|
+
if (index.bytes > maxBytes) {
|
|
266
|
+
if (after === undefined || !cursorFits(index, after))
|
|
267
|
+
throw tooLarge(stream.id, index, maxBytes);
|
|
268
|
+
// The caller keeps the prefix it holds, read when the file was under the limit.
|
|
269
|
+
return {
|
|
270
|
+
stream: { ...stream, liveActor: { ...summary, items: [] } },
|
|
271
|
+
read: {
|
|
272
|
+
streamId: stream.id,
|
|
273
|
+
index,
|
|
274
|
+
after,
|
|
275
|
+
stopped: `Participant ${stream.id}'s items past ${after.items} were not read: ${pastLimitDetail(index, maxBytes)}. The items read before stay shown; open the file with another tool, such as jq, to read the rest.`,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
257
279
|
const tail = after === undefined ? undefined : await readLiveTail(read, stream, index, after);
|
|
258
280
|
if (tail !== undefined)
|
|
259
281
|
return {
|
|
@@ -274,6 +296,15 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
|
|
|
274
296
|
read: { streamId: stream.id, index },
|
|
275
297
|
};
|
|
276
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Whether `after` can be a prefix of the file `index` describes: some bytes, no more items or bytes
|
|
301
|
+
* than the index, and at its end in both counts or in neither. Whether it ends on a line end is
|
|
302
|
+
* checked only when the tail is read.
|
|
303
|
+
*/
|
|
304
|
+
const cursorFits = (index, after) => after.bytes > 0 &&
|
|
305
|
+
after.items <= index.items &&
|
|
306
|
+
after.bytes <= index.bytes &&
|
|
307
|
+
(after.items === index.items) === (after.bytes === index.bytes);
|
|
277
308
|
/**
|
|
278
309
|
* The items of the live file `index` describes past `after`, and the bytes they were read from;
|
|
279
310
|
* undefined when the cursor does not fit the index or the file's lines, so the caller reads the
|
|
@@ -281,10 +312,7 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
|
|
|
281
312
|
* LiveFileUnreadableError, as a whole read does.
|
|
282
313
|
*/
|
|
283
314
|
async function readLiveTail(read, stream, index, after) {
|
|
284
|
-
if (after
|
|
285
|
-
after.items > index.items ||
|
|
286
|
-
after.bytes > index.bytes ||
|
|
287
|
-
(after.items === index.items) !== (after.bytes === index.bytes))
|
|
315
|
+
if (!cursorFits(index, after))
|
|
288
316
|
return undefined;
|
|
289
317
|
if (after.bytes === index.bytes)
|
|
290
318
|
return { items: [], bytes: Buffer.alloc(0) };
|
|
@@ -304,6 +332,8 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
|
|
|
304
332
|
let joined = stream;
|
|
305
333
|
if (stream.trace !== undefined) {
|
|
306
334
|
assertIndex(stream);
|
|
335
|
+
if (stream.trace.bytes > maxBytes)
|
|
336
|
+
throw tooLarge(stream.id, stream.trace, maxBytes);
|
|
307
337
|
const file = await read(stream.trace.path, maxBytes);
|
|
308
338
|
if ("refused" in file)
|
|
309
339
|
throw new StreamTraceRefusedError(stream.id, file.refused);
|
|
@@ -320,7 +350,8 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
|
|
|
320
350
|
* stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
|
|
321
351
|
* stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
|
|
322
352
|
* checked against the index's byte count, sha256, item count and screenshot count, and any
|
|
323
|
-
* difference throws StreamTraceRefusedError.
|
|
353
|
+
* difference throws StreamTraceRefusedError. An index that records more than `maxBytes` throws
|
|
354
|
+
* before the file is read. The rules that need the whole bundle (an unindexed v2
|
|
324
355
|
* actor, two participants indexing one file) are loadStreamTraces'.
|
|
325
356
|
*/
|
|
326
357
|
export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_MAX_BYTES) {
|
|
@@ -387,7 +418,9 @@ function bundleRefusal(bundle, stream, owners) {
|
|
|
387
418
|
* `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
|
|
388
419
|
* feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
|
|
389
420
|
* without the indexed sha256, and `live` records the cursor; a cursor past the index or off the
|
|
390
|
-
* file's lines gets the whole prefix, checked as always.
|
|
421
|
+
* file's lines gets the whole prefix, checked as always. A live file whose index records more than
|
|
422
|
+
* `maxBytes` is not read: for a cursor that fits the index the stream gets no items past it and
|
|
423
|
+
* `live` records `stopped`, and without one the stream is refused.
|
|
391
424
|
*
|
|
392
425
|
* A live file that cannot be read may have been removed by the writer after `bundle` was read: it
|
|
393
426
|
* removes a superseded generation once run.json names the next one, and every live file once the
|
|
@@ -456,11 +489,13 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
|
|
|
456
489
|
* summary, shows none of the items or per-request lists run.json or a file supplied for it (its
|
|
457
490
|
* actor's items and lists, its in-progress items, its screenshot entries), and carries
|
|
458
491
|
* `traceUnavailable` with the reason; a warn event on its timeline says the same. A reader that
|
|
459
|
-
* shows the run then never prices or checks rows the loader refused.
|
|
492
|
+
* shows the run then never prices or checks rows the loader refused. A live file the loader
|
|
493
|
+
* stopped reading (LiveItemsRead's `stopped`) gives its stream a `live-items.not-read` warn event.
|
|
460
494
|
*/
|
|
461
495
|
export function displayedTraces(loaded) {
|
|
462
496
|
const reasons = new Map(loaded.refused.map((error) => [error.streamId, error.message]));
|
|
463
|
-
|
|
497
|
+
const stopped = (loaded.live ?? []).filter((read) => read.stopped !== undefined);
|
|
498
|
+
if (reasons.size === 0 && stopped.length === 0)
|
|
464
499
|
return loaded.bundle;
|
|
465
500
|
const streams = loaded.bundle.streams.map((stream) => {
|
|
466
501
|
const reason = reasons.get(stream.id);
|
|
@@ -484,7 +519,19 @@ export function displayedTraces(loaded) {
|
|
|
484
519
|
message: reasons.get(stream.id),
|
|
485
520
|
streamId: stream.id,
|
|
486
521
|
}));
|
|
487
|
-
|
|
522
|
+
const notRead = stopped.map((read) => ({
|
|
523
|
+
id: `event-live-items-not-read-${read.streamId}`,
|
|
524
|
+
at: loaded.bundle.streams.find((stream) => stream.id === read.streamId)?.updatedAt ?? "",
|
|
525
|
+
level: "warn",
|
|
526
|
+
type: "live-items.not-read",
|
|
527
|
+
message: read.stopped,
|
|
528
|
+
streamId: read.streamId,
|
|
529
|
+
}));
|
|
530
|
+
return {
|
|
531
|
+
...loaded.bundle,
|
|
532
|
+
streams,
|
|
533
|
+
events: [...loaded.bundle.events, ...events, ...notRead],
|
|
534
|
+
};
|
|
488
535
|
}
|
|
489
536
|
/** The bundle with every stream loaded; the first refusal throws. */
|
|
490
537
|
export async function loadRunTraces(source, bundle, maxBytes) {
|
|
@@ -97,6 +97,19 @@ A page that cannot join a body sends no cursors with its next poll. Static
|
|
|
97
97
|
operator path. Agents and CI should use `humanish watch --json --no-open` for
|
|
98
98
|
the same fresh evidence without browser open or a long-running process.
|
|
99
99
|
|
|
100
|
+
The feed reads no live file whose index passes the 32 MiB read limit
|
|
101
|
+
(`RUN_ARTIFACT_MAX_BYTES`); a request for the file by its path still gets the whole file. A page
|
|
102
|
+
that sends a cursor for it keeps the items it holds: the
|
|
103
|
+
server answers with no items past the cursor, gives the page's cursor back as `live`, and adds a
|
|
104
|
+
`live-items.not-read` warning to the participant's timeline, so later polls send the same cursor
|
|
105
|
+
and get the same answer. A poll that sends no cursor for that file gets the participant's trace
|
|
106
|
+
as unavailable, with the file's size and the limit, and the page drops any items it held. Such a
|
|
107
|
+
poll comes from a page opened after the file passed the limit or after the writer started a new
|
|
108
|
+
generation, from the poll after a body the page could not join, and from a page whose cursors
|
|
109
|
+
pass 8,000 characters (`MAX_AFTER_CHARS` in `observer/lib/live.ts`), which sends none. When the
|
|
110
|
+
run finishes, a trace file past the limit is refused too, and every page shows that trace as
|
|
111
|
+
unavailable.
|
|
112
|
+
|
|
100
113
|
### Reopening a run
|
|
101
114
|
|
|
102
115
|
The TUI's **Open in Observer** action opens an HTTP view of the selected run's saved
|
|
@@ -54,7 +54,12 @@ file that does not match its index. Inside humanish, readers load a stream throu
|
|
|
54
54
|
`loadStreamTrace(runPaths, stream)`. A v1 stream returns as it is.
|
|
55
55
|
An indexed stream's file is read under the 32 MiB per-file limit (analysis uses its 16 MiB
|
|
56
56
|
source limit for each trace) and refused unless its size, sha256, item count and screenshot count
|
|
57
|
-
match the index. A trace file
|
|
57
|
+
match the index. A trace or live file whose index records more than the limit is refused before it
|
|
58
|
+
is read, and the refusal says the file is larger than humanish reads of one file and that
|
|
59
|
+
restoring a copy does not help. A trace file whose index is under the limit and whose bytes are
|
|
60
|
+
over it changed after the run wrote it, and its refusal says to restore the run folder from a copy.
|
|
61
|
+
A live file may hold bytes past its index while the writer appends; the loader reads only the
|
|
62
|
+
indexed bytes. A trace file belongs to one participant: every stream whose index names a file
|
|
58
63
|
another stream's index names, compared without case as a case-insensitive file system compares
|
|
59
64
|
names, is refused before any file is read. The writer claims names the same way. In a v2 run.json every `actor`
|
|
60
65
|
must have an index: an actor with no index, with
|
|
@@ -133,8 +138,9 @@ Readers that show a run (the Observer server, run detail and the TUI, the review
|
|
|
133
138
|
provenance) load traces through the same loader and rules. A participant whose trace the loader
|
|
134
139
|
refused shows none of the items run.json or a file supplied for it: its stream carries
|
|
135
140
|
`traceUnavailable` with the reason, its Observer timeline a `trace.unavailable` warning, and its
|
|
136
|
-
run-detail entry `evidenceUnavailable`. The Observer server reads the saved
|
|
137
|
-
|
|
141
|
+
run-detail entry `evidenceUnavailable`. The Observer server reads run.json, and the saved
|
|
142
|
+
observer-data.json, under the same 32 MiB limit; past it the server answers 404 with a reason
|
|
143
|
+
naming the limit. It reads the saved observer-data.json only while a run has no run.json at all; a run.json that is there and cannot be read, parsed or
|
|
138
144
|
used as a bundle answers 404 with the reason, never the saved projection. In safe serving
|
|
139
145
|
(`observe --all --safe`) every read is checked against the files admitted with the run's
|
|
140
146
|
share_ready verification, so a run.json changed after admission is refused and the admitted
|