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.
- package/CHANGELOG.md +15 -0
- package/CONTEXT.md +1 -1
- package/README.md +2 -2
- package/dist/analysis/evidence.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/observer/pinned-files.d.ts +3 -1
- package/dist/observer/pinned-files.js +10 -7
- package/dist/observer/run-routes.js +2 -1
- package/dist/routes/computer-use/live-flush.d.ts +9 -4
- package/dist/routes/computer-use/live-flush.js +16 -3
- package/dist/run/bundle-shape.js +2 -2
- package/dist/run/bundle.d.ts +10 -3
- package/dist/run/bundle.js +11 -2
- package/dist/run/contained-output.d.ts +22 -0
- package/dist/run/contained-output.js +97 -1
- package/dist/run/evidence-files.d.ts +12 -1
- package/dist/run/evidence-files.js +70 -40
- package/dist/run/live-items.d.ts +48 -0
- package/dist/run/live-items.js +242 -0
- package/dist/run/locate.d.ts +10 -0
- package/dist/run/locate.js +10 -0
- package/dist/run/run.d.ts +3 -2
- package/dist/run/run.js +29 -8
- package/dist/run/sandbox-ids.d.ts +0 -5
- package/dist/run/sandbox-ids.js +0 -7
- package/dist/run/stream-trace.d.ts +18 -9
- package/dist/run/stream-trace.js +113 -22
- package/dist/run/streams.d.ts +17 -2
- package/dist/study/parse/actors.js +23 -6
- package/dist/study/parse/execution.js +6 -0
- package/dist/study/plan-base.js +4 -1
- package/dist/verify/artifacts.js +39 -51
- package/dist/verify/screenshot-reads.d.ts +21 -0
- package/dist/verify/screenshot-reads.js +46 -0
- package/dist/verify/verify.js +7 -6
- package/docs/architecture/github-feedback-loop.md +1 -1
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/run-bundle.md +75 -13
- package/docs/contracts/schemas.md +38 -37
- 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
|
+
}
|
package/dist/run/locate.d.ts
CHANGED
|
@@ -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;
|
package/dist/run/locate.js
CHANGED
|
@@ -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:
|
|
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 {
|
|
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
|
|
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.
|
package/dist/run/sandbox-ids.js
CHANGED
|
@@ -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.
|
|
26
|
-
*
|
|
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
|
|
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 {};
|