humanish 0.113.0 → 0.115.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 +1 -1
- 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/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 +10 -46
- 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 +12 -36
- package/dist/actors/computer-use/openai-provider.js +11 -5
- package/dist/actors/contract.d.ts +6 -22
- package/dist/actors/contract.js +0 -13
- package/dist/analysis/admission.d.ts +47 -0
- package/dist/analysis/admission.js +65 -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/execute.d.ts +24 -5
- package/dist/analysis/execute.js +61 -27
- package/dist/analysis/finding-lead.d.ts +27 -0
- package/dist/analysis/finding-lead.js +23 -0
- 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 +8 -3
- package/dist/cli/commands/analyze.js +7 -2
- package/dist/cli/commands/notes.d.ts +3 -0
- package/dist/cli/commands/notes.js +145 -0
- package/dist/cli/commands/observe.js +1 -1
- package/dist/cli/commands/study-run.js +2 -1
- package/dist/cli/findings.js +11 -30
- package/dist/cli/io.d.ts +1 -0
- package/dist/cli/io.js +8 -12
- package/dist/cli/program.js +2 -0
- package/dist/cli/telemetry.js +10 -22
- package/dist/cli/update-check.js +6 -18
- package/dist/cli/user-config.d.ts +11 -0
- package/dist/cli/user-config.js +25 -0
- package/dist/comms/email-catch.js +7 -31
- package/dist/evidence/encoded-text.d.ts +17 -3
- package/dist/evidence/encoded-text.js +121 -46
- package/dist/evidence/redaction.d.ts +11 -0
- package/dist/evidence/redaction.js +21 -10
- package/dist/evidence/secret-scrub.d.ts +14 -1
- package/dist/evidence/secret-scrub.js +125 -28
- package/dist/feedback/draft.d.ts +19 -0
- package/dist/feedback/draft.js +62 -15
- package/dist/feedback/export-bundle.js +3 -3
- package/dist/feedback/export.js +50 -20
- package/dist/feedback/feedback.js +29 -2
- package/dist/keys/key-resolution.js +12 -17
- package/dist/observer/artifact.d.ts +5 -0
- package/dist/observer/artifact.js +18 -2
- package/dist/observer/http.d.ts +7 -0
- package/dist/observer/http.js +19 -1
- package/dist/observer/notes-route.d.ts +20 -0
- package/dist/observer/notes-route.js +120 -0
- package/dist/observer/pinned-files.d.ts +8 -1
- package/dist/observer/pinned-files.js +17 -3
- package/dist/observer/render.js +39 -14
- package/dist/observer/run-routes.d.ts +8 -2
- package/dist/observer/run-routes.js +32 -6
- package/dist/observer/serve.d.ts +3 -0
- package/dist/observer/serve.js +29 -3
- package/dist/observer-app.html +7 -6
- package/dist/run/contained-output.d.ts +8 -0
- package/dist/run/contained-output.js +27 -0
- package/dist/run/evidence-files.d.ts +7 -0
- package/dist/run/evidence-files.js +24 -14
- package/dist/run/note-files.d.ts +54 -0
- package/dist/run/note-files.js +212 -0
- package/dist/run/note-shape.d.ts +35 -0
- package/dist/run/note-shape.js +20 -0
- package/dist/run/notes.d.ts +34 -0
- package/dist/run/notes.js +192 -0
- package/dist/run/participant-caption.d.ts +9 -0
- package/dist/run/participant-caption.js +19 -0
- package/dist/run/participant-records.d.ts +2 -0
- package/dist/run/participant-records.js +17 -2
- package/dist/run/transient-comms-secrets.d.ts +6 -5
- package/dist/run/transient-comms-secrets.js +19 -24
- package/dist/study/doctor.js +1 -1
- package/dist/study/preflight-result.js +2 -1
- package/dist/study/summary.d.ts +4 -5
- package/dist/study/summary.js +6 -4
- package/dist/study/warnings.js +2 -1
- package/dist/tui-app.js +1 -1
- package/docs/architecture/actor-contract.md +3 -3
- package/docs/architecture/restricted-codex-analysis.md +1 -1
- package/docs/architecture/state-driven-executor.md +1 -1
- package/docs/contracts/errors.md +2 -2
- package/docs/contracts/run-bundle.md +61 -0
- package/docs/contracts/schemas.md +17 -14
- package/docs/contracts/study-analysis.md +35 -9
- package/docs/principles/invariants-and-defaults.md +2 -2
- package/docs/product/automatic-analysis.md +22 -16
- package/package.json +8 -7
- package/skills/humanish/SKILL.md +6 -2
|
@@ -38,6 +38,14 @@ 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
|
+
* Creates a new file inside the root under its final name, refusing with EEXIST when that name
|
|
43
|
+
* exists, so it never replaces a file. The file is opened with O_EXCL and O_NOFOLLOW, filled in one
|
|
44
|
+
* write and synced, so a reader may briefly see it empty or short. It never has a temporary name or
|
|
45
|
+
* a second link, so the run directory passes its single-link check at every moment.
|
|
46
|
+
* writeContainedOutputFile renames a finished file into place instead, replacing what is there.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
|
|
41
49
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
42
50
|
export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
|
|
43
51
|
/**
|
|
@@ -123,6 +123,33 @@ export async function writeContainedOutputFile(rootInput, relativePath, data, en
|
|
|
123
123
|
await assertRegularFileOrMissing(filePath, "Selected output files");
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Creates a new file inside the root under its final name, refusing with EEXIST when that name
|
|
128
|
+
* exists, so it never replaces a file. The file is opened with O_EXCL and O_NOFOLLOW, filled in one
|
|
129
|
+
* write and synced, so a reader may briefly see it empty or short. It never has a temporary name or
|
|
130
|
+
* a second link, so the run directory passes its single-link check at every moment.
|
|
131
|
+
* writeContainedOutputFile renames a finished file into place instead, replacing what is there.
|
|
132
|
+
*/
|
|
133
|
+
export async function createContainedOutputFile(rootInput, relativePath, data) {
|
|
134
|
+
const filePath = await prepareContainedOutputFile(rootInput, relativePath);
|
|
135
|
+
const root = await resolveOutputRoot(rootInput);
|
|
136
|
+
await assertContainedDirectoryChain(root, path.dirname(filePath));
|
|
137
|
+
const bytes = Buffer.from(data, "utf8");
|
|
138
|
+
const handle = await open(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
139
|
+
try {
|
|
140
|
+
const { bytesWritten } = await handle.write(bytes, 0, bytes.length, 0);
|
|
141
|
+
if (bytesWritten !== bytes.length)
|
|
142
|
+
throw new Error("The file was not written in full.");
|
|
143
|
+
await handle.sync();
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
await handle.close().catch(() => undefined);
|
|
147
|
+
// O_EXCL created this name here, so removing it removes only this file.
|
|
148
|
+
await unlink(filePath).catch(() => undefined);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
await handle.close();
|
|
152
|
+
}
|
|
126
153
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
127
154
|
export async function readContainedRegularFile(rootInput, relativePath) {
|
|
128
155
|
const handle = await openContainedRegularFile(rootInput, relativePath);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FileHandle } from "node:fs/promises";
|
|
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
|
|
@@ -22,6 +23,12 @@ export declare function readBoundedFile(root: PreparedOutputRoot, relativePath:
|
|
|
22
23
|
* there that could not be read. Errors other than ENOENT propagate.
|
|
23
24
|
*/
|
|
24
25
|
export declare function pathMissing(filePath: string): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
|
|
28
|
+
* position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
|
|
29
|
+
* after it was opened is never read whole.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
|
|
25
32
|
export type BoundedFileResult = {
|
|
26
33
|
state: "read";
|
|
27
34
|
bytes: Buffer;
|
|
@@ -87,6 +87,25 @@ export async function pathMissing(filePath) {
|
|
|
87
87
|
throw error;
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
|
|
92
|
+
* position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
|
|
93
|
+
* after it was opened is never read whole.
|
|
94
|
+
*/
|
|
95
|
+
export async function readOpenedAtMost(handle, maxBytes) {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let total = 0;
|
|
98
|
+
for (;;) {
|
|
99
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
100
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
|
101
|
+
if (bytesRead === 0)
|
|
102
|
+
return Buffer.concat(chunks, total);
|
|
103
|
+
total += bytesRead;
|
|
104
|
+
if (total > maxBytes)
|
|
105
|
+
return null;
|
|
106
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
90
109
|
const unavailable = { state: "unavailable" };
|
|
91
110
|
/** Size refusals are distinguished only after the same contained regular-file checks. */
|
|
92
111
|
export async function readBoundedFileResult(root, relativePath, maxBytes) {
|
|
@@ -133,26 +152,17 @@ export async function readBoundedFileResult(root, relativePath, maxBytes) {
|
|
|
133
152
|
try {
|
|
134
153
|
if (!sameFile(before, await handle.stat({ bigint: true })))
|
|
135
154
|
return unavailable;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
141
|
-
if (bytesRead === 0)
|
|
142
|
-
break;
|
|
143
|
-
total += bytesRead;
|
|
144
|
-
if (total > maxBytes)
|
|
145
|
-
return unavailable;
|
|
146
|
-
chunks.push(chunk.subarray(0, bytesRead));
|
|
147
|
-
}
|
|
148
|
-
if (!sameFile(before, await handle.stat({ bigint: true })) || total !== Number(before.size))
|
|
155
|
+
const bytes = await readOpenedAtMost(handle, maxBytes);
|
|
156
|
+
if (bytes === null ||
|
|
157
|
+
!sameFile(before, await handle.stat({ bigint: true })) ||
|
|
158
|
+
bytes.length !== Number(before.size))
|
|
149
159
|
return unavailable;
|
|
150
160
|
if ((await validateRoot()) !== physicalRoot)
|
|
151
161
|
return unavailable;
|
|
152
162
|
await validateParents();
|
|
153
163
|
if (!sameFile(before, await lstat(candidate, { bigint: true })))
|
|
154
164
|
return unavailable;
|
|
155
|
-
return { state: "read", bytes
|
|
165
|
+
return { state: "read", bytes };
|
|
156
166
|
}
|
|
157
167
|
finally {
|
|
158
168
|
await handle.close();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type RunNote } from "./note-shape.js";
|
|
2
|
+
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
3
|
+
export declare const RUN_NOTES_DIR = "notes";
|
|
4
|
+
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
5
|
+
export declare const MAX_NOTE_FILE_BYTES: number;
|
|
6
|
+
/** A run's notes as read, in the order they were added, and why any file was skipped. */
|
|
7
|
+
export interface RunNotes {
|
|
8
|
+
runId: string;
|
|
9
|
+
notes: RunNote[];
|
|
10
|
+
skipped: string[];
|
|
11
|
+
}
|
|
12
|
+
/** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
|
|
13
|
+
export declare function newRunNoteId(createdAt: Date): string;
|
|
14
|
+
/** The note's file, relative to the run directory. */
|
|
15
|
+
export declare function runNoteFile(id: string): string;
|
|
16
|
+
/** The note as its file holds it. */
|
|
17
|
+
export declare function encodeRunNote(note: RunNote, runId: string): string;
|
|
18
|
+
/** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
|
|
19
|
+
export declare function countRunNotes(prepared: PreparedRunArtifactPaths): Promise<number>;
|
|
20
|
+
/**
|
|
21
|
+
* Where a run's notes are read from: the run's id, its directory, and a reader of the files under
|
|
22
|
+
* it that refuses a link or a special file and stops at `maxBytes`.
|
|
23
|
+
*/
|
|
24
|
+
export interface RunNoteFiles {
|
|
25
|
+
readonly runId: string;
|
|
26
|
+
/** The run directory's physical path, once it is checked to be the directory bound. Throws otherwise. */
|
|
27
|
+
directory(): Promise<string>;
|
|
28
|
+
/** A file under the run directory: its bytes, "limit" when it holds more than `maxBytes`, or null. */
|
|
29
|
+
read(relativePath: string, maxBytes: number): Promise<Buffer | "limit" | null>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The run's notes, read from its prepared paths through the bounded reader or through `from`, the
|
|
33
|
+
* files of a run the caller already holds (the served Observer passes its pinned root's). Each file
|
|
34
|
+
* is read within MAX_NOTE_FILE_BYTES; a file the reader refuses, or that is not a note of this run,
|
|
35
|
+
* is skipped and named in `skipped`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readRunNotes(from: PreparedRunArtifactPaths | RunNoteFiles): Promise<RunNotes>;
|
|
38
|
+
/** What verify's text scan finds in notes about to be shared, and the share safety that gives them. */
|
|
39
|
+
export interface NotesSharingProblem {
|
|
40
|
+
status: "local_only" | "blocked";
|
|
41
|
+
reason: {
|
|
42
|
+
code: "PUBLIC_SAFETY_FINDINGS" | "UNSCANNED_ARTIFACT";
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The run's notes as a caller is about to share them, read once, with what verify's text scan
|
|
48
|
+
* finds in that set as it would be shared. The caller shares this set only, so a note added or
|
|
49
|
+
* changed after verify ran cannot go out unchecked.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readNotesForSharing(prepared: PreparedRunArtifactPaths): Promise<{
|
|
52
|
+
notes: RunNotes;
|
|
53
|
+
problem: NotesSharingProblem | null;
|
|
54
|
+
}>;
|
|
@@ -0,0 +1,212 @@
|
|
|
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, 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.
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { lstat, opendir } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { scanEncodedTextCached } from "../evidence/encoded-text.js";
|
|
12
|
+
import { readBoundedFileResult } from "./evidence-files.js";
|
|
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";
|
|
16
|
+
const RUN_NOTE_SCHEMA = "humanish.run-note.v1";
|
|
17
|
+
export const RUN_NOTES_DIR = "notes";
|
|
18
|
+
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
19
|
+
export const MAX_NOTE_FILE_BYTES = 16 * 1024;
|
|
20
|
+
/** The entries a reader lists in notes/ before it stops. */
|
|
21
|
+
const MAX_LISTED_ENTRIES = 2 * MAX_RUN_NOTES;
|
|
22
|
+
/** A temporary file of the atomic writer, or one a 0.114.0 note writer left. Never a note. */
|
|
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
|
+
});
|
|
44
|
+
/** A new note id: `note-<creation time>-<48 random bits>`, such as `note-20261007t214500123z-…`. */
|
|
45
|
+
export function newRunNoteId(createdAt) {
|
|
46
|
+
const stamp = createdAt.toISOString().replace(/[-:.]/g, "").toLowerCase();
|
|
47
|
+
return `note-${stamp}-${randomBytes(6).toString("hex")}`;
|
|
48
|
+
}
|
|
49
|
+
/** The note's file, relative to the run directory. */
|
|
50
|
+
export function runNoteFile(id) {
|
|
51
|
+
return `${RUN_NOTES_DIR}/${id}.json`;
|
|
52
|
+
}
|
|
53
|
+
/** The note as its file holds it. */
|
|
54
|
+
export function encodeRunNote(note, runId) {
|
|
55
|
+
return `${JSON.stringify({ schema: RUN_NOTE_SCHEMA, runId, ...note }, null, 2)}\n`;
|
|
56
|
+
}
|
|
57
|
+
/** The note in a note file's bytes, when it is a note of `runId` stored under its own `id`. */
|
|
58
|
+
function decodeRunNote(bytes, runId, id) {
|
|
59
|
+
let value;
|
|
60
|
+
try {
|
|
61
|
+
value = JSON.parse(bytes.toString("utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const parsed = runNoteFileSchema.safeParse(value);
|
|
67
|
+
if (!parsed.success || parsed.data.runId !== runId || parsed.data.id !== id)
|
|
68
|
+
return null;
|
|
69
|
+
const { schema: _schema, runId: _runId, ...note } = parsed.data;
|
|
70
|
+
return note;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Up to MAX_LISTED_ENTRIES names in the notes directory at `dir`, or null when there is none.
|
|
74
|
+
* Throws when `dir` is there and is not a plain directory.
|
|
75
|
+
*/
|
|
76
|
+
async function listNoteEntries(dir) {
|
|
77
|
+
try {
|
|
78
|
+
const stats = await lstat(dir);
|
|
79
|
+
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
80
|
+
throw new Error("The notes entry is not a directory.");
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
84
|
+
return null;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const names = [];
|
|
88
|
+
const listing = await opendir(dir);
|
|
89
|
+
try {
|
|
90
|
+
for await (const entry of listing) {
|
|
91
|
+
if (names.length === MAX_LISTED_ENTRIES)
|
|
92
|
+
return { names, truncated: true };
|
|
93
|
+
names.push(entry.name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await listing.close().catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
return { names, truncated: false };
|
|
100
|
+
}
|
|
101
|
+
/** The note ids to read from a listing of notes/, in creation order, and what was left out. */
|
|
102
|
+
function noteListing(listing) {
|
|
103
|
+
const ids = [];
|
|
104
|
+
const skipped = [];
|
|
105
|
+
for (const name of listing.names) {
|
|
106
|
+
if (WRITE_IN_PROGRESS.test(name))
|
|
107
|
+
continue;
|
|
108
|
+
const id = name.endsWith(".json") ? name.slice(0, -".json".length) : "";
|
|
109
|
+
if (NOTE_ID.test(id))
|
|
110
|
+
ids.push(id);
|
|
111
|
+
else
|
|
112
|
+
skipped.push(`${RUN_NOTES_DIR}/${name} is not a note file, so it was skipped.`);
|
|
113
|
+
}
|
|
114
|
+
ids.sort();
|
|
115
|
+
if (listing.truncated)
|
|
116
|
+
skipped.push(`${RUN_NOTES_DIR}/ holds more than ${MAX_LISTED_ENTRIES} entries; only the first ${MAX_LISTED_ENTRIES} were listed.`);
|
|
117
|
+
if (ids.length > MAX_RUN_NOTES) {
|
|
118
|
+
const left = ids.length - MAX_RUN_NOTES;
|
|
119
|
+
skipped.push(`${left} note${left === 1 ? "" : "s"} after the first ${MAX_RUN_NOTES} ${left === 1 ? "was" : "were"} not read.`);
|
|
120
|
+
ids.length = MAX_RUN_NOTES;
|
|
121
|
+
}
|
|
122
|
+
return { ids, skipped };
|
|
123
|
+
}
|
|
124
|
+
/** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
|
|
125
|
+
export async function countRunNotes(prepared) {
|
|
126
|
+
const listing = await listNoteEntries(path.join(prepared.physicalRunRoot, RUN_NOTES_DIR));
|
|
127
|
+
return listing === null ? 0 : noteListing(listing).ids.length + (listing.truncated ? 1 : 0);
|
|
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
|
+
}
|
|
143
|
+
/**
|
|
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`.
|
|
148
|
+
*/
|
|
149
|
+
export async function readRunNotes(from) {
|
|
150
|
+
const files = "physicalRunRoot" in from ? boundedNoteFiles(from) : from;
|
|
151
|
+
const { runId } = files;
|
|
152
|
+
let listing;
|
|
153
|
+
try {
|
|
154
|
+
listing = await listNoteEntries(path.join(await files.directory(), RUN_NOTES_DIR));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return {
|
|
158
|
+
runId,
|
|
159
|
+
notes: [],
|
|
160
|
+
skipped: [`${RUN_NOTES_DIR} in run ${runId} is not a plain folder, so no notes were read.`],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (listing === null)
|
|
164
|
+
return { runId, notes: [], skipped: [] };
|
|
165
|
+
const { ids, skipped } = noteListing(listing);
|
|
166
|
+
const notes = [];
|
|
167
|
+
for (const id of ids) {
|
|
168
|
+
const read = await files.read(runNoteFile(id), MAX_NOTE_FILE_BYTES);
|
|
169
|
+
const note = Buffer.isBuffer(read) ? decodeRunNote(read, runId, id) : null;
|
|
170
|
+
if (note !== null)
|
|
171
|
+
notes.push(note);
|
|
172
|
+
else
|
|
173
|
+
skipped.push(read === "limit"
|
|
174
|
+
? `${runNoteFile(id)} is over ${MAX_NOTE_FILE_BYTES / 1024} KiB, so it was skipped.`
|
|
175
|
+
: `${runNoteFile(id)} is not a readable note of run ${runId}, so it was skipped.`);
|
|
176
|
+
}
|
|
177
|
+
return { runId, notes, skipped };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The run's notes as a caller is about to share them, read once, with what verify's text scan
|
|
181
|
+
* finds in that set as it would be shared. The caller shares this set only, so a note added or
|
|
182
|
+
* changed after verify ran cannot go out unchecked.
|
|
183
|
+
*/
|
|
184
|
+
export async function readNotesForSharing(prepared) {
|
|
185
|
+
const notes = await readRunNotes(prepared);
|
|
186
|
+
if (notes.notes.length === 0)
|
|
187
|
+
return { notes, problem: null };
|
|
188
|
+
const scan = scanEncodedTextCached(JSON.stringify(notes.notes));
|
|
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 };
|
|
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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RunBundle } from "./bundle.js";
|
|
2
|
+
import { type RunNote } from "./note-shape.js";
|
|
3
|
+
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
4
|
+
export interface RunNoteInput {
|
|
5
|
+
atMs: number;
|
|
6
|
+
participant: string | null;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
export type RunNoteErrorCode = "HUMANISH_INVALID_RUN_BUNDLE" | "HUMANISH_NOTE_INVALID" | "HUMANISH_NOTE_NO_CLOCK" | "HUMANISH_NOTE_UNKNOWN_PARTICIPANT" | "HUMANISH_NOTE_OUTSIDE_RUN" | "HUMANISH_NOTES_UNREADABLE" | "HUMANISH_NOTES_FULL" | "HUMANISH_NOTE_ID_TAKEN";
|
|
10
|
+
export type AddRunNoteResult =
|
|
11
|
+
/** `scrubbed`: redaction replaced part of the text before it was written. */
|
|
12
|
+
{
|
|
13
|
+
ok: true;
|
|
14
|
+
note: RunNote;
|
|
15
|
+
scrubbed: boolean;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
error: {
|
|
19
|
+
code: RunNoteErrorCode;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
/** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
|
|
24
|
+
export declare function formatRunTime(ms: number): string;
|
|
25
|
+
/** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
|
|
26
|
+
export declare function runClock(bundle: RunBundle): {
|
|
27
|
+
startMs: number;
|
|
28
|
+
endMs: number;
|
|
29
|
+
} | null;
|
|
30
|
+
/**
|
|
31
|
+
* Adds a note as a new file, notes/<id>.json, created under a name nothing holds. It reads no other
|
|
32
|
+
* note and replaces nothing, so notes added at the same time, from any process, are all kept.
|
|
33
|
+
*/
|
|
34
|
+
export declare function addRunNote(prepared: PreparedRunArtifactPaths, input: RunNoteInput): Promise<AddRunNoteResult>;
|