humanish 0.112.0 → 0.114.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 +41 -0
- package/CONTEXT.md +1 -0
- package/README.md +12 -1
- package/TELEMETRY.md +21 -0
- package/dist/actors/codex/restricted-participant-policy.d.ts +21 -1
- package/dist/actors/codex/restricted-participant-policy.js +20 -2
- package/dist/actors/codex/restricted-participant.d.ts +1 -1
- package/dist/actors/codex/restricted-participant.js +2 -1
- package/dist/actors/computer-use/loop/debrief.d.ts +12 -4
- package/dist/actors/computer-use/loop/debrief.js +57 -20
- package/dist/actors/computer-use/loop/ending.d.ts +5 -2
- package/dist/actors/computer-use/loop/impressions.d.ts +14 -0
- package/dist/actors/computer-use/loop/impressions.js +40 -0
- package/dist/actors/computer-use/loop/session.d.ts +3 -1
- package/dist/actors/computer-use/loop/session.js +2 -0
- package/dist/actors/computer-use/loop/trace.js +2 -0
- package/dist/actors/computer-use/loop/types.d.ts +8 -1
- package/dist/actors/computer-use/loop.d.ts +2 -1
- package/dist/actors/computer-use/loop.js +15 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +9 -4
- package/dist/actors/computer-use/openai-conversation.js +60 -29
- package/dist/actors/computer-use/openai-provider.js +18 -12
- package/dist/actors/contract.d.ts +47 -5
- package/dist/actors/contract.js +13 -0
- package/dist/analysis/admission.d.ts +37 -0
- package/dist/analysis/admission.js +60 -0
- package/dist/analysis/automatic-completion.d.ts +0 -6
- package/dist/analysis/automatic-completion.js +3 -3
- package/dist/analysis/automatic-config.d.ts +10 -2
- package/dist/analysis/automatic-config.js +29 -14
- package/dist/analysis/automatic.js +7 -0
- package/dist/analysis/evidence-sources.d.ts +3 -1
- package/dist/analysis/evidence-sources.js +10 -1
- package/dist/analysis/evidence.js +2 -2
- package/dist/analysis/execute.d.ts +20 -6
- package/dist/analysis/execute.js +61 -28
- package/dist/analysis/image-tokens.d.ts +2 -0
- package/dist/analysis/image-tokens.js +1 -1
- package/dist/analysis/job.d.ts +9 -1
- package/dist/analysis/job.js +11 -2
- package/dist/analysis/service.js +6 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +144 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/runs.js +33 -10
- package/dist/cli/commands/setup.js +95 -6
- package/dist/cli/commands/study-format.js +7 -4
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/commands/study.js +1 -1
- package/dist/cli/commands/tui.js +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +18 -0
- package/dist/cli/findings.js +4 -1
- package/dist/cli/io.d.ts +3 -0
- package/dist/cli/io.js +11 -12
- package/dist/cli/program.js +37 -1
- package/dist/cli/secret-prompt.d.ts +5 -1
- package/dist/cli/secret-prompt.js +6 -2
- package/dist/cli/update-check-worker.d.ts +1 -0
- package/dist/cli/update-check-worker.js +5 -0
- package/dist/cli/update-check.d.ts +46 -0
- package/dist/cli/update-check.js +203 -0
- package/dist/evidence/encoded-text.d.ts +15 -2
- package/dist/evidence/encoded-text.js +68 -4
- package/dist/evidence/secret-scrub.d.ts +1 -1
- package/dist/evidence/secret-scrub.js +23 -2
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +69 -1
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/feedback/export.js +47 -3
- package/dist/feedback/feedback.js +42 -2
- package/dist/keys/key-resolution.js +2 -0
- package/dist/keys/key-status.d.ts +37 -0
- package/dist/keys/key-status.js +68 -0
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/notes-route.d.ts +21 -0
- package/dist/observer/notes-route.js +140 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +32 -3
- package/dist/observer/render.js +38 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +47 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +28 -3
- package/dist/observer-app.html +7 -6
- package/dist/routes/computer-use/local-vm.js +9 -2
- package/dist/routes/computer-use/participant-desktop.d.ts +6 -0
- package/dist/routes/computer-use/participant-execution.js +9 -1
- package/dist/routes/computer-use/rerun-selection.js +23 -21
- package/dist/routes/computer-use/result.js +3 -2
- package/dist/routes/computer-use/self-report.js +7 -1
- package/dist/routes/computer-use/setup.js +14 -0
- package/dist/routes/computer-use/types.d.ts +4 -1
- package/dist/routes/shared-world/bundle-records.js +8 -7
- package/dist/routes/shared-world/bundle.js +1 -1
- package/dist/run/contained-output.d.ts +7 -0
- package/dist/run/contained-output.js +35 -1
- package/dist/run/detail.js +4 -4
- package/dist/run/note-files.d.ts +73 -0
- package/dist/run/note-files.js +184 -0
- package/dist/run/notes.d.ts +37 -0
- package/dist/run/notes.js +198 -0
- package/dist/run/participant-caption.d.ts +11 -1
- package/dist/run/participant-caption.js +30 -1
- package/dist/run/participant-records.d.ts +3 -1
- package/dist/run/participant-records.js +6 -0
- package/dist/run-study.js +17 -1
- package/dist/study/discover.js +6 -1
- package/dist/study/doctor.d.ts +2 -0
- package/dist/study/doctor.js +46 -8
- package/dist/study/init-templates.js +10 -5
- package/dist/study/plan.js +3 -2
- package/dist/study/preflight-result.d.ts +1 -0
- package/dist/study/preflight-result.js +3 -2
- package/dist/study/preflight.d.ts +2 -0
- package/dist/study/preflight.js +54 -14
- package/dist/study/summary.js +2 -2
- package/dist/study/warnings.d.ts +5 -0
- package/dist/study/warnings.js +79 -2
- package/dist/substrates/local/capacity.d.ts +75 -0
- package/dist/substrates/local/capacity.js +143 -0
- package/dist/substrates/local/firecracker-desktop.d.ts +8 -0
- package/dist/substrates/local/firecracker-desktop.js +34 -3
- package/dist/substrates/local/runtime-host.d.ts +16 -3
- package/dist/substrates/local/runtime-host.js +49 -4
- package/dist/substrates/local/runtime.d.ts +10 -0
- package/dist/substrates/local/runtime.js +44 -5
- package/dist/tui-app.js +58 -58
- package/docs/architecture/actor-contract.md +20 -6
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/feedback.md +8 -1
- package/docs/contracts/run-bundle.md +57 -0
- package/docs/contracts/schemas.md +62 -36
- package/docs/contracts/study-analysis.md +41 -8
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +2 -2
- package/skills/humanish/SKILL.md +26 -10
|
@@ -3,8 +3,9 @@ import path from "node:path";
|
|
|
3
3
|
import { inboxRecipientFor, } from "./participant-desktop.js";
|
|
4
4
|
import { createLocalFirecrackerDesktop, } from "../../substrates/local/firecracker-desktop.js";
|
|
5
5
|
import { localBrowserDefaults, localBrowserUnsupportedReason, } from "../../substrates/local/runtime-config.js";
|
|
6
|
-
import { prepareLocalRuntime } from "../../substrates/local/runtime.js";
|
|
7
|
-
import { dockerCommandLine } from "../../substrates/local/runtime-host.js";
|
|
6
|
+
import { localRuntimeCapacity, prepareLocalRuntime } from "../../substrates/local/runtime.js";
|
|
7
|
+
import { dockerCommandLine, usesLima } from "../../substrates/local/runtime-host.js";
|
|
8
|
+
import { outOfMemoryMessage } from "../../substrates/local/capacity.js";
|
|
8
9
|
import { checkRestrictedCodexAnalysisReadiness } from "../../analysis/restricted-codex.js";
|
|
9
10
|
import { createRestrictedCodexParticipant } from "../../actors/codex/restricted-participant.js";
|
|
10
11
|
import { withCloseReport } from "./participant-model.js";
|
|
@@ -110,6 +111,11 @@ function createLocalParticipantDesktop(context, run, warnings, artifactRoot) {
|
|
|
110
111
|
warnings.push("Desktop video/audio recording could not be retained. Screenshots and participant evidence remain available.");
|
|
111
112
|
}
|
|
112
113
|
}
|
|
114
|
+
// Asked before close, which removes the container; the answer comes from Docker's events.
|
|
115
|
+
if ((await session.killedForMemory?.()) === true) {
|
|
116
|
+
evidence.failureCode = "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY";
|
|
117
|
+
evidence.desktopFailure = outOfMemoryMessage(usesLima() ? "lima-vm" : "linux-host");
|
|
118
|
+
}
|
|
113
119
|
try {
|
|
114
120
|
evidence.released = (await session.close()).status === "released";
|
|
115
121
|
}
|
|
@@ -180,6 +186,7 @@ export function prepareLocalVmRun(options) {
|
|
|
180
186
|
options: account ? { ...runOptions, createProvider: accountProvider(state) } : runOptions,
|
|
181
187
|
localVm: {
|
|
182
188
|
desktop: (run, warnings, artifactRoot) => createLocalParticipantDesktop(context, run, warnings, artifactRoot),
|
|
189
|
+
capacity: () => localRuntimeCapacity(),
|
|
183
190
|
analysisRefusal: () => state.cleanupUnconfirmed ? "AUTOMATIC_ANALYSIS_CLEANUP_UNCONFIRMED" : undefined,
|
|
184
191
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
185
192
|
},
|
|
@@ -16,6 +16,12 @@ export type ParticipantDesktopEvidence = {
|
|
|
16
16
|
/** The substrate confirmed the desktop was released (an E2B kill or a local VM shutdown). The participant
|
|
17
17
|
* outcome records it as `killed`. */
|
|
18
18
|
released: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Why the desktop itself ended the session, in words a person reads, when the substrate knows:
|
|
21
|
+
* a local desktop Docker killed for memory. It replaces the session's error for a participant
|
|
22
|
+
* who did not pass.
|
|
23
|
+
*/
|
|
24
|
+
desktopFailure?: string;
|
|
19
25
|
} & Pick<ParticipantRunOutcome, "sandboxId" | "sandboxRelease" | "desktopDurationMs" | "desktopResources" | "streamUrlPresent" | "subjectCommit" | "desktopBrowser" | "desktopGeometry" | "stateStepRecords" | "phaseRecords" | "failureCode" | "commsArtifactPath" | "recording">;
|
|
20
26
|
/**
|
|
21
27
|
* The desktop one participant runs on, owned from preparation through final evidence collection.
|
|
@@ -107,6 +107,15 @@ export async function runCuaParticipant(spec, deps) {
|
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
const { released, desktopFailure, ...desktopEvidence } = desktop.snapshot();
|
|
111
|
+
// A desktop that died under the participant explains the failure better than the transport
|
|
112
|
+
// error the session saw. A session that passed keeps its result.
|
|
113
|
+
if (desktopFailure !== undefined) {
|
|
114
|
+
if (session?.status === "passed")
|
|
115
|
+
warnings.push(desktopFailure);
|
|
116
|
+
else
|
|
117
|
+
sessionError = desktopFailure;
|
|
118
|
+
}
|
|
110
119
|
if (session)
|
|
111
120
|
await recordParticipantTrace(spec, deps, session, warnings);
|
|
112
121
|
const { noEngagement, selfReportedBlocker, reportedFriction } = judgeParticipantSession(session, deps, warnings);
|
|
@@ -116,7 +125,6 @@ export async function runCuaParticipant(spec, deps) {
|
|
|
116
125
|
providerCleanupError !== undefined ||
|
|
117
126
|
providerPolicyError !== undefined ||
|
|
118
127
|
session?.completionReason === "harness_error";
|
|
119
|
-
const { released, ...desktopEvidence } = desktop.snapshot();
|
|
120
128
|
return {
|
|
121
129
|
spec,
|
|
122
130
|
...(session ? { session } : {}),
|
|
@@ -4,42 +4,44 @@ import {} from "../../run/bundle.js";
|
|
|
4
4
|
import { loadRunBundle } from "../../run/locate.js";
|
|
5
5
|
import {} from "../../run/streams.js";
|
|
6
6
|
import { plural } from "../../run/text.js";
|
|
7
|
+
import { access } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { cli } from "../../cli/invocation.js";
|
|
7
10
|
export async function resolveCuaRerunSelection(args) {
|
|
8
|
-
const
|
|
11
|
+
const { studyId, sourceRunId } = args;
|
|
12
|
+
// Every refusal ends with the command that runs the whole study, which is what a user who
|
|
13
|
+
// reached for a rerun too early wants.
|
|
14
|
+
const instead = `To run every participant of ${studyId} instead, leave out --rerun-failed-from: ${cli(`watch ${studyId}`)} or ${cli(`run ${studyId}`)}.`;
|
|
15
|
+
const refuse = (what) => ({ ok: false, message: `${what} ${instead}` });
|
|
16
|
+
const source = await loadRunBundle(args.cwd, sourceRunId);
|
|
9
17
|
if (!source) {
|
|
10
|
-
|
|
18
|
+
const noRuns = sourceRunId === "latest" &&
|
|
19
|
+
!(await access(path.join(args.cwd, ".humanish", "runs", "latest.json")).then(() => true, () => false));
|
|
20
|
+
return refuse(noRuns
|
|
21
|
+
? "--rerun-failed-from latest found no run: this project has no runs yet, and a rerun repeats the failed participants of an earlier live run."
|
|
22
|
+
: `--rerun-failed-from ${sourceRunId} names no run humanish can read in this project. ${cli("runs")} lists its runs.`);
|
|
11
23
|
}
|
|
12
24
|
const bundle = source.bundle;
|
|
25
|
+
const run = `Run ${bundle.runId}${sourceRunId === bundle.runId ? "" : ` (the ${sourceRunId} run)`}`;
|
|
26
|
+
const notFanout = `${run} is not a live computer-use run with more than one participant, so it has no participants to rerun.`;
|
|
13
27
|
if (bundle.mode !== "live") {
|
|
14
|
-
return {
|
|
15
|
-
ok: false,
|
|
16
|
-
message: `source run ${bundle.runId} is ${bundle.mode}; rerun selection only applies to live CUA fan-out evidence.`,
|
|
17
|
-
};
|
|
28
|
+
return refuse(`${run} is a dry run: no participant ran in it, so none can be rerun. A rerun repeats the failed participants of a live run with more than one participant.`);
|
|
18
29
|
}
|
|
19
30
|
const fanoutEvent = bundle.events.some((event) => event.type === "cua-lab.fanout.plan");
|
|
20
|
-
if (!fanoutEvent || bundle.streams.length < 2)
|
|
21
|
-
return
|
|
22
|
-
}
|
|
31
|
+
if (!fanoutEvent || bundle.streams.length < 2)
|
|
32
|
+
return refuse(notFanout);
|
|
23
33
|
const prior = bundle.streams
|
|
24
34
|
.map(snapshotPriorParticipant)
|
|
25
35
|
.filter((entry) => entry !== null);
|
|
26
36
|
const priorById = new Map(prior.map((entry) => [entry.participantId, entry]));
|
|
27
|
-
if (priorById.size < 2)
|
|
28
|
-
return
|
|
29
|
-
ok: false,
|
|
30
|
-
message: `source run ${bundle.runId} does not expose multiple participant ids.`,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
37
|
+
if (priorById.size < 2)
|
|
38
|
+
return refuse(notFanout);
|
|
33
39
|
const explicitIds = uniqueIds(args.participantIds ?? []);
|
|
34
40
|
const selectedIds = explicitIds.length > 0
|
|
35
41
|
? explicitIds
|
|
36
42
|
: prior.filter((entry) => entry.rerunnable).map((entry) => entry.participantId);
|
|
37
|
-
if (selectedIds.length === 0)
|
|
38
|
-
return {
|
|
39
|
-
ok: false,
|
|
40
|
-
message: `source run ${bundle.runId} has no participant to rerun: none failed, was blocked, timed out or ended without engaging.`,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
+
if (selectedIds.length === 0)
|
|
44
|
+
return refuse(`${run} has no participant to rerun: none failed, was blocked, timed out or ended without engaging.`);
|
|
43
45
|
const missingPrior = selectedIds.filter((id) => !priorById.has(id));
|
|
44
46
|
if (missingPrior.length > 0) {
|
|
45
47
|
return {
|
|
@@ -98,8 +98,9 @@ function cuaStudyResult(args) {
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
const failing = (outcomes ?? []).find((outcome) => !participantOk(outcome));
|
|
101
|
-
|
|
102
|
-
const
|
|
101
|
+
// A participant whose desktop failed for a known cause gives the run that cause's code.
|
|
102
|
+
const codedFailure = (outcomes ?? []).find((outcome) => outcome.failureCode !== undefined);
|
|
103
|
+
const code = codedFailure?.failureCode ?? "HUMANISH_COMPUTER_USE_FAILED";
|
|
103
104
|
return {
|
|
104
105
|
code,
|
|
105
106
|
message: observer.ok
|
|
@@ -200,8 +200,14 @@ export function resolveSelfReportedFriction(session) {
|
|
|
200
200
|
// reached the goal and described what was hard on the way has reported friction.
|
|
201
201
|
if (session.trace.declaredOutcome === "blocked")
|
|
202
202
|
return session.reason;
|
|
203
|
+
// Impressions are opinions about the product, not reports of what went wrong in the session.
|
|
204
|
+
const impressions = new Set(session.trace.impressions?.status === "collected"
|
|
205
|
+
? session.trace.impressions.items.map((item) => item.messageId)
|
|
206
|
+
: []);
|
|
203
207
|
const messages = session.trace.items
|
|
204
|
-
.filter((item) => item.kind === "message" &&
|
|
208
|
+
.filter((item) => item.kind === "message" &&
|
|
209
|
+
item.id !== session.trace.debrief?.messageId &&
|
|
210
|
+
!impressions.has(item.id))
|
|
205
211
|
.map((item) => item.text?.trim() ?? "")
|
|
206
212
|
.filter((text) => text.length > 0);
|
|
207
213
|
// A custom session may keep its closing report only in reason even when earlier messages exist.
|
|
@@ -22,6 +22,7 @@ import {} from "./bundle.js";
|
|
|
22
22
|
import { packRunLocalTree } from "./local-tree-pack.js";
|
|
23
23
|
import { projectParticipantSubjects, subjectProvenanceArg } from "./subject-projection.js";
|
|
24
24
|
import { participantSubjectEnv, } from "./types.js";
|
|
25
|
+
import { capacityShortfall } from "../../substrates/local/capacity.js";
|
|
25
26
|
import { studyPersonaIds } from "../../study/persona-resolve.js";
|
|
26
27
|
import { refusedResult } from "../../run/study-result.js";
|
|
27
28
|
/** The physical project, bound before any caller hook runs. */
|
|
@@ -145,6 +146,11 @@ export async function admitCuaRun(plan, input, config) {
|
|
|
145
146
|
});
|
|
146
147
|
if (rejection)
|
|
147
148
|
return refuse(rejection.code, rejection.message, descriptor.id);
|
|
149
|
+
const shortfall = await localCapacityShortfall(input.localVm, participantPlan.concurrency);
|
|
150
|
+
if (shortfall !== undefined && "refusal" in shortfall)
|
|
151
|
+
return refuse("HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED", shortfall.refusal, descriptor.id);
|
|
152
|
+
if (shortfall !== undefined)
|
|
153
|
+
process.stderr.write(`warning: ${shortfall.warning}\n`);
|
|
148
154
|
}
|
|
149
155
|
// Pack the working tree once per run, on the host, before any sandbox or provider call: every
|
|
150
156
|
// fan-out participant below uploads this same archive, so one archiveSha256 describes every
|
|
@@ -192,6 +198,14 @@ export async function admitCuaRun(plan, input, config) {
|
|
|
192
198
|
},
|
|
193
199
|
};
|
|
194
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether a local study's desktops at once fit the local runtime. Read before any desktop: a
|
|
203
|
+
* desktop past the VM's memory is killed mid-session, after its participant has spent.
|
|
204
|
+
*/
|
|
205
|
+
async function localCapacityShortfall(localVm, desktops) {
|
|
206
|
+
const capacity = await localVm?.capacity?.();
|
|
207
|
+
return capacity === undefined ? undefined : capacityShortfall(capacity, desktops);
|
|
208
|
+
}
|
|
195
209
|
/** Starts the run and builds what the participants and the finish read: the deps and the bundle base. */
|
|
196
210
|
export async function startCuaRun(plan, input, admitted, scope) {
|
|
197
211
|
const { cwd, descriptor, participantRuns, participantPlan, publicRepo } = admitted;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Verdict } from "../../run/judge.js";
|
|
2
|
+
import type { LocalCapacity } from "../../substrates/local/capacity.js";
|
|
2
3
|
import type { RunDesktopRecording } from "../../evidence/desktop-recording-types.js";
|
|
3
4
|
import type { CuaExecutor, CuaLiveMetadata, CuaLoopResult, CuaProvider } from "../../actors/computer-use/loop.js";
|
|
4
5
|
import type { CommsReceivingRun } from "../../comms/receiving.js";
|
|
@@ -38,6 +39,8 @@ export declare const MIN_DERIVED_SESSION_TIMEOUT_MS: number;
|
|
|
38
39
|
*/
|
|
39
40
|
export interface LocalVmInput {
|
|
40
41
|
readonly desktop: ParticipantDesktopFactory;
|
|
42
|
+
/** How many desktops the local runtime holds, or undefined when it cannot be read. */
|
|
43
|
+
readonly capacity?: () => Promise<LocalCapacity | undefined>;
|
|
41
44
|
/** The skip reason automatic analysis records instead of running, or undefined to run it. */
|
|
42
45
|
readonly analysisRefusal: () => AutomaticAnalysisRefusal | undefined;
|
|
43
46
|
readonly signal?: AbortSignal;
|
|
@@ -182,7 +185,7 @@ export interface CuaParticipantSummary {
|
|
|
182
185
|
concurrency: number;
|
|
183
186
|
waves: number;
|
|
184
187
|
}
|
|
185
|
-
export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
|
|
188
|
+
export type CuaActorStudyErrorCode = StudyResultErrorCode | "HUMANISH_COMPUTER_USE_FAILED" | "HUMANISH_COMPUTER_USE_KEYS_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_MISSING" | "HUMANISH_COMPUTER_USE_AGENT_SIGNIN_REQUIRED" | "HUMANISH_COMPUTER_USE_SUBJECT_ENV_MISSING" | "HUMANISH_COMPUTER_USE_ACTOR_UNSUPPORTED" | "HUMANISH_COMPUTER_USE_SUBJECT_INVALID" | "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE" | "HUMANISH_COMPUTER_USE_EXECUTOR_NO_PROVIDER" | "HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR" | "HUMANISH_COMPUTER_USE_LOCAL_DESKTOP_MISSING" | "HUMANISH_COMPUTER_USE_LOCAL_CAPACITY_EXCEEDED" | "HUMANISH_COMPUTER_USE_DESKTOP_OUT_OF_MEMORY" | "HUMANISH_COMPUTER_USE_FANOUT_INVALID" | "HUMANISH_COMPUTER_USE_RERUN_INVALID" | "HUMANISH_COMPUTER_USE_DEVICE_GEOMETRY" | "HUMANISH_COMPUTER_USE_UNPRICED_CAP" | "HUMANISH_COMPUTER_USE_COMMS_CATCH_UNREACHABLE" | "HUMANISH_COMPUTER_USE_COMMS_TOKEN_INVALID" | "HUMANISH_WATCH_ALLOW_REQUIRES_OAUTH" | "HUMANISH_WATCH_OAUTH_REQUIRES_TUNNEL" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_TUNNEL_REQUIRES_EXPOSE" | "HUMANISH_WATCH_EXPOSE_REQUIRES_EDGE_AUTH" | "HUMANISH_WATCH_EXPOSE_REQUIRES_LIVE_FOLLOW" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_SERVE_TUNNEL_NOT_FOUND" | "HUMANISH_SERVE_TUNNEL_START_FAILED";
|
|
186
189
|
/** Subject provenance projection: what the actor actually drove. */
|
|
187
190
|
export interface CuaSubjectProjection {
|
|
188
191
|
source: "app-url" | "clone" | "local-tree";
|
|
@@ -81,16 +81,17 @@ function sharedWorldStream(ctx, spec, index, view) {
|
|
|
81
81
|
const { args } = ctx;
|
|
82
82
|
const { name, session, screenshots, lastScreenshot, desktopGeometry, screenshotMode } = view;
|
|
83
83
|
const judged = view.judgment.judgedStatus;
|
|
84
|
+
const caption = participantCaption({
|
|
85
|
+
id: spec.planned.id,
|
|
86
|
+
personaId: spec.persona.id,
|
|
87
|
+
device: spec.planned.device,
|
|
88
|
+
});
|
|
84
89
|
return participantStream(spec, {
|
|
85
90
|
...(spec.evidenceAssignment === undefined
|
|
86
91
|
? {}
|
|
87
92
|
: { assignment: participantAssignment(spec.evidenceAssignment) }),
|
|
88
93
|
kind: "browser",
|
|
89
|
-
label:
|
|
90
|
-
id: spec.planned.id,
|
|
91
|
-
personaId: spec.persona.id,
|
|
92
|
-
device: spec.planned.device,
|
|
93
|
-
}),
|
|
94
|
+
label: caption,
|
|
94
95
|
status: view.status,
|
|
95
96
|
...(judged === undefined ? {} : { judgedStatus: judged }),
|
|
96
97
|
transport: "snapshot",
|
|
@@ -99,9 +100,9 @@ function sharedWorldStream(ctx, spec, index, view) {
|
|
|
99
100
|
? {
|
|
100
101
|
kind: "screenshot",
|
|
101
102
|
url: lastScreenshot,
|
|
102
|
-
title:
|
|
103
|
+
title: `${caption} (${screenshotMode})`,
|
|
103
104
|
}
|
|
104
|
-
: { kind: "placeholder", title:
|
|
105
|
+
: { kind: "placeholder", title: caption },
|
|
105
106
|
...(desktopGeometry.viewport === undefined
|
|
106
107
|
? {}
|
|
107
108
|
: {
|
|
@@ -252,7 +252,7 @@ function concurrencyReview(args, external, inProgress, evidence, events, nextEve
|
|
|
252
252
|
const overlaps = args.judgment.world.overlap;
|
|
253
253
|
const deltas = (stateSeries ?? []).filter((snapshot, i) => i > 0 && snapshot.digest !== (stateSeries ?? [])[i - 1].digest).length;
|
|
254
254
|
const stateSeriesLabel = external
|
|
255
|
-
? "
|
|
255
|
+
? "Shared app state was not recorded because the app is hosted outside humanish"
|
|
256
256
|
: `stateSeries ${plural((stateSeries ?? []).length, "snapshot")}, ${plural(deltas, "delta")}`;
|
|
257
257
|
const convergenceLabel = external
|
|
258
258
|
? `; lobby convergence ${args.lobbyConvergenceDigest ? "shown (all participants reached one /lobby/CODE)" : "not observed"}`
|
|
@@ -38,6 +38,13 @@ export declare function prepareContainedOutputDirectory(rootInput: PreparedOutpu
|
|
|
38
38
|
export declare function prepareContainedOutputDirectoryRoot(rootInput: PreparedOutputRoot, relativePath: string): Promise<PreparedSelectedOutputDirectory>;
|
|
39
39
|
export declare function prepareContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<string>;
|
|
40
40
|
export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string | Uint8Array, encoding?: BufferEncoding): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Writes a new file inside the root without ever replacing one: the bytes go to a temporary file,
|
|
43
|
+
* which link(2) then gives the final name, failing with EEXIST when that name exists, and the
|
|
44
|
+
* temporary name is removed. writeContainedOutputFile renames instead, which replaces a file that
|
|
45
|
+
* took the name in the meantime.
|
|
46
|
+
*/
|
|
47
|
+
export declare function writeNewContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
|
|
41
48
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
42
49
|
export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
|
|
43
50
|
/**
|
|
@@ -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 { link, lstat, mkdir, open, realpath, rename, 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";
|
|
@@ -123,6 +123,40 @@ export async function writeContainedOutputFile(rootInput, relativePath, data, en
|
|
|
123
123
|
await assertRegularFileOrMissing(filePath, "Selected output files");
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Writes a new file inside the root without ever replacing one: the bytes go to a temporary file,
|
|
128
|
+
* which link(2) then gives the final name, failing with EEXIST when that name exists, and the
|
|
129
|
+
* temporary name is removed. writeContainedOutputFile renames instead, which replaces a file that
|
|
130
|
+
* took the name in the meantime.
|
|
131
|
+
*/
|
|
132
|
+
export async function writeNewContainedOutputFile(rootInput, relativePath, data) {
|
|
133
|
+
const filePath = await prepareContainedOutputFile(rootInput, relativePath);
|
|
134
|
+
const root = await resolveOutputRoot(rootInput);
|
|
135
|
+
const parent = path.dirname(filePath);
|
|
136
|
+
const revalidate = async () => {
|
|
137
|
+
if ((await resolveOutputRoot(rootInput)) !== root) {
|
|
138
|
+
throw new Error("Output root changed after it was prepared.");
|
|
139
|
+
}
|
|
140
|
+
await assertContainedDirectoryChain(root, parent);
|
|
141
|
+
};
|
|
142
|
+
await revalidate();
|
|
143
|
+
const temporary = path.join(parent, `.humanish-write-${process.pid}-${randomUUID()}.tmp`);
|
|
144
|
+
let handle;
|
|
145
|
+
try {
|
|
146
|
+
handle = await open(temporary, "wx", 0o600);
|
|
147
|
+
await handle.writeFile(data, "utf8");
|
|
148
|
+
await handle.sync();
|
|
149
|
+
await handle.close();
|
|
150
|
+
handle = undefined;
|
|
151
|
+
await revalidate();
|
|
152
|
+
await link(temporary, filePath);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await handle?.close().catch(() => undefined);
|
|
156
|
+
await unlink(temporary).catch(() => undefined);
|
|
157
|
+
}
|
|
158
|
+
await assertRegularFileOrMissing(filePath, "Selected output files");
|
|
159
|
+
}
|
|
126
160
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
127
161
|
export async function readContainedRegularFile(rootInput, relativePath) {
|
|
128
162
|
const handle = await openContainedRegularFile(rootInput, relativePath);
|
package/dist/run/detail.js
CHANGED
|
@@ -13,7 +13,7 @@ import { estimateActorCostForExecution } from "./pricing.js";
|
|
|
13
13
|
import { resolveRunPath } from "./locate.js";
|
|
14
14
|
import { RUN_BUNDLE_FILE } from "./bundle.js";
|
|
15
15
|
import { savedCaption } from "./participant-caption.js";
|
|
16
|
-
import { streamParticipantIdOf } from "./participant-records.js";
|
|
16
|
+
import { recordedPersonaId, streamParticipantIdOf } from "./participant-records.js";
|
|
17
17
|
import { readContainedRegularFile } from "./contained-output.js";
|
|
18
18
|
import { isPathInside, resolvePhysicalCwd } from "./paths.js";
|
|
19
19
|
const RUN_DETAIL_SCHEMA = "humanish.run-detail.v1";
|
|
@@ -43,12 +43,12 @@ function latestThought(trace) {
|
|
|
43
43
|
}
|
|
44
44
|
return undefined;
|
|
45
45
|
}
|
|
46
|
-
function participantFrom(stream, index) {
|
|
46
|
+
function participantFrom(stream, index, savedPersonaId) {
|
|
47
47
|
// A live run's flush wins over the finished trace: while both exist, the live one is the newer
|
|
48
48
|
// account of what is happening.
|
|
49
49
|
const trace = stream.liveActor ?? stream.actor ?? {};
|
|
50
50
|
const id = stream.id ?? `stream-${index + 1}`;
|
|
51
|
-
const personaId = trace.persona?.id;
|
|
51
|
+
const personaId = trace.persona?.id ?? savedPersonaId;
|
|
52
52
|
const participantId = streamParticipantIdOf(stream);
|
|
53
53
|
// Computed once: calling it twice to test-then-use reads as though the two could differ.
|
|
54
54
|
const thought = latestThought(trace);
|
|
@@ -140,7 +140,7 @@ export async function readRunDetail(cwdInput, runId) {
|
|
|
140
140
|
...(automaticAnalysis === undefined ? {} : { automaticAnalysis }),
|
|
141
141
|
schema: RUN_DETAIL_SCHEMA,
|
|
142
142
|
runId: bundle.runId ?? runId,
|
|
143
|
-
participants: streams.map(participantFrom),
|
|
143
|
+
participants: streams.map((stream, index) => participantFrom(stream, index, recordedPersonaId(bundle, stream))),
|
|
144
144
|
...(isPathInside(cwd, observerAbsolute)
|
|
145
145
|
? { observerPath: path.relative(cwd, observerAbsolute) }
|
|
146
146
|
: { observerPath: observerAbsolute }),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
2
|
+
export declare const RUN_NOTES_DIR = "notes";
|
|
3
|
+
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
4
|
+
export declare const MAX_NOTE_FILE_BYTES: number;
|
|
5
|
+
/** The notes one run keeps. A reader reads this many and names the rest. */
|
|
6
|
+
export declare const MAX_RUN_NOTES = 500;
|
|
7
|
+
export declare const MAX_NOTE_TEXT = 2000;
|
|
8
|
+
export interface RunNote {
|
|
9
|
+
id: string;
|
|
10
|
+
/** Milliseconds from the run clock's start. */
|
|
11
|
+
atMs: number;
|
|
12
|
+
/** The participant's stream id, or null for a note on the whole run. */
|
|
13
|
+
participant: string | null;
|
|
14
|
+
/** The latest timed capture or event at or before the moment, with its participant. */
|
|
15
|
+
nearest: {
|
|
16
|
+
participant: string;
|
|
17
|
+
itemId: string;
|
|
18
|
+
} | null;
|
|
19
|
+
text: string;
|
|
20
|
+
author: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
editedAt: string | null;
|
|
23
|
+
}
|
|
24
|
+
/** A run's notes as read, in the order they were added, and why any file was skipped. */
|
|
25
|
+
export interface RunNotes {
|
|
26
|
+
runId: string;
|
|
27
|
+
notes: RunNote[];
|
|
28
|
+
skipped: string[];
|
|
29
|
+
}
|
|
30
|
+
/** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
|
|
31
|
+
export declare function newRunNoteId(createdAt: Date): string;
|
|
32
|
+
/** The note's file, relative to the run directory. */
|
|
33
|
+
export declare function runNoteFile(id: string): string;
|
|
34
|
+
/** The note as its file holds it. */
|
|
35
|
+
export declare function encodeRunNote(note: RunNote, runId: string): string;
|
|
36
|
+
/** The note in a note file's bytes, when it is a note of `runId` stored under its own `id`. */
|
|
37
|
+
export declare function decodeRunNote(bytes: Buffer, runId: string, id: string): RunNote | null;
|
|
38
|
+
/**
|
|
39
|
+
* Up to MAX_LISTED_ENTRIES names in the notes directory at `dir`, or null when there is none.
|
|
40
|
+
* Throws when `dir` is there and is not a plain directory.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listNoteEntries(dir: string): Promise<{
|
|
43
|
+
names: string[];
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
} | null>;
|
|
46
|
+
/** The note ids to read from a listing of notes/, in creation order, and what was left out. */
|
|
47
|
+
export declare function noteListing(listing: {
|
|
48
|
+
names: readonly string[];
|
|
49
|
+
truncated: boolean;
|
|
50
|
+
}): {
|
|
51
|
+
ids: string[];
|
|
52
|
+
skipped: string[];
|
|
53
|
+
};
|
|
54
|
+
/** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
|
|
55
|
+
export declare function countRunNotes(prepared: PreparedRunArtifactPaths): Promise<number>;
|
|
56
|
+
/**
|
|
57
|
+
* The run's notes. Each file is read through the bounded reader, which refuses a link, a special
|
|
58
|
+
* file or anything over MAX_NOTE_FILE_BYTES before reading past the limit; a file it refuses, or
|
|
59
|
+
* that is not a note of this run, is skipped and named in `skipped`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function readRunNotes(prepared: PreparedRunArtifactPaths): Promise<RunNotes>;
|
|
62
|
+
/** What verify's text scan finds in shared notes: a secret-shaped value, or text it cannot read. */
|
|
63
|
+
type NotesFinding = "sensitive" | "opaque";
|
|
64
|
+
/**
|
|
65
|
+
* The run's notes as a caller is about to share them, read once, with what verify's text scan
|
|
66
|
+
* finds in that set as it would be shared. The caller shares this set only, so a note added or
|
|
67
|
+
* changed after verify ran cannot go out unchecked.
|
|
68
|
+
*/
|
|
69
|
+
export declare function readNotesForSharing(prepared: PreparedRunArtifactPaths): Promise<{
|
|
70
|
+
notes: RunNotes;
|
|
71
|
+
finding: NotesFinding | null;
|
|
72
|
+
}>;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Reviewer notes on disk: one file per note, notes/<id>.json (humanish.run-note.v1) in the run
|
|
2
|
+
// directory. A note is written once, to a temporary file that link(2) gives its name only when
|
|
3
|
+
// nothing holds that name, and is never rewritten, so a writer never reads or replaces another
|
|
4
|
+
// writer's note and no lock is needed. An id starts
|
|
5
|
+
// with its creation time, so the files sort in the order they were added, and ends in random
|
|
6
|
+
// bits. Readers list notes/ and read each file within MAX_NOTE_FILE_BYTES, skipping and naming
|
|
7
|
+
// anything that is not a readable note of the run.
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { lstat, opendir } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { scanEncodedTextCached } from "../evidence/encoded-text.js";
|
|
12
|
+
import { readBoundedFileResult } from "./evidence-files.js";
|
|
13
|
+
import { runIdOf } from "./paths.js";
|
|
14
|
+
import { isNodeError, isRecord } from "./type-guards.js";
|
|
15
|
+
const RUN_NOTE_SCHEMA = "humanish.run-note.v1";
|
|
16
|
+
export const RUN_NOTES_DIR = "notes";
|
|
17
|
+
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
18
|
+
export const MAX_NOTE_FILE_BYTES = 16 * 1024;
|
|
19
|
+
/** The notes one run keeps. A reader reads this many and names the rest. */
|
|
20
|
+
export const MAX_RUN_NOTES = 500;
|
|
21
|
+
export const MAX_NOTE_TEXT = 2000;
|
|
22
|
+
/** The entries a reader lists in notes/ before it stops. */
|
|
23
|
+
const MAX_LISTED_ENTRIES = 2 * MAX_RUN_NOTES;
|
|
24
|
+
const NOTE_ID = /^note-\d{8}t\d{9}z-[0-9a-f]{12}$/;
|
|
25
|
+
/** A temporary file of a write in progress, or of one a stopped writer left. Never a note. */
|
|
26
|
+
const WRITE_IN_PROGRESS = /^\.humanish-write-/;
|
|
27
|
+
/** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
|
|
28
|
+
export function newRunNoteId(createdAt) {
|
|
29
|
+
const stamp = createdAt.toISOString().replace(/[-:.]/g, "").toLowerCase();
|
|
30
|
+
return `note-${stamp}-${randomBytes(6).toString("hex")}`;
|
|
31
|
+
}
|
|
32
|
+
/** The note's file, relative to the run directory. */
|
|
33
|
+
export function runNoteFile(id) {
|
|
34
|
+
return `${RUN_NOTES_DIR}/${id}.json`;
|
|
35
|
+
}
|
|
36
|
+
/** The note as its file holds it. */
|
|
37
|
+
export function encodeRunNote(note, runId) {
|
|
38
|
+
return `${JSON.stringify({ schema: RUN_NOTE_SCHEMA, runId, ...note }, null, 2)}\n`;
|
|
39
|
+
}
|
|
40
|
+
const text = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max;
|
|
41
|
+
const timestamp = (value) => text(value, 64) && Number.isFinite(Date.parse(value));
|
|
42
|
+
/** The note in a note file's bytes, when it is a note of `runId` stored under its own `id`. */
|
|
43
|
+
export function decodeRunNote(bytes, runId, id) {
|
|
44
|
+
let value;
|
|
45
|
+
try {
|
|
46
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (!isRecord(value) ||
|
|
52
|
+
value.schema !== RUN_NOTE_SCHEMA ||
|
|
53
|
+
value.runId !== runId ||
|
|
54
|
+
value.id !== id ||
|
|
55
|
+
typeof value.atMs !== "number" ||
|
|
56
|
+
!Number.isSafeInteger(value.atMs) ||
|
|
57
|
+
value.atMs < 0 ||
|
|
58
|
+
!(value.participant === null || text(value.participant, 256)) ||
|
|
59
|
+
!(value.nearest === null ||
|
|
60
|
+
(isRecord(value.nearest) &&
|
|
61
|
+
text(value.nearest.participant, 256) &&
|
|
62
|
+
text(value.nearest.itemId, 256))) ||
|
|
63
|
+
!text(value.text, MAX_NOTE_TEXT) ||
|
|
64
|
+
!text(value.author, 80) ||
|
|
65
|
+
!timestamp(value.createdAt) ||
|
|
66
|
+
!(value.editedAt === null || timestamp(value.editedAt)))
|
|
67
|
+
return null;
|
|
68
|
+
const nearest = isRecord(value.nearest) ? value.nearest : null;
|
|
69
|
+
return {
|
|
70
|
+
id,
|
|
71
|
+
atMs: value.atMs,
|
|
72
|
+
participant: value.participant,
|
|
73
|
+
nearest: nearest === null
|
|
74
|
+
? null
|
|
75
|
+
: { participant: String(nearest.participant), itemId: String(nearest.itemId) },
|
|
76
|
+
text: value.text,
|
|
77
|
+
author: value.author,
|
|
78
|
+
createdAt: value.createdAt,
|
|
79
|
+
editedAt: value.editedAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Up to MAX_LISTED_ENTRIES names in the notes directory at `dir`, or null when there is none.
|
|
84
|
+
* Throws when `dir` is there and is not a plain directory.
|
|
85
|
+
*/
|
|
86
|
+
export async function listNoteEntries(dir) {
|
|
87
|
+
try {
|
|
88
|
+
const stats = await lstat(dir);
|
|
89
|
+
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
90
|
+
throw new Error("The notes entry is not a directory.");
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
94
|
+
return null;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const names = [];
|
|
98
|
+
const listing = await opendir(dir);
|
|
99
|
+
try {
|
|
100
|
+
for await (const entry of listing) {
|
|
101
|
+
if (names.length === MAX_LISTED_ENTRIES)
|
|
102
|
+
return { names, truncated: true };
|
|
103
|
+
names.push(entry.name);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await listing.close().catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
return { names, truncated: false };
|
|
110
|
+
}
|
|
111
|
+
/** The note ids to read from a listing of notes/, in creation order, and what was left out. */
|
|
112
|
+
export function noteListing(listing) {
|
|
113
|
+
const ids = [];
|
|
114
|
+
const skipped = [];
|
|
115
|
+
for (const name of listing.names) {
|
|
116
|
+
if (WRITE_IN_PROGRESS.test(name))
|
|
117
|
+
continue;
|
|
118
|
+
const id = name.endsWith(".json") ? name.slice(0, -".json".length) : "";
|
|
119
|
+
if (NOTE_ID.test(id))
|
|
120
|
+
ids.push(id);
|
|
121
|
+
else
|
|
122
|
+
skipped.push(`${RUN_NOTES_DIR}/${name} is not a note file, so it was skipped.`);
|
|
123
|
+
}
|
|
124
|
+
ids.sort();
|
|
125
|
+
if (listing.truncated)
|
|
126
|
+
skipped.push(`${RUN_NOTES_DIR}/ holds more than ${MAX_LISTED_ENTRIES} entries; only the first ${MAX_LISTED_ENTRIES} were listed.`);
|
|
127
|
+
if (ids.length > MAX_RUN_NOTES) {
|
|
128
|
+
const left = ids.length - MAX_RUN_NOTES;
|
|
129
|
+
skipped.push(`${left} note${left === 1 ? "" : "s"} after the first ${MAX_RUN_NOTES} ${left === 1 ? "was" : "were"} not read.`);
|
|
130
|
+
ids.length = MAX_RUN_NOTES;
|
|
131
|
+
}
|
|
132
|
+
return { ids, skipped };
|
|
133
|
+
}
|
|
134
|
+
/** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
|
|
135
|
+
export async function countRunNotes(prepared) {
|
|
136
|
+
const listing = await listNoteEntries(path.join(prepared.physicalRunRoot, RUN_NOTES_DIR));
|
|
137
|
+
return listing === null ? 0 : noteListing(listing).ids.length + (listing.truncated ? 1 : 0);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The run's notes. Each file is read through the bounded reader, which refuses a link, a special
|
|
141
|
+
* file or anything over MAX_NOTE_FILE_BYTES before reading past the limit; a file it refuses, or
|
|
142
|
+
* that is not a note of this run, is skipped and named in `skipped`.
|
|
143
|
+
*/
|
|
144
|
+
export async function readRunNotes(prepared) {
|
|
145
|
+
const runId = runIdOf(prepared);
|
|
146
|
+
let listing;
|
|
147
|
+
try {
|
|
148
|
+
listing = await listNoteEntries(path.join(prepared.physicalRunRoot, RUN_NOTES_DIR));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return {
|
|
152
|
+
runId,
|
|
153
|
+
notes: [],
|
|
154
|
+
skipped: [`${RUN_NOTES_DIR} in run ${runId} is not a plain folder, so no notes were read.`],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (listing === null)
|
|
158
|
+
return { runId, notes: [], skipped: [] };
|
|
159
|
+
const { ids, skipped } = noteListing(listing);
|
|
160
|
+
const notes = [];
|
|
161
|
+
for (const id of ids) {
|
|
162
|
+
const read = await readBoundedFileResult(prepared, runNoteFile(id), MAX_NOTE_FILE_BYTES);
|
|
163
|
+
const note = read.state === "read" ? decodeRunNote(read.bytes, runId, id) : null;
|
|
164
|
+
if (note !== null)
|
|
165
|
+
notes.push(note);
|
|
166
|
+
else
|
|
167
|
+
skipped.push(read.state === "limit"
|
|
168
|
+
? `${runNoteFile(id)} is over ${MAX_NOTE_FILE_BYTES / 1024} KiB, so it was skipped.`
|
|
169
|
+
: `${runNoteFile(id)} is not a readable note of run ${runId}, so it was skipped.`);
|
|
170
|
+
}
|
|
171
|
+
return { runId, notes, skipped };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The run's notes as a caller is about to share them, read once, with what verify's text scan
|
|
175
|
+
* finds in that set as it would be shared. The caller shares this set only, so a note added or
|
|
176
|
+
* changed after verify ran cannot go out unchecked.
|
|
177
|
+
*/
|
|
178
|
+
export async function readNotesForSharing(prepared) {
|
|
179
|
+
const notes = await readRunNotes(prepared);
|
|
180
|
+
if (notes.notes.length === 0)
|
|
181
|
+
return { notes, finding: null };
|
|
182
|
+
const scan = scanEncodedTextCached(JSON.stringify(notes.notes));
|
|
183
|
+
return { notes, finding: scan.sensitive ? "sensitive" : scan.opaque ? "opaque" : null };
|
|
184
|
+
}
|