humanish 0.135.0 → 0.136.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 +15 -0
- package/dist/actors/computer-use/loop/session.js +7 -2
- package/dist/actors/scripted-browser/actor.js +3 -3
- package/dist/actors/scripted-browser/steps.d.ts +9 -2
- package/dist/actors/scripted-browser/steps.js +17 -12
- package/dist/analysis/evidence-sources.js +4 -7
- package/dist/analysis/evidence.js +4 -4
- package/dist/analysis/source.d.ts +7 -4
- package/dist/analysis/source.js +22 -4
- package/dist/cli/commands/setup.js +7 -10
- package/dist/evidence/image.d.ts +2 -2
- package/dist/evidence/image.js +47 -32
- package/dist/observer/data.d.ts +7 -3
- package/dist/observer/render.js +2 -4
- package/dist/routes/computer-use/participant-execution.js +1 -1
- package/dist/run/bundle.d.ts +5 -1
- package/dist/run/locate.d.ts +13 -11
- package/dist/run/locate.js +20 -13
- package/dist/run/paths.js +38 -28
- package/dist/run/stream-trace.d.ts +2 -2
- package/dist/run/stream-trace.js +24 -11
- package/dist/run/streams.d.ts +18 -3
- package/dist/study/bundle-estimate.d.ts +21 -1
- package/dist/study/bundle-estimate.js +55 -29
- package/dist/tui-app.js +23 -23
- package/dist/verify/screenshot-reads.js +1 -1
- package/docs/architecture/state-driven-executor.md +4 -3
- package/docs/contracts/study-analysis.md +3 -2
- package/package.json +1 -1
package/dist/run/locate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveExistingRunDirectory, resolveLatestRunDirectory, RUNS_RELATIVE_ROOT, validatePreparedRunArtifactPaths, } from "./paths.js";
|
|
3
3
|
import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
|
|
4
|
-
import { RUN_BUNDLE_FILE } from "./bundle.js";
|
|
4
|
+
import { RUN_BUNDLE_FILE, } from "./bundle.js";
|
|
5
5
|
import { isRunBundle, isRunPointer } from "./bundle-shape.js";
|
|
6
6
|
import { displayedTraces, loadRunTraces, loadStreamTraces } from "./stream-trace.js";
|
|
7
7
|
import { readUnchangedFile } from "./evidence-files.js";
|
|
@@ -119,29 +119,36 @@ export async function loadRunBundle(cwdInput, runInput) {
|
|
|
119
119
|
}
|
|
120
120
|
return loadRunBundlePrepared(cwd, runPaths);
|
|
121
121
|
}
|
|
122
|
+
/** loadRunBundle for a caller that already holds prepared paths. Revalidates them first. */
|
|
123
|
+
export async function loadRunBundlePrepared(cwdInput, runPaths) {
|
|
124
|
+
const run = await readPreparedRunJson(cwdInput, runPaths);
|
|
125
|
+
// A trace that does not match its index throws StreamTraceRefusedError, naming the participant.
|
|
126
|
+
return run === null ? null : { ...run, bundle: await loadRunTraces(runPaths, run.bundle) };
|
|
127
|
+
}
|
|
122
128
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* kept as displayedTraces shows it, for a page that shows the run.
|
|
129
|
+
* loadRunBundlePrepared for a page that shows the run: a participant whose trace the loader
|
|
130
|
+
* refuses, such as one past the read limit, is kept as displayedTraces shows it.
|
|
126
131
|
*/
|
|
127
|
-
export async function
|
|
132
|
+
export async function loadDisplayedRunBundle(cwdInput, runPaths) {
|
|
133
|
+
const run = await readPreparedRunJson(cwdInput, runPaths);
|
|
134
|
+
if (run === null)
|
|
135
|
+
return null;
|
|
136
|
+
return { ...run, bundle: displayedTraces(await loadStreamTraces(runPaths, run.bundle)) };
|
|
137
|
+
}
|
|
138
|
+
/** The prepared run's run.json when it is a run bundle, after revalidating the paths; else null. */
|
|
139
|
+
async function readPreparedRunJson(cwdInput, runPaths) {
|
|
128
140
|
const cwd = path.resolve(cwdInput);
|
|
129
141
|
await validatePreparedRunArtifactPaths(runPaths);
|
|
130
|
-
const bundlePath = path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE);
|
|
131
142
|
const read = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
|
|
132
143
|
// A run.json it refuses is no reason to call the run absent: the caller reports why.
|
|
133
144
|
if (read.status === "refused")
|
|
134
145
|
throw new ContainedReadRefusedError(RUN_BUNDLE_FILE, read);
|
|
135
146
|
const bundle = read.status === "read" ? read.value : null;
|
|
136
|
-
if (!isRunBundle(bundle))
|
|
147
|
+
if (!isRunBundle(bundle))
|
|
137
148
|
return null;
|
|
138
|
-
}
|
|
139
149
|
return {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
? displayedTraces(await loadStreamTraces(runPaths, bundle))
|
|
143
|
-
: await loadRunTraces(runPaths, bundle),
|
|
144
|
-
bundlePath: path.relative(cwd, bundlePath),
|
|
150
|
+
bundle,
|
|
151
|
+
bundlePath: path.relative(cwd, path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE)),
|
|
145
152
|
runDir: runPaths.absoluteRunRoot,
|
|
146
153
|
};
|
|
147
154
|
}
|
package/dist/run/paths.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lstat, mkdir, readdir, realpath, rmdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { mapWithConcurrency } from "./concurrency.js";
|
|
3
4
|
import { isNodeError } from "./type-guards.js";
|
|
4
5
|
export const RUNS_RELATIVE_ROOT = path.join(".humanish", "runs");
|
|
5
6
|
const LATEST_RUN_RELATIVE_PATH = path.join(RUNS_RELATIVE_ROOT, "latest.json");
|
|
@@ -262,35 +263,44 @@ export async function assertRegularFileOrMissing(filePath, subject = "humanish s
|
|
|
262
263
|
throw error;
|
|
263
264
|
}
|
|
264
265
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
266
|
+
// Entries the storage check lists or lstats at once. A run folder holds one entry per screenshot,
|
|
267
|
+
// about 70,000 for 24 participants working 8 hours, and one call at a time left the process idle
|
|
268
|
+
// between calls.
|
|
269
|
+
const STORAGE_CHECK_CONCURRENCY = 32;
|
|
270
|
+
/** Every entry under `root` is a directory or a single-link regular file, checked a level at a time. */
|
|
271
|
+
async function assertNoSymlinkDescendants(root) {
|
|
272
|
+
let directories = [root];
|
|
273
|
+
while (directories.length > 0) {
|
|
274
|
+
const listed = await mapWithConcurrency(directories, STORAGE_CHECK_CONCURRENCY, async (dir) => (await readdir(dir, { withFileTypes: true })).map((entry) => path.join(dir, entry.name)));
|
|
275
|
+
const children = listed.flat();
|
|
276
|
+
const kinds = await mapWithConcurrency(children, STORAGE_CHECK_CONCURRENCY, storageEntryKind);
|
|
277
|
+
directories = children.filter((_, index) => kinds[index] === "directory");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function storageEntryKind(child) {
|
|
281
|
+
// A run directory can be written concurrently with this walk: the atomic writer creates a
|
|
282
|
+
// `.humanish-write-*.tmp` sibling and renames it, and a run also refreshes its own status record
|
|
283
|
+
// on a cadence. An entry that vanished between readdir and lstat therefore proves nothing except
|
|
284
|
+
// that it is gone, and a path that no longer exists cannot be a symlink escape: skip it rather
|
|
285
|
+
// than failing the containment check on a benign race.
|
|
286
|
+
let stats;
|
|
287
|
+
try {
|
|
288
|
+
stats = await lstat(child);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
292
|
+
return "gone";
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
if (stats.isSymbolicLink()) {
|
|
296
|
+
throw new Error("humanish run directories must not contain symbolic links.");
|
|
297
|
+
}
|
|
298
|
+
if (stats.isDirectory())
|
|
299
|
+
return "directory";
|
|
300
|
+
if (!stats.isFile() || stats.nlink > 1) {
|
|
301
|
+
throw new Error("humanish run leaves must be single-link regular files, not hardlinks or special files.");
|
|
293
302
|
}
|
|
303
|
+
return "file";
|
|
294
304
|
}
|
|
295
305
|
async function capturePreparedRunArtifactPaths(paths, runRoot) {
|
|
296
306
|
const physicalRunRoot = await realpath(runRoot);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RunBundle, type StoredRunBundle } from "./bundle.js";
|
|
1
|
+
import { type DisplayedRunBundle, type RunBundle, type StoredRunBundle } 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, StoredRunStream, WithoutRequestLists } from "./streams.js";
|
|
@@ -131,7 +131,7 @@ export declare function displayedTraces(loaded: {
|
|
|
131
131
|
bundle: RunBundle;
|
|
132
132
|
refused: readonly StreamTraceRefusedError[];
|
|
133
133
|
live?: readonly LiveItemsRead[];
|
|
134
|
-
}):
|
|
134
|
+
}): DisplayedRunBundle;
|
|
135
135
|
/**
|
|
136
136
|
* What `reduce` returns for each stream of the bundle, loaded as loadRunTraces loads it, in stream
|
|
137
137
|
* order; the first refusal throws. Streams are loaded one at a time and nothing of a stream's files
|
package/dist/run/stream-trace.js
CHANGED
|
@@ -178,7 +178,7 @@ function containedReader(runPaths) {
|
|
|
178
178
|
}
|
|
179
179
|
/**
|
|
180
180
|
* The stream from the bytes of its trace file: its actor with the file's items, and its artifacts
|
|
181
|
-
* followed by the file's screenshot entries, without the index.
|
|
181
|
+
* followed by the file's screenshot entries, without the index. Its live actor is as it was.
|
|
182
182
|
*/
|
|
183
183
|
function joinTrace(stream, bytes) {
|
|
184
184
|
const index = stream.trace;
|
|
@@ -246,12 +246,19 @@ function assertIndex(stream) {
|
|
|
246
246
|
throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
|
|
247
247
|
}
|
|
248
248
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* the
|
|
249
|
+
* The joined stream as a loaded one, once its actor's conversation, if any, holds the requests
|
|
250
|
+
* list ActorTrace types as present. The writer writes the list with every conversation, in
|
|
251
|
+
* run.json before v4 and in the trace file from v4, so a stream without it was edited, and a reader
|
|
252
|
+
* that counts the requests would throw. Items a v1 run.json or a humanish.live-actor.v1 live actor
|
|
253
|
+
* holds inline are taken as they are.
|
|
253
254
|
*/
|
|
254
|
-
|
|
255
|
+
function asLoaded(stream) {
|
|
256
|
+
const conversation = stream.actor?.conversation;
|
|
257
|
+
if (conversation !== undefined &&
|
|
258
|
+
!(isRecord(conversation) && Array.isArray(conversation.requests)))
|
|
259
|
+
throw new StreamTraceRefusedError(stream.id, "its actor records a conversation without the requests list");
|
|
260
|
+
return stream;
|
|
261
|
+
}
|
|
255
262
|
/** A live actor whose items run.json holds inline: humanish.live-actor.v1, or one with no schema. */
|
|
256
263
|
const isInlineLiveActor = (live) => live.schema === LIVE_ACTOR_V1_SCHEMA || live.schema === undefined;
|
|
257
264
|
/** A live file that could not be read, which a writer that moved on may have removed. */
|
|
@@ -345,7 +352,7 @@ async function readLiveTail(read, stream, index, after) {
|
|
|
345
352
|
*/
|
|
346
353
|
async function readStreamTrace(read, stream, maxBytes, liveAfter) {
|
|
347
354
|
const files = [];
|
|
348
|
-
let joined =
|
|
355
|
+
let joined = stream;
|
|
349
356
|
if (stream.trace !== undefined) {
|
|
350
357
|
assertIndex(stream);
|
|
351
358
|
if (stream.trace.bytes > maxBytes)
|
|
@@ -359,7 +366,11 @@ async function readStreamTrace(read, stream, maxBytes, liveAfter) {
|
|
|
359
366
|
const live = await readLiveItems(read, joined, maxBytes, liveAfter);
|
|
360
367
|
if (live.file !== undefined)
|
|
361
368
|
files.push(live.file);
|
|
362
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
stream: asLoaded(live.stream),
|
|
371
|
+
files,
|
|
372
|
+
...(live.read === undefined ? {} : { live: live.read }),
|
|
373
|
+
};
|
|
363
374
|
}
|
|
364
375
|
/**
|
|
365
376
|
* The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
|
|
@@ -500,7 +511,9 @@ async function loadEveryStream(read, bundle, maxBytes, liveAfter) {
|
|
|
500
511
|
if (!(error instanceof StreamTraceRefusedError))
|
|
501
512
|
throw error;
|
|
502
513
|
refused.push(error);
|
|
503
|
-
|
|
514
|
+
// Kept as run.json holds it, typed as loaded: from v2 its actor has no items, and from v4
|
|
515
|
+
// none of the lists. The loader's `refused` names it, and displayedTraces shows it so.
|
|
516
|
+
streams.push(stream);
|
|
504
517
|
}
|
|
505
518
|
}
|
|
506
519
|
return { bundle: { ...bundle, streams }, refused, files, live };
|
|
@@ -523,12 +536,12 @@ export function displayedTraces(loaded) {
|
|
|
523
536
|
if (reason === undefined)
|
|
524
537
|
return stream;
|
|
525
538
|
const { liveActor: _live, trace: _index, actor, ...rest } = stream;
|
|
526
|
-
return
|
|
539
|
+
return {
|
|
527
540
|
...rest,
|
|
528
541
|
...(actor === undefined ? {} : { actor: { ...withoutRequestLists(actor), items: [] } }),
|
|
529
542
|
artifacts: stream.artifacts.filter((artifact) => !isScreenshot(artifact)),
|
|
530
543
|
traceUnavailable: reason,
|
|
531
|
-
}
|
|
544
|
+
};
|
|
532
545
|
});
|
|
533
546
|
const events = loaded.bundle.streams
|
|
534
547
|
.filter((stream) => reasons.has(stream.id))
|
package/dist/run/streams.d.ts
CHANGED
|
@@ -327,11 +327,26 @@ export type WithoutRequestLists<T> = Omit<T, "providerRequests" | "tokenUsage" |
|
|
|
327
327
|
[K in "conversation" & keyof T]?: Omit<NonNullable<T[K]>, "requests"> | undefined;
|
|
328
328
|
};
|
|
329
329
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
330
|
+
* `T` WithoutRequestLists and with `items` optional: a v1 run.json holds the items inline, and from
|
|
331
|
+
* v2 (an actor) or v3 (a live actor) a file holds them.
|
|
332
|
+
*/
|
|
333
|
+
type StoredTrace<T extends {
|
|
334
|
+
items: unknown;
|
|
335
|
+
}> = WithoutRequestLists<Omit<T, "items">> & Partial<Pick<T, "items">>;
|
|
336
|
+
/**
|
|
337
|
+
* A stream as run.json holds it, in any version: its actor and live actor without the per-request
|
|
338
|
+
* lists, and with items only in a v1 run.json. loadStreamTraces joins both from the files.
|
|
332
339
|
*/
|
|
333
340
|
export type StoredRunStream = Omit<RunStream, "actor" | "liveActor"> & {
|
|
341
|
+
actor?: StoredTrace<ActorTrace>;
|
|
342
|
+
liveActor?: StoredTrace<NonNullable<RunStream["liveActor"]>>;
|
|
343
|
+
};
|
|
344
|
+
/**
|
|
345
|
+
* A stream as a reader that shows the run has it (displayedTraces in src/run/stream-trace.ts):
|
|
346
|
+
* loaded, or with `traceUnavailable` when the loader refused its trace. A refused stream's actor
|
|
347
|
+
* keeps run.json's summary, which from v4 has none of the per-request lists, so the type has none.
|
|
348
|
+
*/
|
|
349
|
+
export type DisplayedRunStream = Omit<RunStream, "actor"> & {
|
|
334
350
|
actor?: WithoutRequestLists<ActorTrace>;
|
|
335
|
-
liveActor?: WithoutRequestLists<NonNullable<RunStream["liveActor"]>>;
|
|
336
351
|
};
|
|
337
352
|
export {};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { DwellWindow } from "../actors/stop-conditions.js";
|
|
2
2
|
import type { ParticipantSchedule } from "./arrivals.js";
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* One participant as the plan has it: its schedule, its dwell window, its mission and instruction,
|
|
5
|
+
* and its task goals.
|
|
6
|
+
*/
|
|
4
7
|
export interface EstimatedParticipant {
|
|
5
8
|
readonly schedule: ParticipantSchedule;
|
|
6
9
|
readonly limits: {
|
|
@@ -8,8 +11,22 @@ export interface EstimatedParticipant {
|
|
|
8
11
|
};
|
|
9
12
|
readonly assignment: {
|
|
10
13
|
readonly mission?: string;
|
|
14
|
+
readonly focus?: string;
|
|
11
15
|
};
|
|
16
|
+
readonly tasks?: readonly {
|
|
17
|
+
readonly goal: string;
|
|
18
|
+
}[];
|
|
12
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The study fields run.json copies into each participant's stream, and the advice to shorten each.
|
|
22
|
+
* The plan does not keep which form declared the participants, so the advice names both paths.
|
|
23
|
+
*/
|
|
24
|
+
declare const COPIED_FIELDS: Readonly<{
|
|
25
|
+
mission: "shorten the mission (actor.mission)";
|
|
26
|
+
focus: "shorten the participants' instructions (participants.instruction or participants[].instruction)";
|
|
27
|
+
tasks: "shorten the task goals (actor.tasks[].goal)";
|
|
28
|
+
}>;
|
|
29
|
+
type CopiedField = keyof typeof COPIED_FIELDS;
|
|
13
30
|
/** A run's estimated bundle, and the scan's limit for its participants. */
|
|
14
31
|
export interface BundleEstimate {
|
|
15
32
|
/** run.json, which analysis reads whole. */
|
|
@@ -34,6 +51,8 @@ export interface BundleEstimate {
|
|
|
34
51
|
readonly open: number;
|
|
35
52
|
readonly openMs: number;
|
|
36
53
|
readonly dwell: boolean;
|
|
54
|
+
/** The copied field with the most bytes over all participants, when one has any. */
|
|
55
|
+
readonly largestField?: CopiedField;
|
|
37
56
|
};
|
|
38
57
|
}
|
|
39
58
|
/**
|
|
@@ -51,3 +70,4 @@ export declare function estimateBundle(participants: readonly EstimatedParticipa
|
|
|
51
70
|
export declare function bundleLimitReason(estimate: BundleEstimate): string | undefined;
|
|
52
71
|
/** The estimate beside the limits, for `study check`. */
|
|
53
72
|
export declare function describeBundle(estimate: BundleEstimate): string;
|
|
73
|
+
export {};
|
|
@@ -7,12 +7,16 @@
|
|
|
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 holds
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// request: each participant's per-request lists are only in its trace. run.json holds each
|
|
11
|
+
// participant's text, its mission, its instruction and its task goals, in its stream and again in
|
|
12
|
+
// the feedback candidate of a participant that reports friction or stops, and the first
|
|
13
|
+
// participant's once more in the scenario goal. A synthetic live run of 3 participants that all
|
|
14
|
+
// reported friction held their instructions 3, 2 and 2 times, and the mission and each of 2 task
|
|
15
|
+
// goals 7 times. None of the three fields is capped, and the estimate counts the feedback
|
|
16
|
+
// candidate's copy for every participant, so it warns at 100 participants with 75,238 bytes of
|
|
17
|
+
// text each. On 2026-10-13 the longest mission on the live computer-use runs kept on the
|
|
18
|
+
// maintainer's machine was 807 characters. On 2026-10-14 the longest instruction on the 170 live
|
|
19
|
+
// runs kept there was 4,061 characters, and none declared task goals.
|
|
16
20
|
// observer-data.json leaves the lists out too and was 0.94 to 1.12 of run.json's size on the live
|
|
17
21
|
// runs measured, so it passes 32 MiB only after run.json passes 16 MiB, and the estimate does not
|
|
18
22
|
// check it.
|
|
@@ -83,10 +87,11 @@ const RATES = Object.freeze({
|
|
|
83
87
|
/**
|
|
84
88
|
* The page's compact copy of observer-data.json, as a share of run.json before the mission: 0.71
|
|
85
89
|
* to 0.84 on the live runs written as v4, where neither carries the per-request lists. The page
|
|
86
|
-
* holds
|
|
87
|
-
* 1 and 4 participants grew the page by 2 and 5 copies
|
|
88
|
-
* participants with 2 feedback candidates held 5 copies in
|
|
89
|
-
*
|
|
90
|
+
* holds each participant's text at its full size once, and the first participant's once more for
|
|
91
|
+
* the run: dry runs of 1 and 4 participants grew the page by 2 and 5 copies of the mission, as
|
|
92
|
+
* run.json grew, and a 0.128.0 run of 2 participants with 2 feedback candidates held 5 copies in
|
|
93
|
+
* run.json and 3 on the page. The synthetic run of 3 held their instructions 2, 1 and 1 times on
|
|
94
|
+
* the page. An analysis adds each participant's assignment, cut to 8,000 bytes.
|
|
90
95
|
*/
|
|
91
96
|
observerPageDataShare: 0.85,
|
|
92
97
|
/**
|
|
@@ -107,6 +112,15 @@ const RATES = Object.freeze({
|
|
|
107
112
|
participantEntries: 3,
|
|
108
113
|
});
|
|
109
114
|
const MIB = 1024 * 1024;
|
|
115
|
+
/**
|
|
116
|
+
* The study fields run.json copies into each participant's stream, and the advice to shorten each.
|
|
117
|
+
* The plan does not keep which form declared the participants, so the advice names both paths.
|
|
118
|
+
*/
|
|
119
|
+
const COPIED_FIELDS = Object.freeze({
|
|
120
|
+
mission: "shorten the mission (actor.mission)",
|
|
121
|
+
focus: "shorten the participants' instructions (participants.instruction or participants[].instruction)",
|
|
122
|
+
tasks: "shorten the task goals (actor.tasks[].goal)",
|
|
123
|
+
});
|
|
110
124
|
/**
|
|
111
125
|
* The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
|
|
112
126
|
* declares an end or a cap. A participant with an end works its whole window; one without ends
|
|
@@ -119,13 +133,14 @@ export function estimateBundle(participants, sessionMs) {
|
|
|
119
133
|
let frames = 0;
|
|
120
134
|
let traces = 0;
|
|
121
135
|
let largestTrace = 0;
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
// The bytes of each copied field over all participants, and the longest participant's text.
|
|
137
|
+
const fieldBytes = { mission: 0, focus: 0, tasks: 0 };
|
|
138
|
+
let longestText = 0;
|
|
124
139
|
let longestWindowMs = 0;
|
|
125
140
|
// The longest any participant without an end is counted, for the messages.
|
|
126
141
|
let openMs = Math.min(sessionMs, RATES.openSessionMs);
|
|
127
142
|
let scheduled = 0;
|
|
128
|
-
for (const { schedule, limits, assignment } of participants) {
|
|
143
|
+
for (const { schedule, limits, assignment, tasks = [] } of participants) {
|
|
129
144
|
const { end } = schedule;
|
|
130
145
|
const budgetMs = end?.windowMs ?? sessionMs;
|
|
131
146
|
let countedMs;
|
|
@@ -146,26 +161,32 @@ export function estimateBundle(participants, sessionMs) {
|
|
|
146
161
|
frames += ownFrames;
|
|
147
162
|
traces += trace;
|
|
148
163
|
largestTrace = Math.max(largestTrace, trace);
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
const mission = storedBytes(assignment.mission);
|
|
165
|
+
const focus = storedBytes(assignment.focus);
|
|
166
|
+
const goals = tasks.reduce((sum, task) => sum + storedBytes(task.goal), 0);
|
|
167
|
+
fieldBytes.mission += mission;
|
|
168
|
+
fieldBytes.focus += focus;
|
|
169
|
+
fieldBytes.tasks += goals;
|
|
170
|
+
longestText = Math.max(longestText, mission + focus + goals);
|
|
155
171
|
}
|
|
172
|
+
const textBytes = fieldBytes.mission + fieldBytes.focus + fieldBytes.tasks;
|
|
173
|
+
// Sorting is stable, so of fields with equal bytes the first declared is named.
|
|
174
|
+
const [largestField] = Object.keys(fieldBytes)
|
|
175
|
+
.filter((field) => fieldBytes[field] > 0)
|
|
176
|
+
.sort((a, b) => fieldBytes[b] - fieldBytes[a]);
|
|
156
177
|
const count = participants.length;
|
|
157
178
|
const dataBytes = RATES.runBytes + count * RATES.participantBytes;
|
|
158
|
-
// run.json holds
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// twice.
|
|
162
|
-
const runFileBytes = dataBytes +
|
|
179
|
+
// run.json holds each participant's text in its stream and in its feedback candidate if it
|
|
180
|
+
// reports friction or stops, counted for every participant, and the first participant's in the
|
|
181
|
+
// run's scenario goal, counted at the longest. The rates were fit on runs with short texts, so a
|
|
182
|
+
// short text is counted twice.
|
|
183
|
+
const runFileBytes = dataBytes + longestText + 2 * textBytes;
|
|
163
184
|
const screenshots = count + turns + frames;
|
|
164
185
|
// The page holds no feedback candidate's copy.
|
|
165
186
|
const observerPageBytes = Math.round(RATES.observerAppBytes +
|
|
166
187
|
RATES.observerPageDataShare * dataBytes +
|
|
167
|
-
|
|
168
|
-
|
|
188
|
+
longestText +
|
|
189
|
+
textBytes +
|
|
169
190
|
RATES.observerPageItemsShare * traces);
|
|
170
191
|
return {
|
|
171
192
|
runFileBytes,
|
|
@@ -182,9 +203,14 @@ export function estimateBundle(participants, sessionMs) {
|
|
|
182
203
|
open: count - scheduled,
|
|
183
204
|
openMs,
|
|
184
205
|
dwell: frames > 0,
|
|
206
|
+
...(largestField === undefined ? {} : { largestField }),
|
|
185
207
|
},
|
|
186
208
|
};
|
|
187
209
|
}
|
|
210
|
+
/** A text's bytes as run.json stores it, a JSON string without its quotes. */
|
|
211
|
+
function storedBytes(text) {
|
|
212
|
+
return text === undefined ? 0 : Buffer.byteLength(JSON.stringify(text)) - 2;
|
|
213
|
+
}
|
|
188
214
|
/**
|
|
189
215
|
* The warning for a run whose estimate passes a limit, or undefined: each limit it passes, what the
|
|
190
216
|
* estimate counted, and what to change.
|
|
@@ -196,7 +222,7 @@ export function bundleLimitReason(estimate) {
|
|
|
196
222
|
const passed = [
|
|
197
223
|
...(runFile
|
|
198
224
|
? [
|
|
199
|
-
`This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads. It holds the mission
|
|
225
|
+
`This study's run.json may grow to about ${mib(estimate.runFileBytes)}, past the ${mib(EVIDENCE_LIMITS.sourceBytes)} analysis reads. It holds the mission, the task goals and each participant's instruction in that participant's stream, and again in the feedback candidate of each participant that reports friction or stops. It holds the first participant's mission, instruction and task goals once more for the run, and the estimate counts a feedback candidate for every participant.`,
|
|
200
226
|
]
|
|
201
227
|
: []),
|
|
202
228
|
...(estimate.largestTraceBytes > RUN_ARTIFACT_MAX_BYTES
|
|
@@ -217,7 +243,7 @@ export function bundleLimitReason(estimate) {
|
|
|
217
243
|
];
|
|
218
244
|
if (passed.length === 0)
|
|
219
245
|
return undefined;
|
|
220
|
-
const { scheduled, longestWindowMs, open, openMs, dwell } = estimate.basis;
|
|
246
|
+
const { scheduled, longestWindowMs, open, openMs, dwell, largestField } = estimate.basis;
|
|
221
247
|
const counted = [
|
|
222
248
|
...(scheduled > 0
|
|
223
249
|
? [
|
|
@@ -233,7 +259,7 @@ export function bundleLimitReason(estimate) {
|
|
|
233
259
|
const changes = [
|
|
234
260
|
...(scheduled > 0 ? ["shorten the longest windows (participants[].endAfterMs)"] : []),
|
|
235
261
|
...(dwell ? ["shorten the dwell window (dwell.ms) or capture less often (dwell.everyMs)"] : []),
|
|
236
|
-
...(runFile
|
|
262
|
+
...(runFile && largestField !== undefined ? [COPIED_FIELDS[largestField]] : []),
|
|
237
263
|
];
|
|
238
264
|
// A trace is one participant's, so splitting the study helps only the run's own files.
|
|
239
265
|
if (runFile || page || entries || changes.length === 0)
|