humanish 0.123.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 +13 -0
- package/CONTEXT.md +1 -1
- 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/evidence/image.js +82 -3
- package/dist/feedback/export-bundle.js +1 -5
- package/dist/feedback/export.js +30 -11
- 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 +14 -4
- package/dist/observer-app.html +4 -4
- package/dist/run/run.js +2 -1
- package/dist/study/plan.js +20 -0
- package/docs/architecture/observer.md +24 -5
- package/docs/contracts/run-bundle.md +3 -1
- package/docs/contracts/study-analysis.md +1 -1
- package/docs/decisions/0002-observer-is-one-self-contained-file.md +2 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,19 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.124.0: Small Observer files for long studies, faster verify, closing reports under zero data retention (2026-10-12)
|
|
12
|
+
|
|
13
|
+
humanish 0.124.0 keeps a long study's Observer files small, checks screenshots about eight times
|
|
14
|
+
faster, and asks an `openai-computer-use` participant for its closing report at a scheduled end
|
|
15
|
+
under zero data retention. `observer/observer-data.json` is now `humanish.observer-data.v2`: the
|
|
16
|
+
run and each participant without their trace items and screenshot entries, which the Observer
|
|
17
|
+
page carries compressed. On a synthetic run of 24 participants working 8 hours, observer-data.json
|
|
18
|
+
fell from 155 MiB to 0.33 MiB and index.html from 94 MiB to 16 MiB, so verify reads both. A
|
|
19
|
+
reader of observer-data.json that needs a participant's items reads the run's traces. verify of a
|
|
20
|
+
synthetic 8-hour run with 8,895 real-size screenshots took 51 s, down from 282 s.
|
|
21
|
+
|
|
22
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.124.0)
|
|
23
|
+
|
|
11
24
|
## 0.123.0: Run bundle v2 writes each trace once, bundle size estimate (2026-10-12)
|
|
12
25
|
|
|
13
26
|
humanish 0.123.0 writes each participant's trace once, outside run.json, so long studies stay
|
package/CONTEXT.md
CHANGED
|
@@ -36,7 +36,7 @@ bundle and output fields keep older spellings; the second table maps them.
|
|
|
36
36
|
| topology | Whether participants share one instance of the subject. `route: shared-world` gives one for all; every other route gives each participant its own (`per-lane-worlds`). `run.json` records it as `sharedWorld.topology`. | `route` in `StudyConfig` (`src/study/types.ts`); `isSharedWorldComposition` in `src/study/routing.ts` |
|
|
37
37
|
| plane | The one instance of the subject that a shared-world study's participants use together. A `provisioned` plane is a `clone` or `local-tree` subject that humanish serves in a sandbox, with a declared subject state. An `external-public` plane is a deployment already running at `subject.appUrl`, whose owner the study names in `subject.publicTarget.owner`. Each plane has at least two participants. | `SharedWorldPlane` in `src/study/plan-types.ts`; `src/routes/shared-world/` |
|
|
38
38
|
| stream | One live or recorded view of a participant, such as a browser or a terminal, in `run.json` and the Observer. | `RunStream` in `src/run/streams.ts` |
|
|
39
|
-
| Observer | The single-file review page built from `observer/observer-data.json` (`humanish.observer-data.
|
|
39
|
+
| Observer | The single-file review page built from `observer/observer-data.json` (`humanish.observer-data.v2`). | `observer/` (the page), `src/observer/` (data, render, serve) |
|
|
40
40
|
| evidence | Files a run keeps: screenshots, traces, transcripts, recordings. `verify` checks them and grades share-safety. | `src/evidence/`; `verifyRun` in `src/verify/verify.ts` |
|
|
41
41
|
| analysis | An independent model reading of a finished run's retained evidence, kept apart from participant feedback. Supported live runs request it automatically, and `humanish analyze` runs it on demand. It records findings and observations as `humanish.study-analysis.v1`. | `src/analysis/`; `runAutomaticAnalysis` in `src/analysis/automatic.ts`; `analyzeRun` in `src/analysis/service.ts`; `ANALYSIS_SCHEMA` in `src/analysis/types.ts` |
|
|
42
42
|
| finding | A result of analysis, with citations to evidence in the run. Each finding groups the observations that support it. | `src/analysis/` |
|
|
@@ -65,7 +65,12 @@ export function statusForCompletionReason(reason) {
|
|
|
65
65
|
}
|
|
66
66
|
export function loopResult(session, stop, debrief) {
|
|
67
67
|
const { provider, settings, trace: recorder, usage } = session;
|
|
68
|
-
const { completionReason,
|
|
68
|
+
const { completionReason, stopCause } = stop;
|
|
69
|
+
// A participant whose session ended on the clock has its closing account as its reason, as one
|
|
70
|
+
// that ended the session itself has its last message; the stop cause keeps the scheduled end.
|
|
71
|
+
const reason = stopCause === "scheduled_end" && debrief?.report !== undefined
|
|
72
|
+
? debrief.report.summary
|
|
73
|
+
: stop.reason;
|
|
69
74
|
const completedAtMs = session.now();
|
|
70
75
|
const status = statusForCompletionReason(completionReason);
|
|
71
76
|
const ids = {};
|
|
@@ -65,12 +65,6 @@ export declare class OpenAiConversation {
|
|
|
65
65
|
* from the request being sent. False when none are asked for, so the rejection stands.
|
|
66
66
|
*/
|
|
67
67
|
dropReasoningSummaries(): boolean;
|
|
68
|
-
/**
|
|
69
|
-
* Whether the server holds the whole session, so a closing report threaded on it reads every
|
|
70
|
-
* turn. An explicit-context conversation summarizes its oldest turns past its budget, configured
|
|
71
|
-
* or switched to at runtime.
|
|
72
|
-
*/
|
|
73
|
-
get serverHoldsSession(): boolean;
|
|
74
68
|
/** The trace's account of how the conversation was carried (ActorConversation). */
|
|
75
69
|
record(): ActorConversation;
|
|
76
70
|
/**
|
|
@@ -72,14 +72,6 @@ export class OpenAiConversation {
|
|
|
72
72
|
this.reasoningSummary = undefined;
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
75
|
-
/**
|
|
76
|
-
* Whether the server holds the whole session, so a closing report threaded on it reads every
|
|
77
|
-
* turn. An explicit-context conversation summarizes its oldest turns past its budget, configured
|
|
78
|
-
* or switched to at runtime.
|
|
79
|
-
*/
|
|
80
|
-
get serverHoldsSession() {
|
|
81
|
-
return this.mode === "threaded" && this.lastResponseId !== undefined;
|
|
82
|
-
}
|
|
83
75
|
/** The trace's account of how the conversation was carried (ActorConversation). */
|
|
84
76
|
record() {
|
|
85
77
|
return this.evidence.snapshot(this.mode);
|
|
@@ -509,14 +509,11 @@ export function createOpenAiResponsesProvider(options) {
|
|
|
509
509
|
get conversation() {
|
|
510
510
|
return conversation.record();
|
|
511
511
|
},
|
|
512
|
-
// A
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
},
|
|
518
|
-
// Offered in both modes. An explicit_context conversation carries its newest screens whole and
|
|
519
|
-
// earlier turns as text, which serves impressions; a closing report needs the whole session.
|
|
512
|
+
// Both are offered in both modes. A threaded request reads the session the server holds; an
|
|
513
|
+
// explicit_context one carries its newest screens whole and its oldest turns summarized past
|
|
514
|
+
// the budget, which the trace's conversation record counts. A participant that worked a long
|
|
515
|
+
// shift writes its closing account from what it carries, as a person writes one from memory.
|
|
516
|
+
debrief: (req, signal) => requestTurn(req, signal, "report"),
|
|
520
517
|
requestImpressions: (req, signal) => requestTurn(req, signal, "impressions"),
|
|
521
518
|
};
|
|
522
519
|
}
|
package/dist/evidence/image.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { crc32, inflateSync } from "node:zlib";
|
|
1
2
|
import { PNG } from "pngjs";
|
|
2
3
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
3
4
|
const PNG_IHDR_LENGTH = 13;
|
|
@@ -145,12 +146,90 @@ export function screenshotEvidenceError(relativePath, bytes) {
|
|
|
145
146
|
if (chunks?.some((chunk) => chunk.type === "IEND" && chunk.end - chunk.start !== 12)) {
|
|
146
147
|
return "PNG IEND chunk must be empty";
|
|
147
148
|
}
|
|
149
|
+
return chunks === null || !pngStructureHolds(bytes, chunks)
|
|
150
|
+
? "could not decode PNG evidence"
|
|
151
|
+
: null;
|
|
152
|
+
}
|
|
153
|
+
/** The bit depths PNG allows for each color type. */
|
|
154
|
+
const PNG_BIT_DEPTHS = {
|
|
155
|
+
0: [1, 2, 4, 8, 16],
|
|
156
|
+
2: [8, 16],
|
|
157
|
+
3: [1, 2, 4, 8],
|
|
158
|
+
4: [8, 16],
|
|
159
|
+
6: [8, 16],
|
|
160
|
+
};
|
|
161
|
+
const PNG_CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
162
|
+
/** The fewest bytes the decoder reads from a chunk; a shorter one fails to decode. */
|
|
163
|
+
const PNG_MIN_CHUNK_BYTES = { gAMA: 4 };
|
|
164
|
+
/**
|
|
165
|
+
* Whether a PNG that passed pngDecodeRefusal decodes, read from its structure: the file ends at an
|
|
166
|
+
* empty IEND, every chunk's CRC matches, IHDR comes once with fields PNG defines, the image data is
|
|
167
|
+
* one run of IDAT chunks, and it inflates, with zlib's native code, to exactly the
|
|
168
|
+
* bytes its rows need with nothing after the stream, each row starting with a filter type from 0
|
|
169
|
+
* to 4. For an image without a palette that is all a decode establishes, at about an eighth of
|
|
170
|
+
* the time of pngjs, which unfilters every pixel in JavaScript; a run holds a screenshot for each
|
|
171
|
+
* turn. A palette image is also decoded, since each pixel must index a palette that comes before
|
|
172
|
+
* the image data; in any other image a palette chunk is only a suggestion, wherever it is.
|
|
173
|
+
*/
|
|
174
|
+
function pngStructureHolds(bytes, chunks) {
|
|
175
|
+
const last = chunks.at(-1);
|
|
176
|
+
if (last?.type !== "IEND" || last.end !== bytes.length)
|
|
177
|
+
return false;
|
|
178
|
+
const crcHolds = (chunk) => crc32(bytes.subarray(chunk.start + 4, chunk.end - 4)) === bytes.readUInt32BE(chunk.end - 4);
|
|
179
|
+
if (!chunks.every(crcHolds))
|
|
180
|
+
return false;
|
|
181
|
+
const types = chunks.map((chunk) => chunk.type);
|
|
182
|
+
const firstIdat = types.indexOf("IDAT");
|
|
183
|
+
const idats = chunks.filter((chunk) => chunk.type === "IDAT");
|
|
184
|
+
if (types.lastIndexOf("IHDR") !== 0 ||
|
|
185
|
+
firstIdat < 0 ||
|
|
186
|
+
types.lastIndexOf("IDAT") !== firstIdat + idats.length - 1)
|
|
187
|
+
return false;
|
|
188
|
+
const [width, height] = [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
189
|
+
const [depth, colorType, compression, filter] = [bytes[24], bytes[25], bytes[26], bytes[27]];
|
|
190
|
+
if (!PNG_BIT_DEPTHS[colorType]?.includes(depth) || compression !== 0 || filter !== 0)
|
|
191
|
+
return false;
|
|
192
|
+
// The decoder sizes a row in 32-bit arithmetic and refuses one wider; no screenshot is.
|
|
193
|
+
const rowBits = width * PNG_CHANNELS[colorType] * depth;
|
|
194
|
+
if (rowBits + 7 > 0x7fffffff)
|
|
195
|
+
return false;
|
|
196
|
+
const trnsBytes = colorType === 0 ? 2 : colorType === 2 ? 6 : 0;
|
|
197
|
+
const tooShort = (chunk) => chunk.end - chunk.start - 12 <
|
|
198
|
+
(chunk.type === "tRNS" ? trnsBytes : (PNG_MIN_CHUNK_BYTES[chunk.type] ?? 0));
|
|
199
|
+
if (chunks.some(tooShort))
|
|
200
|
+
return false;
|
|
201
|
+
const rowBytes = 1 + Math.ceil(rowBits / 8);
|
|
202
|
+
const data = Buffer.concat(idats.map((chunk) => bytes.subarray(chunk.start + 8, chunk.end - 4)));
|
|
203
|
+
let rows;
|
|
148
204
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
205
|
+
// A stream that would inflate past the rows the image needs stops there, so a small file
|
|
206
|
+
// cannot claim gigabytes.
|
|
207
|
+
const options = {
|
|
208
|
+
maxOutputLength: rowBytes * height,
|
|
209
|
+
info: true,
|
|
210
|
+
};
|
|
211
|
+
const inflated = inflateSync(data, options);
|
|
212
|
+
if (inflated.engine.bytesWritten !== data.length)
|
|
213
|
+
return false;
|
|
214
|
+
rows = inflated.buffer;
|
|
151
215
|
}
|
|
152
216
|
catch {
|
|
153
|
-
return
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
if (rows.length !== rowBytes * height)
|
|
220
|
+
return false;
|
|
221
|
+
for (let row = 0; row < rows.length; row += rowBytes)
|
|
222
|
+
if (rows[row] > 4)
|
|
223
|
+
return false;
|
|
224
|
+
return colorType !== 3 || pngDecodes(bytes);
|
|
225
|
+
}
|
|
226
|
+
function pngDecodes(bytes) {
|
|
227
|
+
try {
|
|
228
|
+
PNG.sync.read(bytes, { checkCRC: true });
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
154
233
|
}
|
|
155
234
|
}
|
|
156
235
|
export function assertScreenshotEvidence(relativePath, bytes) {
|
|
@@ -5,7 +5,6 @@ import { lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, rmdir } from "nod
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { renderObserver } from "../observer/render.js";
|
|
8
|
-
import { buildObserverData } from "../observer/data.js";
|
|
9
8
|
import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
10
9
|
import { splitStreamTraces } from "../run/stream-trace.js";
|
|
11
10
|
import { redactScreenshot, redactText } from "../evidence/redaction.js";
|
|
@@ -441,10 +440,7 @@ async function buildDerivative(stage, source, runId, bundle, maxBytes) {
|
|
|
441
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.",
|
|
442
441
|
files: transformed.entries,
|
|
443
442
|
};
|
|
444
|
-
|
|
445
|
-
if (!transformedBundle)
|
|
446
|
-
throw new Error("Derivative bundle could not be loaded.");
|
|
447
|
-
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.
|
|
448
444
|
const rendered = await renderObserver(stage.physicalPath, runId, { open: false });
|
|
449
445
|
if (!rendered.ok)
|
|
450
446
|
throw new Error("Derivative Observer could not be rebuilt.");
|
package/dist/feedback/export.js
CHANGED
|
@@ -13,7 +13,7 @@ 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
18
|
import { loadRunTraces, StreamTraceRefusedError } from "../run/stream-trace.js";
|
|
19
19
|
import { verifyRun } from "../verify/verify.js";
|
|
@@ -25,6 +25,7 @@ import { cli } from "../cli/invocation.js";
|
|
|
25
25
|
import { analysisSharingProblems } from "../analysis/sharing.js";
|
|
26
26
|
import { EVIDENCE_LIMITS, validateAnalysisEvidence } from "../analysis/evidence.js";
|
|
27
27
|
import { readBoundedFile } from "../run/evidence-files.js";
|
|
28
|
+
import { isRecord } from "../run/type-guards.js";
|
|
28
29
|
import { shellQuote } from "../substrates/shell.js";
|
|
29
30
|
import { plural } from "../run/text.js";
|
|
30
31
|
const EXPORT_SCHEMA = "humanish.export-result.v1";
|
|
@@ -80,7 +81,7 @@ export async function exportRun(cwdInput, runInput, options = {}, deps = {}) {
|
|
|
80
81
|
const analysis = await loadAnalysis(runPaths);
|
|
81
82
|
const data = await currentObserverData(runPaths, analysis, slot);
|
|
82
83
|
if (data === undefined)
|
|
83
|
-
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.");
|
|
84
85
|
const omittedRecording = omitRecordings(data.value);
|
|
85
86
|
if (omittedRecording)
|
|
86
87
|
warnings.push(MEDIA_OMISSION);
|
|
@@ -177,24 +178,42 @@ async function readObserverSlot(cwd, runInput, runRoot, runId, verified, deps, w
|
|
|
177
178
|
/**
|
|
178
179
|
* The Observer data to export. A current report must travel with the current source projection,
|
|
179
180
|
* even if an older saved HTML was never refreshed; old recording-only exports keep the saved slot.
|
|
180
|
-
*
|
|
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.
|
|
181
184
|
*/
|
|
182
185
|
async function currentObserverData(runPaths, analysis, slot) {
|
|
183
186
|
const saved = JSON.parse(slot);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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)
|
|
207
|
+
return undefined;
|
|
208
|
+
const stored = JSON.parse(bytes.toString("utf8"));
|
|
209
|
+
if (!isRecord(stored) || !Array.isArray(stored.streams))
|
|
189
210
|
return undefined;
|
|
190
|
-
const stored = JSON.parse(source.toString("utf8"));
|
|
191
211
|
// A trace that no longer matches run.json's index is a source that no longer matches.
|
|
192
|
-
|
|
212
|
+
return loadRunTraces(runPaths, stored).catch((error) => {
|
|
193
213
|
if (error instanceof StreamTraceRefusedError)
|
|
194
214
|
return undefined;
|
|
195
215
|
throw error;
|
|
196
216
|
});
|
|
197
|
-
return bundle === undefined ? undefined : { value: buildObserverData(bundle) };
|
|
198
217
|
}
|
|
199
218
|
/** Portable HTML keeps the lightweight evidence; media remains in the source run. */
|
|
200
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);
|
|
@@ -16,7 +16,8 @@ import { MAX_NOTE_FILE_BYTES, readRunNotes, RUN_NOTES_DIR, } from "../run/note-f
|
|
|
16
16
|
import { analysisCostOf, runCost, runCostLabel } from "../run/run-cost.js";
|
|
17
17
|
import { RUN_STATUS_FILE, RUN_STATUS_STALE_MS, isRunStatusRecord, } from "../run/status.js";
|
|
18
18
|
import { renderObserverHtml } from "./artifact.js";
|
|
19
|
-
import {
|
|
19
|
+
import { splitObserverData } from "./participant-items.js";
|
|
20
|
+
import { buildObserverData, observerDataFromSaved, recordedStreamEmbed, withObserverEndings, } from "./data.js";
|
|
20
21
|
import { buildArtifactSecurityHeaders } from "./http.js";
|
|
21
22
|
import { assertPinnedDirectory, openContainedFile, pinDirectChildDirectory, readContainedFile, relativeToRoot, sha256OfOpenedFile, } from "./pinned-files.js";
|
|
22
23
|
/** The run's artifact paths, only when they are still the pinned directory being served. */
|
|
@@ -116,13 +117,19 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
|
|
|
116
117
|
}), "text/html; charset=utf-8");
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
+
// observer-data.json is the run without its participants' items, as the run saves it. The page
|
|
121
|
+
// polls observer-feed.json, the same data with the items, so one projection serves each poll.
|
|
122
|
+
if (cleanedRelativePath === "observer/observer-data.json" ||
|
|
123
|
+
cleanedRelativePath === "observer/observer-feed.json") {
|
|
120
124
|
const observerData = await observerDataOrReason(runRoot, runtimeStreamUrls);
|
|
121
125
|
if ("unavailable" in observerData) {
|
|
122
126
|
writeResponse(response, 404, `Observer data not found: ${observerData.unavailable}`, "text/plain; charset=utf-8");
|
|
123
127
|
return;
|
|
124
128
|
}
|
|
125
|
-
|
|
129
|
+
const { shell, items } = splitObserverData(observerData);
|
|
130
|
+
writeResponse(response, 200, cleanedRelativePath === "observer/observer-data.json"
|
|
131
|
+
? JSON.stringify(shell, null, 2)
|
|
132
|
+
: JSON.stringify({ data: shell, items }), "application/json; charset=utf-8");
|
|
126
133
|
return;
|
|
127
134
|
}
|
|
128
135
|
if (cleanedRelativePath === "observer/study-analysis.json") {
|
|
@@ -214,7 +221,10 @@ async function savedObserverData(runRoot, record, runtimeStreamUrls) {
|
|
|
214
221
|
const observerBytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, "observer", "observer-data.json"));
|
|
215
222
|
if (!observerBytes)
|
|
216
223
|
throw new Error("observer-data.json unavailable");
|
|
217
|
-
|
|
224
|
+
const saved = observerDataFromSaved(JSON.parse(observerBytes.toString("utf8")));
|
|
225
|
+
if (saved === null)
|
|
226
|
+
throw new Error("observer-data.json holds no participant's items");
|
|
227
|
+
return withRuntimeStreamUrls(withLocalRunStatus(record, withObserverEndings(saved)), runtimeStreamUrls);
|
|
218
228
|
}
|
|
219
229
|
catch {
|
|
220
230
|
return { unavailable: "The run has written no run.json or Observer data yet." };
|