humanish 0.124.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +2 -2
  4. package/dist/analysis/evidence.js +2 -2
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +1 -0
  7. package/dist/observer/pinned-files.d.ts +3 -1
  8. package/dist/observer/pinned-files.js +10 -7
  9. package/dist/observer/run-routes.js +2 -1
  10. package/dist/routes/computer-use/live-flush.d.ts +9 -4
  11. package/dist/routes/computer-use/live-flush.js +16 -3
  12. package/dist/run/bundle-shape.js +2 -2
  13. package/dist/run/bundle.d.ts +10 -3
  14. package/dist/run/bundle.js +11 -2
  15. package/dist/run/contained-output.d.ts +22 -0
  16. package/dist/run/contained-output.js +97 -1
  17. package/dist/run/evidence-files.d.ts +12 -1
  18. package/dist/run/evidence-files.js +70 -40
  19. package/dist/run/live-items.d.ts +48 -0
  20. package/dist/run/live-items.js +242 -0
  21. package/dist/run/locate.d.ts +10 -0
  22. package/dist/run/locate.js +10 -0
  23. package/dist/run/run.d.ts +3 -2
  24. package/dist/run/run.js +29 -8
  25. package/dist/run/sandbox-ids.d.ts +0 -5
  26. package/dist/run/sandbox-ids.js +0 -7
  27. package/dist/run/stream-trace.d.ts +18 -9
  28. package/dist/run/stream-trace.js +113 -22
  29. package/dist/run/streams.d.ts +17 -2
  30. package/dist/study/parse/actors.js +23 -6
  31. package/dist/study/parse/execution.js +6 -0
  32. package/dist/study/plan-base.js +4 -1
  33. package/dist/verify/artifacts.js +39 -51
  34. package/dist/verify/screenshot-reads.d.ts +21 -0
  35. package/dist/verify/screenshot-reads.js +46 -0
  36. package/dist/verify/verify.js +7 -6
  37. package/docs/architecture/github-feedback-loop.md +1 -1
  38. package/docs/contracts/README.md +1 -1
  39. package/docs/contracts/run-bundle.md +75 -13
  40. package/docs/contracts/schemas.md +38 -37
  41. package/package.json +3 -1
@@ -0,0 +1,48 @@
1
+ import type { ActorTraceItem } from "../actors/contract.js";
2
+ import type { RunEvent } from "./bundle.js";
3
+ import { type PreparedOutputRoot } from "./contained-output.js";
4
+ import type { LiveItemsIndex, RunStream } from "./streams.js";
5
+ /** The live actor of a v3 run.json, whose items are in the file `itemsFile` names. */
6
+ export declare const LIVE_ACTOR_SCHEMA = "humanish.live-actor.v2";
7
+ /** The live actor of v1 and v2 run.json, with its items inline. */
8
+ export declare const LIVE_ACTOR_V1_SCHEMA = "humanish.live-actor.v1";
9
+ /** Whether `value` is a live items index naming a live file name and its own generation. */
10
+ export declare function isLiveItemsIndex(value: unknown): value is LiveItemsIndex;
11
+ /**
12
+ * The items the indexed prefix of a live file holds, or why the bytes do not match the index:
13
+ * `bytes` must be exactly the indexed byte count, hash to the indexed sha256, and hold the indexed
14
+ * number of lines, each a JSON object.
15
+ */
16
+ export declare function liveItemsFromBytes(bytes: Buffer, index: LiveItemsIndex): {
17
+ items: ActorTraceItem[];
18
+ } | {
19
+ refused: string;
20
+ };
21
+ /** One run's live files: what each stream position's current generation holds. */
22
+ export interface LiveItemsWriter {
23
+ /**
24
+ * `streams` with each running participant's items in its live file: every `liveActor` that
25
+ * carries `items` becomes humanish.live-actor.v2 with `itemsFile` in their place. A `liveActor`
26
+ * that already carries `itemsFile` (a bundle built from a run.json this writer wrote) stands for
27
+ * the list this writer last wrote for its position, and gets the same checks; any other index
28
+ * (another position's file, an earlier generation, a file this writer did not write) rejects the
29
+ * write, since the writer can vouch for and scan only its own files. Items past what the participant's current
30
+ * generation holds are appended, each through publicSandboxView with `sandboxIds`; a new
31
+ * generation holding the full list starts when appending cannot keep the file equal to the list.
32
+ * Every byte is on disk when this resolves, so a run.json written after it indexes nothing
33
+ * missing. Call `published` once run.json naming the returned streams is written: it removes
34
+ * every superseded generation those streams do not name, including the file of a stream that no
35
+ * longer carries live items. A generation whose removal fails, or whose write's run.json failed
36
+ * so `published` never ran, is removed by the next `published`. `events` is the one warn event
37
+ * the run records once a participant's generation was replaced on two writes in a row, from that
38
+ * write on; empty until then.
39
+ */
40
+ write(streams: readonly RunStream[], sandboxIds: readonly string[]): Promise<{
41
+ streams: RunStream[];
42
+ events: RunEvent[];
43
+ published: () => Promise<void>;
44
+ }>;
45
+ /** Remove every live file this writer created, and live/ once empty: after the final run.json. */
46
+ remove(): Promise<void>;
47
+ }
48
+ export declare function liveItemsWriter(root: PreparedOutputRoot): LiveItemsWriter;
@@ -0,0 +1,242 @@
1
+ // A running participant's items so far live in an append-only file, so a flush writes only the
2
+ // items recorded since the last one. The file is live/<position>.<generation>.ndjson, one item per
3
+ // line; run.json's liveActor indexes the prefix written so far by line count, byte count and a
4
+ // running sha256. A file only grows. When appending cannot keep what the file holds equal to the
5
+ // participant's list (the list shrank, an earlier item changed, an append failed, a sandbox id
6
+ // the run learned occurs in it), the writer starts the next generation with the full list, and
7
+ // removes the earlier one once run.json names the new one. This module owns that format: the
8
+ // trace loader reads through liveItemsFromBytes, the run writer through liveItemsWriter.
9
+ import { createHash } from "node:crypto";
10
+ import { appendContainedOutputFile, createContainedOutputFile, readContainedPrefix, removeContainedOutputFile, } from "./contained-output.js";
11
+ import { publicSandboxView } from "./sandbox-ids.js";
12
+ import { isNonNegativeSafeInteger, isPositiveSafeInteger, isRecord } from "./type-guards.js";
13
+ /** The live actor of a v3 run.json, whose items are in the file `itemsFile` names. */
14
+ export const LIVE_ACTOR_SCHEMA = "humanish.live-actor.v2";
15
+ /** The live actor of v1 and v2 run.json, with its items inline. */
16
+ export const LIVE_ACTOR_V1_SCHEMA = "humanish.live-actor.v1";
17
+ /** live/<position>.<generation>.ndjson: names the writer gives, by stream position, from 1. */
18
+ const LIVE_FILE = /^live\/([1-9]\d{0,5})\.([1-9]\d{0,8})\.ndjson$/;
19
+ /** Whether `value` is a live items index naming a live file name and its own generation. */
20
+ export function isLiveItemsIndex(value) {
21
+ if (!isRecord(value) || typeof value.path !== "string")
22
+ return false;
23
+ const name = LIVE_FILE.exec(value.path);
24
+ return (name !== null &&
25
+ isPositiveSafeInteger(value.generation) &&
26
+ Number(name[2]) === value.generation &&
27
+ isNonNegativeSafeInteger(value.items) &&
28
+ isNonNegativeSafeInteger(value.bytes) &&
29
+ typeof value.sha256 === "string" &&
30
+ /^[0-9a-f]{64}$/.test(value.sha256));
31
+ }
32
+ /**
33
+ * The items the indexed prefix of a live file holds, or why the bytes do not match the index:
34
+ * `bytes` must be exactly the indexed byte count, hash to the indexed sha256, and hold the indexed
35
+ * number of lines, each a JSON object.
36
+ */
37
+ export function liveItemsFromBytes(bytes, index) {
38
+ if (bytes.length !== index.bytes)
39
+ return {
40
+ refused: `${index.path} holds ${bytes.length} bytes and run.json records ${index.bytes}`,
41
+ };
42
+ if (createHash("sha256").update(bytes).digest("hex") !== index.sha256)
43
+ return { refused: `${index.path} does not match the sha256 run.json records for it` };
44
+ let text;
45
+ try {
46
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
47
+ }
48
+ catch {
49
+ return { refused: `${index.path} is not UTF-8 text` };
50
+ }
51
+ const lines = text === "" ? [] : text.split("\n");
52
+ if (lines.length > 0 && lines.pop() !== "")
53
+ return { refused: `${index.path} does not end its indexed bytes at a line end` };
54
+ if (lines.length !== index.items)
55
+ return {
56
+ refused: `${index.path} holds ${lines.length} items and run.json records ${index.items}`,
57
+ };
58
+ const items = [];
59
+ for (const line of lines) {
60
+ let item;
61
+ try {
62
+ item = JSON.parse(line);
63
+ }
64
+ catch {
65
+ return { refused: `${index.path} holds a line that is not JSON` };
66
+ }
67
+ if (!isRecord(item))
68
+ return { refused: `${index.path} holds a line that is not an item` };
69
+ items.push(item);
70
+ }
71
+ return { items };
72
+ }
73
+ /** The warning for a participant whose live items were written in full on two writes in a row. */
74
+ function rewrittenEvent(stream, position) {
75
+ return {
76
+ id: "event-live-items-rewritten",
77
+ at: stream.liveActor?.updatedAt ?? stream.updatedAt,
78
+ level: "warn",
79
+ type: "live-items.rewritten",
80
+ streamId: stream.id,
81
+ message: `The live items of ${stream.id} were written again in full on two flushes in a row (live/${position}.<generation>.ndjson). A full write copies every item recorded so far, so as long as this goes on, each flush takes longer and writes more to disk than the one before. It happens when an item already written was replaced or removed, or an append to the live file failed. Check that the disk holding the run has free space and no write errors. If the disk is fine, report this run: the participant's recorder is replacing items it already recorded.`,
82
+ };
83
+ }
84
+ /** Each item as its line in a live file, scrubbed of the run's raw sandbox ids. */
85
+ function itemLines(items, sandboxIds) {
86
+ return Buffer.from(items.map((item) => `${JSON.stringify(publicSandboxView(item, sandboxIds))}\n`).join(""), "utf8");
87
+ }
88
+ /**
89
+ * The list behind `index` when it is the index of `held`, the generation the writer last wrote for
90
+ * stream `position`; any other index throws.
91
+ */
92
+ function heldItems(held, position, index) {
93
+ if (held === undefined ||
94
+ index.path !== held.path ||
95
+ index.items !== held.items ||
96
+ index.bytes !== held.bytes ||
97
+ index.sha256 !== held.hash.copy().digest("hex"))
98
+ throw new Error(`The live actor of stream ${position} indexes ${index.path}, which is not the file this run last wrote for that stream. Pass the participant's items instead of an index.`);
99
+ return held.written;
100
+ }
101
+ export function liveItemsWriter(root) {
102
+ const current = new Map();
103
+ /** The highest generation started at each position, so a failed start never reuses a name. */
104
+ const started = new Map();
105
+ /** Positions whose last append failed: their next write starts a new generation. */
106
+ const appendFailed = new Set();
107
+ const created = new Set();
108
+ /** Generations no current index names: removed once a published run.json names none of them. */
109
+ const superseded = new Set();
110
+ let knownIds = new Set();
111
+ /** Positions whose generation the previous write replaced, and the current write. */
112
+ let replacedBefore = new Set();
113
+ let replaced = new Set();
114
+ let rewritten;
115
+ const startGeneration = async (position, items, sandboxIds) => {
116
+ const generation = (started.get(position) ?? 0) + 1;
117
+ started.set(position, generation);
118
+ const path = `live/${position}.${generation}.ndjson`;
119
+ const bytes = itemLines(items, sandboxIds);
120
+ created.add(path);
121
+ await createContainedOutputFile(root, path, bytes.toString("utf8"));
122
+ const previous = current.get(position);
123
+ if (previous !== undefined) {
124
+ superseded.add(previous.path);
125
+ replaced.add(position);
126
+ }
127
+ appendFailed.delete(position);
128
+ const next = {
129
+ path,
130
+ generation,
131
+ written: items,
132
+ items: items.length,
133
+ bytes: bytes.length,
134
+ hash: createHash("sha256").update(bytes),
135
+ };
136
+ current.set(position, next);
137
+ return next;
138
+ };
139
+ /**
140
+ * Whether the generation can take an append: the list did not shrink, every item it holds is the
141
+ * object it wrote, its last append did not fail, and no id the run learned since occurs in it.
142
+ */
143
+ const appendable = async (position, held, items, learned) => {
144
+ if (appendFailed.has(position) || items.length < held.items)
145
+ return false;
146
+ for (let index = 0; index < held.items; index += 1)
147
+ if (items[index] !== held.written[index])
148
+ return false;
149
+ if (learned.length === 0)
150
+ return true;
151
+ const read = await readContainedPrefix(root, held.path, held.bytes);
152
+ return (read.status === "read" &&
153
+ read.bytes.length === held.bytes &&
154
+ !learned.some((id) => id.length > 0 && read.bytes.includes(id)));
155
+ };
156
+ const writeItems = async (position, items, sandboxIds, learned) => {
157
+ const held = current.get(position);
158
+ if (held === undefined || !(await appendable(position, held, items, learned)))
159
+ return startGeneration(position, items, sandboxIds);
160
+ if (items.length > held.items) {
161
+ const bytes = itemLines(items.slice(held.items), sandboxIds);
162
+ try {
163
+ await appendContainedOutputFile(root, held.path, bytes, held.bytes);
164
+ }
165
+ catch {
166
+ // What the file holds past its index is unknown now; the full list starts over.
167
+ appendFailed.add(position);
168
+ return startGeneration(position, items, sandboxIds);
169
+ }
170
+ held.hash.update(bytes);
171
+ held.bytes += bytes.length;
172
+ held.items = items.length;
173
+ }
174
+ held.written = items;
175
+ return held;
176
+ };
177
+ return {
178
+ async write(streams, sandboxIds) {
179
+ const learned = sandboxIds.filter((id) => !knownIds.has(id));
180
+ replaced = new Set();
181
+ const live = new Set();
182
+ const written = [];
183
+ for (const [index, stream] of streams.entries()) {
184
+ const position = index + 1;
185
+ const liveActor = stream.liveActor;
186
+ // A live actor written as its index stands for what its generation holds, so an id learned
187
+ // now is looked for in that generation too before the ids count as known.
188
+ const items = Array.isArray(liveActor?.items)
189
+ ? liveActor.items
190
+ : liveActor?.itemsFile === undefined
191
+ ? undefined
192
+ : heldItems(current.get(position), position, liveActor.itemsFile);
193
+ if (liveActor === undefined || items === undefined) {
194
+ written.push(stream);
195
+ continue;
196
+ }
197
+ live.add(position);
198
+ const held = await writeItems(position, items, sandboxIds, learned);
199
+ const { items: _items, ...summary } = liveActor;
200
+ const itemsFile = {
201
+ path: held.path,
202
+ generation: held.generation,
203
+ items: held.items,
204
+ bytes: held.bytes,
205
+ sha256: held.hash.copy().digest("hex"),
206
+ };
207
+ written.push({
208
+ ...stream,
209
+ liveActor: { ...summary, schema: LIVE_ACTOR_SCHEMA, itemsFile },
210
+ });
211
+ }
212
+ // Known once every current generation was checked for them; a write that threw rechecks.
213
+ knownIds = new Set([...knownIds, ...sandboxIds]);
214
+ for (const [position, held] of current)
215
+ if (!live.has(position)) {
216
+ superseded.add(held.path);
217
+ current.delete(position);
218
+ }
219
+ // Replaced on two writes in a row: each flush rewrites the whole list, as before live files.
220
+ const churning = [...replaced].find((position) => replacedBefore.has(position));
221
+ if (rewritten === undefined && churning !== undefined)
222
+ rewritten = rewrittenEvent(streams[churning - 1], churning);
223
+ replacedBefore = replaced;
224
+ const named = new Set(written.map((stream) => stream.liveActor?.itemsFile?.path));
225
+ return {
226
+ streams: written,
227
+ events: rewritten === undefined ? [] : [rewritten],
228
+ published: async () => {
229
+ // A removal that fails stays pending for the next published run.json.
230
+ for (const path of superseded)
231
+ if (!named.has(path))
232
+ await removeContainedOutputFile(root, path).then(() => superseded.delete(path), () => undefined);
233
+ },
234
+ };
235
+ },
236
+ async remove() {
237
+ for (const path of created)
238
+ await removeContainedOutputFile(root, path).catch(() => { });
239
+ await removeContainedOutputFile(root, "live").catch(() => { });
240
+ },
241
+ };
242
+ }
@@ -40,6 +40,16 @@ export declare function readSafeRunArtifact(runPaths: PreparedRunArtifactPaths,
40
40
  /** readSafeRunArtifact's bytes, or null for any file it does not read. */
41
41
  export declare function readSafeRunArtifactBytes(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<Buffer | null>;
42
42
  export declare function readSafeRunArtifactJson(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<unknown>;
43
+ /**
44
+ * A run's bundle as `humanish verify` reads it, for a reader outside humanish: run.json of run
45
+ * `runInput` (a run id, or `latest`) under `cwdInput`'s `.humanish/runs/`, with every participant's
46
+ * trace file and, while the run is in progress, live file joined, so each stream has its
47
+ * `actor.items`, its screenshot entries and its `liveActor.items` as a v1 run.json held them. It
48
+ * reads run bundles v1, v2 and v3. Each file must match the index run.json records for it (path,
49
+ * size, sha256, item count); one that does not throws an Error named `StreamTraceRefusedError`
50
+ * that names the participant. Resolves to null when there is no such run, or its run.json is not
51
+ * a run bundle. `bundlePath` is run.json relative to `cwdInput`, `runDir` the run folder.
52
+ */
43
53
  export declare function loadRunBundle(cwdInput: string, runInput: string): Promise<{
44
54
  bundle: RunBundle;
45
55
  bundlePath: string;
@@ -101,6 +101,16 @@ export async function readSafeRunArtifactJson(runPaths, relativePath) {
101
101
  return null;
102
102
  }
103
103
  }
104
+ /**
105
+ * A run's bundle as `humanish verify` reads it, for a reader outside humanish: run.json of run
106
+ * `runInput` (a run id, or `latest`) under `cwdInput`'s `.humanish/runs/`, with every participant's
107
+ * trace file and, while the run is in progress, live file joined, so each stream has its
108
+ * `actor.items`, its screenshot entries and its `liveActor.items` as a v1 run.json held them. It
109
+ * reads run bundles v1, v2 and v3. Each file must match the index run.json records for it (path,
110
+ * size, sha256, item count); one that does not throws an Error named `StreamTraceRefusedError`
111
+ * that names the participant. Resolves to null when there is no such run, or its run.json is not
112
+ * a run bundle. `bundlePath` is run.json relative to `cwdInput`, `runDir` the run folder.
113
+ */
104
114
  export async function loadRunBundle(cwdInput, runInput) {
105
115
  const cwd = path.resolve(cwdInput);
106
116
  const runPaths = await resolveRunPath(cwd, runInput).catch(() => null);
package/dist/run/run.d.ts CHANGED
@@ -92,7 +92,8 @@ interface Run {
92
92
  /** The literal values the route scrubs from this run's evidence; a value found later is added here. */
93
93
  readonly secrets: RunSecrets;
94
94
  /**
95
- * Publish an in-progress bundle: run.json, review.json, review.md, events.ndjson and
95
+ * Publish an in-progress bundle: each running participant's new items appended to its live file
96
+ * (src/run/live-items.ts), then run.json, review.json, review.md, events.ndjson and
96
97
  * observer/observer-data.json, plus the latest pointer until one pointer write has succeeded,
97
98
  * so a live run is `latest` from its first snapshot and a later flush never takes the pointer
98
99
  * back from a newer run. status.json stays running. Writes are serialized with each other and
@@ -116,7 +117,7 @@ interface Run {
116
117
  /**
117
118
  * The one final publication: run.json with `outcome` set from `outcome`, then the status
118
119
  * outcome copied from it, then review.json, review.md, events.ndjson,
119
- * observer/observer-data.json, and last the latest pointer. The status goes after run.json so
120
+ * observer/observer-data.json, then the removal of the live files, and last the latest pointer. The status goes after run.json so
120
121
  * the index never gets ahead of the evidence, and the pointer goes last so `latest` never
121
122
  * selects a run whose projections are incomplete. Every projection reads the outcome, so none
122
123
  * shows a pass for a run whose ok is false. Rejects before writing on a second call, after the
package/dist/run/run.js CHANGED
@@ -13,7 +13,8 @@ import {} from "./results.js";
13
13
  import { createRunArtifactPaths, validatePreparedRunArtifactPaths, } from "./paths.js";
14
14
  import { registerActiveRun } from "./active-runs.js";
15
15
  import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./contained-output.js";
16
- import { scrubRunSandboxIds, withPublicSandboxIds } from "./sandbox-ids.js";
16
+ import { liveItemsWriter } from "./live-items.js";
17
+ import { publicSandboxView, readRunSandboxIds, scrubRunSandboxIds } from "./sandbox-ids.js";
17
18
  import { RunSecrets } from "./secrets.js";
18
19
  import { RUN_STATUS_FILE, beginRunStatus, runStatusOutcome, } from "./status.js";
19
20
  import { readRunJsonIfExists, runJsonValue } from "./locate.js";
@@ -117,6 +118,28 @@ function observerTarget(options) {
117
118
  render: options.observer.render ?? renderObserver,
118
119
  };
119
120
  }
121
+ /**
122
+ * run.json for `bundle`, written after every live file and trace file it indexes, then the removal
123
+ * of the live files it no longer names. Returns the bundle the review and the projections read:
124
+ * raw sandbox ids scrubbed, each finished trace inline, each running participant's items as the
125
+ * index of its live file, and the live files' warning among the events. Live items are scrubbed
126
+ * as they are appended, so the scrub of the bundle no longer walks them.
127
+ */
128
+ async function writeRunJson(paths, liveItems, bundle) {
129
+ const sandboxIds = await readRunSandboxIds(paths);
130
+ const live = await liveItems.write(bundle.streams, sandboxIds);
131
+ const events = [
132
+ ...bundle.events.filter((event) => !live.events.some((mine) => mine.id === event.id)),
133
+ ...live.events,
134
+ ];
135
+ const publicBundle = publicSandboxView({ ...bundle, streams: live.streams, events }, sandboxIds);
136
+ const split = splitStreamTraces(publicBundle);
137
+ for (const file of split.files)
138
+ await writeContainedOutputFile(paths, file.path, file.bytes);
139
+ await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(split.bundle), "utf8");
140
+ await live.published();
141
+ return publicBundle;
142
+ }
120
143
  function runPublisher(args) {
121
144
  const { options, runId, createdAt, paths, runStatus, now, admit, participantsRan } = args;
122
145
  const observer = observerTarget(options);
@@ -161,24 +184,20 @@ function runPublisher(args) {
161
184
  await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
162
185
  pointerWritten = true;
163
186
  };
187
+ // Each running participant's items so far, appended to its live file as the flushes arrive.
188
+ const liveItems = liveItemsWriter(paths);
164
189
  /** run.json with its outcome, then `afterBundle`, then the review and projections. */
165
190
  const writeBundleFiles = async (bundle, outcome, afterBundle) => {
166
191
  await validatePreparedRunArtifactPaths(paths);
167
192
  // The run owns `outcome`: a bundle read back from disk keeps none of its own.
168
193
  const { outcome: _previous, ...evidence } = bundle;
169
194
  const recorded = interrupted ?? outcome;
170
- const publicBundle = await withPublicSandboxIds(paths, {
195
+ const publicBundle = await writeRunJson(paths, liveItems, {
171
196
  ...evidence,
172
197
  events: withRunEvents(evidence.events),
173
198
  cwd: PUBLIC_TARGET_CWD,
174
199
  ...(recorded === undefined ? {} : { outcome: recorded }),
175
200
  });
176
- // Each trace file first, so run.json never indexes a file that is not yet written. The review
177
- // and the Observer data below read the whole in-memory bundle.
178
- const split = splitStreamTraces(publicBundle);
179
- for (const file of split.files)
180
- await writeContainedOutputFile(paths, file.path, file.bytes);
181
- await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(split.bundle), "utf8");
182
201
  await afterBundle(publicBundle);
183
202
  // The run writes its own status.json; one it refuses renders as a missing one does.
184
203
  const status = recorded === undefined
@@ -202,6 +221,8 @@ function runPublisher(args) {
202
221
  });
203
222
  const publish = async (bundle, outcome) => {
204
223
  await writeBundleFiles(bundle, finishedOutcome(outcome), (publicBundle) => runStatus.finish(runStatusOutcome(publicBundle)));
224
+ // The final run.json indexes each finished trace, so the live files are journal no reader needs.
225
+ await liveItems.remove();
205
226
  await scrubRunSandboxIds(paths);
206
227
  await writePointer();
207
228
  let current = { ok: outcome.ok, execution: outcome.execution };
@@ -30,11 +30,6 @@ export declare function holdsKeyedSandboxId(file: string, text: string): boolean
30
30
  * caller would otherwise write or grade a run's files without the ids it may hold.
31
31
  */
32
32
  export declare function readRunSandboxIds(root: PreparedOutputRoot): Promise<string[]>;
33
- /**
34
- * `value` as a run writes it: raw sandbox ids stay in the run's sandbox-receipts.ndjson, and the
35
- * file names each by marker and digest.
36
- */
37
- export declare function withPublicSandboxIds<T>(root: PreparedOutputRoot, value: T): Promise<T>;
38
33
  /**
39
34
  * The bytes of `file` with each raw id replaced by its label: the same buffer when none occurs, or
40
35
  * when the bytes are not the plain text verify and export read as text.
@@ -115,13 +115,6 @@ export async function readRunSandboxIds(root) {
115
115
  }
116
116
  return [...new Set([...journaled, ...appendedSandboxIds(root)])];
117
117
  }
118
- /**
119
- * `value` as a run writes it: raw sandbox ids stay in the run's sandbox-receipts.ndjson, and the
120
- * file names each by marker and digest.
121
- */
122
- export async function withPublicSandboxIds(root, value) {
123
- return publicSandboxView(value, await readRunSandboxIds(root));
124
- }
125
118
  /**
126
119
  * The bytes of `file` with each raw id replaced by its label: the same buffer when none occurs, or
127
120
  * when the bytes are not the plain text verify and export read as text.
@@ -22,10 +22,12 @@ export declare function splitStreamTraces(bundle: RunBundle): {
22
22
  };
23
23
  /**
24
24
  * Where a loader reads trace files from: the bytes at a run-relative path, at most `maxBytes`, or
25
- * why it could not. Prepared run paths read through the contained reader; the Observer server
26
- * passes a reader over its pinned run folder.
25
+ * why it could not. With `length`, the first `length` bytes, or all of a shorter file: a live file
26
+ * grows past what run.json indexes, and the loader reads only the indexed prefix. Prepared run
27
+ * paths read through the contained reader; the Observer server passes a reader over its pinned run
28
+ * folder.
27
29
  */
28
- export type TraceFileReader = (relativePath: string, maxBytes: number) => Promise<{
30
+ export type TraceFileReader = (relativePath: string, maxBytes: number, length?: number) => Promise<{
29
31
  bytes: Buffer;
30
32
  } | {
31
33
  refused: string;
@@ -39,21 +41,27 @@ export type TraceFileReader = (relativePath: string, maxBytes: number) => Promis
39
41
  * actor, two participants indexing one file) are loadStreamTraces'.
40
42
  */
41
43
  export declare function loadStreamTrace(runPaths: PreparedOutputRoot, stream: RunStream, maxBytes?: number): Promise<RunStream>;
44
+ interface LoadedTraces {
45
+ bundle: RunBundle;
46
+ refused: StreamTraceRefusedError[];
47
+ }
42
48
  /**
43
49
  * Every stream of the bundle loaded through loadStreamTrace, one file at a time, and the refusal
44
50
  * of each stream that could not be (see bundleRefusal for the rules that need the whole bundle).
45
51
  * Each refused stream is kept as run.json holds it; a reader that shows the run passes the result
46
52
  * to displayedTraces. `source` is the run's prepared paths, or a TraceFileReader. `onTrace`
47
- * receives each trace file's path and the bytes the stream was joined from, after they matched
48
- * the index, so a caller scans exactly what it reads.
53
+ * receives each trace and live file's path and the bytes the stream was joined from, after they
54
+ * matched the index, so a caller scans exactly what it reads.
55
+ *
56
+ * A live file that cannot be read may have been removed by the writer after `bundle` was read: it
57
+ * removes a superseded generation once run.json names the next one, and every live file once the
58
+ * run finished. The loader then reads run.json again, once, through the same reader, and loads
59
+ * that bundle instead when it is a run bundle of the same run.
49
60
  */
50
61
  export declare function loadStreamTraces(source: PreparedOutputRoot | TraceFileReader, bundle: RunBundle, options?: {
51
62
  maxBytes?: number;
52
63
  onTrace?: (path: string, bytes: Buffer) => void;
53
- }): Promise<{
54
- bundle: RunBundle;
55
- refused: StreamTraceRefusedError[];
56
- }>;
64
+ }): Promise<LoadedTraces>;
57
65
  /**
58
66
  * A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
59
67
  * summary, shows none of the items run.json or a file supplied for it (its actor's items, its
@@ -66,3 +74,4 @@ export declare function displayedTraces(loaded: {
66
74
  }): RunBundle;
67
75
  /** The bundle with every stream loaded; the first refusal throws. */
68
76
  export declare function loadRunTraces(source: PreparedOutputRoot | TraceFileReader, bundle: RunBundle, maxBytes?: number): Promise<RunBundle>;
77
+ export {};