humanish 0.122.0 → 0.123.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 +16 -0
- package/CONTEXT.md +1 -1
- package/README.md +33 -6
- 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/feedback/derivative-text.d.ts +12 -0
- package/dist/feedback/derivative-text.js +130 -0
- package/dist/feedback/export-bundle.js +38 -131
- package/dist/feedback/export.js +9 -1
- package/dist/observer/run-routes.js +61 -18
- 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 +7 -1
- 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 +22 -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/contracts/README.md +1 -1
- package/docs/contracts/run-bundle.md +87 -6
- package/docs/contracts/schemas.md +14 -7
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,22 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.123.0: Run bundle v2 writes each trace once, bundle size estimate (2026-10-12)
|
|
12
|
+
|
|
13
|
+
humanish 0.123.0 writes each participant's trace once, outside run.json, so long studies stay
|
|
14
|
+
within the bundle's size limits, and estimates a study's bundle before it runs. Run bundles are now
|
|
15
|
+
`humanish.run-bundle.v2`: a participant's trace items live only in its trace file
|
|
16
|
+
(`actor.json`, `actors/<stream>.json` or `actor-<surface>.json`), and run.json indexes each one by
|
|
17
|
+
path, size and sha256. On a synthetic 8-participant, 1,000-turn run, run.json fell from 17 MB to
|
|
18
|
+
74 KB. Every reader loads traces through one loader that checks the index and refuses a missing,
|
|
19
|
+
altered or shared trace; a refused trace shows as evidence unavailable, and an unfinished run
|
|
20
|
+
grades `local_only`. Bundles from earlier versions still load unchanged; npm 0.122.0 and earlier
|
|
21
|
+
refuse a v2 bundle by name. `study check` and `run` estimate the bundle's size and file count and
|
|
22
|
+
warn before any sandbox is created when it may pass the analysis or verify limit, and the
|
|
23
|
+
public-safety scan's file limit grows with the number of participants.
|
|
24
|
+
|
|
25
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.123.0)
|
|
26
|
+
|
|
11
27
|
## 0.122.0: Participants end on the clock, a harness error ends only its participant (2026-10-12)
|
|
12
28
|
|
|
13
29
|
humanish 0.122.0 lets a participant end at a scheduled time and keeps one participant's harness
|
package/CONTEXT.md
CHANGED
|
@@ -12,7 +12,7 @@ bundle and output fields keep older spellings; the second table maps them.
|
|
|
12
12
|
| Term | Meaning | Where it lives in the code |
|
|
13
13
|
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
14
14
|
| study | What a user designs and runs: one YAML file that declares the subject, the actor, the participants and the caps. The file lives at `humanish/studies/<id>.yaml` with schema `humanish.study.v3`, read by `humanish study` and `--study`. humanish refuses a 0.107 `humanish.lab.v2` file, and `humanish migrate` converts it (second table). | `src/study/`; `StudyConfig` in `src/study/types.ts`, parsed by `parseStudy` in `src/study/config.ts` |
|
|
15
|
-
| run | One execution of a study, on disk: `.humanish/runs/<runId>/`, with `run.json` (`humanish.run-bundle.
|
|
15
|
+
| run | One execution of a study, on disk: `.humanish/runs/<runId>/`, with `run.json` (`humanish.run-bundle.v2`) as the source of truth and each participant's trace in its own file. | `src/run/`; `RunBundle` in `src/run/bundle.ts` |
|
|
16
16
|
| dry run | A run with no live participant, from `--dry-run` or `mode: dry-run`. humanish writes a synthetic run bundle, review and Observer with no browser, model, keys or provider spend, and tests no product behavior. The `preview` route only dry-runs; "preview" names that route and nothing else. | `runDryRun` in `src/run/dry-run.ts`; `resolveStudyDryRun` in `src/study/plan.ts` |
|
|
17
17
|
| participant | One simulated person, or agent, taking part in a study. The planner builds each one's record (id, persona id, assignment, labels and the route's own fields). Shared-world participants resolve the plan's records; computer-use participants rebuild the same records from the study config with `computerUseParticipants` (`src/study/plan-participants.ts`) and resolve those. `ResolvedParticipant` adds the bundle ids, persona and prompt. `run.json` records each one as a `simulations[]` entry with one or more `streams[]`. | `ResolvedParticipant` in `src/run/participant.ts`; the planned records in `src/study/plan-participants.ts`. Computer-use and shared-world participants run a `DesktopParticipantRun` (`src/routes/computer-use/types.ts`). The terminal session (`src/routes/terminal/session.ts`), scripted surfaces (`src/routes/scripted/plan.ts`) and fixture simulations (`src/run/dry-run.ts`) still build their own. |
|
|
18
18
|
| persona | Who a participant is: traits, background, device. `humanish/personas/<id>.yaml`. | `src/study/persona.ts`, `src/study/persona-resolve.ts` |
|
package/README.md
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
# humanish
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/humanish) [](https://www.npmjs.com/package/humanish) [](https://github.com/danielgwilson/humanish/actions/workflows/ci.yml?query=branch%3Amain) [](LICENSE) [](https://nodejs.org/)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Synthetic user research for apps, CLIs, and agent-facing product flows. AI participants with a persona and a task use your app on real desktops, and each run leaves evidence you can verify: what they did, where they got stuck, and what it cost.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
<a href="https://humanish.dev/demo">
|
|
8
|
+
<picture>
|
|
9
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-grid-dark.webp">
|
|
10
|
+
<img alt="The humanish Observer replaying a saved study five minutes into its 13-minute recording: a grid of eight participants' desktops in a multiplayer movie-guessing game, seven in the same round and one held at the join page, under tabs for 8 participants and 7 findings" src="https://humanish.dev/runs/lobby-0927/observer-grid-light.webp">
|
|
11
|
+
</picture>
|
|
12
|
+
</a>
|
|
13
|
+
|
|
14
|
+
**[Watch the replay](https://humanish.dev/demo).** The image is the Observer partway through a
|
|
15
|
+
saved study. Eight synthetic participants joined one lobby of a multiplayer movie-guessing game
|
|
16
|
+
on its live deployment, each on its own hosted desktop, and each tile shows one participant's
|
|
17
|
+
screen at the same moment of the recording. Six reached the final standings. Two were blocked,
|
|
18
|
+
among them Player six, who arrived after the game started and is still on the join page. The
|
|
19
|
+
Findings tab holds the seven findings the analysis wrote. The demo page replays this run in the
|
|
20
|
+
Observer, and nothing runs from it.
|
|
10
21
|
|
|
11
22
|
## Learn how a study works
|
|
12
23
|
|
|
@@ -19,6 +30,14 @@ disposable browser VM on your machine. On the computer-use route a model drives
|
|
|
19
30
|
participant: every turn it reads a screenshot and answers with mouse and keyboard actions, until
|
|
20
31
|
the participant finishes, gets stuck or reaches a limit.
|
|
21
32
|
|
|
33
|
+
<picture>
|
|
34
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-player-dark.webp">
|
|
35
|
+
<img alt="The Observer with one participant open: its desktop during a round of a movie-guessing game, replay controls and a filmstrip of captures below, and on the right its trace, with the participant's reasoning and each TAB and ENTER keypress" src="https://humanish.dev/runs/lobby-0927/observer-player-light.webp">
|
|
36
|
+
</picture>
|
|
37
|
+
|
|
38
|
+
Player four from the run above, a keyboard-only participant: its screen, and beside it each
|
|
39
|
+
keypress and the reasoning it wrote before acting.
|
|
40
|
+
|
|
22
41
|
The run writes its evidence to gitignored `.humanish/runs/<run id>/`: screenshots, every
|
|
23
42
|
action, each participant's report and the estimated cost. The Observer replays it,
|
|
24
43
|
`humanish review` prints the findings of the analysis that follows a live run, and
|
|
@@ -26,6 +45,14 @@ action, each participant's report and the estimated cost. The Observer replays i
|
|
|
26
45
|
[How a study works](https://humanish.dev/docs/concepts) walks through one annotated study file,
|
|
27
46
|
and the [study file reference](https://humanish.dev/docs/study-files) lists every field.
|
|
28
47
|
|
|
48
|
+
<picture>
|
|
49
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://humanish.dev/runs/lobby-0927/observer-findings-dark.webp">
|
|
50
|
+
<img alt="The Observer's Findings tab: the first of seven findings, that three participants found keyboard guessing hard to track and one stopped mid-game, with the analysis's summary, what to check next, a cited capture and the timeline of evidence moments behind it" src="https://humanish.dev/runs/lobby-0927/observer-findings-light.webp">
|
|
51
|
+
</picture>
|
|
52
|
+
|
|
53
|
+
The first of the seven findings the analysis wrote for the same run, with the evidence moments
|
|
54
|
+
it cites.
|
|
55
|
+
|
|
29
56
|
## Try it without keys
|
|
30
57
|
|
|
31
58
|
In a project directory, with Node.js 22.19 or newer:
|
|
@@ -308,7 +335,7 @@ and the library exports.
|
|
|
308
335
|
- Breaking changes ship together in one minor release. Its release notes carry a migration
|
|
309
336
|
table: each old form, its new form, and the change to make.
|
|
310
337
|
- The study file and the run bundle carry versioned schemas, `humanish.study.v3` and
|
|
311
|
-
`humanish.run-bundle.
|
|
338
|
+
`humanish.run-bundle.v2`. A breaking change to either one bumps its version.
|
|
312
339
|
|
|
313
340
|
Pin the exact version in `package.json` and read [CHANGELOG.md](CHANGELOG.md) before upgrading.
|
|
314
341
|
|
|
@@ -4,6 +4,7 @@ import {} from "../run/bundle.js";
|
|
|
4
4
|
import { refreshObserver, analyzeRun, readCompletedAnalysisSource, resolveAnalysisRun, } from "./service.js";
|
|
5
5
|
import { preferLargerAnalysisOutput, ANALYSIS_PROMPT_VERSION } from "./execute.js";
|
|
6
6
|
import { captureEvidence } from "./evidence.js";
|
|
7
|
+
import { loadRunTraces } from "../run/stream-trace.js";
|
|
7
8
|
import { hashAnalysisValue } from "./validation.js";
|
|
8
9
|
import { refusedCost } from "./admission.js";
|
|
9
10
|
import { claimAutomaticAnalysis, readAutomaticAnalysisPrepared, requestAutomaticAnalysisCancellationPrepared, } from "./job.js";
|
|
@@ -88,7 +89,7 @@ async function readAutomaticSource(cwd, prepared, configInput, deps, hasKey) {
|
|
|
88
89
|
let config = configInput;
|
|
89
90
|
try {
|
|
90
91
|
const bytes = await readCompletedAnalysisSource(cwd, prepared);
|
|
91
|
-
const bundle = JSON.parse(bytes.toString("utf8"));
|
|
92
|
+
const bundle = await loadRunTraces(prepared, JSON.parse(bytes.toString("utf8")));
|
|
92
93
|
if (bundle.streams.some((stream) => stream.actor?.stopCause === "harness_aborted"))
|
|
93
94
|
return skipped("AUTOMATIC_ANALYSIS_ACTOR_CANCELLED");
|
|
94
95
|
const participantEvidence = hasParticipantEvidence(bundle);
|
|
@@ -3,7 +3,8 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { screenshotEvidenceError } from "../evidence/image.js";
|
|
4
4
|
import { runIdOf } from "../run/paths.js";
|
|
5
5
|
import { isRecord } from "../run/type-guards.js";
|
|
6
|
-
import { RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
6
|
+
import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "../run/bundle.js";
|
|
7
|
+
import { loadStreamTraces } from "../run/stream-trace.js";
|
|
7
8
|
import { ACTION_CAPTURE_VERSION, } from "./types.js";
|
|
8
9
|
import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
|
|
9
10
|
import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, impressionEventIds, } from "./evidence-sources.js";
|
|
@@ -29,7 +30,7 @@ function parseSource(prepared, bytes) {
|
|
|
29
30
|
throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
|
|
30
31
|
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
31
32
|
if (!isRecord(value) ||
|
|
32
|
-
value.schema !==
|
|
33
|
+
(value.schema !== RUN_BUNDLE_SCHEMA && value.schema !== RUN_BUNDLE_V1_SCHEMA) ||
|
|
33
34
|
value.runId !== runIdOf(prepared) ||
|
|
34
35
|
!Array.isArray(value.streams) ||
|
|
35
36
|
value.streams.length > EVIDENCE_LIMITS.participants ||
|
|
@@ -49,24 +50,8 @@ function parseSource(prepared, bytes) {
|
|
|
49
50
|
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
50
51
|
}
|
|
51
52
|
ids.add(stream.id);
|
|
52
|
-
|
|
53
|
-
if (actor !== undefined &&
|
|
54
|
-
(!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
|
|
53
|
+
if (stream.actor !== undefined && !isRecord(stream.actor))
|
|
55
54
|
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
56
|
-
}
|
|
57
|
-
const eventIds = new Set();
|
|
58
|
-
for (const item of isRecord(actor) ? actor.items : []) {
|
|
59
|
-
if (!isRecord(item) ||
|
|
60
|
-
typeof item.id !== "string" ||
|
|
61
|
-
item.id.length === 0 ||
|
|
62
|
-
item.id.length > 256 ||
|
|
63
|
-
eventIds.has(item.id) ||
|
|
64
|
-
typeof item.kind !== "string" ||
|
|
65
|
-
typeof item.title !== "string" ||
|
|
66
|
-
(item.text !== undefined && typeof item.text !== "string"))
|
|
67
|
-
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
68
|
-
eventIds.add(item.id);
|
|
69
|
-
}
|
|
70
55
|
}
|
|
71
56
|
for (const event of value.events) {
|
|
72
57
|
if (!isRecord(event) ||
|
|
@@ -77,6 +62,41 @@ function parseSource(prepared, bytes) {
|
|
|
77
62
|
}
|
|
78
63
|
return value;
|
|
79
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* run.json's bundle with each stream's trace loaded. The source limit applies to each trace file
|
|
67
|
+
* as it does to run.json; run.json's digest, which the analysis record binds, binds each trace's
|
|
68
|
+
* sha256.
|
|
69
|
+
*/
|
|
70
|
+
async function readSource(prepared, bytes, traceBytes) {
|
|
71
|
+
const stored = parseSource(prepared, bytes);
|
|
72
|
+
if (stored.streams.some((stream) => (stream.trace?.bytes ?? 0) > traceBytes))
|
|
73
|
+
throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
|
|
74
|
+
const loaded = await loadStreamTraces(prepared, stored, { maxBytes: traceBytes });
|
|
75
|
+
if (loaded.refused.length > 0)
|
|
76
|
+
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
77
|
+
for (const stream of loaded.bundle.streams)
|
|
78
|
+
validateTraceItems(stream.actor);
|
|
79
|
+
return loaded.bundle;
|
|
80
|
+
}
|
|
81
|
+
function validateTraceItems(actor) {
|
|
82
|
+
if (actor !== undefined &&
|
|
83
|
+
(!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
|
|
84
|
+
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
85
|
+
}
|
|
86
|
+
const eventIds = new Set();
|
|
87
|
+
for (const item of isRecord(actor) ? actor.items : []) {
|
|
88
|
+
if (!isRecord(item) ||
|
|
89
|
+
typeof item.id !== "string" ||
|
|
90
|
+
item.id.length === 0 ||
|
|
91
|
+
item.id.length > 256 ||
|
|
92
|
+
eventIds.has(item.id) ||
|
|
93
|
+
typeof item.kind !== "string" ||
|
|
94
|
+
typeof item.title !== "string" ||
|
|
95
|
+
(item.text !== undefined && typeof item.text !== "string"))
|
|
96
|
+
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
97
|
+
eventIds.add(item.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
80
100
|
/** Select once from retained source. Models receive no filesystem or network resolver. */
|
|
81
101
|
export async function captureEvidence(prepared, bundleBytes, requested = {}) {
|
|
82
102
|
const limits = { ...EVIDENCE_LIMITS, ...requested };
|
|
@@ -92,7 +112,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
|
|
|
92
112
|
const current = await readBoundedFile(prepared, RUN_BUNDLE_FILE, limits.sourceBytes);
|
|
93
113
|
if (!current || !current.equals(bundleBytes))
|
|
94
114
|
throw new Error("ANALYSIS_SOURCE_CHANGED");
|
|
95
|
-
const bundle =
|
|
115
|
+
const bundle = await readSource(prepared, bundleBytes, limits.sourceBytes);
|
|
96
116
|
const captureVersion = ACTION_CAPTURE_VERSION;
|
|
97
117
|
if (bundle.streams.some((stream) => stream.liveActor !== undefined || UNFINISHED_STREAM_STATUSES.has(stream.status))) {
|
|
98
118
|
throw new Error("ANALYSIS_RUN_UNFINISHED");
|
|
@@ -393,7 +413,7 @@ function packEvidence(selected, packings, textByEntry, evidence, images) {
|
|
|
393
413
|
export async function validateAnalysisEvidence(prepared, artifact, bundleBytes) {
|
|
394
414
|
if (artifact.captureVersion !== undefined && artifact.captureVersion !== ACTION_CAPTURE_VERSION)
|
|
395
415
|
throw new Error("ANALYSIS_CAPTURE_VERSION_INVALID");
|
|
396
|
-
const bundle =
|
|
416
|
+
const bundle = await readSource(prepared, bundleBytes, EVIDENCE_LIMITS.sourceBytes);
|
|
397
417
|
if (artifact.runId !== bundle.runId || artifact.sourceRunSha256 !== sha256(bundleBytes))
|
|
398
418
|
throw new Error("ANALYSIS_SOURCE_CHANGED");
|
|
399
419
|
const included = new Set(artifact.coverage.includedStreamIds);
|
|
@@ -84,7 +84,14 @@ export async function runStudyCommand(args) {
|
|
|
84
84
|
// the keys its plan reads. This comes after the option refusals above, which read no key, and
|
|
85
85
|
// before the route's CLI setup and prepareStudy, which do.
|
|
86
86
|
if (resolveStudyDryRun(config, args.options.dryRun, true) === false) {
|
|
87
|
-
const
|
|
87
|
+
const planned = planStudy(config, { cwd: args.options.cwd, dryRun: false });
|
|
88
|
+
// The plan's warnings, such as a bundle estimated past a limit, before anything is created.
|
|
89
|
+
// The study file's own were printed above.
|
|
90
|
+
if (planned.ok)
|
|
91
|
+
for (const warning of planned.planned.plan.warnings ?? [])
|
|
92
|
+
if (!resolved.warnings.includes(warning))
|
|
93
|
+
warnAndQueue(args.command, args.io, warning);
|
|
94
|
+
const announced = announcedKeyNames(config, args.options, planned);
|
|
88
95
|
await discoverCliKeys({
|
|
89
96
|
io: args.io,
|
|
90
97
|
cwd: args.options.cwd,
|
|
@@ -150,10 +157,9 @@ export async function runStudyCommand(args) {
|
|
|
150
157
|
* change who runs, not which keys. Undefined, so every fill is printed, when a scorer is declared,
|
|
151
158
|
* since its host code may read any key, and when the study does not plan.
|
|
152
159
|
*/
|
|
153
|
-
function announcedKeyNames(config, options) {
|
|
160
|
+
function announcedKeyNames(config, options, planned) {
|
|
154
161
|
if (options.scorer !== undefined || config.review?.scorer !== undefined)
|
|
155
162
|
return undefined;
|
|
156
|
-
const planned = planStudy(config, { cwd: options.cwd, dryRun: false });
|
|
157
163
|
return planned.ok ? keyNamesOf(planned.planned.plan) : undefined;
|
|
158
164
|
}
|
|
159
165
|
/** Each route's CLI setup. It returns undefined when it has already written its own result. */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RunBundle } from "../run/bundle.js";
|
|
2
|
+
/** A source file's path and bytes. */
|
|
3
|
+
interface TextFile {
|
|
4
|
+
path: string;
|
|
5
|
+
bytes: Buffer;
|
|
6
|
+
}
|
|
7
|
+
export declare function jsonBytes(value: unknown): Buffer;
|
|
8
|
+
export declare function transformText(file: TextFile, imagePaths: ReadonlySet<string>): Buffer;
|
|
9
|
+
export declare function assertRewritableNumbers(json: string): void;
|
|
10
|
+
/** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
|
|
11
|
+
export declare function derivedBundle(bundle: RunBundle, imagePaths: ReadonlySet<string>): RunBundle;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// How a redacted copy rewrites a run's text: screenshots declared blurred, sandbox ids redacted,
|
|
2
|
+
// inline images and secret-shaped text refused. src/feedback/export-bundle.ts copies the files.
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
|
|
6
|
+
import { containsSensitive, redactSandboxIds } from "../evidence/redaction.js";
|
|
7
|
+
import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
|
|
8
|
+
import { readPlainText } from "../evidence/plain-text.js";
|
|
9
|
+
export function jsonBytes(value) {
|
|
10
|
+
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
11
|
+
}
|
|
12
|
+
function assertNoInlineRaster(text) {
|
|
13
|
+
// Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
|
|
14
|
+
// refused: copying a text extension must not smuggle untransformed pixels.
|
|
15
|
+
if (readingsOf(text).some(containsSensitive))
|
|
16
|
+
throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
|
|
17
|
+
const decoded = decodeEscapes(text);
|
|
18
|
+
if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
|
|
19
|
+
/iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
|
|
20
|
+
throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function decodeText(file) {
|
|
24
|
+
const decoded = readPlainText(file.bytes);
|
|
25
|
+
if (!decoded.ok)
|
|
26
|
+
throw new Error(decoded.reason === "invalid-utf8"
|
|
27
|
+
? "Source text contains invalid UTF-8 or binary bytes."
|
|
28
|
+
: "Source text contains binary control bytes.");
|
|
29
|
+
assertNoInlineRaster(decoded.text);
|
|
30
|
+
return decoded.text;
|
|
31
|
+
}
|
|
32
|
+
function updateScreenshotDeclarations(value, imagePaths) {
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.map((entry) => updateScreenshotDeclarations(entry, imagePaths));
|
|
35
|
+
if (value === null || typeof value !== "object")
|
|
36
|
+
return value;
|
|
37
|
+
const object = value;
|
|
38
|
+
const result = Object.create(null);
|
|
39
|
+
for (const [key, entry] of Object.entries(object))
|
|
40
|
+
result[key] = updateScreenshotDeclarations(entry, imagePaths);
|
|
41
|
+
if (object.schema === ACTOR_TRACE_SCHEMA &&
|
|
42
|
+
object.redaction !== null &&
|
|
43
|
+
typeof object.redaction === "object") {
|
|
44
|
+
const original = object.redaction;
|
|
45
|
+
if (original.screenshots === "raw" || original.screenshots === "blurred") {
|
|
46
|
+
result.redaction = {
|
|
47
|
+
...original,
|
|
48
|
+
screenshots: "blurred",
|
|
49
|
+
notes: `${typeof original.notes === "string" ? original.notes : ""} Exported copy: screenshots blurred during export; original capture posture: ${original.screenshots}. See derivation.json.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(result.items)) {
|
|
53
|
+
result.items = result.items.map((item) => {
|
|
54
|
+
if (item === null || typeof item !== "object")
|
|
55
|
+
return item;
|
|
56
|
+
const entry = item;
|
|
57
|
+
if (entry.screenshotRef === undefined)
|
|
58
|
+
return item;
|
|
59
|
+
if (entry.screenshotRef === null || typeof entry.screenshotRef !== "object")
|
|
60
|
+
throw new Error("Actor screenshot reference is malformed.");
|
|
61
|
+
const ref = entry.screenshotRef;
|
|
62
|
+
if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
|
|
63
|
+
throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
|
|
64
|
+
}
|
|
65
|
+
return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
export function transformText(file, imagePaths) {
|
|
72
|
+
const text = decodeText(file);
|
|
73
|
+
const extension = path.extname(file.path).toLowerCase();
|
|
74
|
+
if (extension === ".json") {
|
|
75
|
+
const parsed = JSON.parse(text);
|
|
76
|
+
// Parsing also resolves escape sequences in string values before inspection.
|
|
77
|
+
assertNoInlineRaster(JSON.stringify(parsed));
|
|
78
|
+
// The copy names no sandbox: an id is local operational evidence, and the copy is shared.
|
|
79
|
+
const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
|
|
80
|
+
if (JSON.stringify(transformed) === JSON.stringify(parsed))
|
|
81
|
+
return file.bytes;
|
|
82
|
+
assertRewritableNumbers(text);
|
|
83
|
+
return jsonBytes(transformed);
|
|
84
|
+
}
|
|
85
|
+
if (extension === ".ndjson" || extension === ".jsonl") {
|
|
86
|
+
const lines = text.split("\n");
|
|
87
|
+
const transformed = lines.map((line) => {
|
|
88
|
+
if (line.trim().length === 0)
|
|
89
|
+
return line;
|
|
90
|
+
const parsed = JSON.parse(line);
|
|
91
|
+
assertNoInlineRaster(JSON.stringify(parsed));
|
|
92
|
+
const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
|
|
93
|
+
if (updated === JSON.stringify(parsed))
|
|
94
|
+
return line;
|
|
95
|
+
assertRewritableNumbers(line);
|
|
96
|
+
return updated;
|
|
97
|
+
});
|
|
98
|
+
return Buffer.from(transformed.join("\n"));
|
|
99
|
+
}
|
|
100
|
+
if (extension === ".yaml" || extension === ".yml") {
|
|
101
|
+
assertNoInlineRaster(JSON.stringify(parseYaml(text, { maxAliasCount: 100, logLevel: "silent" })) ?? "");
|
|
102
|
+
}
|
|
103
|
+
return file.bytes;
|
|
104
|
+
}
|
|
105
|
+
export function assertRewritableNumbers(json) {
|
|
106
|
+
// Opaque adapter JSON stays byte-identical. A known trace that actually needs
|
|
107
|
+
// rewriting must not silently round an identifier outside JS's integer range.
|
|
108
|
+
const unquoted = json.replace(/"(?:\\.|[^"\\])*"/g, '""');
|
|
109
|
+
for (const match of unquoted.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
|
|
110
|
+
const number = Number(match[0]);
|
|
111
|
+
if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
|
|
112
|
+
throw new Error("A trace requiring redaction contains an unsafe numeric value; represent large identifiers as strings before capture.");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
|
|
117
|
+
export function derivedBundle(bundle, imagePaths) {
|
|
118
|
+
const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
|
|
119
|
+
transformed.redaction = {
|
|
120
|
+
status: "passed",
|
|
121
|
+
notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
|
|
122
|
+
};
|
|
123
|
+
for (const stream of transformed.streams) {
|
|
124
|
+
for (const artifact of stream.artifacts) {
|
|
125
|
+
if (artifact.kind === "screenshot")
|
|
126
|
+
artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return transformed;
|
|
130
|
+
}
|
|
@@ -4,18 +4,18 @@ 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
8
|
import { buildObserverData } from "../observer/data.js";
|
|
11
|
-
import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
9
|
+
import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
10
|
+
import { splitStreamTraces } from "../run/stream-trace.js";
|
|
11
|
+
import { redactScreenshot, redactText } from "../evidence/redaction.js";
|
|
12
|
+
import { assertRewritableNumbers, derivedBundle, jsonBytes, transformText, } from "./derivative-text.js";
|
|
13
|
+
import { streamScreenshotPaths } from "../verify/artifacts.js";
|
|
14
|
+
import { runEntryLimit } from "../verify/scan-limit.js";
|
|
16
15
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
17
|
-
import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
|
|
18
|
-
import {} from "../run/bundle.js";
|
|
16
|
+
import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
|
|
17
|
+
import { RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
18
|
+
import { isRecord } from "../run/type-guards.js";
|
|
19
19
|
import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
20
20
|
import { isAnalysisRecordPath } from "../analysis/sharing.js";
|
|
21
21
|
import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
|
|
@@ -43,9 +43,6 @@ const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
|
43
43
|
function hash(bytes) {
|
|
44
44
|
return createHash("sha256").update(bytes).digest("hex");
|
|
45
45
|
}
|
|
46
|
-
function jsonBytes(value) {
|
|
47
|
-
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
48
|
-
}
|
|
49
46
|
function omittedReason(relative) {
|
|
50
47
|
if (relative.startsWith("analysis-automatic/") && isAnalysisRecordPath(relative))
|
|
51
48
|
return "automatic execution intent and liveness do not transfer to a derivative";
|
|
@@ -63,14 +60,17 @@ async function inventory(root, maxBytes) {
|
|
|
63
60
|
const files = [];
|
|
64
61
|
let total = 0;
|
|
65
62
|
let entriesSeen = 0;
|
|
63
|
+
// The scan's limit for the participants run.json records, so a run verify reads in full exports.
|
|
64
|
+
const stored = runJsonValue(await readRunJsonIfExists(root, RUN_BUNDLE_FILE));
|
|
65
|
+
const entryLimit = runEntryLimit(isRecord(stored) && Array.isArray(stored.simulations) ? stored.simulations.length : 0);
|
|
66
66
|
const walk = async (relative) => {
|
|
67
67
|
const directory = path.join(root.physicalRunRoot, relative);
|
|
68
68
|
const before = await lstat(directory, { bigint: true });
|
|
69
69
|
if (!before.isDirectory() || before.isSymbolicLink())
|
|
70
70
|
throw new Error("Source contains an unsafe directory.");
|
|
71
71
|
for (const name of (await readdir(directory)).sort()) {
|
|
72
|
-
if (++entriesSeen >
|
|
73
|
-
throw new Error(`Source exceeds ${
|
|
72
|
+
if (++entriesSeen > entryLimit)
|
|
73
|
+
throw new Error(`Source exceeds ${entryLimit} inventory entries.`);
|
|
74
74
|
if (name.includes("\\") || name.includes("\0"))
|
|
75
75
|
throw new Error("Source contains an unsafe path segment.");
|
|
76
76
|
const rel = relative ? `${relative}/${name}` : name;
|
|
@@ -147,65 +147,6 @@ async function prospectivePhysicalPath(requested) {
|
|
|
147
147
|
return path.join(await prospectivePhysicalPath(parent), path.basename(requested));
|
|
148
148
|
}
|
|
149
149
|
}
|
|
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
150
|
function assertFeedbackReferences(bundle, source) {
|
|
210
151
|
const retained = new Set(source.files.filter((file) => omittedReason(file.path) === undefined).map((file) => file.path));
|
|
211
152
|
retained.add("observer/index.html");
|
|
@@ -220,51 +161,6 @@ function assertFeedbackReferences(bundle, source) {
|
|
|
220
161
|
}
|
|
221
162
|
}
|
|
222
163
|
}
|
|
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
164
|
/** PNG paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
|
|
269
165
|
function citedPngPaths(bundle) {
|
|
270
166
|
const cited = [
|
|
@@ -304,6 +200,13 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
304
200
|
const entries = [];
|
|
305
201
|
const imagePaths = keptPngPaths(source, bundle);
|
|
306
202
|
const sandboxIds = inventorySandboxIds(source.files);
|
|
203
|
+
// run.json and each trace file come from one rewritten bundle, so each trace's index binds the
|
|
204
|
+
// bytes written here. A v1 source is written as v2.
|
|
205
|
+
const derived = splitStreamTraces(publicSandboxView(derivedBundle(bundle, imagePaths), sandboxIds));
|
|
206
|
+
const derivedFiles = new Map([
|
|
207
|
+
["run.json", jsonBytes(derived.bundle)],
|
|
208
|
+
...derived.files.map((file) => [file.path, file.bytes]),
|
|
209
|
+
]);
|
|
307
210
|
let images = 0;
|
|
308
211
|
for (const file of source.files) {
|
|
309
212
|
const isPng = path.extname(file.path).toLowerCase() === ".png";
|
|
@@ -332,20 +235,11 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
332
235
|
if (!TEXT_EXTENSIONS.has(extension))
|
|
333
236
|
throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG and UTF-8 text only.`);
|
|
334
237
|
bytes = transformText(file, imagePaths);
|
|
335
|
-
|
|
238
|
+
const rewritten = derivedFiles.get(file.path);
|
|
239
|
+
if (rewritten !== undefined) {
|
|
336
240
|
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);
|
|
241
|
+
bytes = rewritten;
|
|
242
|
+
derivedFiles.delete(file.path);
|
|
349
243
|
}
|
|
350
244
|
bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
|
|
351
245
|
action = bytes.equals(file.bytes) ? "copied" : "updated";
|
|
@@ -353,6 +247,19 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
353
247
|
await writeContainedOutputFile(stagePaths, file.path, bytes);
|
|
354
248
|
entries.push({ path: file.path, sourceSha256: file.sha256, outputSha256: hash(bytes), action });
|
|
355
249
|
}
|
|
250
|
+
// A v1 source stream with no actor trace file gets one; its content came from run.json.
|
|
251
|
+
const runJson = source.files.find((file) => file.path === "run.json");
|
|
252
|
+
for (const [relative, bytes] of derivedFiles) {
|
|
253
|
+
if (relative === "run.json" || runJson === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
await writeContainedOutputFile(stagePaths, relative, bytes);
|
|
256
|
+
entries.push({
|
|
257
|
+
path: relative,
|
|
258
|
+
sourceSha256: runJson.sha256,
|
|
259
|
+
outputSha256: hash(bytes),
|
|
260
|
+
action: "updated",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
356
263
|
return { images, entries };
|
|
357
264
|
}
|
|
358
265
|
async function removeOwnedDirectory(root, strict = false) {
|