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.
Files changed (82) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CONTEXT.md +1 -1
  3. package/dist/actors/closing-report-limits.d.ts +11 -0
  4. package/dist/actors/closing-report-limits.js +13 -0
  5. package/dist/actors/closing-report.d.ts +68 -0
  6. package/dist/actors/closing-report.js +55 -0
  7. package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
  8. package/dist/actors/codex/restricted-participant-policy.js +12 -40
  9. package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
  10. package/dist/actors/computer-use/loop/debrief.js +10 -46
  11. package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
  12. package/dist/actors/computer-use/loop/impressions.js +6 -1
  13. package/dist/actors/computer-use/loop.d.ts +0 -1
  14. package/dist/actors/computer-use/loop.js +3 -3
  15. package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
  16. package/dist/actors/computer-use/openai-conversation.js +3 -29
  17. package/dist/actors/computer-use/openai-provider.js +11 -5
  18. package/dist/actors/contract.d.ts +2 -20
  19. package/dist/actors/contract.js +0 -13
  20. package/dist/analysis/admission.d.ts +14 -4
  21. package/dist/analysis/admission.js +14 -9
  22. package/dist/analysis/execute.d.ts +5 -0
  23. package/dist/analysis/execute.js +3 -0
  24. package/dist/analysis/finding-lead.d.ts +27 -0
  25. package/dist/analysis/finding-lead.js +23 -0
  26. package/dist/analysis/service.js +5 -3
  27. package/dist/cli/commands/notes.js +4 -3
  28. package/dist/cli/findings.js +10 -32
  29. package/dist/cli/io.js +4 -7
  30. package/dist/cli/telemetry.js +10 -22
  31. package/dist/cli/update-check.js +6 -18
  32. package/dist/cli/user-config.d.ts +11 -0
  33. package/dist/cli/user-config.js +25 -0
  34. package/dist/comms/email-catch.js +7 -31
  35. package/dist/evidence/encoded-text.d.ts +12 -11
  36. package/dist/evidence/encoded-text.js +81 -70
  37. package/dist/evidence/redaction.d.ts +11 -0
  38. package/dist/evidence/redaction.js +21 -10
  39. package/dist/evidence/secret-scrub.d.ts +14 -1
  40. package/dist/evidence/secret-scrub.js +108 -32
  41. package/dist/feedback/draft.d.ts +1 -1
  42. package/dist/feedback/draft.js +13 -16
  43. package/dist/feedback/export-bundle.js +3 -3
  44. package/dist/feedback/export.js +32 -46
  45. package/dist/feedback/feedback.js +6 -19
  46. package/dist/keys/key-resolution.js +12 -17
  47. package/dist/observer/artifact.d.ts +1 -1
  48. package/dist/observer/http.d.ts +7 -0
  49. package/dist/observer/http.js +19 -1
  50. package/dist/observer/notes-route.d.ts +0 -1
  51. package/dist/observer/notes-route.js +24 -44
  52. package/dist/observer/pinned-files.js +4 -19
  53. package/dist/observer/render.js +3 -2
  54. package/dist/observer/run-routes.js +13 -28
  55. package/dist/observer/serve.js +2 -1
  56. package/dist/observer-app.html +3 -3
  57. package/dist/run/contained-output.d.ts +6 -5
  58. package/dist/run/contained-output.js +19 -26
  59. package/dist/run/evidence-files.d.ts +7 -0
  60. package/dist/run/evidence-files.js +24 -14
  61. package/dist/run/note-files.d.ts +26 -45
  62. package/dist/run/note-files.js +86 -58
  63. package/dist/run/note-shape.d.ts +35 -0
  64. package/dist/run/note-shape.js +20 -0
  65. package/dist/run/notes.d.ts +3 -6
  66. package/dist/run/notes.js +18 -24
  67. package/dist/run/participant-records.d.ts +2 -0
  68. package/dist/run/participant-records.js +17 -2
  69. package/dist/run/transient-comms-secrets.d.ts +6 -5
  70. package/dist/run/transient-comms-secrets.js +19 -24
  71. package/dist/study/summary.d.ts +4 -5
  72. package/dist/study/summary.js +4 -2
  73. package/dist/study/warnings.js +2 -1
  74. package/dist/tui-app.js +1 -1
  75. package/docs/architecture/actor-contract.md +3 -3
  76. package/docs/architecture/restricted-codex-analysis.md +1 -1
  77. package/docs/architecture/state-driven-executor.md +1 -1
  78. package/docs/contracts/run-bundle.md +7 -3
  79. package/docs/contracts/schemas.md +12 -12
  80. package/docs/contracts/study-analysis.md +12 -4
  81. package/package.json +7 -6
  82. 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
- * Writes a new file inside the root without ever replacing one: the bytes go to a temporary file,
43
- * which link(2) then gives the final name, failing with EEXIST when that name exists, and the
44
- * temporary name is removed. writeContainedOutputFile renames instead, which replaces a file that
45
- * took the name in the meantime.
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 writeNewContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
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 { link, lstat, mkdir, open, realpath, rename, unlink, } from "node:fs/promises";
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
- * Writes a new file inside the root without ever replacing one: the bytes go to a temporary file,
128
- * which link(2) then gives the final name, failing with EEXIST when that name exists, and the
129
- * temporary name is removed. writeContainedOutputFile renames instead, which replaces a file that
130
- * took the name in the meantime.
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 writeNewContainedOutputFile(rootInput, relativePath, data) {
133
+ export async function createContainedOutputFile(rootInput, relativePath, data) {
133
134
  const filePath = await prepareContainedOutputFile(rootInput, relativePath);
134
135
  const root = await resolveOutputRoot(rootInput);
135
- const parent = path.dirname(filePath);
136
- const revalidate = async () => {
137
- if ((await resolveOutputRoot(rootInput)) !== root) {
138
- throw new Error("Output root changed after it was prepared.");
139
- }
140
- await assertContainedDirectoryChain(root, parent);
141
- };
142
- await revalidate();
143
- const temporary = path.join(parent, `.humanish-write-${process.pid}-${randomUUID()}.tmp`);
144
- let handle;
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
- handle = await open(temporary, "wx", 0o600);
147
- await handle.writeFile(data, "utf8");
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
- finally {
155
- await handle?.close().catch(() => undefined);
156
- await unlink(temporary).catch(() => undefined);
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 assertRegularFileOrMissing(filePath, "Selected output files");
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 chunks = [];
137
- let total = 0;
138
- while (total <= maxBytes) {
139
- const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
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: Buffer.concat(chunks, total) };
165
+ return { state: "read", bytes };
156
166
  }
157
167
  finally {
158
168
  await handle.close();
@@ -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
- * The run's notes. Each file is read through the bounded reader, which refuses a link, a special
58
- * file or anything over MAX_NOTE_FILE_BYTES before reading past the limit; a file it refuses, or
59
- * that is not a note of this run, is skipped and named in `skipped`.
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 declare function readRunNotes(prepared: PreparedRunArtifactPaths): Promise<RunNotes>;
62
- /** What verify's text scan finds in shared notes: a secret-shaped value, or text it cannot read. */
63
- type NotesFinding = "sensitive" | "opaque";
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
- finding: NotesFinding | null;
53
+ problem: NotesSharingProblem | null;
72
54
  }>;
73
- export {};
@@ -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, to a temporary file that link(2) gives its name only when
3
- // nothing holds that name, and is never rewritten, so a writer never reads or replaces another
4
- // writer's note and no lock is needed. An id starts
5
- // with its creation time, so the files sort in the order they were added, and ends in random
6
- // bits. Readers list notes/ and read each file within MAX_NOTE_FILE_BYTES, skipping and naming
7
- // anything that is not a readable note of the run.
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 { runIdOf } from "./paths.js";
14
- import { isNodeError, isRecord } from "./type-guards.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";
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
- const NOTE_ID = /^note-\d{8}t\d{9}z-[0-9a-f]{12}$/;
25
- /** A temporary file of a write in progress, or of one a stopped writer left. Never a note. */
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
- export function decodeRunNote(bytes, runId, id) {
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
- if (!isRecord(value) ||
52
- value.schema !== RUN_NOTE_SCHEMA ||
53
- value.runId !== runId ||
54
- value.id !== id ||
55
- typeof value.atMs !== "number" ||
56
- !Number.isSafeInteger(value.atMs) ||
57
- value.atMs < 0 ||
58
- !(value.participant === null || text(value.participant, 256)) ||
59
- !(value.nearest === null ||
60
- (isRecord(value.nearest) &&
61
- text(value.nearest.participant, 256) &&
62
- text(value.nearest.itemId, 256))) ||
63
- !text(value.text, MAX_NOTE_TEXT) ||
64
- !text(value.author, 80) ||
65
- !timestamp(value.createdAt) ||
66
- !(value.editedAt === null || timestamp(value.editedAt)))
66
+ const parsed = runNoteFileSchema.safeParse(value);
67
+ if (!parsed.success || parsed.data.runId !== runId || parsed.data.id !== id)
67
68
  return null;
68
- const nearest = isRecord(value.nearest) ? value.nearest : null;
69
- return {
70
- id,
71
- atMs: value.atMs,
72
- participant: value.participant,
73
- nearest: nearest === null
74
- ? null
75
- : { participant: String(nearest.participant), itemId: String(nearest.itemId) },
76
- text: value.text,
77
- author: value.author,
78
- createdAt: value.createdAt,
79
- editedAt: value.editedAt,
80
- };
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
- export async function listNoteEntries(dir) {
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
- export function noteListing(listing) {
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. Each file is read through the bounded reader, which refuses a link, a special
141
- * file or anything over MAX_NOTE_FILE_BYTES before reading past the limit; a file it refuses, or
142
- * that is not a note of this run, is skipped and named in `skipped`.
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(prepared) {
145
- const runId = runIdOf(prepared);
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(prepared.physicalRunRoot, RUN_NOTES_DIR));
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 readBoundedFileResult(prepared, runNoteFile(id), MAX_NOTE_FILE_BYTES);
163
- const note = read.state === "read" ? decodeRunNote(read.bytes, runId, id) : null;
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.state === "limit"
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, finding: null };
187
+ return { notes, problem: null };
182
188
  const scan = scanEncodedTextCached(JSON.stringify(notes.notes));
183
- return { notes, finding: scan.sensitive ? "sensitive" : scan.opaque ? "opaque" : null };
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
+ }
@@ -1,7 +1,6 @@
1
1
  import type { RunBundle } from "./bundle.js";
2
- import { type RunNote } from "./note-files.js";
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, published under a name nothing holds. It reads no
35
- * other note and replaces nothing, so notes added at the same time, from any process, are all kept.
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 { writeNewContainedOutputFile } from "./contained-output.js";
6
+ import { createContainedOutputFile } from "./contained-output.js";
6
7
  import { loadRunBundlePrepared } from "./locate.js";
7
- import { countRunNotes, encodeRunNote, MAX_NOTE_TEXT, MAX_RUN_NOTES, newRunNoteId, runNoteFile, } from "./note-files.js";
8
- import { streamCaptions } from "./participant-caption.js";
9
- import { recordedPersonaId, streamParticipantIdOf } from "./participant-records.js";
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, published under a name nothing holds. It reads no
140
- * other note and replaces nothing, so notes added at the same time, from any process, are all kept.
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
- // Containment checks throw on a link or special file anywhere in the run directory.
148
- const loaded = await loadRunBundlePrepared(physicalCwdOf(prepared), prepared).catch(() => null);
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. \`humanish verify --run ${runIdOf(prepared)}\` says what is wrong with it.`);
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 writeNewContainedOutputFile(prepared, runNoteFile(note.id), encodeRunNote(note, bundle.runId));
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")