humanish 0.114.0 → 0.116.0

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