humanish 0.115.0 → 0.117.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 (195) hide show
  1. package/CHANGELOG.md +41 -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/codex/restricted-participant-policy.d.ts +0 -2
  6. package/dist/actors/codex/restricted-participant-policy.js +4 -32
  7. package/dist/actors/codex/restricted-participant.d.ts +5 -0
  8. package/dist/actors/codex/restricted-participant.js +3 -2
  9. package/dist/actors/computer-use/actor.d.ts +4 -0
  10. package/dist/actors/computer-use/actor.js +2 -0
  11. package/dist/actors/computer-use/loop/actions.d.ts +4 -4
  12. package/dist/actors/computer-use/loop/actions.js +25 -18
  13. package/dist/actors/computer-use/loop/debrief.js +61 -27
  14. package/dist/actors/computer-use/loop/session.d.ts +3 -0
  15. package/dist/actors/computer-use/loop/session.js +9 -1
  16. package/dist/actors/computer-use/loop/trace.js +1 -0
  17. package/dist/actors/computer-use/loop/types.d.ts +16 -14
  18. package/dist/actors/computer-use/loop.d.ts +1 -1
  19. package/dist/actors/computer-use/loop.js +1 -1
  20. package/dist/actors/computer-use/wait.d.ts +74 -0
  21. package/dist/actors/computer-use/wait.js +100 -0
  22. package/dist/actors/contract.d.ts +14 -0
  23. package/dist/actors/scripted-browser/steps.js +6 -5
  24. package/dist/analysis/analysis-limits.d.ts +35 -0
  25. package/dist/analysis/analysis-limits.js +41 -0
  26. package/dist/analysis/validation.js +14 -21
  27. package/dist/browser-control/protocol.d.ts +86 -6
  28. package/dist/browser-control/protocol.js +13 -6
  29. package/dist/cli/commands/analyze.js +2 -1
  30. package/dist/cli/commands/notes.js +2 -1
  31. package/dist/cli/commands/run-signals.js +5 -1
  32. package/dist/cli/commands/runs.js +2 -1
  33. package/dist/cli/commands/setup.js +3 -3
  34. package/dist/cli/commands/study-format.d.ts +5 -2
  35. package/dist/cli/commands/study-format.js +29 -9
  36. package/dist/cli/commands/study-route-computer-use.js +2 -2
  37. package/dist/cli/commands/study-route-scripted.js +1 -1
  38. package/dist/cli/commands/tui.js +59 -17
  39. package/dist/cli/doctor.js +9 -2
  40. package/dist/cli/findings-text.d.ts +5 -0
  41. package/dist/cli/findings-text.js +169 -0
  42. package/dist/cli/findings.d.ts +0 -4
  43. package/dist/cli/findings.js +5 -167
  44. package/dist/cli/io.js +6 -1
  45. package/dist/cli/update-check.js +4 -1
  46. package/dist/comms/connections.js +4 -3
  47. package/dist/comms/setup.js +18 -12
  48. package/dist/evidence/redaction.js +9 -1
  49. package/dist/evidence/secret-scrub.d.ts +15 -5
  50. package/dist/evidence/secret-scrub.js +67 -9
  51. package/dist/feedback/draft.js +1 -1
  52. package/dist/feedback/export-bundle.js +18 -14
  53. package/dist/feedback/feedback.js +25 -7
  54. package/dist/guest/desktop-executor.js +3 -1
  55. package/dist/guest/desktop-keys.js +59 -5
  56. package/dist/keys/key-resolution.d.ts +9 -0
  57. package/dist/keys/key-resolution.js +60 -48
  58. package/dist/keys/key-status.d.ts +2 -0
  59. package/dist/keys/key-status.js +9 -16
  60. package/dist/observer/pinned-files.js +1 -1
  61. package/dist/observer/render.js +3 -2
  62. package/dist/observer-app.html +5 -5
  63. package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
  64. package/dist/routes/computer-use/bundle-parts.js +2 -2
  65. package/dist/routes/computer-use/bundle.d.ts +4 -5
  66. package/dist/routes/computer-use/bundle.js +1 -3
  67. package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
  68. package/dist/routes/computer-use/fanout-records.js +10 -11
  69. package/dist/routes/computer-use/live-phase.d.ts +1 -1
  70. package/dist/routes/computer-use/live-phase.js +0 -1
  71. package/dist/routes/computer-use/local-vm.js +5 -2
  72. package/dist/routes/computer-use/participant-execution.d.ts +3 -2
  73. package/dist/routes/computer-use/participant-model.js +9 -0
  74. package/dist/routes/computer-use/participant-runs.d.ts +6 -2
  75. package/dist/routes/computer-use/participant-runs.js +9 -5
  76. package/dist/routes/computer-use/plan.d.ts +3 -0
  77. package/dist/routes/computer-use/plan.js +18 -13
  78. package/dist/routes/computer-use/rerun-selection.js +2 -2
  79. package/dist/routes/computer-use/result.js +3 -6
  80. package/dist/routes/computer-use/setup.d.ts +4 -6
  81. package/dist/routes/computer-use/setup.js +2 -6
  82. package/dist/routes/computer-use/single-bundle.d.ts +4 -5
  83. package/dist/routes/computer-use/single-bundle.js +22 -25
  84. package/dist/routes/computer-use/subject-projection.d.ts +4 -8
  85. package/dist/routes/computer-use/subject-projection.js +9 -31
  86. package/dist/routes/computer-use/types.d.ts +5 -42
  87. package/dist/routes/scripted/scenario.js +28 -12
  88. package/dist/routes/scripted/session-result.js +6 -3
  89. package/dist/routes/shared-world/bundle-records.js +2 -8
  90. package/dist/routes/shared-world/participant-specs.d.ts +0 -2
  91. package/dist/routes/shared-world/participant-specs.js +1 -20
  92. package/dist/routes/shared-world/plan.d.ts +3 -0
  93. package/dist/routes/shared-world/plan.js +25 -4
  94. package/dist/routes/shared-world/setup.js +2 -2
  95. package/dist/routes/terminal/ledger.d.ts +9 -9
  96. package/dist/routes/terminal/ledger.js +63 -40
  97. package/dist/routes/terminal/live-finish.js +7 -10
  98. package/dist/routes/terminal/live-sandbox.d.ts +1 -1
  99. package/dist/routes/terminal/live-sandbox.js +14 -11
  100. package/dist/routes/terminal/plan.d.ts +3 -0
  101. package/dist/routes/terminal/plan.js +8 -5
  102. package/dist/routes/terminal/sandbox.d.ts +4 -3
  103. package/dist/routes/terminal/sandbox.js +5 -5
  104. package/dist/routes/terminal/token-usage.d.ts +1 -2
  105. package/dist/routes/terminal/token-usage.js +4 -6
  106. package/dist/routes/terminal/types.d.ts +9 -1
  107. package/dist/run/bundle-shape.js +6 -1
  108. package/dist/run/bundle.d.ts +5 -3
  109. package/dist/run/contained-output.d.ts +53 -5
  110. package/dist/run/contained-output.js +141 -42
  111. package/dist/run/detail.d.ts +2 -1
  112. package/dist/run/detail.js +12 -7
  113. package/dist/run/escape-sequences.d.ts +11 -0
  114. package/dist/run/escape-sequences.js +93 -0
  115. package/dist/run/evidence-files.d.ts +8 -8
  116. package/dist/run/evidence-files.js +19 -23
  117. package/dist/run/locate.d.ts +32 -3
  118. package/dist/run/locate.js +45 -40
  119. package/dist/run/notes.d.ts +0 -8
  120. package/dist/run/notes.js +5 -51
  121. package/dist/run/outcomes.d.ts +24 -0
  122. package/dist/run/outcomes.js +19 -0
  123. package/dist/run/paths.d.ts +2 -0
  124. package/dist/run/paths.js +5 -1
  125. package/dist/run/preflight-receipts.js +13 -8
  126. package/dist/run/project.js +7 -4
  127. package/dist/run/reclaim-outcomes.d.ts +8 -6
  128. package/dist/run/reclaim-outcomes.js +10 -6
  129. package/dist/run/reclaim.d.ts +5 -0
  130. package/dist/run/reclaim.js +43 -16
  131. package/dist/run/run-clock.d.ts +50 -0
  132. package/dist/run/run-clock.js +66 -0
  133. package/dist/run/run.js +5 -2
  134. package/dist/run/sandbox-ids.d.ts +7 -2
  135. package/dist/run/sandbox-ids.js +27 -11
  136. package/dist/run/secrets.d.ts +2 -1
  137. package/dist/run/secrets.js +18 -22
  138. package/dist/run/stored-runs.js +14 -7
  139. package/dist/run/transient-comms-secrets.js +6 -17
  140. package/dist/run-study.js +22 -13
  141. package/dist/study/adapter-scorer-loader.js +6 -4
  142. package/dist/study/config.js +4 -1
  143. package/dist/study/discover.js +9 -6
  144. package/dist/study/init-paths.js +7 -5
  145. package/dist/study/keys.d.ts +2 -0
  146. package/dist/study/keys.js +2 -0
  147. package/dist/study/migrate/v2.js +2 -1
  148. package/dist/study/parse/actors.js +8 -0
  149. package/dist/study/persona-resolve.d.ts +2 -2
  150. package/dist/study/persona-resolve.js +9 -5
  151. package/dist/study/plan-participants.d.ts +4 -0
  152. package/dist/study/plan-participants.js +4 -0
  153. package/dist/study/plan-types.d.ts +2 -2
  154. package/dist/study/plan.d.ts +5 -3
  155. package/dist/study/plan.js +18 -9
  156. package/dist/study/preflight-probes.js +11 -3
  157. package/dist/study/preflight.js +9 -5
  158. package/dist/study/summary.d.ts +8 -2
  159. package/dist/study/summary.js +20 -22
  160. package/dist/study/types.d.ts +14 -0
  161. package/dist/study/validation.d.ts +5 -0
  162. package/dist/study/validation.js +8 -0
  163. package/dist/study/warnings.d.ts +19 -12
  164. package/dist/study/warnings.js +50 -17
  165. package/dist/subject/serve.d.ts +3 -0
  166. package/dist/subject/serve.js +4 -1
  167. package/dist/substrates/detached.js +14 -7
  168. package/dist/substrates/e2b/connection.d.ts +12 -0
  169. package/dist/substrates/e2b/connection.js +35 -0
  170. package/dist/substrates/e2b/desktop-executor.d.ts +1 -5
  171. package/dist/substrates/e2b/desktop-executor.js +45 -12
  172. package/dist/substrates/e2b/lifetime.d.ts +24 -4
  173. package/dist/substrates/e2b/lifetime.js +41 -4
  174. package/dist/substrates/e2b/sandbox.d.ts +4 -3
  175. package/dist/substrates/e2b/sandbox.js +13 -18
  176. package/dist/substrates/e2b/sdk.d.ts +20 -9
  177. package/dist/substrates/local/capacity.d.ts +18 -2
  178. package/dist/substrates/local/capacity.js +21 -2
  179. package/dist/substrates/local/runtime-host.js +21 -14
  180. package/dist/substrates/local/runtime.js +3 -11
  181. package/dist/tui/contract.d.ts +30 -5
  182. package/dist/tui/contract.js +4 -1
  183. package/dist/tui-app.js +150 -147
  184. package/dist/verify/artifacts.d.ts +9 -2
  185. package/dist/verify/artifacts.js +37 -13
  186. package/dist/verify/liveness.js +19 -5
  187. package/dist/verify/verify.js +54 -27
  188. package/docs/architecture/browser-control.md +10 -4
  189. package/docs/architecture/guest-desktop.md +7 -2
  190. package/docs/architecture/state-driven-executor.md +4 -2
  191. package/docs/contracts/run-bundle.md +18 -10
  192. package/docs/contracts/schemas.md +46 -29
  193. package/docs/contracts/study-analysis.md +10 -1
  194. package/docs/product/automatic-analysis.md +7 -1
  195. package/package.json +2 -2
@@ -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;
@@ -8,10 +8,11 @@
8
8
  // reclaim-receipt.json is written before the first kill and after each one, so an exit at any
9
9
  // point leaves a record of every sandbox not yet confirmed gone. `--check` asks E2B the same
10
10
  // questions and kills nothing.
11
+ import { e2bAccount, e2bConnection } from "../substrates/e2b/connection.js";
11
12
  import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
12
- import { containedPathAbsent, readContainedRegularFile, } from "./contained-output.js";
13
+ import { readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
13
14
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
14
- import { readRunJsonIfExists, resolveRunPath } from "./locate.js";
15
+ import { readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
15
16
  import { RUN_BUNDLE_FILE } from "./bundle.js";
16
17
  import { RUN_STATUS_FILE } from "./status.js";
17
18
  import { isRecord } from "./type-guards.js";
@@ -80,7 +81,7 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
80
81
  return {
81
82
  ...base,
82
83
  runId,
83
- error: { code: "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE", message: UNREADABLE_MESSAGE },
84
+ error: { code: "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE", message: reclaimed.message },
84
85
  };
85
86
  const { state, receiptCount, outcomes, tagSearch, createsInFlight } = reclaimed;
86
87
  return {
@@ -105,8 +106,14 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
105
106
  * whatever its records say.
106
107
  */
107
108
  async function recordedNoSandbox(runPaths) {
108
- const bundle = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
109
- const status = await readRunJsonIfExists(runPaths, RUN_STATUS_FILE);
109
+ const bundleRead = await readRunJsonIfExists(runPaths, RUN_BUNDLE_FILE);
110
+ const statusRead = await readRunJsonIfExists(runPaths, RUN_STATUS_FILE);
111
+ // A record that is there and refused could say anything, so neither shortcut applies and
112
+ // reclaim runs in full. A missing one says nothing, as before.
113
+ if (bundleRead.status === "refused" || statusRead.status === "refused")
114
+ return undefined;
115
+ const bundle = runJsonValue(bundleRead);
116
+ const status = runJsonValue(statusRead);
110
117
  const records = [bundle, status].filter(isRecord);
111
118
  const reason = records.length > 0 && records.every((record) => record.mode === "dry-run")
112
119
  ? "dry-run"
@@ -118,15 +125,18 @@ async function recordedNoSandbox(runPaths) {
118
125
  : undefined;
119
126
  if (reason === undefined)
120
127
  return undefined;
121
- if (!(await containedPathAbsent(runPaths, RECLAIM_RECEIPT_ARTIFACT))) {
122
- const earlier = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
128
+ const earlierRead = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
129
+ if (earlierRead.status !== "missing") {
130
+ const earlier = runJsonValue(earlierRead);
123
131
  if (!isRecord(earlier) || !Array.isArray(earlier.outcomes) || earlier.outcomes.length > 0)
124
132
  return undefined;
125
133
  }
126
- if (await containedPathAbsent(runPaths, SANDBOX_RECEIPTS_ARTIFACT))
134
+ const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
135
+ if (journal.status === "missing")
127
136
  return reason;
128
- const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT);
129
- return journal !== null && journal.toString("utf8").trim() === "" ? reason : undefined;
137
+ return journal.status === "read" && journal.bytes.toString("utf8").trim() === ""
138
+ ? reason
139
+ : undefined;
130
140
  }
131
141
  /** The execution failure kinds that leave a sandbox or a provider's resources unconfirmed. */
132
142
  const CLEANUP_KINDS = new Set(["sandbox-cleanup", "provider-cleanup"]);
@@ -190,7 +200,7 @@ export async function reclaimPreflightSandboxes(cwd, hooks = {}) {
190
200
  const reclaimed = await reclaimRoot(journal.root, journal.id, hooks, warnings);
191
201
  if (reclaimed.kind === "unreadable") {
192
202
  // The receipt may name a live sandbox; the journal stays for a later reclaim.
193
- warnings.push(`Preflight ${journal.id} left alone: ${UNREADABLE_MESSAGE}`);
203
+ warnings.push(`Preflight ${journal.id} left alone: ${reclaimed.message}`);
194
204
  states.push("unknown");
195
205
  continue;
196
206
  }
@@ -241,15 +251,28 @@ const E2B_DEBUG_REFUSAL = {
241
251
  message: `${E2B_DEBUG_ENV}=true puts the E2B SDK in debug mode, where Sandbox.kill reports success without contacting E2B. Nothing was killed and the receipts were kept; unset ${E2B_DEBUG_ENV} and run reclaim again.`,
242
252
  };
243
253
  const UNREADABLE_MESSAGE = "sandbox-receipts.ndjson is present but could not be read safely (not a single regular file, or a read error). Nothing was killed and the receipts were kept; check the file, then run reclaim again.";
254
+ /**
255
+ * Why reclaim stopped before it killed or wrote anything: the receipts journal, or the receipt an
256
+ * earlier reclaim wrote, is there and cannot be read.
257
+ */
258
+ function unreadableMessage(file, refusal) {
259
+ if (file === SANDBOX_RECEIPTS_ARTIFACT && refusal.reason !== "too-large")
260
+ return UNREADABLE_MESSAGE;
261
+ const kept = file === SANDBOX_RECEIPTS_ARTIFACT ? "the receipts were kept" : "the receipt was kept";
262
+ return `${refusalText(file, refusal)}. Nothing was killed and ${kept}; check the file, then run reclaim again.`;
263
+ }
244
264
  /**
245
265
  * Find and stop (or, for `--check`, look up) every sandbox one directory owns: its receipts, the
246
266
  * creates this process reported, and E2B's tagged list, each once by exact id.
247
267
  */
248
268
  async function reclaimRoot(root, label, hooks, warnings) {
249
- const bytes = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT);
250
- if (bytes === null && !(await containedPathAbsent(root, SANDBOX_RECEIPTS_ARTIFACT)))
251
- return { kind: "unreadable" };
252
- const journal = bytes === null ? "" : bytes.toString("utf8");
269
+ const journalRead = await readContainedRegularFile(root, SANDBOX_RECEIPTS_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
270
+ if (journalRead.status === "refused")
271
+ return {
272
+ kind: "unreadable",
273
+ message: unreadableMessage(SANDBOX_RECEIPTS_ARTIFACT, journalRead),
274
+ };
275
+ const journal = journalRead.status === "read" ? journalRead.bytes.toString("utf8") : "";
253
276
  const receipts = parseSandboxReceipts(journal);
254
277
  const owners = parseSandboxOwners(journal, label);
255
278
  const check = hooks.check === true;
@@ -259,7 +282,8 @@ async function reclaimRoot(root, label, hooks, warnings) {
259
282
  let e2b;
260
283
  let moduleError;
261
284
  try {
262
- e2b = await (hooks.loadModule ?? loadE2BDesktopModule)();
285
+ const module = await (hooks.loadModule ?? loadE2BDesktopModule)();
286
+ e2b = e2bAccount(module, e2bConnection(hooks.env ?? process.env));
263
287
  }
264
288
  catch (error) {
265
289
  moduleError = `Cannot load @e2b/desktop to reach E2B: ${redactText(toErrorMessage(error))}`;
@@ -269,6 +293,9 @@ async function reclaimRoot(root, label, hooks, warnings) {
269
293
  // An earlier reclaim may have killed a sandbox no receipt names (the signal handler's creates
270
294
  // and tag finds). Its outcome is carried forward, so a later reclaim never erases that record.
271
295
  const earlier = check ? [] : await earlierOutcomes(root, label);
296
+ // The new receipt replaces the old one, so an old one that cannot be read stops reclaim here.
297
+ if (!Array.isArray(earlier))
298
+ return { kind: "unreadable", message: unreadableMessage(RECLAIM_RECEIPT_ARTIFACT, earlier) };
272
299
  const set = targetSet({
273
300
  e2b,
274
301
  check,
@@ -0,0 +1,50 @@
1
+ import type { ActorTraceItem } from "../actors/contract.js";
2
+ import type { RunDesktopRecording } from "../evidence/desktop-recording-types.js";
3
+ /** What the clock reads from a stream. A run.json stream and an Observer stream both have it. */
4
+ export interface ClockStream {
5
+ actor?: {
6
+ items: readonly ActorTraceItem[];
7
+ };
8
+ liveActor?: {
9
+ items: readonly ActorTraceItem[];
10
+ };
11
+ recording?: RunDesktopRecording;
12
+ }
13
+ /** Where the run clock starts and ends, in epoch ms. */
14
+ export interface RunClock {
15
+ startMs: number;
16
+ endMs: number;
17
+ /** Every moment on the clock, ascending without repeats, for keyboard seeking. */
18
+ boundariesMs: number[];
19
+ }
20
+ /** A desktop video's place on the run clock, in epoch ms. */
21
+ export interface RecordingInterval {
22
+ startMs: number;
23
+ endMs: number;
24
+ recording: RunDesktopRecording;
25
+ }
26
+ /**
27
+ * A stream's recorded trace items: the finished actor's, else the flush of a participant still
28
+ * running, so every reader of a live run sees the items grow.
29
+ */
30
+ export declare function traceItems(stream: ClockStream): readonly ActorTraceItem[];
31
+ /**
32
+ * Whether a trace item is a capture, a frame of the participant's replay: a screenshot, or a
33
+ * scripted action that carries its own screenshot. A notice can cite an earlier screenshot for
34
+ * context, and does not capture it again.
35
+ */
36
+ export declare function isCapture<Item extends Pick<ActorTraceItem, "kind" | "screenshotRef">>(item: Item): item is Item & {
37
+ screenshotRef: NonNullable<ActorTraceItem["screenshotRef"]>;
38
+ };
39
+ /**
40
+ * A participant's capture times in epoch ms, in recorded order. Null when a capture has no stamp
41
+ * or a stamp goes back: such a participant adds nothing to the run clock, and its replay runs at
42
+ * an average pace. Times are never made up from the actor's duration.
43
+ */
44
+ export declare function captureTimes(stamps: readonly (number | undefined)[]): number[] | null;
45
+ /** Where a desktop video sits on the run clock; null without a readable start and a length. */
46
+ export declare function recordingInterval(recording: RunDesktopRecording | undefined): RecordingInterval | null;
47
+ /** The run clock of every stream of a run, or null when none has a timed capture or a video. */
48
+ export declare function runClock(streams: readonly ClockStream[]): RunClock | null;
49
+ /** A run clock time or span in whole minutes and seconds: `02:31`. Minutes go past 59. */
50
+ export declare function formatRunTime(ms: number): string;
@@ -0,0 +1,66 @@
1
+ // The run clock: the Observer's study timeline, and the clock a reviewer note's time counts from.
2
+ // It runs from the first to the last moment any participant recorded: a timed capture, or the start
3
+ // or end of a desktop video. The Observer bundles this module, so it imports types only
4
+ // (docs/decisions/0002-observer-is-one-self-contained-file.md).
5
+ /**
6
+ * A stream's recorded trace items: the finished actor's, else the flush of a participant still
7
+ * running, so every reader of a live run sees the items grow.
8
+ */
9
+ export function traceItems(stream) {
10
+ return stream.actor?.items ?? stream.liveActor?.items ?? [];
11
+ }
12
+ /**
13
+ * Whether a trace item is a capture, a frame of the participant's replay: a screenshot, or a
14
+ * scripted action that carries its own screenshot. A notice can cite an earlier screenshot for
15
+ * context, and does not capture it again.
16
+ */
17
+ export function isCapture(item) {
18
+ return (item.kind === "screenshot" || item.kind === "ui_action") && Boolean(item.screenshotRef);
19
+ }
20
+ /**
21
+ * A participant's capture times in epoch ms, in recorded order. Null when a capture has no stamp
22
+ * or a stamp goes back: such a participant adds nothing to the run clock, and its replay runs at
23
+ * an average pace. Times are never made up from the actor's duration.
24
+ */
25
+ export function captureTimes(stamps) {
26
+ const times = [];
27
+ for (const time of stamps) {
28
+ if (time === undefined || !Number.isFinite(time) || time < (times.at(-1) ?? -Infinity))
29
+ return null;
30
+ times.push(time);
31
+ }
32
+ return times;
33
+ }
34
+ /** Where a desktop video sits on the run clock; null without a readable start and a length. */
35
+ export function recordingInterval(recording) {
36
+ if (!recording)
37
+ return null;
38
+ const startMs = Date.parse(recording.startedAt);
39
+ const endMs = startMs + recording.durationMs;
40
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || recording.durationMs <= 0)
41
+ return null;
42
+ return { startMs, endMs, recording };
43
+ }
44
+ /** The run clock of every stream of a run, or null when none has a timed capture or a video. */
45
+ export function runClock(streams) {
46
+ const moments = new Set();
47
+ for (const stream of streams) {
48
+ const stamps = traceItems(stream)
49
+ .filter(isCapture)
50
+ .map((item) => (item.at === undefined ? Number.NaN : Date.parse(item.at)));
51
+ for (const time of captureTimes(stamps) ?? [])
52
+ moments.add(time);
53
+ const video = recordingInterval(stream.recording);
54
+ if (video)
55
+ moments.add(video.startMs).add(video.endMs);
56
+ }
57
+ const boundariesMs = [...moments].sort((a, b) => a - b);
58
+ if (boundariesMs.length === 0)
59
+ return null;
60
+ return { startMs: boundariesMs[0], endMs: boundariesMs.at(-1), boundariesMs };
61
+ }
62
+ /** A run clock time or span in whole minutes and seconds: `02:31`. Minutes go past 59. */
63
+ export function formatRunTime(ms) {
64
+ const seconds = Math.max(0, Math.floor(ms / 1000));
65
+ return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
66
+ }
package/dist/run/run.js CHANGED
@@ -15,7 +15,7 @@ import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./conta
15
15
  import { scrubRunSandboxIds, withPublicSandboxIds } from "./sandbox-ids.js";
16
16
  import { RunSecrets } from "./secrets.js";
17
17
  import { RUN_STATUS_FILE, beginRunStatus, runStatusOutcome, } from "./status.js";
18
- import { readRunJsonIfExists } from "./locate.js";
18
+ import { readRunJsonIfExists, runJsonValue } from "./locate.js";
19
19
  /** An entry point called in a state that does not admit it. */
20
20
  class RunLifecycleError extends Error {
21
21
  name = "RunLifecycleError";
@@ -166,7 +166,10 @@ function runPublisher(args) {
166
166
  });
167
167
  await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(publicBundle), "utf8");
168
168
  await afterBundle(publicBundle);
169
- const status = recorded === undefined ? await readRunJsonIfExists(paths, RUN_STATUS_FILE) : undefined;
169
+ // The run writes its own status.json; one it refuses renders as a missing one does.
170
+ const status = recorded === undefined
171
+ ? runJsonValue(await readRunJsonIfExists(paths, RUN_STATUS_FILE))
172
+ : undefined;
170
173
  await writeContainedOutputFile(paths, "review.json", json(publicBundle.review), "utf8");
171
174
  await writeContainedOutputFile(paths, "review.md", options.renderReview(publicBundle, status), "utf8");
172
175
  const events = publicBundle.events.map((event) => JSON.stringify(event)).join("\n");