humanish 0.124.0 → 0.125.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/CONTEXT.md +1 -1
- package/README.md +2 -2
- package/dist/analysis/evidence.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/observer/pinned-files.d.ts +3 -1
- package/dist/observer/pinned-files.js +10 -7
- package/dist/observer/run-routes.js +2 -1
- package/dist/routes/computer-use/live-flush.d.ts +9 -4
- package/dist/routes/computer-use/live-flush.js +16 -3
- package/dist/run/bundle-shape.js +2 -2
- package/dist/run/bundle.d.ts +10 -3
- package/dist/run/bundle.js +11 -2
- package/dist/run/contained-output.d.ts +22 -0
- package/dist/run/contained-output.js +97 -1
- package/dist/run/evidence-files.d.ts +12 -1
- package/dist/run/evidence-files.js +70 -40
- package/dist/run/live-items.d.ts +48 -0
- package/dist/run/live-items.js +242 -0
- package/dist/run/locate.d.ts +10 -0
- package/dist/run/locate.js +10 -0
- package/dist/run/run.d.ts +3 -2
- package/dist/run/run.js +29 -8
- package/dist/run/sandbox-ids.d.ts +0 -5
- package/dist/run/sandbox-ids.js +0 -7
- package/dist/run/stream-trace.d.ts +18 -9
- package/dist/run/stream-trace.js +113 -22
- package/dist/run/streams.d.ts +17 -2
- package/dist/study/parse/actors.js +23 -6
- package/dist/study/parse/execution.js +6 -0
- package/dist/study/plan-base.js +4 -1
- package/dist/verify/artifacts.js +39 -51
- package/dist/verify/screenshot-reads.d.ts +21 -0
- package/dist/verify/screenshot-reads.js +46 -0
- package/dist/verify/verify.js +7 -6
- package/docs/architecture/github-feedback-loop.md +1 -1
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/run-bundle.md +75 -13
- package/docs/contracts/schemas.md +38 -37
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,21 @@ The Unreleased section holds the full notes for the next version until it is tag
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.125.0: Run bundle v3 with live items in append-only files, loadRunBundle, faster repeated verify (2026-10-12)
|
|
12
|
+
|
|
13
|
+
humanish 0.125.0 keeps each live flush of a long study small, exports the run bundle loader, and
|
|
14
|
+
verifies a finished study faster. Run bundles are now `humanish.run-bundle.v3`: a running
|
|
15
|
+
participant's items so far live in an append-only file under `live/`, and each flush appends only
|
|
16
|
+
the items recorded since the last one. On a synthetic run of 24 participants working 8 hours, a
|
|
17
|
+
flush wrote about 0.23 MB in about 0.24 s, where 0.124.0 wrote up to 120 MB in 3.0 s. A finished
|
|
18
|
+
v3 bundle differs from v2 only in its schema name; 0.124.0 and earlier refuse it by name.
|
|
19
|
+
`loadRunBundle(cwd, run)` returns run.json with every trace and live file joined and checked, for
|
|
20
|
+
v1, v2 and v3 bundles. The five verifies of a finished study read each screenshot once: 10 s in
|
|
21
|
+
all for 1,203 screenshots, down from 35 s. A key in a study's title is redacted from the bundle,
|
|
22
|
+
and one in a persona id or desktop template fails at parse instead of blocking the run.
|
|
23
|
+
|
|
24
|
+
[Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.125.0)
|
|
25
|
+
|
|
11
26
|
## 0.124.0: Small Observer files for long studies, faster verify, closing reports under zero data retention (2026-10-12)
|
|
12
27
|
|
|
13
28
|
humanish 0.124.0 keeps a long study's Observer files small, checks screenshots about eight times
|
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.v3`) 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
|
@@ -335,7 +335,7 @@ and the library exports.
|
|
|
335
335
|
- Breaking changes ship together in one minor release. Its release notes carry a migration
|
|
336
336
|
table: each old form, its new form, and the change to make.
|
|
337
337
|
- The study file and the run bundle carry versioned schemas, `humanish.study.v3` and
|
|
338
|
-
`humanish.run-bundle.
|
|
338
|
+
`humanish.run-bundle.v3`. A breaking change to either one bumps its version.
|
|
339
339
|
|
|
340
340
|
Pin the exact version in `package.json` and read [CHANGELOG.md](CHANGELOG.md) before upgrading.
|
|
341
341
|
|
|
@@ -483,7 +483,7 @@ for the supported library seam.
|
|
|
483
483
|
|
|
484
484
|
- run a study: `runStudy`, `RunStudyOptions`, `StudyOutcome`, `StudyResult`, `StudyEvent`, `routeOf`,
|
|
485
485
|
`parseStudy`, `STUDY_SCHEMA`;
|
|
486
|
-
- read a run: `verifyRun`, `renderObserver`, `RunBundle`, `ActorTrace`;
|
|
486
|
+
- read a run: `verifyRun`, `loadRunBundle`, `renderObserver`, `RunBundle`, `ActorTrace`;
|
|
487
487
|
- bring a participant: `ComputerUseProvider`, `ComputerUseExecutor`, `ProviderContext`,
|
|
488
488
|
`createOpenAiResponsesProvider`, `runComputerUseLoop`, `defaultRedactionHooks`;
|
|
489
489
|
- score a run: `AdapterScorerModule` and its scoring contexts.
|
|
@@ -3,7 +3,7 @@ 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 {
|
|
6
|
+
import { isReadRunBundleSchema, RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
7
7
|
import { loadStreamTraces } from "../run/stream-trace.js";
|
|
8
8
|
import { ACTION_CAPTURE_VERSION, } from "./types.js";
|
|
9
9
|
import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
|
|
@@ -30,7 +30,7 @@ function parseSource(prepared, bytes) {
|
|
|
30
30
|
throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
|
|
31
31
|
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
32
32
|
if (!isRecord(value) ||
|
|
33
|
-
(value.schema
|
|
33
|
+
!isReadRunBundleSchema(value.schema) ||
|
|
34
34
|
value.runId !== runIdOf(prepared) ||
|
|
35
35
|
!Array.isArray(value.streams) ||
|
|
36
36
|
value.streams.length > EVIDENCE_LIMITS.participants ||
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export { parseStudy } from "./study/config.js";
|
|
|
8
8
|
export { STUDY_SCHEMA } from "./study/types.js";
|
|
9
9
|
export type { StudyConfig } from "./study/types.js";
|
|
10
10
|
export { verifyRun } from "./verify/verify.js";
|
|
11
|
+
export { loadRunBundle } from "./run/locate.js";
|
|
11
12
|
export type { VerifyResult } from "./verify/verify.js";
|
|
12
13
|
export { renderObserver } from "./observer/render.js";
|
|
13
14
|
export type { ObserverResult } from "./observer/render.js";
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { parseStudy } from "./study/config.js";
|
|
|
8
8
|
export { STUDY_SCHEMA } from "./study/types.js";
|
|
9
9
|
// Read a run.
|
|
10
10
|
export { verifyRun } from "./verify/verify.js";
|
|
11
|
+
export { loadRunBundle } from "./run/locate.js";
|
|
11
12
|
export { renderObserver } from "./observer/render.js";
|
|
12
13
|
// Bring a participant.
|
|
13
14
|
export { runComputerUseLoop, stableProgressKey } from "./actors/computer-use/loop.js";
|
|
@@ -17,10 +17,12 @@ export interface PinnedDirectory {
|
|
|
17
17
|
/**
|
|
18
18
|
* The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
|
|
19
19
|
* file larger than that is refused from its opened size and the read stops there, so a large file
|
|
20
|
-
* is never held in memory.
|
|
20
|
+
* is never held in memory. With `length`, only the file's first `length` bytes are read, or all of
|
|
21
|
+
* a shorter file.
|
|
21
22
|
*/
|
|
22
23
|
export declare function readContainedFile(root: PinnedDirectory, filePathInput: string, options?: {
|
|
23
24
|
maxBytes?: number;
|
|
25
|
+
length?: number;
|
|
24
26
|
}): Promise<Buffer | null>;
|
|
25
27
|
export declare function openContainedFile(root: PinnedDirectory, filePathInput: string): Promise<{
|
|
26
28
|
handle: FileHandle;
|
|
@@ -5,23 +5,26 @@ import { createHash } from "node:crypto";
|
|
|
5
5
|
import { constants as fsConstants } from "node:fs";
|
|
6
6
|
import { lstat, open, realpath } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { readOpenedAtMost } from "../run/contained-output.js";
|
|
8
|
+
import { readOpenedAtMost, readOpenedPrefix } from "../run/contained-output.js";
|
|
9
9
|
import { isPathInside, isSafeRunIdSegment } from "../run/paths.js";
|
|
10
10
|
/**
|
|
11
11
|
* The file's bytes, or null when it cannot be read under the root's checks. With `maxBytes`, a
|
|
12
12
|
* file larger than that is refused from its opened size and the read stops there, so a large file
|
|
13
|
-
* is never held in memory.
|
|
13
|
+
* is never held in memory. With `length`, only the file's first `length` bytes are read, or all of
|
|
14
|
+
* a shorter file.
|
|
14
15
|
*/
|
|
15
16
|
export async function readContainedFile(root, filePathInput, options = {}) {
|
|
16
17
|
const opened = await openContainedFile(root, filePathInput);
|
|
17
18
|
if (!opened)
|
|
18
19
|
return null;
|
|
19
20
|
try {
|
|
20
|
-
const body = options.
|
|
21
|
-
? await opened.handle.
|
|
22
|
-
:
|
|
23
|
-
?
|
|
24
|
-
:
|
|
21
|
+
const body = options.length !== undefined
|
|
22
|
+
? await readOpenedPrefix(opened.handle, options.length)
|
|
23
|
+
: options.maxBytes === undefined
|
|
24
|
+
? await opened.handle.readFile()
|
|
25
|
+
: opened.size > options.maxBytes
|
|
26
|
+
? null
|
|
27
|
+
: await readOpenedAtMost(opened.handle, options.maxBytes);
|
|
25
28
|
if (body === null)
|
|
26
29
|
return null;
|
|
27
30
|
// A write that landed during the read changed the file's mtime and ctime.
|
|
@@ -165,9 +165,10 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
|
|
|
165
165
|
}
|
|
166
166
|
/** The run's trace files, read through its pinned folder. */
|
|
167
167
|
function pinnedTraceReader(runRoot) {
|
|
168
|
-
return async (relativePath, maxBytes) => {
|
|
168
|
+
return async (relativePath, maxBytes, length) => {
|
|
169
169
|
const bytes = await readContainedFile(runRoot, path.join(runRoot.physicalPath, relativePath), {
|
|
170
170
|
maxBytes,
|
|
171
|
+
...(length === undefined ? {} : { length }),
|
|
171
172
|
}).catch(() => null);
|
|
172
173
|
return bytes === null ? { refused: `${relativePath} could not be read` } : { bytes };
|
|
173
174
|
};
|
|
@@ -5,16 +5,21 @@ import type { RunBundle } from "../../run/bundle.js";
|
|
|
5
5
|
import type { RunStudyHomes } from "../../study/run-study-homes.js";
|
|
6
6
|
import type { DesktopParticipantRun } from "./types.js";
|
|
7
7
|
export interface LiveTraceFlush {
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
8
|
+
/**
|
|
9
|
+
* Record a participant's recorded-so-far items; the bundle write follows on the flush schedule.
|
|
10
|
+
* Each item is deep-frozen here: the run writer appends an item to the participant's live file
|
|
11
|
+
* once, so an item edited after it was recorded would leave the file stale. A producer that
|
|
12
|
+
* needs a different item records a new one.
|
|
13
|
+
*/
|
|
10
14
|
flush: (participantId: string, items: readonly ActorTraceItem[], usage?: ActorTokenUsage, metadata?: CuaLiveMetadata) => void;
|
|
11
15
|
/** Stop flushing, clear the timer and wait for an in-flight write. Call it on every exit. */
|
|
12
16
|
stop: () => Promise<void>;
|
|
13
17
|
}
|
|
14
18
|
/**
|
|
15
19
|
* Incremental live flush: as each participant's loop reports its recorded-so-far items,
|
|
16
|
-
*
|
|
17
|
-
* Observer's 5s poll sees the timeline grow
|
|
20
|
+
* publish the in-progress bundle with per-stream `liveActor` partials so the attached
|
|
21
|
+
* Observer's 5s poll sees the timeline grow; the run writer appends each participant's new items
|
|
22
|
+
* to its live file. Throttled (one write per interval, trailing
|
|
18
23
|
* write guaranteed). `write` is the run's `writeSnapshot`, which serializes writes and refuses
|
|
19
24
|
* any snapshot once the final write began, so a late flush cannot resurrect the in-progress
|
|
20
25
|
* bundle. A flush failure is swallowed: mid-run observability must never break the run itself.
|
|
@@ -2,8 +2,9 @@ import { attachObserverRuntimeStreamUrls } from "../../observer/render.js";
|
|
|
2
2
|
import {} from "../../observer/run-routes.js";
|
|
3
3
|
/**
|
|
4
4
|
* Incremental live flush: as each participant's loop reports its recorded-so-far items,
|
|
5
|
-
*
|
|
6
|
-
* Observer's 5s poll sees the timeline grow
|
|
5
|
+
* publish the in-progress bundle with per-stream `liveActor` partials so the attached
|
|
6
|
+
* Observer's 5s poll sees the timeline grow; the run writer appends each participant's new items
|
|
7
|
+
* to its live file. Throttled (one write per interval, trailing
|
|
7
8
|
* write guaranteed). `write` is the run's `writeSnapshot`, which serializes writes and refuses
|
|
8
9
|
* any snapshot once the final write began, so a late flush cannot resurrect the in-progress
|
|
9
10
|
* bundle. A flush failure is swallowed: mid-run observability must never break the run itself.
|
|
@@ -56,7 +57,9 @@ export function startLiveTraceFlush(args) {
|
|
|
56
57
|
ids: { model },
|
|
57
58
|
}),
|
|
58
59
|
...liveMetadataByStream.get(stream.id),
|
|
59
|
-
|
|
60
|
+
// The flush's own copy, replaced and never changed, so the run writer compares
|
|
61
|
+
// each write's items with the last by identity.
|
|
62
|
+
items: liveItems,
|
|
60
63
|
},
|
|
61
64
|
};
|
|
62
65
|
}),
|
|
@@ -94,6 +97,8 @@ export function startLiveTraceFlush(args) {
|
|
|
94
97
|
const streamId = streamIdByParticipant.get(participantId);
|
|
95
98
|
if (streamId === undefined)
|
|
96
99
|
return;
|
|
100
|
+
for (const item of items)
|
|
101
|
+
deepFreeze(item);
|
|
97
102
|
liveItemsByStream.set(streamId, items.slice());
|
|
98
103
|
if (metadata !== undefined)
|
|
99
104
|
liveMetadataByStream.set(streamId, metadata);
|
|
@@ -112,6 +117,14 @@ export function startLiveTraceFlush(args) {
|
|
|
112
117
|
};
|
|
113
118
|
return { flush, stop };
|
|
114
119
|
}
|
|
120
|
+
/** `value` and every object and array it holds, frozen. */
|
|
121
|
+
function deepFreeze(value) {
|
|
122
|
+
if (value === null || typeof value !== "object" || Object.isFrozen(value))
|
|
123
|
+
return;
|
|
124
|
+
Object.freeze(value);
|
|
125
|
+
for (const child of Object.values(value))
|
|
126
|
+
deepFreeze(child);
|
|
127
|
+
}
|
|
115
128
|
/**
|
|
116
129
|
* The live desktop views (runtime stream URLs) participants report, attached to whichever Observer shows
|
|
117
130
|
* the run. The URLs carry auth, so they live in memory and on the Observer result, never in run
|
package/dist/run/bundle-shape.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isCommsReceivingEvidence } from "../comms/receiving-evidence.js";
|
|
2
2
|
import { isCapturedGitState } from "./git-state.js";
|
|
3
|
-
import { PUBLIC_TARGET_CWD, REVIEW_SCHEMA,
|
|
3
|
+
import { PUBLIC_TARGET_CWD, REVIEW_SCHEMA, isReadRunBundleSchema, } from "./bundle.js";
|
|
4
4
|
import { CLEANUP_SCHEMA, } from "./results.js";
|
|
5
5
|
import { isRunSimulationStatus, isRunStream, isRunStreamKind } from "./stream-shape.js";
|
|
6
6
|
import { isLocalEvidenceArtifactPath } from "./paths.js";
|
|
@@ -10,7 +10,7 @@ import { isSharedWorldEvidence } from "./shared-world-shape.js";
|
|
|
10
10
|
import { isNonNegativeSafeInteger, isPositiveSafeInteger, isRecord } from "./type-guards.js";
|
|
11
11
|
export function isRunBundle(value) {
|
|
12
12
|
return (isRecord(value) &&
|
|
13
|
-
(value.schema
|
|
13
|
+
isReadRunBundleSchema(value.schema) &&
|
|
14
14
|
(value.commsReceiving === undefined || isCommsReceivingEvidence(value.commsReceiving)) &&
|
|
15
15
|
(value.publication === undefined ||
|
|
16
16
|
(isRecord(value.publication) &&
|
package/dist/run/bundle.d.ts
CHANGED
|
@@ -5,10 +5,17 @@ import type { SharedWorldEvidence } from "./shared-world-evidence.js";
|
|
|
5
5
|
import { type RunStudyProvenance } from "./study-provenance.js";
|
|
6
6
|
import type { RunInterruptSignal } from "./status.js";
|
|
7
7
|
import type { RunSimulationStatus, RunStream, RunStreamKind } from "./streams.js";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The run bundle version this release writes: each stream's trace, and each running participant's
|
|
10
|
+
* items so far, are files run.json indexes.
|
|
11
|
+
*/
|
|
12
|
+
export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
|
|
13
|
+
/** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
|
|
14
|
+
export declare const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
|
|
10
15
|
/** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
|
|
11
16
|
export declare const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
|
|
17
|
+
/** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
|
|
18
|
+
export declare function isReadRunBundleSchema(value: unknown): value is RunBundle["schema"];
|
|
12
19
|
/** The run bundle file, relative to the run directory. */
|
|
13
20
|
export declare const RUN_BUNDLE_FILE = "run.json";
|
|
14
21
|
export declare const REVIEW_SCHEMA = "humanish.review.v1";
|
|
@@ -231,7 +238,7 @@ export interface RunBundle {
|
|
|
231
238
|
restrictions: ["real-communications"];
|
|
232
239
|
};
|
|
233
240
|
commsReceiving?: CommsReceivingEvidence;
|
|
234
|
-
schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
|
|
241
|
+
schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V2_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
|
|
235
242
|
runId: string;
|
|
236
243
|
mode: "dry-run" | "live";
|
|
237
244
|
simCount: number;
|
package/dist/run/bundle.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { captureGitState } from "./git-state.js";
|
|
3
3
|
import { studyFields } from "./study-provenance.js";
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The run bundle version this release writes: each stream's trace, and each running participant's
|
|
6
|
+
* items so far, are files run.json indexes.
|
|
7
|
+
*/
|
|
8
|
+
export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
|
|
9
|
+
/** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
|
|
10
|
+
export const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
|
|
6
11
|
/** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
|
|
7
12
|
export const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
|
|
13
|
+
/** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
|
|
14
|
+
export function isReadRunBundleSchema(value) {
|
|
15
|
+
return (value === RUN_BUNDLE_SCHEMA || value === RUN_BUNDLE_V2_SCHEMA || value === RUN_BUNDLE_V1_SCHEMA);
|
|
16
|
+
}
|
|
8
17
|
/** The run bundle file, relative to the run directory. */
|
|
9
18
|
export const RUN_BUNDLE_FILE = "run.json";
|
|
10
19
|
export const REVIEW_SCHEMA = "humanish.review.v1";
|
|
@@ -46,6 +46,21 @@ export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot,
|
|
|
46
46
|
* writeContainedOutputFile renames a finished file into place instead, replacing what is there.
|
|
47
47
|
*/
|
|
48
48
|
export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Appends `data` to an existing single-link regular file inside the root that holds exactly
|
|
51
|
+
* `size` bytes, the count the caller says it wrote, so an append never lands after bytes the
|
|
52
|
+
* caller did not write. The checks writeContainedOutputFile makes run before the open; the opened
|
|
53
|
+
* file must be the one checked, and the root and its folders are checked again before the write.
|
|
54
|
+
* The data is synced before this resolves, so a run.json written after it never indexes bytes the
|
|
55
|
+
* disk may not hold.
|
|
56
|
+
*/
|
|
57
|
+
export declare function appendContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: Uint8Array, size: number): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Removes a single-link regular file or an empty folder inside the root. Nothing at the path, or a
|
|
60
|
+
* folder on it missing, is not an error. A link, a hard-linked file or a folder that holds anything
|
|
61
|
+
* is refused and kept.
|
|
62
|
+
*/
|
|
63
|
+
export declare function removeContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<void>;
|
|
49
64
|
/**
|
|
50
65
|
* A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
|
|
51
66
|
* and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
|
|
@@ -99,6 +114,13 @@ export declare function readContainedRegularFile(rootInput: PreparedOutputRoot,
|
|
|
99
114
|
* after it was opened is never read whole.
|
|
100
115
|
*/
|
|
101
116
|
export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
|
|
117
|
+
/**
|
|
118
|
+
* The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
|
|
119
|
+
* or all of a file that holds fewer. Bytes past `length`, such as a later append, are not read.
|
|
120
|
+
*/
|
|
121
|
+
export declare function readContainedPrefix(rootInput: PreparedOutputRoot, relativePath: string, length: number): Promise<ContainedRead>;
|
|
122
|
+
/** The first `length` bytes of an opened file, read by position, or all of a shorter one. */
|
|
123
|
+
export declare function readOpenedPrefix(handle: FileHandle, length: number): Promise<Buffer>;
|
|
102
124
|
/** The caller owns this checked descriptor and must close it after reading/streaming. */
|
|
103
125
|
export declare function openContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<FileHandle | null>;
|
|
104
126
|
export declare function assertSafeOutputPathSegment(value: string, label?: string): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, open, realpath, rename, rmdir, unlink, } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { assertDirectoryIdentity, assertRegularFileOrMissing, isPathInside, prepareHumanishStorageDirectory, resolveExistingHumanishStorageDirectory, validatePreparedRunArtifactPaths, validatePreparedRunRootIdentity, } from "./paths.js";
|
|
@@ -150,6 +150,70 @@ export async function createContainedOutputFile(rootInput, relativePath, data) {
|
|
|
150
150
|
}
|
|
151
151
|
await handle.close();
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Appends `data` to an existing single-link regular file inside the root that holds exactly
|
|
155
|
+
* `size` bytes, the count the caller says it wrote, so an append never lands after bytes the
|
|
156
|
+
* caller did not write. The checks writeContainedOutputFile makes run before the open; the opened
|
|
157
|
+
* file must be the one checked, and the root and its folders are checked again before the write.
|
|
158
|
+
* The data is synced before this resolves, so a run.json written after it never indexes bytes the
|
|
159
|
+
* disk may not hold.
|
|
160
|
+
*/
|
|
161
|
+
export async function appendContainedOutputFile(rootInput, relativePath, data, size) {
|
|
162
|
+
const filePath = await prepareContainedOutputFile(rootInput, relativePath);
|
|
163
|
+
const root = await resolveOutputRoot(rootInput);
|
|
164
|
+
await assertContainedDirectoryChain(root, path.dirname(filePath));
|
|
165
|
+
const before = await lstat(filePath, { bigint: true });
|
|
166
|
+
if (!before.isFile() || before.nlink > 1n)
|
|
167
|
+
throw new Error(`${relativePath} is not a single-link regular file.`);
|
|
168
|
+
// O_NONBLOCK: a file swapped for a FIFO after the lstat fails the open at once (no reader), where
|
|
169
|
+
// a blocking open would wait for a reader and hold up every later write in the run's queue.
|
|
170
|
+
// Writes to a regular file ignore it.
|
|
171
|
+
const handle = await open(filePath, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
172
|
+
try {
|
|
173
|
+
const after = await handle.stat({ bigint: true });
|
|
174
|
+
if (!after.isFile() || after.nlink > 1n || after.dev !== before.dev || after.ino !== before.ino)
|
|
175
|
+
throw new Error(`${relativePath} changed while it was opened.`);
|
|
176
|
+
if (after.size !== BigInt(size))
|
|
177
|
+
throw new Error(`${relativePath} holds ${after.size} bytes, not ${size}.`);
|
|
178
|
+
if ((await resolveOutputRoot(rootInput)) !== root)
|
|
179
|
+
throw new Error("Output root changed after it was prepared.");
|
|
180
|
+
await assertContainedDirectoryChain(root, path.dirname(filePath));
|
|
181
|
+
const { bytesWritten } = await handle.write(data, 0, data.length);
|
|
182
|
+
if (bytesWritten !== data.length)
|
|
183
|
+
throw new Error(`${relativePath} was not appended in full.`);
|
|
184
|
+
await handle.datasync();
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await handle.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Removes a single-link regular file or an empty folder inside the root. Nothing at the path, or a
|
|
192
|
+
* folder on it missing, is not an error. A link, a hard-linked file or a folder that holds anything
|
|
193
|
+
* is refused and kept.
|
|
194
|
+
*/
|
|
195
|
+
export async function removeContainedOutputFile(rootInput, relativePath) {
|
|
196
|
+
assertSafeRelativeOutputPath(relativePath, false);
|
|
197
|
+
const root = await resolveOutputRoot(rootInput);
|
|
198
|
+
const candidate = path.resolve(root, normalizeRelativeOutputPath(relativePath));
|
|
199
|
+
if (!isPathInside(root, candidate) || candidate === root)
|
|
200
|
+
throw new Error("Output file must stay inside its selected root.");
|
|
201
|
+
let stats;
|
|
202
|
+
try {
|
|
203
|
+
await assertContainedDirectoryChain(root, path.dirname(candidate));
|
|
204
|
+
stats = await lstat(candidate, { bigint: true });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
208
|
+
return;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
if (stats.isDirectory())
|
|
212
|
+
return rmdir(candidate);
|
|
213
|
+
if (!stats.isFile() || stats.nlink > 1n)
|
|
214
|
+
throw new Error(`${relativePath} is not a single-link regular file.`);
|
|
215
|
+
await unlink(candidate);
|
|
216
|
+
}
|
|
153
217
|
// The most humanish reads of one file of each kind. A larger file is refused unread.
|
|
154
218
|
/**
|
|
155
219
|
* A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
|
|
@@ -240,6 +304,38 @@ export async function readOpenedAtMost(handle, maxBytes) {
|
|
|
240
304
|
chunks.push(chunk.subarray(0, bytesRead));
|
|
241
305
|
}
|
|
242
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
|
|
309
|
+
* or all of a file that holds fewer. Bytes past `length`, such as a later append, are not read.
|
|
310
|
+
*/
|
|
311
|
+
export async function readContainedPrefix(rootInput, relativePath, length) {
|
|
312
|
+
const opened = await openContained(rootInput, relativePath);
|
|
313
|
+
if (opened.status === "missing")
|
|
314
|
+
return opened;
|
|
315
|
+
if (opened.status === "refused")
|
|
316
|
+
return { status: "refused", reason: opened.reason, limit: length };
|
|
317
|
+
try {
|
|
318
|
+
return { status: "read", bytes: await readOpenedPrefix(opened.handle, length) };
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return { status: "refused", reason: "unreadable", limit: length };
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
await opened.handle.close().catch(() => { });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** The first `length` bytes of an opened file, read by position, or all of a shorter one. */
|
|
328
|
+
export async function readOpenedPrefix(handle, length) {
|
|
329
|
+
const bytes = Buffer.allocUnsafe(length);
|
|
330
|
+
let total = 0;
|
|
331
|
+
while (total < length) {
|
|
332
|
+
const { bytesRead } = await handle.read(bytes, total, length - total, total);
|
|
333
|
+
if (bytesRead === 0)
|
|
334
|
+
break;
|
|
335
|
+
total += bytesRead;
|
|
336
|
+
}
|
|
337
|
+
return bytes.subarray(0, total);
|
|
338
|
+
}
|
|
243
339
|
/** The caller owns this checked descriptor and must close it after reading/streaming. */
|
|
244
340
|
export async function openContainedRegularFile(rootInput, relativePath) {
|
|
245
341
|
const opened = await openContained(rootInput, relativePath);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type BigIntStats } from "node:fs";
|
|
1
2
|
import { type PreparedOutputRoot } from "./contained-output.js";
|
|
2
3
|
/**
|
|
3
4
|
* Analysis inputs are retained local artifacts, never URLs or caller-selected outputs. The path is
|
|
@@ -17,6 +18,8 @@ export declare function decodesToPlainRelativePath(value: string, maxLength: num
|
|
|
17
18
|
* authority to select another file or initiate a network request.
|
|
18
19
|
*/
|
|
19
20
|
export declare function readBoundedFile(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<Buffer | null>;
|
|
21
|
+
/** The fields of a file's lstat that sameFile compares: which file it is, its size and times. */
|
|
22
|
+
export type FileStamp = Pick<BigIntStats, "dev" | "ino" | "size" | "mtimeNs" | "ctimeNs">;
|
|
20
23
|
/**
|
|
21
24
|
* After a bounded read returned nothing: true when the file is absent, false when something is
|
|
22
25
|
* there that could not be read. Errors other than ENOENT propagate.
|
|
@@ -25,6 +28,7 @@ export declare function pathMissing(filePath: string): Promise<boolean>;
|
|
|
25
28
|
export type BoundedFileResult = {
|
|
26
29
|
state: "read";
|
|
27
30
|
bytes: Buffer;
|
|
31
|
+
stamp: FileStamp;
|
|
28
32
|
} | {
|
|
29
33
|
state: "limit";
|
|
30
34
|
size: bigint;
|
|
@@ -37,6 +41,13 @@ export declare function readBoundedFileResult(root: PreparedOutputRoot, relative
|
|
|
37
41
|
* At most `maxBytes` of a contained single-link regular file that stays the same file, with the
|
|
38
42
|
* same size and times, from the first check to the last. Size refusals are distinguished only after
|
|
39
43
|
* the same checks. `relativePath` uses `/`, and any name a contained read accepts
|
|
40
|
-
* (isSafeRelativeFilePath) is read: verify and export read the names they always did.
|
|
44
|
+
* (isSafeRelativeFilePath) is read: verify and export read the names they always did. A read
|
|
45
|
+
* returns the stamp it held the file to.
|
|
41
46
|
*/
|
|
42
47
|
export declare function readUnchangedFile(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<BoundedFileResult>;
|
|
48
|
+
/**
|
|
49
|
+
* True when the file readUnchangedFile would read at `relativePath` is still the file `stamp`
|
|
50
|
+
* describes: it passes the same root, parent and leaf checks, before and after its lstat matches
|
|
51
|
+
* the stamp. Opens nothing.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isUnchangedFile(root: PreparedOutputRoot, relativePath: string, stamp: FileStamp): Promise<boolean>;
|
|
@@ -99,47 +99,19 @@ export async function readBoundedFileResult(root, relativePath, maxBytes) {
|
|
|
99
99
|
* At most `maxBytes` of a contained single-link regular file that stays the same file, with the
|
|
100
100
|
* same size and times, from the first check to the last. Size refusals are distinguished only after
|
|
101
101
|
* the same checks. `relativePath` uses `/`, and any name a contained read accepts
|
|
102
|
-
* (isSafeRelativeFilePath) is read: verify and export read the names they always did.
|
|
102
|
+
* (isSafeRelativeFilePath) is read: verify and export read the names they always did. A read
|
|
103
|
+
* returns the stamp it held the file to.
|
|
103
104
|
*/
|
|
104
105
|
export async function readUnchangedFile(root, relativePath, maxBytes) {
|
|
105
|
-
if (!
|
|
106
|
-
relativePath.includes("\\") ||
|
|
107
|
-
!Number.isSafeInteger(maxBytes) ||
|
|
108
|
-
maxBytes < 1)
|
|
106
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
109
107
|
return unavailable;
|
|
110
|
-
const validateRoot = async () => {
|
|
111
|
-
if ("physicalRunRoot" in root) {
|
|
112
|
-
await validatePreparedRunRootIdentity(root);
|
|
113
|
-
return root.physicalRunRoot;
|
|
114
|
-
}
|
|
115
|
-
await assertPreparedSelectedOutputDirectory(root);
|
|
116
|
-
return root.physicalPath;
|
|
117
|
-
};
|
|
118
108
|
try {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
if (!isPathInside(physicalRoot, candidate) || candidate === physicalRoot)
|
|
122
|
-
return unavailable;
|
|
123
|
-
const validateParents = async () => {
|
|
124
|
-
let current = physicalRoot;
|
|
125
|
-
for (const segment of relativePath.split("/").slice(0, -1)) {
|
|
126
|
-
current = path.join(current, segment);
|
|
127
|
-
const info = await lstat(current);
|
|
128
|
-
if (!info.isDirectory() || info.isSymbolicLink())
|
|
129
|
-
throw new Error("Unsafe analysis input directory.");
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
await validateParents();
|
|
133
|
-
const before = await lstat(candidate, { bigint: true });
|
|
134
|
-
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n)
|
|
135
|
-
return unavailable;
|
|
136
|
-
if ((await realpath(candidate)) !== candidate)
|
|
109
|
+
const located = await locateContainedFile(root, relativePath);
|
|
110
|
+
if (located === null)
|
|
137
111
|
return unavailable;
|
|
112
|
+
const { candidate, before, stillSameFile } = located;
|
|
138
113
|
if (before.size > BigInt(maxBytes)) {
|
|
139
|
-
if ((await
|
|
140
|
-
return unavailable;
|
|
141
|
-
await validateParents();
|
|
142
|
-
if (!sameFile(before, await lstat(candidate, { bigint: true })))
|
|
114
|
+
if (!(await stillSameFile(before)))
|
|
143
115
|
return unavailable;
|
|
144
116
|
return { state: "limit", size: before.size };
|
|
145
117
|
}
|
|
@@ -153,12 +125,10 @@ export async function readUnchangedFile(root, relativePath, maxBytes) {
|
|
|
153
125
|
!sameFile(before, await handle.stat({ bigint: true })) ||
|
|
154
126
|
bytes.length !== Number(before.size))
|
|
155
127
|
return unavailable;
|
|
156
|
-
if ((await
|
|
157
|
-
return unavailable;
|
|
158
|
-
await validateParents();
|
|
159
|
-
if (!sameFile(before, await lstat(candidate, { bigint: true })))
|
|
128
|
+
if (!(await stillSameFile(before)))
|
|
160
129
|
return unavailable;
|
|
161
|
-
|
|
130
|
+
const { dev, ino, size, mtimeNs, ctimeNs } = before;
|
|
131
|
+
return { state: "read", bytes, stamp: { dev, ino, size, mtimeNs, ctimeNs } };
|
|
162
132
|
}
|
|
163
133
|
finally {
|
|
164
134
|
await handle.close();
|
|
@@ -168,3 +138,63 @@ export async function readUnchangedFile(root, relativePath, maxBytes) {
|
|
|
168
138
|
return unavailable;
|
|
169
139
|
}
|
|
170
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* True when the file readUnchangedFile would read at `relativePath` is still the file `stamp`
|
|
143
|
+
* describes: it passes the same root, parent and leaf checks, before and after its lstat matches
|
|
144
|
+
* the stamp. Opens nothing.
|
|
145
|
+
*/
|
|
146
|
+
export async function isUnchangedFile(root, relativePath, stamp) {
|
|
147
|
+
try {
|
|
148
|
+
const located = await locateContainedFile(root, relativePath);
|
|
149
|
+
return (located !== null && sameFile(stamp, located.before) && (await located.stillSameFile(stamp)));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The contained single-link regular file at `relativePath`: its path, its lstat, and a recheck that
|
|
157
|
+
* the root, its parents and the leaf are unchanged since. Null for a path or leaf a read refuses;
|
|
158
|
+
* a root or parent that fails its check throws.
|
|
159
|
+
*/
|
|
160
|
+
async function locateContainedFile(root, relativePath) {
|
|
161
|
+
if (!isSafeRelativeFilePath(relativePath) || relativePath.includes("\\"))
|
|
162
|
+
return null;
|
|
163
|
+
const validateRoot = async () => {
|
|
164
|
+
if ("physicalRunRoot" in root) {
|
|
165
|
+
await validatePreparedRunRootIdentity(root);
|
|
166
|
+
return root.physicalRunRoot;
|
|
167
|
+
}
|
|
168
|
+
await assertPreparedSelectedOutputDirectory(root);
|
|
169
|
+
return root.physicalPath;
|
|
170
|
+
};
|
|
171
|
+
const physicalRoot = await validateRoot();
|
|
172
|
+
const candidate = path.join(physicalRoot, relativePath);
|
|
173
|
+
if (!isPathInside(physicalRoot, candidate) || candidate === physicalRoot)
|
|
174
|
+
return null;
|
|
175
|
+
const validateParents = async () => {
|
|
176
|
+
let current = physicalRoot;
|
|
177
|
+
for (const segment of relativePath.split("/").slice(0, -1)) {
|
|
178
|
+
current = path.join(current, segment);
|
|
179
|
+
const info = await lstat(current);
|
|
180
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
181
|
+
throw new Error("Unsafe analysis input directory.");
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
await validateParents();
|
|
185
|
+
const before = await lstat(candidate, { bigint: true });
|
|
186
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n)
|
|
187
|
+
return null;
|
|
188
|
+
if ((await realpath(candidate)) !== candidate)
|
|
189
|
+
return null;
|
|
190
|
+
return {
|
|
191
|
+
candidate,
|
|
192
|
+
before,
|
|
193
|
+
stillSameFile: async (stamp) => {
|
|
194
|
+
if ((await validateRoot()) !== physicalRoot)
|
|
195
|
+
return false;
|
|
196
|
+
await validateParents();
|
|
197
|
+
return sameFile(stamp, await lstat(candidate, { bigint: true }));
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|