humanish 0.114.0 → 0.116.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 -1
- package/README.md +7 -6
- package/TELEMETRY.md +6 -0
- package/dist/actors/closing-report-limits.d.ts +11 -0
- package/dist/actors/closing-report-limits.js +13 -0
- package/dist/actors/closing-report.d.ts +68 -0
- package/dist/actors/closing-report.js +55 -0
- package/dist/actors/codex/app-server-ui.js +11 -4
- package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
- package/dist/actors/codex/restricted-participant-policy.js +12 -40
- package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
- package/dist/actors/computer-use/loop/debrief.js +68 -70
- package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
- package/dist/actors/computer-use/loop/impressions.js +6 -1
- package/dist/actors/computer-use/loop.d.ts +0 -1
- package/dist/actors/computer-use/loop.js +3 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
- package/dist/actors/computer-use/openai-conversation.js +3 -29
- package/dist/actors/computer-use/openai-provider.js +11 -5
- package/dist/actors/contract.d.ts +2 -20
- package/dist/actors/contract.js +0 -13
- package/dist/actors/scripted-browser/steps.js +6 -5
- package/dist/analysis/admission.d.ts +14 -4
- package/dist/analysis/admission.js +14 -9
- package/dist/analysis/analysis-limits.d.ts +35 -0
- package/dist/analysis/analysis-limits.js +41 -0
- package/dist/analysis/execute.d.ts +5 -0
- package/dist/analysis/execute.js +3 -0
- package/dist/analysis/finding-lead.d.ts +27 -0
- package/dist/analysis/finding-lead.js +23 -0
- package/dist/analysis/service.js +5 -3
- package/dist/analysis/validation.js +14 -21
- package/dist/cli/commands/analyze.js +2 -1
- package/dist/cli/commands/notes.js +5 -3
- package/dist/cli/commands/run-signals.js +5 -1
- package/dist/cli/commands/runs.js +2 -1
- package/dist/cli/commands/setup.js +3 -3
- package/dist/cli/commands/study-format.d.ts +5 -2
- package/dist/cli/commands/study-format.js +29 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +1 -1
- package/dist/cli/commands/tui.js +59 -17
- package/dist/cli/doctor.js +9 -2
- package/dist/cli/findings-text.d.ts +5 -0
- package/dist/cli/findings-text.js +169 -0
- package/dist/cli/findings.d.ts +0 -4
- package/dist/cli/findings.js +11 -195
- package/dist/cli/io.js +10 -8
- package/dist/cli/telemetry.js +10 -22
- package/dist/cli/update-check.js +10 -19
- package/dist/cli/user-config.d.ts +11 -0
- package/dist/cli/user-config.js +25 -0
- package/dist/comms/connections.js +4 -3
- package/dist/comms/email-catch.js +7 -31
- package/dist/comms/setup.js +18 -12
- package/dist/evidence/encoded-text.d.ts +12 -11
- package/dist/evidence/encoded-text.js +81 -70
- package/dist/evidence/redaction.d.ts +11 -0
- package/dist/evidence/redaction.js +30 -11
- package/dist/evidence/secret-scrub.d.ts +26 -3
- package/dist/evidence/secret-scrub.js +172 -38
- package/dist/feedback/draft.d.ts +1 -1
- package/dist/feedback/draft.js +13 -16
- package/dist/feedback/export-bundle.js +21 -17
- package/dist/feedback/export.js +32 -46
- package/dist/feedback/feedback.js +31 -26
- package/dist/guest/desktop-keys.js +59 -5
- package/dist/keys/key-resolution.d.ts +9 -0
- package/dist/keys/key-resolution.js +62 -55
- package/dist/keys/key-status.d.ts +2 -0
- package/dist/keys/key-status.js +9 -16
- package/dist/observer/artifact.d.ts +1 -1
- package/dist/observer/http.d.ts +7 -0
- package/dist/observer/http.js +19 -1
- package/dist/observer/notes-route.d.ts +0 -1
- package/dist/observer/notes-route.js +24 -44
- package/dist/observer/pinned-files.js +4 -19
- package/dist/observer/render.js +6 -4
- package/dist/observer/run-routes.js +13 -28
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +7 -7
- package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
- package/dist/routes/computer-use/bundle-parts.js +2 -2
- package/dist/routes/computer-use/bundle.d.ts +4 -5
- package/dist/routes/computer-use/bundle.js +1 -3
- package/dist/routes/computer-use/fanout-records.js +10 -11
- package/dist/routes/computer-use/live-phase.d.ts +1 -1
- package/dist/routes/computer-use/live-phase.js +0 -1
- package/dist/routes/computer-use/participant-execution.d.ts +3 -2
- package/dist/routes/computer-use/participant-runs.d.ts +6 -2
- package/dist/routes/computer-use/participant-runs.js +9 -5
- package/dist/routes/computer-use/plan.d.ts +3 -0
- package/dist/routes/computer-use/plan.js +14 -11
- package/dist/routes/computer-use/rerun-selection.js +2 -2
- package/dist/routes/computer-use/result.js +3 -6
- package/dist/routes/computer-use/setup.d.ts +4 -6
- package/dist/routes/computer-use/setup.js +2 -6
- package/dist/routes/computer-use/single-bundle.d.ts +4 -5
- package/dist/routes/computer-use/single-bundle.js +22 -25
- package/dist/routes/computer-use/subject-projection.d.ts +4 -8
- package/dist/routes/computer-use/subject-projection.js +9 -31
- package/dist/routes/computer-use/types.d.ts +4 -41
- package/dist/routes/scripted/scenario.js +28 -12
- package/dist/routes/scripted/session-result.js +6 -3
- package/dist/routes/shared-world/bundle-records.js +2 -8
- package/dist/routes/shared-world/participant-specs.d.ts +0 -2
- package/dist/routes/shared-world/participant-specs.js +1 -20
- package/dist/routes/shared-world/plan.d.ts +3 -0
- package/dist/routes/shared-world/plan.js +23 -3
- package/dist/routes/shared-world/setup.js +2 -2
- package/dist/routes/terminal/ledger.d.ts +9 -9
- package/dist/routes/terminal/ledger.js +63 -40
- package/dist/routes/terminal/live-finish.js +7 -10
- package/dist/routes/terminal/plan.d.ts +3 -0
- package/dist/routes/terminal/plan.js +8 -5
- package/dist/routes/terminal/token-usage.d.ts +1 -2
- package/dist/routes/terminal/token-usage.js +4 -6
- package/dist/routes/terminal/types.d.ts +9 -1
- package/dist/run/bundle-shape.js +6 -1
- package/dist/run/bundle.d.ts +5 -3
- package/dist/run/contained-output.d.ts +59 -10
- package/dist/run/contained-output.js +160 -68
- package/dist/run/detail.d.ts +2 -1
- package/dist/run/detail.js +12 -7
- package/dist/run/escape-sequences.d.ts +11 -0
- package/dist/run/escape-sequences.js +93 -0
- package/dist/run/evidence-files.d.ts +8 -1
- package/dist/run/evidence-files.js +24 -18
- package/dist/run/locate.d.ts +32 -3
- package/dist/run/locate.js +45 -40
- package/dist/run/note-files.d.ts +26 -45
- package/dist/run/note-files.js +86 -58
- package/dist/run/note-shape.d.ts +35 -0
- package/dist/run/note-shape.js +20 -0
- package/dist/run/notes.d.ts +3 -14
- package/dist/run/notes.js +23 -75
- package/dist/run/outcomes.d.ts +24 -0
- package/dist/run/outcomes.js +19 -0
- package/dist/run/participant-records.d.ts +2 -0
- package/dist/run/participant-records.js +17 -2
- package/dist/run/paths.d.ts +2 -0
- package/dist/run/paths.js +5 -1
- package/dist/run/preflight-receipts.js +13 -8
- package/dist/run/project.js +7 -4
- package/dist/run/reclaim-outcomes.d.ts +8 -6
- package/dist/run/reclaim-outcomes.js +10 -6
- package/dist/run/reclaim.d.ts +5 -0
- package/dist/run/reclaim.js +43 -16
- package/dist/run/run-clock.d.ts +50 -0
- package/dist/run/run-clock.js +66 -0
- package/dist/run/run.js +5 -2
- package/dist/run/sandbox-ids.d.ts +7 -2
- package/dist/run/sandbox-ids.js +27 -11
- package/dist/run/secrets.d.ts +2 -1
- package/dist/run/secrets.js +18 -22
- package/dist/run/stored-runs.js +14 -7
- package/dist/run/transient-comms-secrets.d.ts +6 -5
- package/dist/run/transient-comms-secrets.js +23 -39
- package/dist/run-study.js +22 -13
- package/dist/study/adapter-scorer-loader.js +6 -4
- package/dist/study/discover.js +9 -6
- package/dist/study/init-paths.js +7 -5
- package/dist/study/persona-resolve.d.ts +2 -2
- package/dist/study/persona-resolve.js +9 -5
- package/dist/study/plan-types.d.ts +2 -2
- package/dist/study/plan.d.ts +5 -3
- package/dist/study/plan.js +18 -9
- package/dist/study/preflight-probes.js +11 -3
- package/dist/study/preflight.js +9 -5
- package/dist/study/summary.d.ts +12 -7
- package/dist/study/summary.js +24 -24
- package/dist/study/warnings.d.ts +19 -12
- package/dist/study/warnings.js +39 -17
- package/dist/substrates/e2b/connection.d.ts +12 -0
- package/dist/substrates/e2b/connection.js +35 -0
- package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
- package/dist/substrates/e2b/desktop-executor.js +42 -9
- package/dist/substrates/e2b/lifetime.d.ts +24 -4
- package/dist/substrates/e2b/lifetime.js +41 -4
- package/dist/substrates/e2b/sandbox.d.ts +4 -3
- package/dist/substrates/e2b/sandbox.js +13 -18
- package/dist/substrates/e2b/sdk.d.ts +20 -9
- package/dist/substrates/local/capacity.d.ts +18 -2
- package/dist/substrates/local/capacity.js +21 -2
- package/dist/substrates/local/runtime-host.js +21 -14
- package/dist/substrates/local/runtime.js +3 -11
- package/dist/tui/contract.d.ts +30 -5
- package/dist/tui/contract.js +4 -1
- package/dist/tui-app.js +149 -147
- package/dist/verify/artifacts.d.ts +9 -2
- package/dist/verify/artifacts.js +37 -13
- package/dist/verify/liveness.js +19 -5
- package/dist/verify/verify.js +54 -27
- package/docs/architecture/actor-contract.md +3 -3
- package/docs/architecture/guest-desktop.md +7 -2
- package/docs/architecture/restricted-codex-analysis.md +1 -1
- package/docs/architecture/state-driven-executor.md +5 -3
- package/docs/contracts/run-bundle.md +25 -13
- package/docs/contracts/schemas.md +38 -29
- package/docs/contracts/study-analysis.md +22 -5
- package/docs/product/automatic-analysis.md +7 -1
- package/package.json +8 -7
- package/skills/humanish/SKILL.md +1 -2
package/dist/run/note-files.js
CHANGED
|
@@ -1,29 +1,46 @@
|
|
|
1
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,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// anything that is not a readable note of the run.
|
|
2
|
+
// directory. A note is written once, by creating its name only when nothing holds it, and is never
|
|
3
|
+
// rewritten, so a writer never reads or replaces another writer's note and no lock is needed. An
|
|
4
|
+
// id starts with its creation time, so the files sort in the order they were added, and ends in
|
|
5
|
+
// random bits. Readers list notes/ and read each file within MAX_NOTE_FILE_BYTES, skipping and
|
|
6
|
+
// naming anything that is not a readable note of the run, including a note still being written.
|
|
8
7
|
import { randomBytes } from "node:crypto";
|
|
9
8
|
import { lstat, opendir } from "node:fs/promises";
|
|
10
9
|
import path from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
11
|
import { scanEncodedTextCached } from "../evidence/encoded-text.js";
|
|
12
12
|
import { readBoundedFileResult } from "./evidence-files.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { MAX_NOTE_TEXT, MAX_RUN_NOTES, NOTE_FIELD_LIMITS, NOTE_ID, } from "./note-shape.js";
|
|
14
|
+
import { runIdOf, validatePreparedRunRootIdentity, } from "./paths.js";
|
|
15
|
+
import { isNodeError } from "./type-guards.js";
|
|
15
16
|
const RUN_NOTE_SCHEMA = "humanish.run-note.v1";
|
|
16
17
|
export const RUN_NOTES_DIR = "notes";
|
|
17
18
|
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
18
19
|
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
20
|
/** The entries a reader lists in notes/ before it stops. */
|
|
23
21
|
const MAX_LISTED_ENTRIES = 2 * MAX_RUN_NOTES;
|
|
24
|
-
|
|
25
|
-
/** A temporary file of a write in progress, or of one a stopped writer left. Never a note. */
|
|
22
|
+
/** A temporary file of the atomic writer, or one a 0.114.0 note writer left. Never a note. */
|
|
26
23
|
const WRITE_IN_PROGRESS = /^\.humanish-write-/;
|
|
24
|
+
const text = (max) => z.string().min(1).max(max);
|
|
25
|
+
const timestamp = text(NOTE_FIELD_LIMITS.timestamp).refine((value) => Number.isFinite(Date.parse(value)));
|
|
26
|
+
/** A note file. Fields a later release adds are dropped on reading, so they never hide a note. */
|
|
27
|
+
const runNoteFileSchema = z.object({
|
|
28
|
+
schema: z.literal(RUN_NOTE_SCHEMA),
|
|
29
|
+
runId: z.string(),
|
|
30
|
+
id: z.string().regex(NOTE_ID),
|
|
31
|
+
atMs: z.int().min(0),
|
|
32
|
+
participant: text(NOTE_FIELD_LIMITS.participant).nullable(),
|
|
33
|
+
nearest: z
|
|
34
|
+
.object({
|
|
35
|
+
participant: text(NOTE_FIELD_LIMITS.participant),
|
|
36
|
+
itemId: text(NOTE_FIELD_LIMITS.itemId),
|
|
37
|
+
})
|
|
38
|
+
.nullable(),
|
|
39
|
+
text: text(MAX_NOTE_TEXT),
|
|
40
|
+
author: text(NOTE_FIELD_LIMITS.author),
|
|
41
|
+
createdAt: timestamp,
|
|
42
|
+
editedAt: timestamp.nullable(),
|
|
43
|
+
});
|
|
27
44
|
/** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
|
|
28
45
|
export function newRunNoteId(createdAt) {
|
|
29
46
|
const stamp = createdAt.toISOString().replace(/[-:.]/g, "").toLowerCase();
|
|
@@ -37,10 +54,8 @@ export function runNoteFile(id) {
|
|
|
37
54
|
export function encodeRunNote(note, runId) {
|
|
38
55
|
return `${JSON.stringify({ schema: RUN_NOTE_SCHEMA, runId, ...note }, null, 2)}\n`;
|
|
39
56
|
}
|
|
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
57
|
/** The note in a note file's bytes, when it is a note of `runId` stored under its own `id`. */
|
|
43
|
-
|
|
58
|
+
function decodeRunNote(bytes, runId, id) {
|
|
44
59
|
let value;
|
|
45
60
|
try {
|
|
46
61
|
value = JSON.parse(bytes.toString("utf8"));
|
|
@@ -48,42 +63,17 @@ export function decodeRunNote(bytes, runId, id) {
|
|
|
48
63
|
catch {
|
|
49
64
|
return null;
|
|
50
65
|
}
|
|
51
|
-
|
|
52
|
-
|
|
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)))
|
|
66
|
+
const parsed = runNoteFileSchema.safeParse(value);
|
|
67
|
+
if (!parsed.success || parsed.data.runId !== runId || parsed.data.id !== id)
|
|
67
68
|
return null;
|
|
68
|
-
const
|
|
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
|
-
};
|
|
69
|
+
const { schema: _schema, runId: _runId, ...note } = parsed.data;
|
|
70
|
+
return note;
|
|
81
71
|
}
|
|
82
72
|
/**
|
|
83
73
|
* Up to MAX_LISTED_ENTRIES names in the notes directory at `dir`, or null when there is none.
|
|
84
74
|
* Throws when `dir` is there and is not a plain directory.
|
|
85
75
|
*/
|
|
86
|
-
|
|
76
|
+
async function listNoteEntries(dir) {
|
|
87
77
|
try {
|
|
88
78
|
const stats = await lstat(dir);
|
|
89
79
|
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
@@ -109,7 +99,7 @@ export async function listNoteEntries(dir) {
|
|
|
109
99
|
return { names, truncated: false };
|
|
110
100
|
}
|
|
111
101
|
/** The note ids to read from a listing of notes/, in creation order, and what was left out. */
|
|
112
|
-
|
|
102
|
+
function noteListing(listing) {
|
|
113
103
|
const ids = [];
|
|
114
104
|
const skipped = [];
|
|
115
105
|
for (const name of listing.names) {
|
|
@@ -136,16 +126,32 @@ export async function countRunNotes(prepared) {
|
|
|
136
126
|
const listing = await listNoteEntries(path.join(prepared.physicalRunRoot, RUN_NOTES_DIR));
|
|
137
127
|
return listing === null ? 0 : noteListing(listing).ids.length + (listing.truncated ? 1 : 0);
|
|
138
128
|
}
|
|
129
|
+
/** The run's note files through the bounded reader, which rechecks the run directory on each read. */
|
|
130
|
+
function boundedNoteFiles(prepared) {
|
|
131
|
+
return {
|
|
132
|
+
runId: runIdOf(prepared),
|
|
133
|
+
async directory() {
|
|
134
|
+
await validatePreparedRunRootIdentity(prepared);
|
|
135
|
+
return prepared.physicalRunRoot;
|
|
136
|
+
},
|
|
137
|
+
async read(relativePath, maxBytes) {
|
|
138
|
+
const read = await readBoundedFileResult(prepared, relativePath, maxBytes);
|
|
139
|
+
return read.state === "read" ? read.bytes : read.state === "limit" ? "limit" : null;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
139
143
|
/**
|
|
140
|
-
* The run's notes
|
|
141
|
-
*
|
|
142
|
-
* that is not a note of this run,
|
|
144
|
+
* The run's notes, read from its prepared paths through the bounded reader or through `from`, the
|
|
145
|
+
* files of a run the caller already holds (the served Observer passes its pinned root's). Each file
|
|
146
|
+
* is read within MAX_NOTE_FILE_BYTES; a file the reader refuses, or that is not a note of this run,
|
|
147
|
+
* is skipped and named in `skipped`.
|
|
143
148
|
*/
|
|
144
|
-
export async function readRunNotes(
|
|
145
|
-
const
|
|
149
|
+
export async function readRunNotes(from) {
|
|
150
|
+
const files = "physicalRunRoot" in from ? boundedNoteFiles(from) : from;
|
|
151
|
+
const { runId } = files;
|
|
146
152
|
let listing;
|
|
147
153
|
try {
|
|
148
|
-
listing = await listNoteEntries(path.join(
|
|
154
|
+
listing = await listNoteEntries(path.join(await files.directory(), RUN_NOTES_DIR));
|
|
149
155
|
}
|
|
150
156
|
catch {
|
|
151
157
|
return {
|
|
@@ -159,12 +165,12 @@ export async function readRunNotes(prepared) {
|
|
|
159
165
|
const { ids, skipped } = noteListing(listing);
|
|
160
166
|
const notes = [];
|
|
161
167
|
for (const id of ids) {
|
|
162
|
-
const read = await
|
|
163
|
-
const note =
|
|
168
|
+
const read = await files.read(runNoteFile(id), MAX_NOTE_FILE_BYTES);
|
|
169
|
+
const note = Buffer.isBuffer(read) ? decodeRunNote(read, runId, id) : null;
|
|
164
170
|
if (note !== null)
|
|
165
171
|
notes.push(note);
|
|
166
172
|
else
|
|
167
|
-
skipped.push(read
|
|
173
|
+
skipped.push(read === "limit"
|
|
168
174
|
? `${runNoteFile(id)} is over ${MAX_NOTE_FILE_BYTES / 1024} KiB, so it was skipped.`
|
|
169
175
|
: `${runNoteFile(id)} is not a readable note of run ${runId}, so it was skipped.`);
|
|
170
176
|
}
|
|
@@ -178,7 +184,29 @@ export async function readRunNotes(prepared) {
|
|
|
178
184
|
export async function readNotesForSharing(prepared) {
|
|
179
185
|
const notes = await readRunNotes(prepared);
|
|
180
186
|
if (notes.notes.length === 0)
|
|
181
|
-
return { notes,
|
|
187
|
+
return { notes, problem: null };
|
|
182
188
|
const scan = scanEncodedTextCached(JSON.stringify(notes.notes));
|
|
183
|
-
|
|
189
|
+
if (scan.sensitive)
|
|
190
|
+
return {
|
|
191
|
+
notes,
|
|
192
|
+
problem: {
|
|
193
|
+
status: "blocked",
|
|
194
|
+
reason: {
|
|
195
|
+
code: "PUBLIC_SAFETY_FINDINGS",
|
|
196
|
+
message: "The reviewer notes being shared match secret, token or local-path patterns.",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
if (scan.opaque)
|
|
201
|
+
return {
|
|
202
|
+
notes,
|
|
203
|
+
problem: {
|
|
204
|
+
status: "local_only",
|
|
205
|
+
reason: {
|
|
206
|
+
code: "UNSCANNED_ARTIFACT",
|
|
207
|
+
message: "The reviewer notes being shared hold encoded text the scan cannot read.",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
return { notes, problem: null };
|
|
184
212
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** The characters a note's text may hold. */
|
|
2
|
+
export declare const MAX_NOTE_TEXT = 2000;
|
|
3
|
+
/** The notes one run keeps. A reader reads this many and names the rest. */
|
|
4
|
+
export declare const MAX_RUN_NOTES = 500;
|
|
5
|
+
/** The characters of a participant id, a trace item id or a timestamp a note records. */
|
|
6
|
+
export declare const NOTE_FIELD_LIMITS: {
|
|
7
|
+
participant: number;
|
|
8
|
+
itemId: number;
|
|
9
|
+
author: number;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
};
|
|
12
|
+
/** A note id: `note-<creation time>-<48 random bits in hex>`. */
|
|
13
|
+
export declare const NOTE_ID: RegExp;
|
|
14
|
+
/** Where an Observer page sends a note to the loopback server that rendered it. */
|
|
15
|
+
export declare const NOTES_PATH = "/api/notes";
|
|
16
|
+
/** The request header that carries the server's note token. */
|
|
17
|
+
export declare const NOTES_TOKEN_HEADER = "x-humanish-notes-token";
|
|
18
|
+
export interface RunNote {
|
|
19
|
+
id: string;
|
|
20
|
+
/** Milliseconds from the run clock's start. */
|
|
21
|
+
atMs: number;
|
|
22
|
+
/** The participant's stream id, or null for a note on the whole run. */
|
|
23
|
+
participant: string | null;
|
|
24
|
+
/** The latest timed capture or event at or before the moment, with its participant. */
|
|
25
|
+
nearest: {
|
|
26
|
+
participant: string;
|
|
27
|
+
itemId: string;
|
|
28
|
+
} | null;
|
|
29
|
+
text: string;
|
|
30
|
+
author: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
editedAt: string | null;
|
|
33
|
+
}
|
|
34
|
+
/** Notes in run clock order, the earliest added first at the same moment. */
|
|
35
|
+
export declare function byRunTime(notes: readonly RunNote[]): RunNote[];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A reviewer note as the server keeps it and the Observer shows it: its fields, their limits, and
|
|
2
|
+
// where an Observer page sends a new one. The Observer bundles this module, so it has no runtime imports
|
|
3
|
+
// (observer/tests/contract-lock.test.ts checks that); the server validates notes against these
|
|
4
|
+
// limits with zod in note-files.ts and notes-route.ts.
|
|
5
|
+
/** The characters a note's text may hold. */
|
|
6
|
+
export const MAX_NOTE_TEXT = 2000;
|
|
7
|
+
/** The notes one run keeps. A reader reads this many and names the rest. */
|
|
8
|
+
export const MAX_RUN_NOTES = 500;
|
|
9
|
+
/** The characters of a participant id, a trace item id or a timestamp a note records. */
|
|
10
|
+
export const NOTE_FIELD_LIMITS = { participant: 256, itemId: 256, author: 80, timestamp: 64 };
|
|
11
|
+
/** A note id: `note-<creation time>-<48 random bits in hex>`. */
|
|
12
|
+
export const NOTE_ID = /^note-\d{8}t\d{9}z-[0-9a-f]{12}$/;
|
|
13
|
+
/** Where an Observer page sends a note to the loopback server that rendered it. */
|
|
14
|
+
export const NOTES_PATH = "/api/notes";
|
|
15
|
+
/** The request header that carries the server's note token. */
|
|
16
|
+
export const NOTES_TOKEN_HEADER = "x-humanish-notes-token";
|
|
17
|
+
/** Notes in run clock order, the earliest added first at the same moment. */
|
|
18
|
+
export function byRunTime(notes) {
|
|
19
|
+
return [...notes].sort((left, right) => left.atMs - right.atMs || left.createdAt.localeCompare(right.createdAt));
|
|
20
|
+
}
|
package/dist/run/notes.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { type RunNote } from "./note-files.js";
|
|
1
|
+
import { type RunNote } from "./note-shape.js";
|
|
3
2
|
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
4
|
-
export { MAX_NOTE_TEXT, readNotesForSharing, readRunNotes, type RunNote, type RunNotes, } from "./note-files.js";
|
|
5
3
|
export interface RunNoteInput {
|
|
6
4
|
atMs: number;
|
|
7
5
|
participant: string | null;
|
|
@@ -21,17 +19,8 @@ export type AddRunNoteResult =
|
|
|
21
19
|
message: string;
|
|
22
20
|
};
|
|
23
21
|
};
|
|
24
|
-
/** Each participant's caption, by stream id, as the Observer and the TUI name them. */
|
|
25
|
-
export declare function runParticipantCaptions(bundle: RunBundle): Map<string, string>;
|
|
26
|
-
/** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
|
|
27
|
-
export declare function formatRunTime(ms: number): string;
|
|
28
|
-
/** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
|
|
29
|
-
export declare function runClock(bundle: RunBundle): {
|
|
30
|
-
startMs: number;
|
|
31
|
-
endMs: number;
|
|
32
|
-
} | null;
|
|
33
22
|
/**
|
|
34
|
-
* Adds a note as a new file, notes/<id>.json,
|
|
35
|
-
*
|
|
23
|
+
* Adds a note as a new file, notes/<id>.json, created under a name nothing holds. It reads no other
|
|
24
|
+
* note and replaces nothing, so notes added at the same time, from any process, are all kept.
|
|
36
25
|
*/
|
|
37
26
|
export declare function addRunNote(prepared: PreparedRunArtifactPaths, input: RunNoteInput): Promise<AddRunNoteResult>;
|
package/dist/run/notes.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// Reviewer notes: free text a person adds at a moment of a recorded run. Each note is its own file
|
|
2
|
-
// in the run directory (note-files.ts). A note's time counts from the run clock
|
|
3
|
-
//
|
|
2
|
+
// in the run directory (note-files.ts). A note's time counts from the start of the run clock
|
|
3
|
+
// (run-clock.ts), which is the Observer's study timeline.
|
|
4
|
+
import { cli } from "../cli/invocation.js";
|
|
4
5
|
import { redactText } from "../evidence/redaction.js";
|
|
5
|
-
import {
|
|
6
|
+
import { createContainedOutputFile } from "./contained-output.js";
|
|
6
7
|
import { loadRunBundlePrepared } from "./locate.js";
|
|
7
|
-
import { countRunNotes, encodeRunNote,
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { countRunNotes, encodeRunNote, newRunNoteId, runNoteFile } from "./note-files.js";
|
|
9
|
+
import { MAX_NOTE_TEXT, MAX_RUN_NOTES } from "./note-shape.js";
|
|
10
|
+
import { runParticipantCaptions, streamParticipantIdOf } from "./participant-records.js";
|
|
10
11
|
import { physicalCwdOf, runIdOf } from "./paths.js";
|
|
12
|
+
import { formatRunTime, runClock, traceItems } from "./run-clock.js";
|
|
11
13
|
import { transientCommsKnownValueScrub } from "./transient-comms-secrets.js";
|
|
12
14
|
import { isNodeError } from "./type-guards.js";
|
|
13
|
-
export { MAX_NOTE_TEXT, readNotesForSharing, readRunNotes, } from "./note-files.js";
|
|
14
15
|
const DEFAULT_AUTHOR = "you";
|
|
15
16
|
/** New ids a note tries when the one before was taken. */
|
|
16
17
|
const PUBLISH_ATTEMPTS = 5;
|
|
@@ -18,19 +19,6 @@ const refuse = (code, message) => ({
|
|
|
18
19
|
ok: false,
|
|
19
20
|
error: { code, message },
|
|
20
21
|
});
|
|
21
|
-
/** Each participant's caption, by stream id, as the Observer and the TUI name them. */
|
|
22
|
-
export function runParticipantCaptions(bundle) {
|
|
23
|
-
return streamCaptions(bundle.streams.map((stream) => {
|
|
24
|
-
const participantId = streamParticipantIdOf(stream);
|
|
25
|
-
const personaId = recordedPersonaId(bundle, stream);
|
|
26
|
-
return {
|
|
27
|
-
id: stream.id,
|
|
28
|
-
label: stream.label,
|
|
29
|
-
...(participantId === undefined ? {} : { participantId }),
|
|
30
|
-
...(personaId === undefined ? {} : { personaId }),
|
|
31
|
-
};
|
|
32
|
-
}));
|
|
33
|
-
}
|
|
34
22
|
/**
|
|
35
23
|
* The stream id a note's participant names: a stream id, or the participant's own id from the study
|
|
36
24
|
* (`charge-nurse`). Both kinds of id are matched, so a name that is one stream's id and another
|
|
@@ -51,59 +39,12 @@ function participantStreamId(bundle, named) {
|
|
|
51
39
|
const known = bundle.streams.map((stream) => `${captions.get(stream.id)} (${streamParticipantIdOf(stream) ?? stream.id})`);
|
|
52
40
|
return refuse("HUMANISH_NOTE_UNKNOWN_PARTICIPANT", `Run ${bundle.runId} has no participant ${named}. Its participants are ${known.join(", ")}; leave the participant out for a note on the whole run.`);
|
|
53
41
|
}
|
|
54
|
-
/** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
|
|
55
|
-
export function formatRunTime(ms) {
|
|
56
|
-
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
57
|
-
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
|
58
|
-
}
|
|
59
|
-
/** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
|
|
60
|
-
export function runClock(bundle) {
|
|
61
|
-
const moments = bundle.streams.flatMap((stream) => [
|
|
62
|
-
...(captureTimes(stream) ?? []),
|
|
63
|
-
...recordingMoments(stream),
|
|
64
|
-
]);
|
|
65
|
-
if (moments.length === 0)
|
|
66
|
-
return null;
|
|
67
|
-
// A loop, since spreading a long recording's capture times into Math.min can overflow the stack.
|
|
68
|
-
let startMs = Infinity;
|
|
69
|
-
let endMs = -Infinity;
|
|
70
|
-
for (const moment of moments) {
|
|
71
|
-
startMs = Math.min(startMs, moment);
|
|
72
|
-
endMs = Math.max(endMs, moment);
|
|
73
|
-
}
|
|
74
|
-
return { startMs, endMs };
|
|
75
|
-
}
|
|
76
|
-
function traceItems(stream) {
|
|
77
|
-
return stream.actor?.items ?? stream.liveActor?.items ?? [];
|
|
78
|
-
}
|
|
79
|
-
const stamp = (item) => item.at === undefined ? Number.NaN : Date.parse(item.at);
|
|
80
|
-
/**
|
|
81
|
-
* A participant's capture times, as the Observer's study clock reads them: only when every capture
|
|
82
|
-
* is stamped and the stamps never go back. Otherwise the participant adds nothing to the clock.
|
|
83
|
-
*/
|
|
84
|
-
function captureTimes(stream) {
|
|
85
|
-
const times = traceItems(stream)
|
|
86
|
-
.filter((item) => (item.kind === "screenshot" || item.kind === "ui_action") && item.screenshotRef)
|
|
87
|
-
.map(stamp);
|
|
88
|
-
const ordered = times.every((time, index) => Number.isFinite(time) && (index === 0 || time >= times[index - 1]));
|
|
89
|
-
return ordered ? times : null;
|
|
90
|
-
}
|
|
91
|
-
function recordingMoments(stream) {
|
|
92
|
-
const recording = stream.recording;
|
|
93
|
-
if (!recording)
|
|
94
|
-
return [];
|
|
95
|
-
const startMs = Date.parse(recording.startedAt);
|
|
96
|
-
const endMs = startMs + recording.durationMs;
|
|
97
|
-
return Number.isFinite(startMs) && Number.isFinite(endMs) && recording.durationMs > 0
|
|
98
|
-
? [startMs, endMs]
|
|
99
|
-
: [];
|
|
100
|
-
}
|
|
101
42
|
/** The latest stamped trace item at or before `moment`, among the given participants. */
|
|
102
43
|
function nearestItem(streams, moment) {
|
|
103
44
|
let best = null;
|
|
104
45
|
for (const stream of streams)
|
|
105
46
|
for (const item of traceItems(stream)) {
|
|
106
|
-
const time =
|
|
47
|
+
const time = item.at === undefined ? Number.NaN : Date.parse(item.at);
|
|
107
48
|
if (Number.isFinite(time) && time <= moment && (best === null || time >= best.time))
|
|
108
49
|
best = { participant: stream.id, itemId: item.id, time };
|
|
109
50
|
}
|
|
@@ -136,20 +77,27 @@ function noteTextProblem(text) {
|
|
|
136
77
|
return null;
|
|
137
78
|
}
|
|
138
79
|
/**
|
|
139
|
-
* Adds a note as a new file, notes/<id>.json,
|
|
140
|
-
*
|
|
80
|
+
* Adds a note as a new file, notes/<id>.json, created under a name nothing holds. It reads no other
|
|
81
|
+
* note and replaces nothing, so notes added at the same time, from any process, are all kept.
|
|
141
82
|
*/
|
|
142
83
|
export async function addRunNote(prepared, input) {
|
|
143
84
|
const text = scrubNoteText(input.text);
|
|
144
85
|
const problem = noteTextProblem(text);
|
|
145
86
|
if (problem)
|
|
146
87
|
return refuse("HUMANISH_NOTE_INVALID", problem);
|
|
147
|
-
|
|
148
|
-
|
|
88
|
+
let loaded;
|
|
89
|
+
try {
|
|
90
|
+
loaded = await loadRunBundlePrepared(physicalCwdOf(prepared), prepared);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
// The storage check throws on a link or a special file anywhere in the run directory, or on a
|
|
94
|
+
// run directory that moved.
|
|
95
|
+
return refuse("HUMANISH_INVALID_RUN_BUNDLE", `No note was added to run ${runIdOf(prepared)}: its directory failed humanish's storage check. ${error instanceof Error ? error.message : String(error)} Fix that in the run directory and add the note again.`);
|
|
96
|
+
}
|
|
149
97
|
if (!loaded)
|
|
150
|
-
return refuse("HUMANISH_INVALID_RUN_BUNDLE", `Run ${runIdOf(prepared)} has no run.json humanish can read safely, so no note was added.
|
|
98
|
+
return refuse("HUMANISH_INVALID_RUN_BUNDLE", `Run ${runIdOf(prepared)} has no run.json humanish can read safely, so no note was added. \`${cli(`verify --run ${runIdOf(prepared)}`)}\` says what is wrong with it.`);
|
|
151
99
|
const { bundle } = loaded;
|
|
152
|
-
const clock = runClock(bundle);
|
|
100
|
+
const clock = runClock(bundle.streams);
|
|
153
101
|
if (!clock)
|
|
154
102
|
return refuse("HUMANISH_NOTE_NO_CLOCK", `Run ${bundle.runId} has no captures with recorded times, so a note cannot point at a moment of it.`);
|
|
155
103
|
const participant = input.participant === null ? null : participantStreamId(bundle, input.participant);
|
|
@@ -181,7 +129,7 @@ export async function addRunNote(prepared, input) {
|
|
|
181
129
|
for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt += 1) {
|
|
182
130
|
const note = { id: newRunNoteId(now), ...fields };
|
|
183
131
|
try {
|
|
184
|
-
await
|
|
132
|
+
await createContainedOutputFile(prepared, runNoteFile(note.id), encodeRunNote(note, bundle.runId));
|
|
185
133
|
}
|
|
186
134
|
catch (error) {
|
|
187
135
|
if (isNodeError(error) && error.code === "EEXIST")
|
package/dist/run/outcomes.d.ts
CHANGED
|
@@ -21,6 +21,30 @@ type ParticipantOutcomeDetail = {
|
|
|
21
21
|
* goal says where the claim came from, as the run's participant line does.
|
|
22
22
|
*/
|
|
23
23
|
export declare function sessionEndingInWords(trace: ActorTrace): string;
|
|
24
|
+
/** The app a participant summary says the participant used. */
|
|
25
|
+
interface SummaryApp {
|
|
26
|
+
/** "the app", or "the shared app" when every participant uses one instance. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Where the participant's browser ran, such as "in a hosted desktop browser". */
|
|
29
|
+
place?: string;
|
|
30
|
+
/** What a participant that never ran would have used, such as the app's URL. Defaults to `name`. */
|
|
31
|
+
target?: string;
|
|
32
|
+
}
|
|
33
|
+
/** What one participant's session came to when the bundle is written. */
|
|
34
|
+
interface SummarySession {
|
|
35
|
+
trace?: ActorTrace | undefined;
|
|
36
|
+
/** Why the run skipped the participant before its session started. */
|
|
37
|
+
skippedReason?: string | undefined;
|
|
38
|
+
sessionError?: string | undefined;
|
|
39
|
+
/** The run is still going and has no outcome for this participant. */
|
|
40
|
+
inProgress: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The sentence a participant's simulation record carries. An ended session, a skip and a session
|
|
44
|
+
* error each say what happened, in that order; a participant without one is still using the app or
|
|
45
|
+
* never ran.
|
|
46
|
+
*/
|
|
47
|
+
export declare function participantSummary(name: string, app: SummaryApp, session: SummarySession): string;
|
|
24
48
|
/** "; the gaps list the other 2", or "all 3" when nobody passed. Empty when the gaps list nobody. */
|
|
25
49
|
export declare function gapsListClause(listed: number, total: number): string;
|
|
26
50
|
/** One line a stakeholder can read, with the denominator attached to every number. */
|
package/dist/run/outcomes.js
CHANGED
|
@@ -146,6 +146,25 @@ export function sessionEndingInWords(trace) {
|
|
|
146
146
|
return "ended";
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* The sentence a participant's simulation record carries. An ended session, a skip and a session
|
|
151
|
+
* error each say what happened, in that order; a participant without one is still using the app or
|
|
152
|
+
* never ran.
|
|
153
|
+
*/
|
|
154
|
+
export function participantSummary(name, app, session) {
|
|
155
|
+
const where = app.place === undefined ? app.name : `${app.name} ${app.place}`;
|
|
156
|
+
if (session.trace !== undefined) {
|
|
157
|
+
const used = app.place === undefined ? where : `${where},`;
|
|
158
|
+
return `${name} used ${used} and ${sessionEndingInWords(session.trace)}.`;
|
|
159
|
+
}
|
|
160
|
+
if (session.skippedReason !== undefined)
|
|
161
|
+
return `${name} was ${session.skippedReason}.`;
|
|
162
|
+
if (session.sessionError !== undefined)
|
|
163
|
+
return `${name} did not finish a session: ${session.sessionError}`;
|
|
164
|
+
if (session.inProgress)
|
|
165
|
+
return `${name} is using ${where}.`;
|
|
166
|
+
return `${name} would use ${app.target ?? app.name}; no session ran.`;
|
|
167
|
+
}
|
|
149
168
|
/** "; the gaps list the other 2", or "all 3" when nobody passed. Empty when the gaps list nobody. */
|
|
150
169
|
export function gapsListClause(listed, total) {
|
|
151
170
|
if (listed <= 0)
|
|
@@ -28,3 +28,5 @@ export declare function eventRecordIdOf(event: RunEvent): string | undefined;
|
|
|
28
28
|
export declare function streamParticipantIdOf(stream: Pick<RunStream, "laneId">): string | undefined;
|
|
29
29
|
/** The persona recorded for a stream, including dry runs without an actor trace. */
|
|
30
30
|
export declare function recordedPersonaId(bundle: Partial<Pick<RunBundle, "simulations">>, stream: Partial<Pick<RunStream, "simId">>): string | undefined;
|
|
31
|
+
/** Each participant's caption, by stream id, as the Observer and the TUI name them. */
|
|
32
|
+
export declare function runParticipantCaptions(bundle: Pick<RunBundle, "streams" | "simulations">): Map<string, string>;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// The run bundle's participant records, written and read. A route describes each participant (its
|
|
2
2
|
// record and stream fields, its events) and these functions give them the bundle's saved shape and
|
|
3
|
-
// field names; readers ask them for the ids a saved record carries
|
|
4
|
-
// (its types and bundleHead) and run/streams.ts are where those contract
|
|
3
|
+
// field names; readers ask them for the ids a saved record carries and the caption it shows. This
|
|
4
|
+
// module and run/bundle.ts (its types and bundleHead) and run/streams.ts are where those contract
|
|
5
|
+
// spellings live.
|
|
6
|
+
import { streamCaptions } from "./participant-caption.js";
|
|
5
7
|
/** Ids a route chose itself, for routes whose ids are not the `sim-NNN` default. */
|
|
6
8
|
export function participantIdsOf(recordId, streamId) {
|
|
7
9
|
return { recordId, streamId };
|
|
@@ -67,3 +69,16 @@ export function recordedPersonaId(bundle, stream) {
|
|
|
67
69
|
return undefined;
|
|
68
70
|
return bundle.simulations.find((record) => record?.id === stream.simId)?.personaId;
|
|
69
71
|
}
|
|
72
|
+
/** Each participant's caption, by stream id, as the Observer and the TUI name them. */
|
|
73
|
+
export function runParticipantCaptions(bundle) {
|
|
74
|
+
return streamCaptions(bundle.streams.map((stream) => {
|
|
75
|
+
const participantId = streamParticipantIdOf(stream);
|
|
76
|
+
const personaId = recordedPersonaId(bundle, stream);
|
|
77
|
+
return {
|
|
78
|
+
id: stream.id,
|
|
79
|
+
label: stream.label,
|
|
80
|
+
...(participantId === undefined ? {} : { participantId }),
|
|
81
|
+
...(personaId === undefined ? {} : { personaId }),
|
|
82
|
+
};
|
|
83
|
+
}));
|
|
84
|
+
}
|
package/dist/run/paths.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface LatestRunPathPointer {
|
|
|
27
27
|
*/
|
|
28
28
|
export declare function isSafeRunIdSegment(runId: string): boolean;
|
|
29
29
|
export declare function resolveRunsRoot(cwdInput: string): string;
|
|
30
|
+
/** The latest-run pointer, `.humanish/runs/latest.json`, which the newest run writes. */
|
|
31
|
+
export declare function resolveLatestRunPointer(cwdInput: string): string;
|
|
30
32
|
export declare function prepareRunArtifactPaths(cwdInput: string, runId: string): Promise<PreparedRunArtifactPaths>;
|
|
31
33
|
/** A new run's id names a directory that already exists. */
|
|
32
34
|
export interface RunIdInUse {
|
package/dist/run/paths.js
CHANGED
|
@@ -18,6 +18,10 @@ export function isSafeRunIdSegment(runId) {
|
|
|
18
18
|
export function resolveRunsRoot(cwdInput) {
|
|
19
19
|
return path.resolve(cwdInput, RUNS_RELATIVE_ROOT);
|
|
20
20
|
}
|
|
21
|
+
/** The latest-run pointer, `.humanish/runs/latest.json`, which the newest run writes. */
|
|
22
|
+
export function resolveLatestRunPointer(cwdInput) {
|
|
23
|
+
return path.resolve(cwdInput, LATEST_RUN_RELATIVE_PATH);
|
|
24
|
+
}
|
|
21
25
|
function resolveRunDirectory(cwdInput, runId) {
|
|
22
26
|
if (!isSafeRunIdSegment(runId)) {
|
|
23
27
|
throw new Error("Run id must be one non-empty path segment.");
|
|
@@ -45,7 +49,7 @@ function resolveRunArtifactPaths(cwdInput, runId) {
|
|
|
45
49
|
return {
|
|
46
50
|
absoluteRunRoot,
|
|
47
51
|
relativeRunRoot: path.join(RUNS_RELATIVE_ROOT, runId),
|
|
48
|
-
absoluteLatestPointer:
|
|
52
|
+
absoluteLatestPointer: resolveLatestRunPointer(cwdInput),
|
|
49
53
|
relativeLatestPointer: LATEST_RUN_RELATIVE_PATH,
|
|
50
54
|
};
|
|
51
55
|
}
|
|
@@ -8,7 +8,7 @@ import { readdir, rmdir, unlink } from "node:fs/promises";
|
|
|
8
8
|
import { hostname } from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT } from "./sandbox-receipts.js";
|
|
11
|
-
import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, prepareManagedHumanishOutputDirectory, readContainedRegularFile, writeContainedOutputFile, } from "./contained-output.js";
|
|
11
|
+
import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, prepareManagedHumanishOutputDirectory, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
|
|
12
12
|
const PREFLIGHT_DIR = "preflight";
|
|
13
13
|
// Written when the probe finished without confirming its kill: the owner no longer holds the
|
|
14
14
|
// sandbox, so reclaim may act even while the owning process lives on.
|
|
@@ -54,11 +54,12 @@ export async function openPreflightJournal(cwd, leaseMs) {
|
|
|
54
54
|
await writeContainedOutputFile(root, OWNER_FILE, `${JSON.stringify(owner)}\n`, "utf8");
|
|
55
55
|
return { id, root, owner };
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
/** The owner record, or undefined when it is missing, refused or malformed: "missing or unreadable". */
|
|
58
|
+
function parseOwner(read) {
|
|
59
|
+
if (read.status !== "read")
|
|
59
60
|
return undefined;
|
|
60
61
|
try {
|
|
61
|
-
const value = JSON.parse(bytes.toString("utf8"));
|
|
62
|
+
const value = JSON.parse(read.bytes.toString("utf8"));
|
|
62
63
|
if (typeof value.hostname !== "string" ||
|
|
63
64
|
typeof value.pid !== "number" ||
|
|
64
65
|
typeof value.createdAt !== "string" ||
|
|
@@ -90,7 +91,7 @@ export async function listPreflightJournals(cwd) {
|
|
|
90
91
|
const root = await bindExistingManagedHumanishOutputDirectory(cwd, PREFLIGHT_DIR, entry.name);
|
|
91
92
|
if (!root)
|
|
92
93
|
continue;
|
|
93
|
-
const owner = parseOwner(await readContainedRegularFile(root, OWNER_FILE));
|
|
94
|
+
const owner = parseOwner(await readContainedRegularFile(root, OWNER_FILE, RUN_ARTIFACT_MAX_BYTES));
|
|
94
95
|
journals.push({ id: entry.name, root, ...(owner === undefined ? {} : { owner }) });
|
|
95
96
|
}
|
|
96
97
|
return journals;
|
|
@@ -118,10 +119,14 @@ function leaseEndsAt(journal, receipts) {
|
|
|
118
119
|
* another process). Anything else may be a live probe, and the reason says why it was left.
|
|
119
120
|
*/
|
|
120
121
|
export async function preflightReclaimDecision(journal, nowMs) {
|
|
121
|
-
|
|
122
|
+
// Only a marker that reads hands the journal over: one refused (a link, say) keeps it.
|
|
123
|
+
const marker = await readContainedRegularFile(journal.root, ABANDONED_MARKER, RUN_ARTIFACT_MAX_BYTES);
|
|
124
|
+
if (marker.status === "read")
|
|
122
125
|
return { reclaim: true };
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
// Refused receipts give no lease, as missing ones do; the owner's lease still decides, and
|
|
127
|
+
// reclaim refuses the unreadable journal before it kills anything.
|
|
128
|
+
const receipts = await readContainedRegularFile(journal.root, SANDBOX_RECEIPTS_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
|
|
129
|
+
const endsAt = leaseEndsAt(journal, receipts.status === "read" ? receipts.bytes.toString("utf8") : null);
|
|
125
130
|
if (endsAt !== undefined && nowMs > endsAt)
|
|
126
131
|
return { reclaim: true };
|
|
127
132
|
const until = endsAt === undefined ? "" : `; its lease ends at ${new Date(endsAt).toISOString()}`;
|