humanish 0.122.0 → 0.124.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 +29 -0
- package/CONTEXT.md +2 -2
- package/README.md +33 -6
- package/dist/actors/computer-use/loop/trace.js +6 -1
- package/dist/actors/computer-use/openai-conversation.d.ts +0 -6
- package/dist/actors/computer-use/openai-conversation.js +0 -8
- package/dist/actors/computer-use/openai-provider.js +5 -8
- package/dist/analysis/automatic.js +2 -1
- package/dist/analysis/evidence.js +41 -21
- package/dist/cli/commands/study-run.js +9 -3
- package/dist/evidence/image.js +82 -3
- package/dist/feedback/derivative-text.d.ts +12 -0
- package/dist/feedback/derivative-text.js +130 -0
- package/dist/feedback/export-bundle.js +39 -136
- package/dist/feedback/export.js +36 -9
- package/dist/observer/artifact.js +2 -9
- package/dist/observer/data.d.ts +9 -2
- package/dist/observer/data.js +17 -2
- package/dist/observer/page-data.d.ts +5 -0
- package/dist/observer/page-data.js +21 -0
- package/dist/observer/participant-items.d.ts +45 -0
- package/dist/observer/participant-items.js +85 -0
- package/dist/observer/render.js +2 -1
- package/dist/observer/run-routes.js +75 -22
- package/dist/observer-app.html +4 -4
- package/dist/run/bundle-shape.js +2 -2
- package/dist/run/bundle.d.ts +5 -2
- package/dist/run/bundle.js +4 -1
- package/dist/run/detail.d.ts +6 -0
- package/dist/run/detail.js +14 -1
- package/dist/run/locate.js +3 -1
- package/dist/run/projection.d.ts +7 -0
- package/dist/run/projection.js +10 -0
- package/dist/run/run.js +9 -2
- package/dist/run/stored-runs.js +10 -4
- package/dist/run/stream-shape.d.ts +11 -1
- package/dist/run/stream-shape.js +26 -2
- package/dist/run/stream-trace.d.ts +68 -0
- package/dist/run/stream-trace.js +260 -0
- package/dist/run/streams.d.ts +20 -0
- package/dist/study/bundle-estimate.d.ts +48 -0
- package/dist/study/bundle-estimate.js +159 -0
- package/dist/study/doctor.js +4 -0
- package/dist/study/plan-types.d.ts +3 -0
- package/dist/study/plan.js +42 -2
- package/dist/study/preflight.js +8 -0
- package/dist/tui-app.js +87 -87
- package/dist/verify/artifacts.d.ts +17 -10
- package/dist/verify/artifacts.js +22 -13
- package/dist/verify/scan-limit.d.ts +6 -0
- package/dist/verify/scan-limit.js +25 -0
- package/dist/verify/verify.d.ts +1 -1
- package/dist/verify/verify.js +68 -18
- package/docs/architecture/github-feedback-loop.md +1 -1
- package/docs/architecture/observer.md +24 -5
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/run-bundle.md +89 -6
- package/docs/contracts/schemas.md +14 -7
- package/docs/contracts/study-analysis.md +1 -1
- package/docs/decisions/0002-observer-is-one-self-contained-file.md +2 -1
- package/package.json +7 -4
|
@@ -4,18 +4,17 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, rmdir } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { parse as parseYaml } from "yaml";
|
|
8
|
-
import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
|
|
9
7
|
import { renderObserver } from "../observer/render.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
8
|
+
import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
9
|
+
import { splitStreamTraces } from "../run/stream-trace.js";
|
|
10
|
+
import { redactScreenshot, redactText } from "../evidence/redaction.js";
|
|
11
|
+
import { assertRewritableNumbers, derivedBundle, jsonBytes, transformText, } from "./derivative-text.js";
|
|
12
|
+
import { streamScreenshotPaths } from "../verify/artifacts.js";
|
|
13
|
+
import { runEntryLimit } from "../verify/scan-limit.js";
|
|
16
14
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
17
|
-
import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
|
|
18
|
-
import {} from "../run/bundle.js";
|
|
15
|
+
import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
|
|
16
|
+
import { RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
17
|
+
import { isRecord } from "../run/type-guards.js";
|
|
19
18
|
import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
20
19
|
import { isAnalysisRecordPath } from "../analysis/sharing.js";
|
|
21
20
|
import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
|
|
@@ -43,9 +42,6 @@ const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
|
43
42
|
function hash(bytes) {
|
|
44
43
|
return createHash("sha256").update(bytes).digest("hex");
|
|
45
44
|
}
|
|
46
|
-
function jsonBytes(value) {
|
|
47
|
-
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
48
|
-
}
|
|
49
45
|
function omittedReason(relative) {
|
|
50
46
|
if (relative.startsWith("analysis-automatic/") && isAnalysisRecordPath(relative))
|
|
51
47
|
return "automatic execution intent and liveness do not transfer to a derivative";
|
|
@@ -63,14 +59,17 @@ async function inventory(root, maxBytes) {
|
|
|
63
59
|
const files = [];
|
|
64
60
|
let total = 0;
|
|
65
61
|
let entriesSeen = 0;
|
|
62
|
+
// The scan's limit for the participants run.json records, so a run verify reads in full exports.
|
|
63
|
+
const stored = runJsonValue(await readRunJsonIfExists(root, RUN_BUNDLE_FILE));
|
|
64
|
+
const entryLimit = runEntryLimit(isRecord(stored) && Array.isArray(stored.simulations) ? stored.simulations.length : 0);
|
|
66
65
|
const walk = async (relative) => {
|
|
67
66
|
const directory = path.join(root.physicalRunRoot, relative);
|
|
68
67
|
const before = await lstat(directory, { bigint: true });
|
|
69
68
|
if (!before.isDirectory() || before.isSymbolicLink())
|
|
70
69
|
throw new Error("Source contains an unsafe directory.");
|
|
71
70
|
for (const name of (await readdir(directory)).sort()) {
|
|
72
|
-
if (++entriesSeen >
|
|
73
|
-
throw new Error(`Source exceeds ${
|
|
71
|
+
if (++entriesSeen > entryLimit)
|
|
72
|
+
throw new Error(`Source exceeds ${entryLimit} inventory entries.`);
|
|
74
73
|
if (name.includes("\\") || name.includes("\0"))
|
|
75
74
|
throw new Error("Source contains an unsafe path segment.");
|
|
76
75
|
const rel = relative ? `${relative}/${name}` : name;
|
|
@@ -147,65 +146,6 @@ async function prospectivePhysicalPath(requested) {
|
|
|
147
146
|
return path.join(await prospectivePhysicalPath(parent), path.basename(requested));
|
|
148
147
|
}
|
|
149
148
|
}
|
|
150
|
-
function assertNoInlineRaster(text) {
|
|
151
|
-
// Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
|
|
152
|
-
// refused: copying a text extension must not smuggle untransformed pixels.
|
|
153
|
-
if (readingsOf(text).some(containsSensitive))
|
|
154
|
-
throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
|
|
155
|
-
const decoded = decodeEscapes(text);
|
|
156
|
-
if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
|
|
157
|
-
/iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
|
|
158
|
-
throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
function decodeText(file) {
|
|
162
|
-
const decoded = readPlainText(file.bytes);
|
|
163
|
-
if (!decoded.ok)
|
|
164
|
-
throw new Error(decoded.reason === "invalid-utf8"
|
|
165
|
-
? "Source text contains invalid UTF-8 or binary bytes."
|
|
166
|
-
: "Source text contains binary control bytes.");
|
|
167
|
-
assertNoInlineRaster(decoded.text);
|
|
168
|
-
return decoded.text;
|
|
169
|
-
}
|
|
170
|
-
function updateScreenshotDeclarations(value, imagePaths) {
|
|
171
|
-
if (Array.isArray(value))
|
|
172
|
-
return value.map((entry) => updateScreenshotDeclarations(entry, imagePaths));
|
|
173
|
-
if (value === null || typeof value !== "object")
|
|
174
|
-
return value;
|
|
175
|
-
const object = value;
|
|
176
|
-
const result = Object.create(null);
|
|
177
|
-
for (const [key, entry] of Object.entries(object))
|
|
178
|
-
result[key] = updateScreenshotDeclarations(entry, imagePaths);
|
|
179
|
-
if (object.schema === ACTOR_TRACE_SCHEMA &&
|
|
180
|
-
object.redaction !== null &&
|
|
181
|
-
typeof object.redaction === "object") {
|
|
182
|
-
const original = object.redaction;
|
|
183
|
-
if (original.screenshots === "raw" || original.screenshots === "blurred") {
|
|
184
|
-
result.redaction = {
|
|
185
|
-
...original,
|
|
186
|
-
screenshots: "blurred",
|
|
187
|
-
notes: `${typeof original.notes === "string" ? original.notes : ""} Exported copy: screenshots blurred during export; original capture posture: ${original.screenshots}. See derivation.json.`,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
if (Array.isArray(result.items)) {
|
|
191
|
-
result.items = result.items.map((item) => {
|
|
192
|
-
if (item === null || typeof item !== "object")
|
|
193
|
-
return item;
|
|
194
|
-
const entry = item;
|
|
195
|
-
if (entry.screenshotRef === undefined)
|
|
196
|
-
return item;
|
|
197
|
-
if (entry.screenshotRef === null || typeof entry.screenshotRef !== "object")
|
|
198
|
-
throw new Error("Actor screenshot reference is malformed.");
|
|
199
|
-
const ref = entry.screenshotRef;
|
|
200
|
-
if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
|
|
201
|
-
throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
|
|
202
|
-
}
|
|
203
|
-
return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return result;
|
|
208
|
-
}
|
|
209
149
|
function assertFeedbackReferences(bundle, source) {
|
|
210
150
|
const retained = new Set(source.files.filter((file) => omittedReason(file.path) === undefined).map((file) => file.path));
|
|
211
151
|
retained.add("observer/index.html");
|
|
@@ -220,51 +160,6 @@ function assertFeedbackReferences(bundle, source) {
|
|
|
220
160
|
}
|
|
221
161
|
}
|
|
222
162
|
}
|
|
223
|
-
function transformText(file, imagePaths) {
|
|
224
|
-
const text = decodeText(file);
|
|
225
|
-
const extension = path.extname(file.path).toLowerCase();
|
|
226
|
-
if (extension === ".json") {
|
|
227
|
-
const parsed = JSON.parse(text);
|
|
228
|
-
// Parsing also resolves escape sequences in string values before inspection.
|
|
229
|
-
assertNoInlineRaster(JSON.stringify(parsed));
|
|
230
|
-
// The copy names no sandbox: an id is local operational evidence, and the copy is shared.
|
|
231
|
-
const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
|
|
232
|
-
if (JSON.stringify(transformed) === JSON.stringify(parsed))
|
|
233
|
-
return file.bytes;
|
|
234
|
-
assertRewritableNumbers(text);
|
|
235
|
-
return jsonBytes(transformed);
|
|
236
|
-
}
|
|
237
|
-
if (extension === ".ndjson" || extension === ".jsonl") {
|
|
238
|
-
const lines = text.split("\n");
|
|
239
|
-
const transformed = lines.map((line) => {
|
|
240
|
-
if (line.trim().length === 0)
|
|
241
|
-
return line;
|
|
242
|
-
const parsed = JSON.parse(line);
|
|
243
|
-
assertNoInlineRaster(JSON.stringify(parsed));
|
|
244
|
-
const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
|
|
245
|
-
if (updated === JSON.stringify(parsed))
|
|
246
|
-
return line;
|
|
247
|
-
assertRewritableNumbers(line);
|
|
248
|
-
return updated;
|
|
249
|
-
});
|
|
250
|
-
return Buffer.from(transformed.join("\n"));
|
|
251
|
-
}
|
|
252
|
-
if (extension === ".yaml" || extension === ".yml") {
|
|
253
|
-
assertNoInlineRaster(JSON.stringify(parseYaml(text, { maxAliasCount: 100, logLevel: "silent" })) ?? "");
|
|
254
|
-
}
|
|
255
|
-
return file.bytes;
|
|
256
|
-
}
|
|
257
|
-
function assertRewritableNumbers(json) {
|
|
258
|
-
// Opaque adapter JSON stays byte-identical. A known trace that actually needs
|
|
259
|
-
// rewriting must not silently round an identifier outside JS's integer range.
|
|
260
|
-
const unquoted = json.replace(/"(?:\\.|[^"\\])*"/g, '""');
|
|
261
|
-
for (const match of unquoted.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
|
|
262
|
-
const number = Number(match[0]);
|
|
263
|
-
if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
|
|
264
|
-
throw new Error("A trace requiring redaction contains an unsafe numeric value; represent large identifiers as strings before capture.");
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
163
|
/** PNG paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
|
|
269
164
|
function citedPngPaths(bundle) {
|
|
270
165
|
const cited = [
|
|
@@ -304,6 +199,13 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
304
199
|
const entries = [];
|
|
305
200
|
const imagePaths = keptPngPaths(source, bundle);
|
|
306
201
|
const sandboxIds = inventorySandboxIds(source.files);
|
|
202
|
+
// run.json and each trace file come from one rewritten bundle, so each trace's index binds the
|
|
203
|
+
// bytes written here. A v1 source is written as v2.
|
|
204
|
+
const derived = splitStreamTraces(publicSandboxView(derivedBundle(bundle, imagePaths), sandboxIds));
|
|
205
|
+
const derivedFiles = new Map([
|
|
206
|
+
["run.json", jsonBytes(derived.bundle)],
|
|
207
|
+
...derived.files.map((file) => [file.path, file.bytes]),
|
|
208
|
+
]);
|
|
307
209
|
let images = 0;
|
|
308
210
|
for (const file of source.files) {
|
|
309
211
|
const isPng = path.extname(file.path).toLowerCase() === ".png";
|
|
@@ -332,20 +234,11 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
332
234
|
if (!TEXT_EXTENSIONS.has(extension))
|
|
333
235
|
throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG and UTF-8 text only.`);
|
|
334
236
|
bytes = transformText(file, imagePaths);
|
|
335
|
-
|
|
237
|
+
const rewritten = derivedFiles.get(file.path);
|
|
238
|
+
if (rewritten !== undefined) {
|
|
336
239
|
assertRewritableNumbers(file.bytes.toString("utf8"));
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
status: "passed",
|
|
340
|
-
notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
|
|
341
|
-
};
|
|
342
|
-
for (const stream of transformed.streams) {
|
|
343
|
-
for (const artifact of stream.artifacts) {
|
|
344
|
-
if (artifact.kind === "screenshot")
|
|
345
|
-
artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
bytes = jsonBytes(transformed);
|
|
240
|
+
bytes = rewritten;
|
|
241
|
+
derivedFiles.delete(file.path);
|
|
349
242
|
}
|
|
350
243
|
bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
|
|
351
244
|
action = bytes.equals(file.bytes) ? "copied" : "updated";
|
|
@@ -353,6 +246,19 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
353
246
|
await writeContainedOutputFile(stagePaths, file.path, bytes);
|
|
354
247
|
entries.push({ path: file.path, sourceSha256: file.sha256, outputSha256: hash(bytes), action });
|
|
355
248
|
}
|
|
249
|
+
// A v1 source stream with no actor trace file gets one; its content came from run.json.
|
|
250
|
+
const runJson = source.files.find((file) => file.path === "run.json");
|
|
251
|
+
for (const [relative, bytes] of derivedFiles) {
|
|
252
|
+
if (relative === "run.json" || runJson === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
await writeContainedOutputFile(stagePaths, relative, bytes);
|
|
255
|
+
entries.push({
|
|
256
|
+
path: relative,
|
|
257
|
+
sourceSha256: runJson.sha256,
|
|
258
|
+
outputSha256: hash(bytes),
|
|
259
|
+
action: "updated",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
356
262
|
return { images, entries };
|
|
357
263
|
}
|
|
358
264
|
async function removeOwnedDirectory(root, strict = false) {
|
|
@@ -534,10 +440,7 @@ async function buildDerivative(stage, source, runId, bundle, maxBytes) {
|
|
|
534
440
|
note: "A redacted copy of the same study, not a new attempt. Original pixels are not present. Text and findings still require human review before sharing.",
|
|
535
441
|
files: transformed.entries,
|
|
536
442
|
};
|
|
537
|
-
|
|
538
|
-
if (!transformedBundle)
|
|
539
|
-
throw new Error("Derivative bundle could not be loaded.");
|
|
540
|
-
await writeContainedOutputFile(stagePaths, "observer/observer-data.json", jsonBytes(buildObserverData(transformedBundle.bundle)));
|
|
443
|
+
// Rendering writes observer-data.json and index.html from the derivative's run.json.
|
|
541
444
|
const rendered = await renderObserver(stage.physicalPath, runId, { open: false });
|
|
542
445
|
if (!rendered.ok)
|
|
543
446
|
throw new Error("Derivative Observer could not be rebuilt.");
|
package/dist/feedback/export.js
CHANGED
|
@@ -13,8 +13,9 @@ import { createHash } from "node:crypto";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { renderObserver } from "../observer/render.js";
|
|
15
15
|
import { renderObserverHtml } from "../observer/artifact.js";
|
|
16
|
-
import { buildObserverData } from "../observer/data.js";
|
|
16
|
+
import { buildObserverData, OBSERVER_DATA_SCHEMA, observerDataFromSaved, } from "../observer/data.js";
|
|
17
17
|
import { resolveRunPath } from "../run/locate.js";
|
|
18
|
+
import { loadRunTraces, StreamTraceRefusedError } from "../run/stream-trace.js";
|
|
18
19
|
import { verifyRun } from "../verify/verify.js";
|
|
19
20
|
import {} from "../run/bundle.js";
|
|
20
21
|
import { exportRedactedBundle } from "./export-bundle.js";
|
|
@@ -24,6 +25,7 @@ import { cli } from "../cli/invocation.js";
|
|
|
24
25
|
import { analysisSharingProblems } from "../analysis/sharing.js";
|
|
25
26
|
import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
|
|
26
27
|
import { readBoundedFile } from "../run/evidence-files.js";
|
|
28
|
+
import { isRecord } from "../run/type-guards.js";
|
|
27
29
|
import { shellQuote } from "../substrates/shell.js";
|
|
28
30
|
import { plural } from "../run/text.js";
|
|
29
31
|
const EXPORT_SCHEMA = "humanish.export-result.v1";
|
|
@@ -79,7 +81,7 @@ export async function exportRun(cwdInput, runInput, options = {}, deps = {}) {
|
|
|
79
81
|
const analysis = await loadAnalysis(runPaths);
|
|
80
82
|
const data = await currentObserverData(runPaths, analysis, slot);
|
|
81
83
|
if (data === undefined)
|
|
82
|
-
return exportFailure(cwd, runInput, "HUMANISH_EXPORT_VERIFY_FAILED", "
|
|
84
|
+
return exportFailure(cwd, runInput, "HUMANISH_EXPORT_VERIFY_FAILED", "The run's evidence changed, or a participant's trace could not be read, while preparing the export.");
|
|
83
85
|
const omittedRecording = omitRecordings(data.value);
|
|
84
86
|
if (omittedRecording)
|
|
85
87
|
warnings.push(MEDIA_OMISSION);
|
|
@@ -176,17 +178,42 @@ async function readObserverSlot(cwd, runInput, runRoot, runId, verified, deps, w
|
|
|
176
178
|
/**
|
|
177
179
|
* The Observer data to export. A current report must travel with the current source projection,
|
|
178
180
|
* even if an older saved HTML was never refreshed; old recording-only exports keep the saved slot.
|
|
179
|
-
*
|
|
181
|
+
* A page saved since observer-data v2 holds no participant's items in its slot, and verify does not
|
|
182
|
+
* read the page's compressed copy, so its export is the current projection of the run and its
|
|
183
|
+
* traces. Undefined when run.json no longer matches the analysis, or a v2 page's run cannot be read.
|
|
180
184
|
*/
|
|
181
185
|
async function currentObserverData(runPaths, analysis, slot) {
|
|
182
186
|
const saved = JSON.parse(slot);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
const source = async () => readBoundedFile(runPaths, "run.json", EVIDENCE_LIMITS.sourceBytes);
|
|
188
|
+
if (analysis.state !== "ready" || !analysis.analysis) {
|
|
189
|
+
const schema = saved?.schema;
|
|
190
|
+
if (schema === "humanish.observer-data.v1")
|
|
191
|
+
return { value: observerDataFromSaved(saved) };
|
|
192
|
+
if (schema !== OBSERVER_DATA_SCHEMA)
|
|
193
|
+
return { value: saved };
|
|
194
|
+
const bundle = await sourceTraces(runPaths, await source());
|
|
195
|
+
return bundle === undefined ? undefined : { value: buildObserverData(bundle) };
|
|
196
|
+
}
|
|
197
|
+
const bytes = await source();
|
|
198
|
+
if (!bytes ||
|
|
199
|
+
createHash("sha256").update(bytes).digest("hex") !== analysis.analysis.sourceRunSha256)
|
|
200
|
+
return undefined;
|
|
201
|
+
const bundle = await sourceTraces(runPaths, bytes);
|
|
202
|
+
return bundle === undefined ? undefined : { value: buildObserverData(bundle) };
|
|
203
|
+
}
|
|
204
|
+
/** The run with its traces read, or undefined when run.json is missing or a trace is refused. */
|
|
205
|
+
async function sourceTraces(runPaths, bytes) {
|
|
206
|
+
if (!bytes)
|
|
188
207
|
return undefined;
|
|
189
|
-
|
|
208
|
+
const stored = JSON.parse(bytes.toString("utf8"));
|
|
209
|
+
if (!isRecord(stored) || !Array.isArray(stored.streams))
|
|
210
|
+
return undefined;
|
|
211
|
+
// A trace that no longer matches run.json's index is a source that no longer matches.
|
|
212
|
+
return loadRunTraces(runPaths, stored).catch((error) => {
|
|
213
|
+
if (error instanceof StreamTraceRefusedError)
|
|
214
|
+
return undefined;
|
|
215
|
+
throw error;
|
|
216
|
+
});
|
|
190
217
|
}
|
|
191
218
|
/** Portable HTML keeps the lightweight evidence; media remains in the source run. */
|
|
192
219
|
function omitRecordings(data) {
|
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { projectShareCheckedAnalysis, analysisSharingProblems } from "../analysis/sharing.js";
|
|
9
9
|
import { withObserverEndings } from "./data.js";
|
|
10
|
+
import { escapeJsonScript, observerDataElements } from "./page-data.js";
|
|
10
11
|
const OBSERVER_DATA_PLACEHOLDER = "__HUMANISH_OBSERVER_DATA__";
|
|
11
12
|
const OBSERVER_DATA_SLOT = `<script id="observer-data" type="application/json">${OBSERVER_DATA_PLACEHOLDER}</script>`;
|
|
12
13
|
let cachedObserverArtifact = null;
|
|
@@ -166,7 +167,7 @@ function renderObserverAppHtml(data, snapshot, analysis, notes, assets) {
|
|
|
166
167
|
return (snapshot
|
|
167
168
|
? artifact.replace("</head>", '<meta name="humanish-observer-mode" content="snapshot"></head>')
|
|
168
169
|
: artifact)
|
|
169
|
-
.replace(OBSERVER_DATA_SLOT, () =>
|
|
170
|
+
.replace(OBSERVER_DATA_SLOT, () => observerDataElements(data))
|
|
170
171
|
.replace(/<script id="study-analysis" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="study-analysis" type="application/json">${escapeJsonScript(analysis)}</script>`)
|
|
171
172
|
.replace(/<script id="run-notes" type="application\/json">[\s\S]*?<\/script>/, () => `<script id="run-notes" type="application/json">${escapeJsonScript(notes)}</script>`)
|
|
172
173
|
.replace("</body>", () => `${renderExportAssets(assets)}</body>`)
|
|
@@ -241,11 +242,3 @@ function escapeHtml(value) {
|
|
|
241
242
|
}
|
|
242
243
|
});
|
|
243
244
|
}
|
|
244
|
-
function escapeJsonScript(value) {
|
|
245
|
-
return JSON.stringify(value)
|
|
246
|
-
.replace(/</g, "\\u003c")
|
|
247
|
-
.replace(/>/g, "\\u003e")
|
|
248
|
-
.replace(/&/g, "\\u0026")
|
|
249
|
-
.replace(/\u2028/g, "\\u2028")
|
|
250
|
-
.replace(/\u2029/g, "\\u2029");
|
|
251
|
-
}
|
package/dist/observer/data.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ActorEnding } from "../actors/stop-cause.js";
|
|
|
2
2
|
import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
|
|
3
3
|
import { type RunDisplay } from "../run/display.js";
|
|
4
4
|
import type { RunStream, RunStreamKind } from "../run/streams.js";
|
|
5
|
-
export declare const OBSERVER_DATA_SCHEMA = "humanish.observer-data.
|
|
5
|
+
export declare const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v2";
|
|
6
6
|
interface ObserverArtifactLink {
|
|
7
7
|
label: string;
|
|
8
8
|
href: string;
|
|
@@ -10,7 +10,7 @@ interface ObserverArtifactLink {
|
|
|
10
10
|
}
|
|
11
11
|
export interface ObserverData {
|
|
12
12
|
schema: typeof OBSERVER_DATA_SCHEMA;
|
|
13
|
-
schemaVersion:
|
|
13
|
+
schemaVersion: 2;
|
|
14
14
|
generatedAt: string;
|
|
15
15
|
/** Served-only liveness observation. Never a replacement for the recorded evidence verdict. */
|
|
16
16
|
runtime?: {
|
|
@@ -123,6 +123,13 @@ interface ObserverLaneGroup {
|
|
|
123
123
|
* carried its outcome.
|
|
124
124
|
*/
|
|
125
125
|
export declare function buildObserverData(bundle: RunBundle, generatedAt?: string, statusRecord?: unknown): ObserverData;
|
|
126
|
+
/**
|
|
127
|
+
* A saved Observer projection as this version reads it, unchecked as before: an observer-data.json
|
|
128
|
+
* written before v2 holds every participant's items. A v2 one holds none, so it is read only when
|
|
129
|
+
* no participant has any: showing participants with no steps would say they did nothing, and the
|
|
130
|
+
* items are in the run's traces. Null for a v2 projection that leaves items out.
|
|
131
|
+
*/
|
|
132
|
+
export declare function observerDataFromSaved(value: unknown): ObserverData | null;
|
|
126
133
|
/** Refresh presentation from recorded actor evidence, including older exported snapshots. */
|
|
127
134
|
export declare function withObserverEndings(data: ObserverData): ObserverData;
|
|
128
135
|
export {};
|
package/dist/observer/data.js
CHANGED
|
@@ -3,7 +3,8 @@ import { isCommsReceivingEvidence, receivingAnalysisContext } from "../comms/rec
|
|
|
3
3
|
import { formatParticipantOutcomes, formatRunTaskFunnel, participantOutcomeDetails, withCuaReviewProvenance, } from "../run/outcomes.js";
|
|
4
4
|
import { cuaGoalSource, CUA_COMPLETION_NOTE } from "../actors/goal-source.js";
|
|
5
5
|
import { bundleDisplayFacts, runDisplay } from "../run/display.js";
|
|
6
|
-
|
|
6
|
+
import { joinObserverData, shellNeedsItems } from "./participant-items.js";
|
|
7
|
+
export const OBSERVER_DATA_SCHEMA = "humanish.observer-data.v2";
|
|
7
8
|
/** Discard a forged or stale runtime grant before projecting persisted evidence. */
|
|
8
9
|
export function recordedStreamEmbed(embed) {
|
|
9
10
|
if (!embed)
|
|
@@ -80,7 +81,7 @@ export function buildObserverData(bundle, generatedAt = new Date().toISOString()
|
|
|
80
81
|
const active = streams.filter((stream) => stream.status === "running" || stream.status === "preparing").length;
|
|
81
82
|
return withObserverEndings({
|
|
82
83
|
schema: OBSERVER_DATA_SCHEMA,
|
|
83
|
-
schemaVersion:
|
|
84
|
+
schemaVersion: 2,
|
|
84
85
|
generatedAt,
|
|
85
86
|
run: {
|
|
86
87
|
runId: bundle.runId,
|
|
@@ -141,6 +142,20 @@ export function buildObserverData(bundle, generatedAt = new Date().toISOString()
|
|
|
141
142
|
},
|
|
142
143
|
});
|
|
143
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* A saved Observer projection as this version reads it, unchecked as before: an observer-data.json
|
|
147
|
+
* written before v2 holds every participant's items. A v2 one holds none, so it is read only when
|
|
148
|
+
* no participant has any: showing participants with no steps would say they did nothing, and the
|
|
149
|
+
* items are in the run's traces. Null for a v2 projection that leaves items out.
|
|
150
|
+
*/
|
|
151
|
+
export function observerDataFromSaved(value) {
|
|
152
|
+
const saved = value;
|
|
153
|
+
if (saved.schema === "humanish.observer-data.v1")
|
|
154
|
+
return { ...saved, schema: OBSERVER_DATA_SCHEMA, schemaVersion: 2 };
|
|
155
|
+
return shellNeedsItems(saved)
|
|
156
|
+
? null
|
|
157
|
+
: joinObserverData(saved, null);
|
|
158
|
+
}
|
|
144
159
|
/** Refresh presentation from recorded actor evidence, including older exported snapshots. */
|
|
145
160
|
export function withObserverEndings(data) {
|
|
146
161
|
const streams = (data.streams ?? []).map(({ ending: _previousEnding, ...stream }) => {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ObserverData } from "./data.js";
|
|
2
|
+
/** The page's data elements for `data`, in place of the built page's data slot. */
|
|
3
|
+
export declare function observerDataElements(data: ObserverData): string;
|
|
4
|
+
/** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
|
|
5
|
+
export declare function escapeJsonScript(value: unknown): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// How an Observer page carries a run's data: observer-data.json's content as JSON, and every
|
|
2
|
+
// participant's trace items gzip-compressed in base64, which a file:// page decodes without a
|
|
3
|
+
// fetch. The items are most of a long run's bytes and compress about eight times.
|
|
4
|
+
import { gzipSync } from "node:zlib";
|
|
5
|
+
import { splitObserverData } from "./participant-items.js";
|
|
6
|
+
/** The page's data elements for `data`, in place of the built page's data slot. */
|
|
7
|
+
export function observerDataElements(data) {
|
|
8
|
+
const { shell, items } = splitObserverData(data);
|
|
9
|
+
const packed = gzipSync(JSON.stringify(items)).toString("base64");
|
|
10
|
+
return (`<script id="observer-data" type="application/json">${escapeJsonScript(shell)}</script>` +
|
|
11
|
+
`<script id="observer-items" type="application/octet-stream" data-encoding="gzip">${packed}</script>`);
|
|
12
|
+
}
|
|
13
|
+
/** JSON that stays inert inside a script element: no `</script>` breakout, no HTML entities. */
|
|
14
|
+
export function escapeJsonScript(value) {
|
|
15
|
+
return JSON.stringify(value)
|
|
16
|
+
.replace(/</g, "\\u003c")
|
|
17
|
+
.replace(/>/g, "\\u003e")
|
|
18
|
+
.replace(/&/g, "\\u0026")
|
|
19
|
+
.replace(/\u2028/g, "\\u2028")
|
|
20
|
+
.replace(/\u2029/g, "\\u2029");
|
|
21
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ActorTraceItem } from "../actors/contract.js";
|
|
2
|
+
import type { ObserverData, ObserverStream } from "./data.js";
|
|
3
|
+
export declare const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v1";
|
|
4
|
+
/** A stream's actor and live actor, each without its items. */
|
|
5
|
+
type ObserverShellStream = Omit<ObserverStream, "actor" | "liveActor"> & {
|
|
6
|
+
actor?: Omit<NonNullable<ObserverStream["actor"]>, "items">;
|
|
7
|
+
liveActor?: Omit<NonNullable<ObserverStream["liveActor"]>, "items">;
|
|
8
|
+
/** How many screenshot entries of `artifacts` travel with the items, when any do. */
|
|
9
|
+
screenshotArtifacts?: number;
|
|
10
|
+
};
|
|
11
|
+
/** observer-data.json: the Observer's data with no participant's trace items. */
|
|
12
|
+
export type ObserverShell = Omit<ObserverData, "streams"> & {
|
|
13
|
+
streams: ObserverShellStream[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Each participant's trace items by stream id, with the stream's artifact list when it names
|
|
17
|
+
* screenshots: one per turn, so it grows with the items.
|
|
18
|
+
*/
|
|
19
|
+
export interface ObserverItems {
|
|
20
|
+
schema: typeof OBSERVER_ITEMS_SCHEMA;
|
|
21
|
+
runId: string;
|
|
22
|
+
streams: Record<string, {
|
|
23
|
+
actor?: ActorTraceItem[];
|
|
24
|
+
liveActor?: ActorTraceItem[];
|
|
25
|
+
artifacts?: ObserverStream["artifacts"];
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
/** The data as observer-data.json and the page's items carry it. */
|
|
29
|
+
export declare function splitObserverData(data: ObserverData): {
|
|
30
|
+
shell: ObserverShell;
|
|
31
|
+
items: ObserverItems;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Whether `items` are this run's and hold every participant's items that the data leaves out. The
|
|
35
|
+
* page reads nothing short of that: participants shown with no steps would say they did nothing.
|
|
36
|
+
*/
|
|
37
|
+
export declare function itemsCoverShell(shell: ObserverShell, items: ObserverItems): boolean;
|
|
38
|
+
/** Whether the data leaves out items or screenshot entries that only an items document holds. */
|
|
39
|
+
export declare function shellNeedsItems(shell: ObserverShell): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The data with each participant's items back in place. A stream the items do not cover keeps an
|
|
42
|
+
* empty list, so the page shows the participant with no recorded steps.
|
|
43
|
+
*/
|
|
44
|
+
export declare function joinObserverData(shell: ObserverShell, items: ObserverItems | null): ObserverData;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The Observer's data in two parts: observer-data.json, the run and every participant without
|
|
2
|
+
// their trace items, and the items themselves. A participant working for hours records thousands
|
|
3
|
+
// of items, so the items are most of the bytes; apart, the saved observer-data.json stays small
|
|
4
|
+
// enough to verify, and the page carries the items compressed. The Observer bundles this module,
|
|
5
|
+
// so it imports types only (docs/decisions/0002-observer-is-one-self-contained-file.md).
|
|
6
|
+
export const OBSERVER_ITEMS_SCHEMA = "humanish.observer-items.v1";
|
|
7
|
+
/** The data as observer-data.json and the page's items carry it. */
|
|
8
|
+
export function splitObserverData(data) {
|
|
9
|
+
// No prototype, so a stream id such as `__proto__` is an entry like any other.
|
|
10
|
+
const items = Object.create(null);
|
|
11
|
+
const streams = data.streams.map((stream) => {
|
|
12
|
+
const { actor, liveActor, ...rest } = stream;
|
|
13
|
+
const entry = {};
|
|
14
|
+
const shell = rest;
|
|
15
|
+
// Saved projections are read unchecked, and one written by an older version may have no list.
|
|
16
|
+
if (Array.isArray(stream.artifacts) &&
|
|
17
|
+
stream.artifacts.some((artifact) => artifact.kind === "screenshot")) {
|
|
18
|
+
entry.artifacts = stream.artifacts;
|
|
19
|
+
shell.artifacts = stream.artifacts.filter((artifact) => artifact.kind !== "screenshot");
|
|
20
|
+
shell.screenshotArtifacts = stream.artifacts.length - shell.artifacts.length;
|
|
21
|
+
}
|
|
22
|
+
if (actor !== undefined) {
|
|
23
|
+
const { items: actorItems, ...summary } = actor;
|
|
24
|
+
entry.actor = actorItems;
|
|
25
|
+
shell.actor = summary;
|
|
26
|
+
}
|
|
27
|
+
if (liveActor !== undefined) {
|
|
28
|
+
const { items: liveItems, ...summary } = liveActor;
|
|
29
|
+
entry.liveActor = liveItems;
|
|
30
|
+
shell.liveActor = summary;
|
|
31
|
+
}
|
|
32
|
+
if (Object.keys(entry).length > 0)
|
|
33
|
+
items[stream.id] = entry;
|
|
34
|
+
return shell;
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
shell: { ...data, streams },
|
|
38
|
+
items: { schema: OBSERVER_ITEMS_SCHEMA, runId: data.run.runId, streams: items },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether `items` are this run's and hold every participant's items that the data leaves out. The
|
|
43
|
+
* page reads nothing short of that: participants shown with no steps would say they did nothing.
|
|
44
|
+
*/
|
|
45
|
+
export function itemsCoverShell(shell, items) {
|
|
46
|
+
if (items.runId !== shell.run.runId)
|
|
47
|
+
return false;
|
|
48
|
+
return shell.streams.every((stream) => {
|
|
49
|
+
const entry = Object.hasOwn(items.streams, stream.id) ? items.streams[stream.id] : undefined;
|
|
50
|
+
return ((stream.actor === undefined || Array.isArray(entry?.actor)) &&
|
|
51
|
+
(stream.liveActor === undefined || Array.isArray(entry?.liveActor)) &&
|
|
52
|
+
(stream.screenshotArtifacts === undefined ||
|
|
53
|
+
(Array.isArray(entry?.artifacts) &&
|
|
54
|
+
entry.artifacts.filter((artifact) => artifact.kind === "screenshot").length ===
|
|
55
|
+
stream.screenshotArtifacts)));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Whether the data leaves out items or screenshot entries that only an items document holds. */
|
|
59
|
+
export function shellNeedsItems(shell) {
|
|
60
|
+
return shell.streams.some((stream) => stream.actor !== undefined ||
|
|
61
|
+
stream.liveActor !== undefined ||
|
|
62
|
+
stream.screenshotArtifacts !== undefined);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The data with each participant's items back in place. A stream the items do not cover keeps an
|
|
66
|
+
* empty list, so the page shows the participant with no recorded steps.
|
|
67
|
+
*/
|
|
68
|
+
export function joinObserverData(shell, items) {
|
|
69
|
+
const byStream = items !== null && items.runId === shell.run.runId ? items.streams : {};
|
|
70
|
+
return {
|
|
71
|
+
...shell,
|
|
72
|
+
streams: shell.streams.map((stream) => {
|
|
73
|
+
const entry = Object.hasOwn(byStream, stream.id) ? byStream[stream.id] : undefined;
|
|
74
|
+
const { actor, liveActor, screenshotArtifacts: _moved, ...rest } = stream;
|
|
75
|
+
return {
|
|
76
|
+
...rest,
|
|
77
|
+
artifacts: entry?.artifacts ?? stream.artifacts,
|
|
78
|
+
...(actor === undefined ? {} : { actor: { ...actor, items: entry?.actor ?? [] } }),
|
|
79
|
+
...(liveActor === undefined
|
|
80
|
+
? {}
|
|
81
|
+
: { liveActor: { ...liveActor, items: entry?.liveActor ?? [] } }),
|
|
82
|
+
};
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
package/dist/observer/render.js
CHANGED
|
@@ -17,6 +17,7 @@ import { RUN_STATUS_FILE } from "../run/status.js";
|
|
|
17
17
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
18
18
|
import { renderObserverHtml } from "./artifact.js";
|
|
19
19
|
import { buildObserverData } from "./data.js";
|
|
20
|
+
import { splitObserverData } from "./participant-items.js";
|
|
20
21
|
import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin } from "./http.js";
|
|
21
22
|
import { listenOnLoopback } from "./listen.js";
|
|
22
23
|
import { NOTES_PATH } from "../run/note-shape.js";
|
|
@@ -116,7 +117,7 @@ export async function renderObserver(cwdInput, runInput, options = {}) {
|
|
|
116
117
|
verifiedAt: new Date().toISOString(),
|
|
117
118
|
reasons: verified.shareSafety.reasons.map((reason) => reason.code),
|
|
118
119
|
};
|
|
119
|
-
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(observerData, null, 2)}\n`, "utf8");
|
|
120
|
+
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "observer-data.json"), `${JSON.stringify(splitObserverData(observerData).shell, null, 2)}\n`, "utf8");
|
|
120
121
|
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "study-analysis.json"), `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
|
|
121
122
|
await writeContainedOutputFile(preparedRunPaths, path.join("observer", "index.html"), renderObserverHtml(observerData, { analysis, notes: await readRunNotes(preparedRunPaths) }), "utf8");
|
|
122
123
|
await validatePreparedRunArtifactPaths(preparedRunPaths);
|