humanish 0.114.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 +19 -0
- package/CONTEXT.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 +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/analysis/admission.d.ts +14 -4
- package/dist/analysis/admission.js +14 -9
- 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/cli/commands/notes.js +4 -3
- package/dist/cli/findings.js +10 -32
- package/dist/cli/io.js +4 -7
- 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 +12 -11
- package/dist/evidence/encoded-text.js +81 -70
- 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 +108 -32
- package/dist/feedback/draft.d.ts +1 -1
- package/dist/feedback/draft.js +13 -16
- package/dist/feedback/export-bundle.js +3 -3
- package/dist/feedback/export.js +32 -46
- package/dist/feedback/feedback.js +6 -19
- package/dist/keys/key-resolution.js +12 -17
- 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 +3 -2
- package/dist/observer/run-routes.js +13 -28
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +3 -3
- package/dist/run/contained-output.d.ts +6 -5
- package/dist/run/contained-output.js +19 -26
- package/dist/run/evidence-files.d.ts +7 -0
- package/dist/run/evidence-files.js +24 -14
- 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 -6
- package/dist/run/notes.js +18 -24
- 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/summary.d.ts +4 -5
- package/dist/study/summary.js +4 -2
- 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/run-bundle.md +7 -3
- package/docs/contracts/schemas.md +12 -12
- package/docs/contracts/study-analysis.md +12 -4
- package/package.json +7 -6
- package/skills/humanish/SKILL.md +1 -2
|
@@ -39,12 +39,13 @@ export declare function prepareContainedOutputDirectoryRoot(rootInput: PreparedO
|
|
|
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
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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.
|
|
46
47
|
*/
|
|
47
|
-
export declare function
|
|
48
|
+
export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
|
|
48
49
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
49
50
|
export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
|
|
50
51
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { 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";
|
|
@@ -124,38 +124,31 @@ export async function writeContainedOutputFile(rootInput, relativePath, data, en
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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.
|
|
131
132
|
*/
|
|
132
|
-
export async function
|
|
133
|
+
export async function createContainedOutputFile(rootInput, relativePath, data) {
|
|
133
134
|
const filePath = await prepareContainedOutputFile(rootInput, relativePath);
|
|
134
135
|
const root = await resolveOutputRoot(rootInput);
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
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;
|
|
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);
|
|
145
139
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
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.");
|
|
148
143
|
await handle.sync();
|
|
149
|
-
await handle.close();
|
|
150
|
-
handle = undefined;
|
|
151
|
-
await revalidate();
|
|
152
|
-
await link(temporary, filePath);
|
|
153
144
|
}
|
|
154
|
-
|
|
155
|
-
await handle
|
|
156
|
-
|
|
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;
|
|
157
150
|
}
|
|
158
|
-
await
|
|
151
|
+
await handle.close();
|
|
159
152
|
}
|
|
160
153
|
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
161
154
|
export async function readContainedRegularFile(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();
|
package/dist/run/note-files.d.ts
CHANGED
|
@@ -1,26 +1,8 @@
|
|
|
1
|
+
import { type RunNote } from "./note-shape.js";
|
|
1
2
|
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
2
3
|
export declare const RUN_NOTES_DIR = "notes";
|
|
3
4
|
/** The bytes one note file may hold: 2000 characters of text with JSON escapes, and its fields. */
|
|
4
5
|
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
6
|
/** A run's notes as read, in the order they were added, and why any file was skipped. */
|
|
25
7
|
export interface RunNotes {
|
|
26
8
|
runId: string;
|
|
@@ -33,34 +15,34 @@ export declare function newRunNoteId(createdAt: Date): string;
|
|
|
33
15
|
export declare function runNoteFile(id: string): string;
|
|
34
16
|
/** The note as its file holds it. */
|
|
35
17
|
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
18
|
/** How many note files notes/ lists, without reading any. Throws when notes/ is unusable. */
|
|
55
19
|
export declare function countRunNotes(prepared: PreparedRunArtifactPaths): Promise<number>;
|
|
56
20
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* that is not a note of this run, is skipped and named in `skipped`.
|
|
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`.
|
|
60
23
|
*/
|
|
61
|
-
export
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
|
64
46
|
/**
|
|
65
47
|
* The run's notes as a caller is about to share them, read once, with what verify's text scan
|
|
66
48
|
* finds in that set as it would be shared. The caller shares this set only, so a note added or
|
|
@@ -68,6 +50,5 @@ type NotesFinding = "sensitive" | "opaque";
|
|
|
68
50
|
*/
|
|
69
51
|
export declare function readNotesForSharing(prepared: PreparedRunArtifactPaths): Promise<{
|
|
70
52
|
notes: RunNotes;
|
|
71
|
-
|
|
53
|
+
problem: NotesSharingProblem | null;
|
|
72
54
|
}>;
|
|
73
|
-
export {};
|
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,6 @@
|
|
|
1
1
|
import type { RunBundle } from "./bundle.js";
|
|
2
|
-
import { type RunNote } from "./note-
|
|
2
|
+
import { type RunNote } from "./note-shape.js";
|
|
3
3
|
import { type PreparedRunArtifactPaths } from "./paths.js";
|
|
4
|
-
export { MAX_NOTE_TEXT, readNotesForSharing, readRunNotes, type RunNote, type RunNotes, } from "./note-files.js";
|
|
5
4
|
export interface RunNoteInput {
|
|
6
5
|
atMs: number;
|
|
7
6
|
participant: string | null;
|
|
@@ -21,8 +20,6 @@ export type AddRunNoteResult =
|
|
|
21
20
|
message: string;
|
|
22
21
|
};
|
|
23
22
|
};
|
|
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
23
|
/** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
|
|
27
24
|
export declare function formatRunTime(ms: number): string;
|
|
28
25
|
/** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
|
|
@@ -31,7 +28,7 @@ export declare function runClock(bundle: RunBundle): {
|
|
|
31
28
|
endMs: number;
|
|
32
29
|
} | null;
|
|
33
30
|
/**
|
|
34
|
-
* Adds a note as a new file, notes/<id>.json,
|
|
35
|
-
*
|
|
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.
|
|
36
33
|
*/
|
|
37
34
|
export declare function addRunNote(prepared: PreparedRunArtifactPaths, input: RunNoteInput): Promise<AddRunNoteResult>;
|
package/dist/run/notes.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// Reviewer notes: free text a person adds at a moment of a recorded run. Each note is its own file
|
|
2
2
|
// in the run directory (note-files.ts). A note's time counts from the run clock's start, the
|
|
3
3
|
// first timed capture or desktop video of any participant, which is the Observer's study clock.
|
|
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";
|
|
11
12
|
import { transientCommsKnownValueScrub } from "./transient-comms-secrets.js";
|
|
12
13
|
import { isNodeError } from "./type-guards.js";
|
|
13
|
-
export { MAX_NOTE_TEXT, readNotesForSharing, readRunNotes, } from "./note-files.js";
|
|
14
14
|
const DEFAULT_AUTHOR = "you";
|
|
15
15
|
/** New ids a note tries when the one before was taken. */
|
|
16
16
|
const PUBLISH_ATTEMPTS = 5;
|
|
@@ -18,19 +18,6 @@ const refuse = (code, message) => ({
|
|
|
18
18
|
ok: false,
|
|
19
19
|
error: { code, message },
|
|
20
20
|
});
|
|
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
21
|
/**
|
|
35
22
|
* The stream id a note's participant names: a stream id, or the participant's own id from the study
|
|
36
23
|
* (`charge-nurse`). Both kinds of id are matched, so a name that is one stream's id and another
|
|
@@ -136,18 +123,25 @@ function noteTextProblem(text) {
|
|
|
136
123
|
return null;
|
|
137
124
|
}
|
|
138
125
|
/**
|
|
139
|
-
* Adds a note as a new file, notes/<id>.json,
|
|
140
|
-
*
|
|
126
|
+
* Adds a note as a new file, notes/<id>.json, created under a name nothing holds. It reads no other
|
|
127
|
+
* note and replaces nothing, so notes added at the same time, from any process, are all kept.
|
|
141
128
|
*/
|
|
142
129
|
export async function addRunNote(prepared, input) {
|
|
143
130
|
const text = scrubNoteText(input.text);
|
|
144
131
|
const problem = noteTextProblem(text);
|
|
145
132
|
if (problem)
|
|
146
133
|
return refuse("HUMANISH_NOTE_INVALID", problem);
|
|
147
|
-
|
|
148
|
-
|
|
134
|
+
let loaded;
|
|
135
|
+
try {
|
|
136
|
+
loaded = await loadRunBundlePrepared(physicalCwdOf(prepared), prepared);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
// The storage check throws on a link or a special file anywhere in the run directory, or on a
|
|
140
|
+
// run directory that moved.
|
|
141
|
+
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.`);
|
|
142
|
+
}
|
|
149
143
|
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.
|
|
144
|
+
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
145
|
const { bundle } = loaded;
|
|
152
146
|
const clock = runClock(bundle);
|
|
153
147
|
if (!clock)
|
|
@@ -181,7 +175,7 @@ export async function addRunNote(prepared, input) {
|
|
|
181
175
|
for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt += 1) {
|
|
182
176
|
const note = { id: newRunNoteId(now), ...fields };
|
|
183
177
|
try {
|
|
184
|
-
await
|
|
178
|
+
await createContainedOutputFile(prepared, runNoteFile(note.id), encodeRunNote(note, bundle.runId));
|
|
185
179
|
}
|
|
186
180
|
catch (error) {
|
|
187
181
|
if (isNodeError(error) && error.code === "EEXIST")
|