humanish 0.115.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 (154) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -6
  3. package/TELEMETRY.md +6 -0
  4. package/dist/actors/codex/app-server-ui.js +11 -4
  5. package/dist/actors/computer-use/loop/debrief.js +61 -27
  6. package/dist/actors/scripted-browser/steps.js +6 -5
  7. package/dist/analysis/analysis-limits.d.ts +35 -0
  8. package/dist/analysis/analysis-limits.js +41 -0
  9. package/dist/analysis/validation.js +14 -21
  10. package/dist/cli/commands/analyze.js +2 -1
  11. package/dist/cli/commands/notes.js +2 -1
  12. package/dist/cli/commands/run-signals.js +5 -1
  13. package/dist/cli/commands/runs.js +2 -1
  14. package/dist/cli/commands/setup.js +3 -3
  15. package/dist/cli/commands/study-format.d.ts +5 -2
  16. package/dist/cli/commands/study-format.js +29 -9
  17. package/dist/cli/commands/study-route-computer-use.js +2 -2
  18. package/dist/cli/commands/study-route-scripted.js +1 -1
  19. package/dist/cli/commands/tui.js +59 -17
  20. package/dist/cli/doctor.js +9 -2
  21. package/dist/cli/findings-text.d.ts +5 -0
  22. package/dist/cli/findings-text.js +169 -0
  23. package/dist/cli/findings.d.ts +0 -4
  24. package/dist/cli/findings.js +5 -167
  25. package/dist/cli/io.js +6 -1
  26. package/dist/cli/update-check.js +4 -1
  27. package/dist/comms/connections.js +4 -3
  28. package/dist/comms/setup.js +18 -12
  29. package/dist/evidence/redaction.js +9 -1
  30. package/dist/evidence/secret-scrub.d.ts +15 -5
  31. package/dist/evidence/secret-scrub.js +67 -9
  32. package/dist/feedback/draft.js +1 -1
  33. package/dist/feedback/export-bundle.js +18 -14
  34. package/dist/feedback/feedback.js +25 -7
  35. package/dist/guest/desktop-keys.js +59 -5
  36. package/dist/keys/key-resolution.d.ts +9 -0
  37. package/dist/keys/key-resolution.js +60 -48
  38. package/dist/keys/key-status.d.ts +2 -0
  39. package/dist/keys/key-status.js +9 -16
  40. package/dist/observer/pinned-files.js +1 -1
  41. package/dist/observer/render.js +3 -2
  42. package/dist/observer-app.html +7 -7
  43. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  44. package/dist/routes/computer-use/bundle-parts.js +2 -2
  45. package/dist/routes/computer-use/bundle.d.ts +4 -5
  46. package/dist/routes/computer-use/bundle.js +1 -3
  47. package/dist/routes/computer-use/fanout-records.js +10 -11
  48. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  49. package/dist/routes/computer-use/live-phase.js +0 -1
  50. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  51. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  52. package/dist/routes/computer-use/participant-runs.js +9 -5
  53. package/dist/routes/computer-use/plan.d.ts +3 -0
  54. package/dist/routes/computer-use/plan.js +14 -11
  55. package/dist/routes/computer-use/rerun-selection.js +2 -2
  56. package/dist/routes/computer-use/result.js +3 -6
  57. package/dist/routes/computer-use/setup.d.ts +4 -6
  58. package/dist/routes/computer-use/setup.js +2 -6
  59. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  60. package/dist/routes/computer-use/single-bundle.js +22 -25
  61. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  62. package/dist/routes/computer-use/subject-projection.js +9 -31
  63. package/dist/routes/computer-use/types.d.ts +4 -41
  64. package/dist/routes/scripted/scenario.js +28 -12
  65. package/dist/routes/scripted/session-result.js +6 -3
  66. package/dist/routes/shared-world/bundle-records.js +2 -8
  67. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  68. package/dist/routes/shared-world/participant-specs.js +1 -20
  69. package/dist/routes/shared-world/plan.d.ts +3 -0
  70. package/dist/routes/shared-world/plan.js +23 -3
  71. package/dist/routes/shared-world/setup.js +2 -2
  72. package/dist/routes/terminal/ledger.d.ts +9 -9
  73. package/dist/routes/terminal/ledger.js +63 -40
  74. package/dist/routes/terminal/live-finish.js +7 -10
  75. package/dist/routes/terminal/plan.d.ts +3 -0
  76. package/dist/routes/terminal/plan.js +8 -5
  77. package/dist/routes/terminal/token-usage.d.ts +1 -2
  78. package/dist/routes/terminal/token-usage.js +4 -6
  79. package/dist/routes/terminal/types.d.ts +9 -1
  80. package/dist/run/bundle-shape.js +6 -1
  81. package/dist/run/bundle.d.ts +5 -3
  82. package/dist/run/contained-output.d.ts +53 -5
  83. package/dist/run/contained-output.js +141 -42
  84. package/dist/run/detail.d.ts +2 -1
  85. package/dist/run/detail.js +12 -7
  86. package/dist/run/escape-sequences.d.ts +11 -0
  87. package/dist/run/escape-sequences.js +93 -0
  88. package/dist/run/evidence-files.d.ts +8 -8
  89. package/dist/run/evidence-files.js +19 -23
  90. package/dist/run/locate.d.ts +32 -3
  91. package/dist/run/locate.js +45 -40
  92. package/dist/run/notes.d.ts +0 -8
  93. package/dist/run/notes.js +5 -51
  94. package/dist/run/outcomes.d.ts +24 -0
  95. package/dist/run/outcomes.js +19 -0
  96. package/dist/run/paths.d.ts +2 -0
  97. package/dist/run/paths.js +5 -1
  98. package/dist/run/preflight-receipts.js +13 -8
  99. package/dist/run/project.js +7 -4
  100. package/dist/run/reclaim-outcomes.d.ts +8 -6
  101. package/dist/run/reclaim-outcomes.js +10 -6
  102. package/dist/run/reclaim.d.ts +5 -0
  103. package/dist/run/reclaim.js +43 -16
  104. package/dist/run/run-clock.d.ts +50 -0
  105. package/dist/run/run-clock.js +66 -0
  106. package/dist/run/run.js +5 -2
  107. package/dist/run/sandbox-ids.d.ts +7 -2
  108. package/dist/run/sandbox-ids.js +27 -11
  109. package/dist/run/secrets.d.ts +2 -1
  110. package/dist/run/secrets.js +18 -22
  111. package/dist/run/stored-runs.js +14 -7
  112. package/dist/run/transient-comms-secrets.js +6 -17
  113. package/dist/run-study.js +22 -13
  114. package/dist/study/adapter-scorer-loader.js +6 -4
  115. package/dist/study/discover.js +9 -6
  116. package/dist/study/init-paths.js +7 -5
  117. package/dist/study/persona-resolve.d.ts +2 -2
  118. package/dist/study/persona-resolve.js +9 -5
  119. package/dist/study/plan-types.d.ts +2 -2
  120. package/dist/study/plan.d.ts +5 -3
  121. package/dist/study/plan.js +18 -9
  122. package/dist/study/preflight-probes.js +11 -3
  123. package/dist/study/preflight.js +9 -5
  124. package/dist/study/summary.d.ts +8 -2
  125. package/dist/study/summary.js +20 -22
  126. package/dist/study/warnings.d.ts +19 -12
  127. package/dist/study/warnings.js +38 -17
  128. package/dist/substrates/e2b/connection.d.ts +12 -0
  129. package/dist/substrates/e2b/connection.js +35 -0
  130. package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
  131. package/dist/substrates/e2b/desktop-executor.js +42 -9
  132. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  133. package/dist/substrates/e2b/lifetime.js +41 -4
  134. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  135. package/dist/substrates/e2b/sandbox.js +13 -18
  136. package/dist/substrates/e2b/sdk.d.ts +20 -9
  137. package/dist/substrates/local/capacity.d.ts +18 -2
  138. package/dist/substrates/local/capacity.js +21 -2
  139. package/dist/substrates/local/runtime-host.js +21 -14
  140. package/dist/substrates/local/runtime.js +3 -11
  141. package/dist/tui/contract.d.ts +30 -5
  142. package/dist/tui/contract.js +4 -1
  143. package/dist/tui-app.js +149 -147
  144. package/dist/verify/artifacts.d.ts +9 -2
  145. package/dist/verify/artifacts.js +37 -13
  146. package/dist/verify/liveness.js +19 -5
  147. package/dist/verify/verify.js +54 -27
  148. package/docs/architecture/guest-desktop.md +7 -2
  149. package/docs/architecture/state-driven-executor.md +4 -2
  150. package/docs/contracts/run-bundle.md +18 -10
  151. package/docs/contracts/schemas.md +27 -18
  152. package/docs/contracts/study-analysis.md +10 -1
  153. package/docs/product/automatic-analysis.md +7 -1
  154. package/package.json +2 -2
@@ -2,11 +2,12 @@ import { constants } from "node:fs";
2
2
  import { lstat, open, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { isPathInside, validatePreparedRunRootIdentity } from "./paths.js";
5
- import { assertPreparedSelectedOutputDirectory, } from "./contained-output.js";
5
+ import { assertPreparedSelectedOutputDirectory, isSafeRelativeFilePath, readOpenedAtMost, } from "./contained-output.js";
6
6
  import { isNodeError } from "./type-guards.js";
7
7
  // Reading a file from a retained run directory for analysis: the path must be a plain relative path
8
8
  // inside the run, the file a single-link regular file that stays inside it, and the read bounded.
9
- // Evidence capture, analysis jobs, the analysis store, cost reading and export all read through here.
9
+ // Evidence capture, analysis jobs, the analysis store, cost reading, verify and export all read
10
+ // through here.
10
11
  /**
11
12
  * Analysis inputs are retained local artifacts, never URLs or caller-selected outputs. The path is
12
13
  * percent-decoded up to five times, and each round must still be a plain relative path, so an
@@ -87,29 +88,24 @@ export async function pathMissing(filePath) {
87
88
  throw error;
88
89
  }
89
90
  }
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
- }
109
91
  const unavailable = { state: "unavailable" };
110
- /** Size refusals are distinguished only after the same contained regular-file checks. */
92
+ /** readUnchangedFile for an analysis input, whose path must also be an evidence path. */
111
93
  export async function readBoundedFileResult(root, relativePath, maxBytes) {
112
- if (!isEvidencePath(relativePath) || !Number.isSafeInteger(maxBytes) || maxBytes < 1)
94
+ return isEvidencePath(relativePath)
95
+ ? readUnchangedFile(root, relativePath, maxBytes)
96
+ : unavailable;
97
+ }
98
+ /**
99
+ * At most `maxBytes` of a contained single-link regular file that stays the same file, with the
100
+ * same size and times, from the first check to the last. Size refusals are distinguished only after
101
+ * the same checks. `relativePath` uses `/`, and any name a contained read accepts
102
+ * (isSafeRelativeFilePath) is read: verify and export read the names they always did.
103
+ */
104
+ export async function readUnchangedFile(root, relativePath, maxBytes) {
105
+ if (!isSafeRelativeFilePath(relativePath) ||
106
+ relativePath.includes("\\") ||
107
+ !Number.isSafeInteger(maxBytes) ||
108
+ maxBytes < 1)
113
109
  return unavailable;
114
110
  const validateRoot = async () => {
115
111
  if ("physicalRunRoot" in root) {
@@ -1,14 +1,43 @@
1
1
  import { type PreparedRunArtifactPaths } from "./paths.js";
2
- import { type PreparedSelectedOutputDirectory } from "./contained-output.js";
2
+ import { type ContainedRefusal, type PreparedSelectedOutputDirectory } from "./contained-output.js";
3
3
  import type { RunPointer } from "./results.js";
4
4
  import { type RunBundle } from "./bundle.js";
5
+ import { type BoundedFileResult } from "./evidence-files.js";
5
6
  /** The run input that resolves through latest.json rather than naming a run directory. */
6
7
  export declare const LATEST_RUN_ALIAS = "latest";
7
8
  /** Resolve "latest" or an explicit run id to its prepared artifact paths. */
8
9
  export declare function resolveRunPath(cwd: string, runInput: string): Promise<PreparedRunArtifactPaths | null>;
9
10
  export declare function readLatest(runsRoot: PreparedSelectedOutputDirectory): Promise<RunPointer | null>;
10
- export declare function readRunJsonIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise<unknown>;
11
- export declare function readRunTextIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise<string | null>;
11
+ /** A run file as text: its text, nothing at the path, or why it was refused. */
12
+ export type RunTextRead = {
13
+ status: "read";
14
+ text: string;
15
+ } | {
16
+ status: "missing";
17
+ } | ContainedRefusal;
18
+ /** A run file as JSON. `value` is undefined when the file is there but is not valid JSON. */
19
+ export type RunJsonRead = {
20
+ status: "read";
21
+ value: unknown;
22
+ } | {
23
+ status: "missing";
24
+ } | ContainedRefusal;
25
+ /**
26
+ * The JSON, or null for a file that is missing, refused or not valid JSON: for a caller that
27
+ * treats the three alike, as every reader did before a refusal had its own state.
28
+ */
29
+ export declare function runJsonValue(read: RunJsonRead): unknown;
30
+ /** A run file as JSON, within RUN_ARTIFACT_MAX_BYTES. */
31
+ export declare function readRunJsonIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise<RunJsonRead>;
32
+ /** A run file as text, within RUN_ARTIFACT_MAX_BYTES. */
33
+ export declare function readRunTextIfExists(runPaths: PreparedRunArtifactPaths, ...segments: string[]): Promise<RunTextRead>;
34
+ /**
35
+ * A run file, read whole through readUnchangedFile: `limit` when it holds more than
36
+ * RUN_ARTIFACT_MAX_BYTES, `unavailable` when it is missing, unsafe, or changes while it is read.
37
+ * A `\` in the path reads as `/`.
38
+ */
39
+ export declare function readSafeRunArtifact(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<BoundedFileResult>;
40
+ /** readSafeRunArtifact's bytes, or null for any file it does not read. */
12
41
  export declare function readSafeRunArtifactBytes(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<Buffer | null>;
13
42
  export declare function readSafeRunArtifactJson(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<unknown>;
14
43
  export declare function loadRunBundle(cwdInput: string, runInput: string): Promise<{
@@ -1,10 +1,9 @@
1
- import { lstat } from "node:fs/promises";
2
1
  import path from "node:path";
3
- import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveExistingRunDirectory, resolveLatestRunDirectory, validatePreparedRunArtifactPaths, } from "./paths.js";
4
- import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, readContainedRegularFile, } from "./contained-output.js";
2
+ import { bindExistingRunArtifactPaths, isSafeRunIdSegment, resolveExistingRunDirectory, resolveLatestRunDirectory, RUNS_RELATIVE_ROOT, validatePreparedRunArtifactPaths, } from "./paths.js";
3
+ import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutputDirectory, ContainedReadRefusedError, LATEST_POINTER_MAX_BYTES, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
5
4
  import { RUN_BUNDLE_FILE } from "./bundle.js";
6
5
  import { isRunBundle, isRunPointer } from "./bundle-shape.js";
7
- import { isNodeError } from "./type-guards.js";
6
+ import { readUnchangedFile } from "./evidence-files.js";
8
7
  /** The run input that resolves through latest.json rather than naming a run directory. */
9
8
  export const LATEST_RUN_ALIAS = "latest";
10
9
  /** Resolve "latest" or an explicit run id to its prepared artifact paths. */
@@ -33,59 +32,61 @@ export async function resolveRunPath(cwd, runInput) {
33
32
  return bindExistingRunArtifactPaths(cwd, runInput);
34
33
  }
35
34
  export async function readLatest(runsRoot) {
36
- const latestPath = path.join(runsRoot.physicalPath, "latest.json");
37
- let latestStats;
38
- try {
39
- latestStats = await lstat(latestPath, { bigint: true });
40
- }
41
- catch (error) {
42
- if (isNodeError(error) && error.code === "ENOENT") {
43
- return null;
44
- }
45
- throw error;
46
- }
47
- if (latestStats.isSymbolicLink() || !latestStats.isFile() || latestStats.nlink !== 1n) {
48
- throw new Error("Latest run pointer must be a single-link regular file.");
49
- }
50
- const bytes = await readContainedRegularFile(runsRoot, "latest.json");
51
- if (!bytes) {
52
- throw new Error("Latest run pointer changed while it was being read.");
35
+ const read = await readContainedRegularFile(runsRoot, "latest.json", LATEST_POINTER_MAX_BYTES);
36
+ if (read.status === "missing")
37
+ return null;
38
+ if (read.status === "refused") {
39
+ if (read.reason === "too-large")
40
+ throw new ContainedReadRefusedError(path.join(RUNS_RELATIVE_ROOT, "latest.json"), read);
41
+ throw new Error(read.reason === "not-regular" || read.reason === "directory"
42
+ ? "Latest run pointer must be a single-link regular file."
43
+ : "Latest run pointer changed while it was being read.");
53
44
  }
54
45
  let latest;
55
46
  try {
56
- latest = JSON.parse(bytes.toString("utf8"));
47
+ latest = JSON.parse(read.bytes.toString("utf8"));
57
48
  }
58
49
  catch {
59
50
  return null;
60
51
  }
61
52
  return isRunPointer(latest) ? latest : null;
62
53
  }
54
+ /**
55
+ * The JSON, or null for a file that is missing, refused or not valid JSON: for a caller that
56
+ * treats the three alike, as every reader did before a refusal had its own state.
57
+ */
58
+ export function runJsonValue(read) {
59
+ return read.status === "read" ? (read.value ?? null) : null;
60
+ }
61
+ /** A run file as JSON, within RUN_ARTIFACT_MAX_BYTES. */
63
62
  export async function readRunJsonIfExists(runPaths, ...segments) {
64
- const text = await readRunTextIfExists(runPaths, ...segments);
65
- if (text === null) {
66
- return null;
67
- }
63
+ const read = await readRunTextIfExists(runPaths, ...segments);
64
+ if (read.status !== "read")
65
+ return read;
68
66
  try {
69
- return JSON.parse(text);
67
+ return { status: "read", value: JSON.parse(read.text) };
70
68
  }
71
69
  catch {
72
- return null;
70
+ return { status: "read", value: undefined };
73
71
  }
74
72
  }
73
+ /** A run file as text, within RUN_ARTIFACT_MAX_BYTES. */
75
74
  export async function readRunTextIfExists(runPaths, ...segments) {
76
- const bytes = await readContainedRegularFile(runPaths, segments.join("/"));
77
- return bytes?.toString("utf8") ?? null;
75
+ const read = await readContainedRegularFile(runPaths, segments.join("/"), RUN_ARTIFACT_MAX_BYTES);
76
+ return read.status === "read" ? { status: "read", text: read.bytes.toString("utf8") } : read;
78
77
  }
78
+ /**
79
+ * A run file, read whole through readUnchangedFile: `limit` when it holds more than
80
+ * RUN_ARTIFACT_MAX_BYTES, `unavailable` when it is missing, unsafe, or changes while it is read.
81
+ * A `\` in the path reads as `/`.
82
+ */
83
+ export async function readSafeRunArtifact(runPaths, relativePath) {
84
+ return readUnchangedFile(runPaths, relativePath.replace(/\\/g, "/"), RUN_ARTIFACT_MAX_BYTES);
85
+ }
86
+ /** readSafeRunArtifact's bytes, or null for any file it does not read. */
79
87
  export async function readSafeRunArtifactBytes(runPaths, relativePath) {
80
- const normalized = relativePath.replace(/\\/g, "/");
81
- const segments = normalized.split("/");
82
- if (path.isAbsolute(relativePath) ||
83
- path.win32.isAbsolute(relativePath) ||
84
- segments.length === 0 ||
85
- segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
86
- return null;
87
- }
88
- return readContainedRegularFile(runPaths, normalized);
88
+ const read = await readSafeRunArtifact(runPaths, relativePath);
89
+ return read.state === "read" ? read.bytes : null;
89
90
  }
90
91
  export async function readSafeRunArtifactJson(runPaths, relativePath) {
91
92
  const bytes = await readSafeRunArtifactBytes(runPaths, relativePath);
@@ -112,7 +113,11 @@ export async function loadRunBundlePrepared(cwdInput, runPaths) {
112
113
  const cwd = path.resolve(cwdInput);
113
114
  await validatePreparedRunArtifactPaths(runPaths);
114
115
  const bundlePath = path.join(runPaths.absoluteRunRoot, RUN_BUNDLE_FILE);
115
- const bundle = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
116
+ const read = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
117
+ // A run.json it refuses is no reason to call the run absent: the caller reports why.
118
+ if (read.status === "refused")
119
+ throw new ContainedReadRefusedError(RUN_BUNDLE_FILE, read);
120
+ const bundle = read.status === "read" ? read.value : null;
116
121
  if (!isRunBundle(bundle)) {
117
122
  return null;
118
123
  }
@@ -1,4 +1,3 @@
1
- import type { RunBundle } from "./bundle.js";
2
1
  import { type RunNote } from "./note-shape.js";
3
2
  import { type PreparedRunArtifactPaths } from "./paths.js";
4
3
  export interface RunNoteInput {
@@ -20,13 +19,6 @@ export type AddRunNoteResult =
20
19
  message: string;
21
20
  };
22
21
  };
23
- /** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
24
- export declare function formatRunTime(ms: number): string;
25
- /** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
26
- export declare function runClock(bundle: RunBundle): {
27
- startMs: number;
28
- endMs: number;
29
- } | null;
30
22
  /**
31
23
  * Adds a note as a new file, notes/<id>.json, created under a name nothing holds. It reads no other
32
24
  * note and replaces nothing, so notes added at the same time, from any process, are all kept.
package/dist/run/notes.js CHANGED
@@ -1,6 +1,6 @@
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
4
  import { cli } from "../cli/invocation.js";
5
5
  import { redactText } from "../evidence/redaction.js";
6
6
  import { createContainedOutputFile } from "./contained-output.js";
@@ -9,6 +9,7 @@ import { countRunNotes, encodeRunNote, newRunNoteId, runNoteFile } from "./note-
9
9
  import { MAX_NOTE_TEXT, MAX_RUN_NOTES } from "./note-shape.js";
10
10
  import { runParticipantCaptions, streamParticipantIdOf } from "./participant-records.js";
11
11
  import { physicalCwdOf, runIdOf } from "./paths.js";
12
+ import { formatRunTime, runClock, traceItems } from "./run-clock.js";
12
13
  import { transientCommsKnownValueScrub } from "./transient-comms-secrets.js";
13
14
  import { isNodeError } from "./type-guards.js";
14
15
  const DEFAULT_AUTHOR = "you";
@@ -38,59 +39,12 @@ function participantStreamId(bundle, named) {
38
39
  const known = bundle.streams.map((stream) => `${captions.get(stream.id)} (${streamParticipantIdOf(stream) ?? stream.id})`);
39
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.`);
40
41
  }
41
- /** A run clock time as the Observer shows it: whole minutes and seconds, `02:31`. */
42
- export function formatRunTime(ms) {
43
- const seconds = Math.max(0, Math.floor(ms / 1000));
44
- return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
45
- }
46
- /** The run clock: the first and last timed capture or desktop video moment, in epoch ms. */
47
- export function runClock(bundle) {
48
- const moments = bundle.streams.flatMap((stream) => [
49
- ...(captureTimes(stream) ?? []),
50
- ...recordingMoments(stream),
51
- ]);
52
- if (moments.length === 0)
53
- return null;
54
- // A loop, since spreading a long recording's capture times into Math.min can overflow the stack.
55
- let startMs = Infinity;
56
- let endMs = -Infinity;
57
- for (const moment of moments) {
58
- startMs = Math.min(startMs, moment);
59
- endMs = Math.max(endMs, moment);
60
- }
61
- return { startMs, endMs };
62
- }
63
- function traceItems(stream) {
64
- return stream.actor?.items ?? stream.liveActor?.items ?? [];
65
- }
66
- const stamp = (item) => item.at === undefined ? Number.NaN : Date.parse(item.at);
67
- /**
68
- * A participant's capture times, as the Observer's study clock reads them: only when every capture
69
- * is stamped and the stamps never go back. Otherwise the participant adds nothing to the clock.
70
- */
71
- function captureTimes(stream) {
72
- const times = traceItems(stream)
73
- .filter((item) => (item.kind === "screenshot" || item.kind === "ui_action") && item.screenshotRef)
74
- .map(stamp);
75
- const ordered = times.every((time, index) => Number.isFinite(time) && (index === 0 || time >= times[index - 1]));
76
- return ordered ? times : null;
77
- }
78
- function recordingMoments(stream) {
79
- const recording = stream.recording;
80
- if (!recording)
81
- return [];
82
- const startMs = Date.parse(recording.startedAt);
83
- const endMs = startMs + recording.durationMs;
84
- return Number.isFinite(startMs) && Number.isFinite(endMs) && recording.durationMs > 0
85
- ? [startMs, endMs]
86
- : [];
87
- }
88
42
  /** The latest stamped trace item at or before `moment`, among the given participants. */
89
43
  function nearestItem(streams, moment) {
90
44
  let best = null;
91
45
  for (const stream of streams)
92
46
  for (const item of traceItems(stream)) {
93
- const time = stamp(item);
47
+ const time = item.at === undefined ? Number.NaN : Date.parse(item.at);
94
48
  if (Number.isFinite(time) && time <= moment && (best === null || time >= best.time))
95
49
  best = { participant: stream.id, itemId: item.id, time };
96
50
  }
@@ -143,7 +97,7 @@ export async function addRunNote(prepared, input) {
143
97
  if (!loaded)
144
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.`);
145
99
  const { bundle } = loaded;
146
- const clock = runClock(bundle);
100
+ const clock = runClock(bundle.streams);
147
101
  if (!clock)
148
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.`);
149
103
  const participant = input.participant === null ? null : participantStreamId(bundle, input.participant);
@@ -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)
@@ -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()}`;
@@ -2,7 +2,7 @@
2
2
  // (package.json, humanish/ personas and scenarios) without following links out of it.
3
3
  import { lstat, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, readContainedRegularFile, } from "./contained-output.js";
5
+ import { assertPreparedSelectedOutputDirectory, assertSafeOutputPathSegment, readContainedRegularFile, refusalText, PROJECT_FILE_MAX_BYTES, } from "./contained-output.js";
6
6
  import { isNodeError, isRecord } from "./type-guards.js";
7
7
  async function inspectImplicitProjectPath(projectRoot, relativePath) {
8
8
  const segments = relativePath.replace(/\\/g, "/").split("/");
@@ -61,11 +61,14 @@ export async function readImplicitProjectFile(projectRoot, relativePath) {
61
61
  if (!stats.isFile() || stats.nlink !== 1n) {
62
62
  throw new Error(`Implicit project file must be a single-link regular file: ${relativePath}`);
63
63
  }
64
- const bytes = await readContainedRegularFile(projectRoot, relativePath.replace(/\\/g, "/"));
65
- if (!bytes) {
64
+ const read = await readContainedRegularFile(projectRoot, relativePath.replace(/\\/g, "/"), PROJECT_FILE_MAX_BYTES);
65
+ if (read.status === "refused" && read.reason === "too-large")
66
+ throw new Error(`Implicit project file ${refusalText(relativePath, read)}.`);
67
+ // Gone or swapped since it was inspected above.
68
+ if (read.status !== "read") {
66
69
  throw new Error(`Implicit project file changed while it was being read: ${relativePath}`);
67
70
  }
68
- return bytes.toString("utf8");
71
+ return read.bytes.toString("utf8");
69
72
  }
70
73
  export async function readPackageName(projectRoot) {
71
74
  const text = await readImplicitProjectFile(projectRoot, "package.json");
@@ -1,6 +1,6 @@
1
1
  import { type E2BTagSearch } from "../substrates/e2b/sandbox.js";
2
- import type { E2BDesktopModule } from "../substrates/e2b/sdk.js";
3
- import { type PreparedOutputRoot } from "./contained-output.js";
2
+ import type { E2BAccount } from "../substrates/e2b/connection.js";
3
+ import { type ContainedRefusal, type PreparedOutputRoot } from "./contained-output.js";
4
4
  export declare const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
5
5
  export declare const RECLAIM_RECEIPT_ARTIFACT = "reclaim-receipt.json";
6
6
  /**
@@ -50,7 +50,7 @@ export interface Target {
50
50
  * kills leaves a record of each one not yet confirmed gone.
51
51
  */
52
52
  export declare function targetSet(options: {
53
- e2b: E2BDesktopModule | undefined;
53
+ e2b: E2BAccount | undefined;
54
54
  check: boolean;
55
55
  requestTimeoutMs: number;
56
56
  earlier: ReclaimOutcome[];
@@ -66,16 +66,18 @@ export declare function targetSet(options: {
66
66
  finish(outcomes: readonly ReclaimOutcome[], state: ReclaimState): Promise<void>;
67
67
  };
68
68
  /** Search E2B by each distinct tag set; done only when every search finished. */
69
- export declare function searchTagSets(e2b: E2BDesktopModule | undefined, tagSets: readonly Record<string, string>[], options: {
69
+ export declare function searchTagSets(e2b: E2BAccount | undefined, tagSets: readonly Record<string, string>[], options: {
70
70
  requestTimeoutMs: number;
71
71
  unavailable: string;
72
72
  }): Promise<E2BTagSearch>;
73
73
  export declare const GONE: Set<ReclaimOutcomeState>;
74
74
  /**
75
75
  * The outcomes an earlier reclaim of this directory recorded, by digest. A receipt written before
76
- * 0.110 names raw ids, which are digested; an unreadable or foreign receipt carries nothing.
76
+ * 0.110 names raw ids, which are digested; a missing, malformed or foreign receipt carries
77
+ * nothing. A receipt that is there and refused is returned as the refusal, so reclaim stops
78
+ * before its own receipt replaces one it could not read.
77
79
  */
78
- export declare function earlierOutcomes(root: PreparedOutputRoot, label: string): Promise<ReclaimOutcome[]>;
80
+ export declare function earlierOutcomes(root: PreparedOutputRoot, label: string): Promise<ReclaimOutcome[] | ContainedRefusal>;
79
81
  export declare function rootState(outcomes: readonly ReclaimOutcome[], check: boolean, searched: boolean): ReclaimState;
80
82
  /** Resolves when `settled` does, or after `ms` if given, whichever is first. */
81
83
  export declare function settledWithin(settled: Promise<void>, ms: number | undefined): Promise<void>;
@@ -4,7 +4,7 @@
4
4
  // receipt writer. src/run/reclaim.ts decides what to find; this module records what happened.
5
5
  import { REDACTED_SANDBOX_ID, redactText, sandboxIdDigest, toErrorMessage, } from "../evidence/redaction.js";
6
6
  import { destroyE2BSandbox, findE2BSandboxesByTags, inspectE2BSandbox, } from "../substrates/e2b/sandbox.js";
7
- import { readContainedRegularFile, writeContainedOutputFile, } from "./contained-output.js";
7
+ import { readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, writeContainedOutputFile, } from "./contained-output.js";
8
8
  import { scrubSandboxIds } from "./sandbox-ids.js";
9
9
  import { isRecord } from "./type-guards.js";
10
10
  export const RECLAIM_RESULT_SCHEMA = "humanish.reclaim-result.v1";
@@ -122,15 +122,19 @@ const OUTCOME_STATES = new Set([
122
122
  ]);
123
123
  /**
124
124
  * The outcomes an earlier reclaim of this directory recorded, by digest. A receipt written before
125
- * 0.110 names raw ids, which are digested; an unreadable or foreign receipt carries nothing.
125
+ * 0.110 names raw ids, which are digested; a missing, malformed or foreign receipt carries
126
+ * nothing. A receipt that is there and refused is returned as the refusal, so reclaim stops
127
+ * before its own receipt replaces one it could not read.
126
128
  */
127
129
  export async function earlierOutcomes(root, label) {
130
+ const read = await readContainedRegularFile(root, RECLAIM_RECEIPT_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
131
+ if (read.status === "refused")
132
+ return read;
133
+ if (read.status === "missing")
134
+ return [];
128
135
  let parsed;
129
136
  try {
130
- const bytes = await readContainedRegularFile(root, RECLAIM_RECEIPT_ARTIFACT);
131
- if (bytes === null)
132
- return [];
133
- parsed = JSON.parse(bytes.toString("utf8"));
137
+ parsed = JSON.parse(read.bytes.toString("utf8"));
134
138
  }
135
139
  catch {
136
140
  return [];
@@ -33,6 +33,11 @@ export interface ReclaimResult {
33
33
  export interface ReclaimHooks {
34
34
  /** Tests inject a fake @e2b/desktop module; default loads the real one. */
35
35
  loadModule?: () => Promise<E2BDesktopModule>;
36
+ /**
37
+ * The env whose E2B_API_KEY and E2B_DOMAIN every E2B call carries. Defaults to process.env, where
38
+ * the CLI put the run's key before the run started.
39
+ */
40
+ env?: Readonly<Record<string, string | undefined>>;
36
41
  requestTimeoutMs?: number;
37
42
  /** Ask E2B whether each sandbox still exists, and kill nothing. */
38
43
  check?: boolean;