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
package/dist/run/stream-trace.js
CHANGED
|
@@ -13,15 +13,28 @@ 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
|
+
* A trace or live file whose index records more than the loader reads of one file. The loader reads
|
|
32
|
+
* no file past the limit, so a copy of the file is refused too.
|
|
33
|
+
*/
|
|
34
|
+
export class StreamTraceTooLargeError extends StreamTraceRefusedError {
|
|
35
|
+
}
|
|
36
|
+
/** The refusal of a trace or live file whose index records more than `maxBytes`. */
|
|
37
|
+
const tooLarge = (streamId, index, maxBytes) => new StreamTraceTooLargeError(streamId, pastLimitDetail(index, maxBytes), "Restoring a copy does not help; open the file with another tool, such as jq, to read its items.");
|
|
25
38
|
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
26
39
|
const isScreenshot = (artifact) => artifact.kind === "screenshot";
|
|
27
40
|
/**
|
|
@@ -226,6 +239,13 @@ function assertIndex(stream) {
|
|
|
226
239
|
if (stream.actor.items !== undefined)
|
|
227
240
|
throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
|
|
228
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* A stream of run.json typed as a loaded one, for the streams the loader joins no trace into: one
|
|
244
|
+
* without an index, which v1 wrote with its actor's lists, and a refused one, kept as run.json
|
|
245
|
+
* holds it. From v4 a refused stream's actor has none of the lists, and its `conversation` lacks
|
|
246
|
+
* the `requests` ActorTrace declares; the loader's `refused` names each such stream.
|
|
247
|
+
*/
|
|
248
|
+
const unjoined = (stream) => stream;
|
|
229
249
|
/** A live actor whose items run.json holds inline: humanish.live-actor.v1, or one with no schema. */
|
|
230
250
|
const isInlineLiveActor = (live) => live.schema === LIVE_ACTOR_V1_SCHEMA || live.schema === undefined;
|
|
231
251
|
/** A live file that could not be read, which a writer that moved on may have removed. */
|
|
@@ -250,10 +270,22 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
|
|
|
250
270
|
return refuse("run.json records a malformed live items index");
|
|
251
271
|
if (live.items !== undefined)
|
|
252
272
|
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
273
|
const { itemsFile: _index, ...summary } = live;
|
|
256
274
|
const after = liveAfter?.(index);
|
|
275
|
+
if (index.bytes > maxBytes) {
|
|
276
|
+
if (after === undefined || !cursorFits(index, after))
|
|
277
|
+
throw tooLarge(stream.id, index, maxBytes);
|
|
278
|
+
// The caller keeps the prefix it holds, read when the file was under the limit.
|
|
279
|
+
return {
|
|
280
|
+
stream: { ...stream, liveActor: { ...summary, items: [] } },
|
|
281
|
+
read: {
|
|
282
|
+
streamId: stream.id,
|
|
283
|
+
index,
|
|
284
|
+
after,
|
|
285
|
+
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.`,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
257
289
|
const tail = after === undefined ? undefined : await readLiveTail(read, stream, index, after);
|
|
258
290
|
if (tail !== undefined)
|
|
259
291
|
return {
|
|
@@ -274,6 +306,15 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
|
|
|
274
306
|
read: { streamId: stream.id, index },
|
|
275
307
|
};
|
|
276
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Whether `after` can be a prefix of the file `index` describes: some bytes, no more items or bytes
|
|
311
|
+
* than the index, and at its end in both counts or in neither. Whether it ends on a line end is
|
|
312
|
+
* checked only when the tail is read.
|
|
313
|
+
*/
|
|
314
|
+
const cursorFits = (index, after) => after.bytes > 0 &&
|
|
315
|
+
after.items <= index.items &&
|
|
316
|
+
after.bytes <= index.bytes &&
|
|
317
|
+
(after.items === index.items) === (after.bytes === index.bytes);
|
|
277
318
|
/**
|
|
278
319
|
* The items of the live file `index` describes past `after`, and the bytes they were read from;
|
|
279
320
|
* undefined when the cursor does not fit the index or the file's lines, so the caller reads the
|
|
@@ -281,10 +322,7 @@ async function readLiveItems(read, stream, maxBytes, liveAfter) {
|
|
|
281
322
|
* LiveFileUnreadableError, as a whole read does.
|
|
282
323
|
*/
|
|
283
324
|
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))
|
|
325
|
+
if (!cursorFits(index, after))
|
|
288
326
|
return undefined;
|
|
289
327
|
if (after.bytes === index.bytes)
|
|
290
328
|
return { items: [], bytes: Buffer.alloc(0) };
|
|
@@ -301,9 +339,11 @@ async function readLiveTail(read, stream, index, after) {
|
|
|
301
339
|
*/
|
|
302
340
|
async function readStreamTrace(read, stream, maxBytes, liveAfter) {
|
|
303
341
|
const files = [];
|
|
304
|
-
let joined = stream;
|
|
342
|
+
let joined = unjoined(stream);
|
|
305
343
|
if (stream.trace !== undefined) {
|
|
306
344
|
assertIndex(stream);
|
|
345
|
+
if (stream.trace.bytes > maxBytes)
|
|
346
|
+
throw tooLarge(stream.id, stream.trace, maxBytes);
|
|
307
347
|
const file = await read(stream.trace.path, maxBytes);
|
|
308
348
|
if ("refused" in file)
|
|
309
349
|
throw new StreamTraceRefusedError(stream.id, file.refused);
|
|
@@ -320,7 +360,8 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
|
|
|
320
360
|
* stream without an index is returned as it is (a v1 stream, or one with no actor). An indexed
|
|
321
361
|
* stream's file must have a trace file name (isStreamTracePath); it is read under `maxBytes` and
|
|
322
362
|
* checked against the index's byte count, sha256, item count and screenshot count, and any
|
|
323
|
-
* difference throws StreamTraceRefusedError.
|
|
363
|
+
* difference throws StreamTraceRefusedError. An index that records more than `maxBytes` throws
|
|
364
|
+
* before the file is read. The rules that need the whole bundle (an unindexed v2
|
|
324
365
|
* actor, two participants indexing one file) are loadStreamTraces'.
|
|
325
366
|
*/
|
|
326
367
|
export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_MAX_BYTES) {
|
|
@@ -387,7 +428,9 @@ function bundleRefusal(bundle, stream, owners) {
|
|
|
387
428
|
* `liveAfter` names, for a live file's index, the prefix the caller holds already (the Observer
|
|
388
429
|
* feed's cursor). The loader then reads and joins only the items past it, checked by liveItemsAfter
|
|
389
430
|
* 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.
|
|
431
|
+
* file's lines gets the whole prefix, checked as always. A live file whose index records more than
|
|
432
|
+
* `maxBytes` is not read: for a cursor that fits the index the stream gets no items past it and
|
|
433
|
+
* `live` records `stopped`, and without one the stream is refused.
|
|
391
434
|
*
|
|
392
435
|
* A live file that cannot be read may have been removed by the writer after `bundle` was read: it
|
|
393
436
|
* removes a superseded generation once run.json names the next one, and every live file once the
|
|
@@ -420,20 +463,25 @@ async function rereadBundle(read, runId) {
|
|
|
420
463
|
return undefined;
|
|
421
464
|
}
|
|
422
465
|
}
|
|
423
|
-
|
|
466
|
+
/** bundleRefusal for each stream of `bundle`, with the streams that index each file counted once. */
|
|
467
|
+
function bundleRefusals(bundle) {
|
|
424
468
|
const owners = new Map();
|
|
425
469
|
for (const stream of bundle.streams)
|
|
426
470
|
if (isRecord(stream.trace)) {
|
|
427
471
|
const key = traceFileKey(String(stream.trace.path));
|
|
428
472
|
owners.set(key, [...(owners.get(key) ?? []), stream.id]);
|
|
429
473
|
}
|
|
474
|
+
return (stream) => bundleRefusal(bundle, stream, owners);
|
|
475
|
+
}
|
|
476
|
+
async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
|
|
477
|
+
const refusal = bundleRefusals(bundle);
|
|
430
478
|
const streams = [];
|
|
431
479
|
const refused = [];
|
|
432
480
|
const files = [];
|
|
433
481
|
const live = [];
|
|
434
482
|
for (const stream of bundle.streams) {
|
|
435
483
|
try {
|
|
436
|
-
const reason =
|
|
484
|
+
const reason = refusal(stream);
|
|
437
485
|
if (reason !== undefined)
|
|
438
486
|
throw new StreamTraceRefusedError(stream.id, reason);
|
|
439
487
|
const loaded = await readStreamTrace(read, stream, maxBytes, liveAfter);
|
|
@@ -446,7 +494,7 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
|
|
|
446
494
|
if (!(error instanceof StreamTraceRefusedError))
|
|
447
495
|
throw error;
|
|
448
496
|
refused.push(error);
|
|
449
|
-
streams.push(stream);
|
|
497
|
+
streams.push(unjoined(stream));
|
|
450
498
|
}
|
|
451
499
|
}
|
|
452
500
|
return { bundle: { ...bundle, streams }, refused, files, live };
|
|
@@ -456,23 +504,25 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
|
|
|
456
504
|
* summary, shows none of the items or per-request lists run.json or a file supplied for it (its
|
|
457
505
|
* actor's items and lists, its in-progress items, its screenshot entries), and carries
|
|
458
506
|
* `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.
|
|
507
|
+
* shows the run then never prices or checks rows the loader refused. A live file the loader
|
|
508
|
+
* stopped reading (LiveItemsRead's `stopped`) gives its stream a `live-items.not-read` warn event.
|
|
460
509
|
*/
|
|
461
510
|
export function displayedTraces(loaded) {
|
|
462
511
|
const reasons = new Map(loaded.refused.map((error) => [error.streamId, error.message]));
|
|
463
|
-
|
|
512
|
+
const stopped = (loaded.live ?? []).filter((read) => read.stopped !== undefined);
|
|
513
|
+
if (reasons.size === 0 && stopped.length === 0)
|
|
464
514
|
return loaded.bundle;
|
|
465
515
|
const streams = loaded.bundle.streams.map((stream) => {
|
|
466
516
|
const reason = reasons.get(stream.id);
|
|
467
517
|
if (reason === undefined)
|
|
468
518
|
return stream;
|
|
469
519
|
const { liveActor: _live, trace: _index, actor, ...rest } = stream;
|
|
470
|
-
return {
|
|
520
|
+
return unjoined({
|
|
471
521
|
...rest,
|
|
472
522
|
...(actor === undefined ? {} : { actor: { ...withoutRequestLists(actor), items: [] } }),
|
|
473
523
|
artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
|
|
474
524
|
traceUnavailable: reason,
|
|
475
|
-
};
|
|
525
|
+
});
|
|
476
526
|
});
|
|
477
527
|
const events = loaded.bundle.streams
|
|
478
528
|
.filter((stream) => reasons.has(stream.id))
|
|
@@ -484,7 +534,38 @@ export function displayedTraces(loaded) {
|
|
|
484
534
|
message: reasons.get(stream.id),
|
|
485
535
|
streamId: stream.id,
|
|
486
536
|
}));
|
|
487
|
-
|
|
537
|
+
const notRead = stopped.map((read) => ({
|
|
538
|
+
id: `event-live-items-not-read-${read.streamId}`,
|
|
539
|
+
at: loaded.bundle.streams.find((stream) => stream.id === read.streamId)?.updatedAt ?? "",
|
|
540
|
+
level: "warn",
|
|
541
|
+
type: "live-items.not-read",
|
|
542
|
+
message: read.stopped,
|
|
543
|
+
streamId: read.streamId,
|
|
544
|
+
}));
|
|
545
|
+
return {
|
|
546
|
+
...loaded.bundle,
|
|
547
|
+
streams,
|
|
548
|
+
events: [...loaded.bundle.events, ...events, ...notRead],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* What `reduce` returns for each stream of the bundle, loaded as loadRunTraces loads it, in stream
|
|
553
|
+
* order; the first refusal throws. Streams are loaded one at a time and nothing of a stream's files
|
|
554
|
+
* is kept past its `reduce` call, so a caller that keeps part of each trace holds one trace at a
|
|
555
|
+
* time where loadRunTraces holds them all. Unlike loadStreamTraces it does not read run.json again
|
|
556
|
+
* when a live file cannot be read, so it suits a run whose participants have finished.
|
|
557
|
+
*/
|
|
558
|
+
export async function reduceRunTraces(runPaths, bundle, reduce) {
|
|
559
|
+
const read = containedReader(runPaths);
|
|
560
|
+
const refusal = bundleRefusals(bundle);
|
|
561
|
+
const kept = [];
|
|
562
|
+
for (const [index, stream] of bundle.streams.entries()) {
|
|
563
|
+
const reason = refusal(stream);
|
|
564
|
+
if (reason !== undefined)
|
|
565
|
+
throw new StreamTraceRefusedError(stream.id, reason);
|
|
566
|
+
kept.push(reduce((await readStreamTrace(read, stream, RUN_ARTIFACT_MAX_BYTES)).stream, index));
|
|
567
|
+
}
|
|
568
|
+
return kept;
|
|
488
569
|
}
|
|
489
570
|
/** The bundle with every stream loaded; the first refusal throws. */
|
|
490
571
|
export async function loadRunTraces(source, bundle, maxBytes) {
|
package/dist/run/streams.d.ts
CHANGED
|
@@ -316,4 +316,22 @@ export interface RunStream {
|
|
|
316
316
|
kind: "bundle" | "review" | "observer" | "events" | "screenshot" | "trace" | "log" | "filesystem" | "recording";
|
|
317
317
|
}>;
|
|
318
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* `T` without a participant's per-request lists (REQUEST_LISTS in src/run/stream-trace.ts):
|
|
321
|
+
* `tokenUsage.turns`, `conversation.requests` and `providerRequests`. From run bundle v4 only the
|
|
322
|
+
* trace file holds them, so a reader of run.json or of the Observer's data has none to read.
|
|
323
|
+
*/
|
|
324
|
+
export type WithoutRequestLists<T> = Omit<T, "providerRequests" | "tokenUsage" | "conversation"> & {
|
|
325
|
+
[K in "tokenUsage" & keyof T]?: Omit<NonNullable<T[K]>, "turns"> | undefined;
|
|
326
|
+
} & {
|
|
327
|
+
[K in "conversation" & keyof T]?: Omit<NonNullable<T[K]>, "requests"> | undefined;
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* A stream as run.json holds it, in any version: its actor and live actor WithoutRequestLists.
|
|
331
|
+
* loadStreamTraces joins the lists from the trace file.
|
|
332
|
+
*/
|
|
333
|
+
export type StoredRunStream = Omit<RunStream, "actor" | "liveActor"> & {
|
|
334
|
+
actor?: WithoutRequestLists<ActorTrace>;
|
|
335
|
+
liveActor?: WithoutRequestLists<NonNullable<RunStream["liveActor"]>>;
|
|
336
|
+
};
|
|
319
337
|
export {};
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { DwellWindow } from "../actors/stop-conditions.js";
|
|
2
2
|
import type { ParticipantSchedule } from "./arrivals.js";
|
|
3
|
-
/** One participant as the plan has it: its schedule
|
|
3
|
+
/** One participant as the plan has it: its schedule, its dwell window and its mission. */
|
|
4
4
|
export interface EstimatedParticipant {
|
|
5
5
|
readonly schedule: ParticipantSchedule;
|
|
6
6
|
readonly limits: {
|
|
7
7
|
readonly dwell?: DwellWindow;
|
|
8
8
|
};
|
|
9
|
+
readonly assignment: {
|
|
10
|
+
readonly mission?: string;
|
|
11
|
+
};
|
|
9
12
|
}
|
|
10
13
|
/** A run's estimated bundle, and the scan's limit for its participants. */
|
|
11
14
|
export interface BundleEstimate {
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// How large a computer-use run's bundle grows, estimated from its plan before anything runs, and
|
|
2
|
-
// whether it would pass a limit a later command holds it to: analysis reads run.json
|
|
3
|
-
//
|
|
4
|
-
// RUN_ARTIFACT_MAX_BYTES,
|
|
5
|
-
// files and folders.
|
|
2
|
+
// whether it would pass a limit a later command holds it to: analysis reads run.json up to
|
|
3
|
+
// EVIDENCE_LIMITS.sourceBytes, analysis, verify and the Observer read each participant's trace up to
|
|
4
|
+
// RUN_ARTIFACT_MAX_BYTES, verify reads no file larger than RUN_ARTIFACT_MAX_BYTES, and the
|
|
5
|
+
// public-safety scan visits at most runEntryLimit(participants) files and folders.
|
|
6
6
|
//
|
|
7
7
|
// observer/index.html carries every participant's items gzipped, so with busy participants it
|
|
8
8
|
// passes verify's 32 MiB long before run.json passes analysis's 16 MiB: at these rates, 20
|
|
9
9
|
// participants working 8 hours. From run bundle v4 run.json grows by participant, not by model
|
|
10
|
-
// request: each participant's per-request lists are only in its trace. run.json
|
|
11
|
-
//
|
|
10
|
+
// request: each participant's per-request lists are only in its trace. run.json holds the mission
|
|
11
|
+
// once for the run, once in each participant's stream and once more in the feedback candidate of
|
|
12
|
+
// each participant that reports friction or stops. The mission is not capped, and the estimate
|
|
13
|
+
// counts that last copy for every participant, so it warns at 100 participants with a mission of
|
|
14
|
+
// 75,238 bytes. On 2026-10-13 the longest mission on the live computer-use runs kept on the
|
|
15
|
+
// maintainer's machine was 807 characters.
|
|
12
16
|
// observer-data.json leaves the lists out too and was 0.94 to 1.12 of run.json's size on the live
|
|
13
17
|
// runs measured, so it passes 32 MiB only after run.json passes 16 MiB, and the estimate does not
|
|
14
18
|
// check it.
|
|
@@ -63,8 +67,12 @@ const RATES = Object.freeze({
|
|
|
63
67
|
/** observer/index.html's app before any run data: 1,199,831 bytes on the dry runs. */
|
|
64
68
|
observerAppBytes: 1_200_000,
|
|
65
69
|
/**
|
|
66
|
-
* The page's compact copy of observer-data.json, as a share of run.json: 0.71
|
|
67
|
-
* live runs written as v4, where neither carries the per-request lists.
|
|
70
|
+
* The page's compact copy of observer-data.json, as a share of run.json before the mission: 0.71
|
|
71
|
+
* to 0.84 on the live runs written as v4, where neither carries the per-request lists. The page
|
|
72
|
+
* holds the mission at its full size once for the run and once for each participant: dry runs of
|
|
73
|
+
* 1 and 4 participants grew the page by 2 and 5 copies, as run.json grew, and a 0.128.0 run of 2
|
|
74
|
+
* participants with 2 feedback candidates held 5 copies in run.json and 3 on the page. An
|
|
75
|
+
* analysis adds each participant's assignment, cut to 8,000 bytes.
|
|
68
76
|
*/
|
|
69
77
|
observerPageDataShare: 0.85,
|
|
70
78
|
/**
|
|
@@ -97,11 +105,13 @@ export function estimateBundle(participants, sessionMs) {
|
|
|
97
105
|
let frames = 0;
|
|
98
106
|
let traces = 0;
|
|
99
107
|
let largestTrace = 0;
|
|
108
|
+
let missionBytes = 0;
|
|
109
|
+
let longestMission = 0;
|
|
100
110
|
let longestWindowMs = 0;
|
|
101
111
|
// The longest any participant without an end is counted, for the messages.
|
|
102
112
|
let openMs = Math.min(sessionMs, RATES.openSessionMs);
|
|
103
113
|
let scheduled = 0;
|
|
104
|
-
for (const { schedule, limits } of participants) {
|
|
114
|
+
for (const { schedule, limits, assignment } of participants) {
|
|
105
115
|
const { end } = schedule;
|
|
106
116
|
const budgetMs = end?.windowMs ?? sessionMs;
|
|
107
117
|
let countedMs;
|
|
@@ -122,12 +132,26 @@ export function estimateBundle(participants, sessionMs) {
|
|
|
122
132
|
frames += ownFrames;
|
|
123
133
|
traces += trace;
|
|
124
134
|
largestTrace = Math.max(largestTrace, trace);
|
|
135
|
+
// The mission's bytes as run.json stores them, a JSON string without its quotes.
|
|
136
|
+
const mission = assignment.mission === undefined
|
|
137
|
+
? 0
|
|
138
|
+
: Buffer.byteLength(JSON.stringify(assignment.mission)) - 2;
|
|
139
|
+
missionBytes += mission;
|
|
140
|
+
longestMission = Math.max(longestMission, mission);
|
|
125
141
|
}
|
|
126
142
|
const count = participants.length;
|
|
127
|
-
const
|
|
143
|
+
const dataBytes = RATES.runBytes + count * RATES.participantBytes;
|
|
144
|
+
// run.json holds the mission in the run's scenario goal, in each participant's stream and in the
|
|
145
|
+
// feedback candidate of each participant that reports friction or stops, counted for every
|
|
146
|
+
// participant. The rates were fit on runs with short missions, so a short mission is counted
|
|
147
|
+
// twice.
|
|
148
|
+
const runFileBytes = dataBytes + longestMission + 2 * missionBytes;
|
|
128
149
|
const screenshots = count + turns + frames;
|
|
150
|
+
// The page holds no feedback candidate's copy.
|
|
129
151
|
const observerPageBytes = Math.round(RATES.observerAppBytes +
|
|
130
|
-
RATES.observerPageDataShare *
|
|
152
|
+
RATES.observerPageDataShare * dataBytes +
|
|
153
|
+
longestMission +
|
|
154
|
+
missionBytes +
|
|
131
155
|
RATES.observerPageItemsShare * traces);
|
|
132
156
|
return {
|
|
133
157
|
runFileBytes,
|
|
@@ -158,12 +182,12 @@ export function bundleLimitReason(estimate) {
|
|
|
158
182
|
const passed = [
|
|
159
183
|
...(runFile
|
|
160
184
|
? [
|
|
161
|
-
`This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads.`,
|
|
185
|
+
`This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads. It holds the mission once for the run, once in each participant's stream and once more for each participant that reports friction or stops, and the estimate counts the last copy for every participant.`,
|
|
162
186
|
]
|
|
163
187
|
: []),
|
|
164
|
-
...(estimate.largestTraceBytes >
|
|
188
|
+
...(estimate.largestTraceBytes > RUN_ARTIFACT_MAX_BYTES
|
|
165
189
|
? [
|
|
166
|
-
`One participant's trace may grow to about ${mib(estimate.largestTraceBytes)}, past the ${mib(
|
|
190
|
+
`One participant's trace may grow to about ${mib(estimate.largestTraceBytes)}, past the ${mib(RUN_ARTIFACT_MAX_BYTES)} humanish reads of each trace.`,
|
|
167
191
|
]
|
|
168
192
|
: []),
|
|
169
193
|
...(page
|
|
@@ -195,6 +219,7 @@ export function bundleLimitReason(estimate) {
|
|
|
195
219
|
const changes = [
|
|
196
220
|
...(scheduled > 0 ? ["shorten the longest windows (participants[].endAfterMs)"] : []),
|
|
197
221
|
...(dwell ? ["shorten the dwell window (dwell.ms) or capture less often (dwell.everyMs)"] : []),
|
|
222
|
+
...(runFile ? ["shorten the mission (actor.mission)"] : []),
|
|
198
223
|
];
|
|
199
224
|
// A trace is one participant's, so splitting the study helps only the run's own files.
|
|
200
225
|
if (runFile || page || entries || changes.length === 0)
|
|
@@ -211,7 +236,7 @@ export function bundleLimitReason(estimate) {
|
|
|
211
236
|
}
|
|
212
237
|
/** The estimate beside the limits, for `study check`. */
|
|
213
238
|
export function describeBundle(estimate) {
|
|
214
|
-
return `run.json about ${mib(estimate.runFileBytes)} of the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads; largest trace about ${mib(estimate.largestTraceBytes)} of the ${mib(
|
|
239
|
+
return `run.json about ${mib(estimate.runFileBytes)} of the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads; largest trace about ${mib(estimate.largestTraceBytes)} of the ${mib(RUN_ARTIFACT_MAX_BYTES)} humanish reads of each; Observer page about ${mib(estimate.observerPageBytes)} of the ${mib(RUN_ARTIFACT_MAX_BYTES)} verify reads; about ${count(estimate.entries)} files and folders of the ${count(estimate.entryLimit)} the public-safety scan reads; about ${mib(estimate.totalBytes, 0)} in all. Participants with a declared end are counted for their whole window and the others for up to ${formatDuration(estimate.basis.openMs)}, at a model turn every ${formatDuration(RATES.secondsPerTurn * 1000)}.`;
|
|
215
240
|
}
|
|
216
241
|
/** Bytes in MiB to `digits` places, or two under 0.1 MiB, so a short study's trace does not read 0.0. */
|
|
217
242
|
function mib(bytes, digits = 1) {
|
package/dist/verify/verify.js
CHANGED
|
@@ -80,7 +80,7 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
|
|
|
80
80
|
// Every check below reads the streams with their traces loaded; a trace that does not match its
|
|
81
81
|
// index fails the evidence-artifacts check and its stream is checked without its items.
|
|
82
82
|
const traces = await loadTraces(runPaths, runJsonValue(bundleRead));
|
|
83
|
-
const { bundle, refusedTraces } = traces;
|
|
83
|
+
const { bundle, recorded, refusedTraces } = traces;
|
|
84
84
|
const cleanupRead = await readRunJsonIfExists(runPaths, "cleanup.json");
|
|
85
85
|
const review = {
|
|
86
86
|
json: await readRunJsonIfExists(runPaths, "review.json"),
|
|
@@ -101,20 +101,20 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
|
|
|
101
101
|
unscannedArtifacts.files.push(SANDBOX_RECEIPTS_ARTIFACT);
|
|
102
102
|
}
|
|
103
103
|
const publicSafetyFindings = await scanRunPublicSafetyArtifacts(runPaths, derivedPublicSafetyFindings, {
|
|
104
|
-
recordingPaths: new Set(
|
|
104
|
+
recordingPaths: new Set(bundle
|
|
105
105
|
? bundle.streams.flatMap((stream) => (stream.recording ? [stream.recording.path] : []))
|
|
106
106
|
: []),
|
|
107
|
-
screenshotPaths:
|
|
107
|
+
screenshotPaths: bundle ? streamScreenshotPaths(bundle) : new Set(),
|
|
108
108
|
// A run from before 0.110 with no receipts still names its ids in run.json.
|
|
109
|
-
sandboxIds: [...new Set([...journaledSandboxIds, ...keyedSandboxIds(
|
|
109
|
+
sandboxIds: [...new Set([...journaledSandboxIds, ...keyedSandboxIds(recorded)])],
|
|
110
110
|
sandboxIdFiles,
|
|
111
|
-
}, unscannedArtifacts,
|
|
111
|
+
}, unscannedArtifacts, bundle ? bundle.simulations.length : 0);
|
|
112
112
|
addTraceScan(traces, publicSafetyFindings, unscannedArtifacts);
|
|
113
113
|
// JSON escapes can hide a sensitive value from the byte scan while the
|
|
114
114
|
// decoded recording exposes it to Observer, feedback, or analysis input.
|
|
115
115
|
if (publicSafetyFindings.length < MAX_REPORTED_FINDINGS &&
|
|
116
|
-
|
|
117
|
-
containsSensitive(JSON.stringify(
|
|
116
|
+
recorded !== null &&
|
|
117
|
+
containsSensitive(JSON.stringify(recorded))) {
|
|
118
118
|
publicSafetyFindings.push("sensitive decoded run.json");
|
|
119
119
|
}
|
|
120
120
|
checks.push({
|
|
@@ -124,14 +124,14 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
|
|
|
124
124
|
? "no run text artifact or public-proof path matches a known secret or browser-profile pattern"
|
|
125
125
|
: `public-safety findings: ${publicSafetyFindings.slice(0, 5).join(", ")}`,
|
|
126
126
|
});
|
|
127
|
-
const missingEvidenceArtifacts =
|
|
127
|
+
const missingEvidenceArtifacts = bundle
|
|
128
128
|
? await missingLocalEvidenceArtifacts(runPaths, bundle)
|
|
129
129
|
: [];
|
|
130
|
-
const redactedShapeFrames =
|
|
130
|
+
const redactedShapeFrames = bundle
|
|
131
131
|
? await redactedShapeFramePaths(runPaths, bundle)
|
|
132
132
|
: new Set();
|
|
133
|
-
const invalidEvidenceReferences =
|
|
134
|
-
checks.push(evidenceArtifactsCheck(
|
|
133
|
+
const invalidEvidenceReferences = bundle ? invalidRunEvidenceReferences(bundle) : [];
|
|
134
|
+
checks.push(evidenceArtifactsCheck(bundle !== undefined, [
|
|
135
135
|
...refusedTraces,
|
|
136
136
|
...(invalidEvidenceReferences.length > 0
|
|
137
137
|
? [`invalid evidence artifact references: ${invalidEvidenceReferences.join(", ")}`]
|
|
@@ -148,10 +148,10 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
|
|
|
148
148
|
message: "Derived analysis contains sensitive text or unsafe artifact paths; sharing is blocked, original recording remains independently verifiable.",
|
|
149
149
|
});
|
|
150
150
|
const ok = checks.every((check) => check.ok);
|
|
151
|
-
const notFinished =
|
|
151
|
+
const notFinished = bundle
|
|
152
152
|
? await runNotFinished(runPaths, bundle)
|
|
153
153
|
: { warnings: [], unfinished: undefined };
|
|
154
|
-
const warnings =
|
|
154
|
+
const warnings = bundle
|
|
155
155
|
? [
|
|
156
156
|
...rawScreenshotPostureWarnings(bundle, redactedShapeFrames),
|
|
157
157
|
...undeclaredSubjectStateWarnings(bundle),
|
|
@@ -160,7 +160,7 @@ export async function verifyResolvedRun(cwd, runInput, runPaths) {
|
|
|
160
160
|
...notFinished.warnings,
|
|
161
161
|
]
|
|
162
162
|
: [];
|
|
163
|
-
const shareSafety =
|
|
163
|
+
const shareSafety = bundle
|
|
164
164
|
? buildShareSafety({
|
|
165
165
|
ok,
|
|
166
166
|
bundle,
|
|
@@ -226,12 +226,19 @@ function evidenceArtifactsCheck(shaped, problems) {
|
|
|
226
226
|
/**
|
|
227
227
|
* run.json with each stream's trace loaded, one file at a time, and the refusal of each trace that
|
|
228
228
|
* does not match its index, with the ids of those streams. A run.json that fails the shape guard
|
|
229
|
-
* is
|
|
229
|
+
* gives no bundle. `recorded` is the loaded bundle, or run.json as parsed when it has none.
|
|
230
230
|
*/
|
|
231
231
|
async function loadTraces(runPaths, stored) {
|
|
232
232
|
const scanned = { sensitive: [], opaque: [] };
|
|
233
233
|
if (!isRunBundle(stored))
|
|
234
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
bundle: undefined,
|
|
236
|
+
recorded: stored,
|
|
237
|
+
refusedTraces: [],
|
|
238
|
+
refusedStreams: new Set(),
|
|
239
|
+
...scanned,
|
|
240
|
+
pages: [],
|
|
241
|
+
};
|
|
235
242
|
// Each trace is source evidence: scanned by the bytes it was joined from, apart from the
|
|
236
243
|
// directory walk and any path exemption the walk makes.
|
|
237
244
|
const { bundle, refused } = await loadStreamTraces(runPaths, stored, {
|
|
@@ -243,6 +250,7 @@ async function loadTraces(runPaths, stored) {
|
|
|
243
250
|
});
|
|
244
251
|
return {
|
|
245
252
|
bundle,
|
|
253
|
+
recorded: bundle,
|
|
246
254
|
refusedTraces: refused.map((error) => error.message),
|
|
247
255
|
refusedStreams: new Set(refused.map((error) => error.streamId)),
|
|
248
256
|
...scanned,
|
|
@@ -381,13 +389,12 @@ function findingsCheck(name, findings, okMessage, failLabel) {
|
|
|
381
389
|
*/
|
|
382
390
|
const SHAPE_UNCHECKED = "not checked, because run.json failed the shape check";
|
|
383
391
|
/**
|
|
384
|
-
* The bundle-content checks
|
|
385
|
-
* `refusedStreams` names the streams whose traces
|
|
386
|
-
* check reports.
|
|
392
|
+
* The bundle-content checks of the loaded bundle `valid`, in report order. Without one (run.json
|
|
393
|
+
* failed its shape guard) no check has findings. `refusedStreams` names the streams whose traces
|
|
394
|
+
* the loader refused, which the evidence-artifacts check reports.
|
|
387
395
|
*/
|
|
388
|
-
async function evidenceChecks(runPaths,
|
|
396
|
+
async function evidenceChecks(runPaths, valid, cleanupRead, refusedStreams) {
|
|
389
397
|
const cleanupJson = runJsonValue(cleanupRead);
|
|
390
|
-
const valid = isRunBundle(bundle) ? bundle : null;
|
|
391
398
|
const pass = (message) => (valid ? message : SHAPE_UNCHECKED);
|
|
392
399
|
const checks = [
|
|
393
400
|
findingsCheck("terminal-product evidence", valid ? await validateTerminalProductEvidence(runPaths, valid) : [], pass("live terminal-product evidence is complete (ledgers, redacted output, proven teardown, known spend within the cap), or the run has none"), "terminal-product findings"),
|
|
@@ -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
|
|
@@ -52,9 +52,14 @@ a run id or `latest`: it returns run.json with every trace file and live file jo
|
|
|
52
52
|
as below, for v1, v2, v3 and v4 bundles, with each participant's per-request lists, and throws an Error named `StreamTraceRefusedError` for a
|
|
53
53
|
file that does not match its index. Inside humanish, readers load a stream through
|
|
54
54
|
`loadStreamTrace(runPaths, stream)`. A v1 stream returns as it is.
|
|
55
|
-
An indexed stream's file is read under the 32 MiB per-file limit
|
|
56
|
-
|
|
57
|
-
match the index. A trace file
|
|
55
|
+
An indexed stream's file is read under the 32 MiB per-file limit, by analysis as by every other
|
|
56
|
+
reader, and refused unless its size, sha256, item count and screenshot count
|
|
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
|
|
@@ -183,7 +183,19 @@ stopped, an operator can remove the empty lock directory and retry.
|
|
|
183
183
|
|
|
184
184
|
Each request's packet admits up to 16 participants, 800 evidence items, 40 PNG
|
|
185
185
|
captures, 160 KiB of text and 20 MiB of images. Individual source files, image
|
|
186
|
-
dimensions and result sizes have separate limits.
|
|
186
|
+
dimensions and result sizes have separate limits. Analysis reads run.json up to
|
|
187
|
+
16 MiB and each participant's trace up to 32 MiB, as verify and the Observer do.
|
|
188
|
+
It reads the traces one at a time and keeps of each only what selection can
|
|
189
|
+
reach: the participant's share of its request's 800 evidence items, worked out
|
|
190
|
+
from run.json before any trace is read, and the first 80 captures selection may
|
|
191
|
+
read. Beside the one trace it is reading, analysis holds at most 800 entries for
|
|
192
|
+
each request and 80 for each participant; their size grows with each trace
|
|
193
|
+
item's text. A run.json past 16 MiB is refused as `ANALYSIS_SOURCE_TOO_LARGE`, and
|
|
194
|
+
a run.json or trace without the fields analysis reads as `ANALYSIS_SOURCE_INVALID`;
|
|
195
|
+
each message names the file, says why, and says no request was sent. Analysis
|
|
196
|
+
verifies the run before it reads a trace, so a trace that does not match its
|
|
197
|
+
index in run.json, or whose index records more than 32 MiB, fails verify and
|
|
198
|
+
`humanish analyze` reports `ANALYSIS_VERIFY_FAILED`. Count and text budgets are
|
|
187
199
|
distributed across included participants, with unused capacity from short
|
|
188
200
|
sessions available to longer ones. Reclaimed capture slots go first to eligible
|
|
189
201
|
participants with fewer admitted images, including those deferred by their
|