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.
- package/CHANGELOG.md +41 -0
- package/README.md +7 -6
- package/TELEMETRY.md +6 -0
- package/dist/actors/codex/app-server-ui.js +11 -4
- package/dist/actors/codex/restricted-participant-policy.d.ts +0 -2
- package/dist/actors/codex/restricted-participant-policy.js +4 -32
- package/dist/actors/codex/restricted-participant.d.ts +5 -0
- package/dist/actors/codex/restricted-participant.js +3 -2
- package/dist/actors/computer-use/actor.d.ts +4 -0
- package/dist/actors/computer-use/actor.js +2 -0
- package/dist/actors/computer-use/loop/actions.d.ts +4 -4
- package/dist/actors/computer-use/loop/actions.js +25 -18
- package/dist/actors/computer-use/loop/debrief.js +61 -27
- package/dist/actors/computer-use/loop/session.d.ts +3 -0
- package/dist/actors/computer-use/loop/session.js +9 -1
- package/dist/actors/computer-use/loop/trace.js +1 -0
- package/dist/actors/computer-use/loop/types.d.ts +16 -14
- package/dist/actors/computer-use/loop.d.ts +1 -1
- package/dist/actors/computer-use/loop.js +1 -1
- package/dist/actors/computer-use/wait.d.ts +74 -0
- package/dist/actors/computer-use/wait.js +100 -0
- package/dist/actors/contract.d.ts +14 -0
- package/dist/actors/scripted-browser/steps.js +6 -5
- package/dist/analysis/analysis-limits.d.ts +35 -0
- package/dist/analysis/analysis-limits.js +41 -0
- package/dist/analysis/validation.js +14 -21
- package/dist/browser-control/protocol.d.ts +86 -6
- package/dist/browser-control/protocol.js +13 -6
- package/dist/cli/commands/analyze.js +2 -1
- package/dist/cli/commands/notes.js +2 -1
- package/dist/cli/commands/run-signals.js +5 -1
- package/dist/cli/commands/runs.js +2 -1
- package/dist/cli/commands/setup.js +3 -3
- package/dist/cli/commands/study-format.d.ts +5 -2
- package/dist/cli/commands/study-format.js +29 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +1 -1
- package/dist/cli/commands/tui.js +59 -17
- package/dist/cli/doctor.js +9 -2
- package/dist/cli/findings-text.d.ts +5 -0
- package/dist/cli/findings-text.js +169 -0
- package/dist/cli/findings.d.ts +0 -4
- package/dist/cli/findings.js +5 -167
- package/dist/cli/io.js +6 -1
- package/dist/cli/update-check.js +4 -1
- package/dist/comms/connections.js +4 -3
- package/dist/comms/setup.js +18 -12
- package/dist/evidence/redaction.js +9 -1
- package/dist/evidence/secret-scrub.d.ts +15 -5
- package/dist/evidence/secret-scrub.js +67 -9
- package/dist/feedback/draft.js +1 -1
- package/dist/feedback/export-bundle.js +18 -14
- package/dist/feedback/feedback.js +25 -7
- package/dist/guest/desktop-executor.js +3 -1
- package/dist/guest/desktop-keys.js +59 -5
- package/dist/keys/key-resolution.d.ts +9 -0
- package/dist/keys/key-resolution.js +60 -48
- package/dist/keys/key-status.d.ts +2 -0
- package/dist/keys/key-status.js +9 -16
- package/dist/observer/pinned-files.js +1 -1
- package/dist/observer/render.js +3 -2
- package/dist/observer-app.html +5 -5
- package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
- package/dist/routes/computer-use/bundle-parts.js +2 -2
- package/dist/routes/computer-use/bundle.d.ts +4 -5
- package/dist/routes/computer-use/bundle.js +1 -3
- package/dist/routes/computer-use/e2b-desktop/prepare.js +40 -31
- package/dist/routes/computer-use/fanout-records.js +10 -11
- package/dist/routes/computer-use/live-phase.d.ts +1 -1
- package/dist/routes/computer-use/live-phase.js +0 -1
- package/dist/routes/computer-use/local-vm.js +5 -2
- package/dist/routes/computer-use/participant-execution.d.ts +3 -2
- package/dist/routes/computer-use/participant-model.js +9 -0
- package/dist/routes/computer-use/participant-runs.d.ts +6 -2
- package/dist/routes/computer-use/participant-runs.js +9 -5
- package/dist/routes/computer-use/plan.d.ts +3 -0
- package/dist/routes/computer-use/plan.js +18 -13
- package/dist/routes/computer-use/rerun-selection.js +2 -2
- package/dist/routes/computer-use/result.js +3 -6
- package/dist/routes/computer-use/setup.d.ts +4 -6
- package/dist/routes/computer-use/setup.js +2 -6
- package/dist/routes/computer-use/single-bundle.d.ts +4 -5
- package/dist/routes/computer-use/single-bundle.js +22 -25
- package/dist/routes/computer-use/subject-projection.d.ts +4 -8
- package/dist/routes/computer-use/subject-projection.js +9 -31
- package/dist/routes/computer-use/types.d.ts +5 -42
- package/dist/routes/scripted/scenario.js +28 -12
- package/dist/routes/scripted/session-result.js +6 -3
- package/dist/routes/shared-world/bundle-records.js +2 -8
- package/dist/routes/shared-world/participant-specs.d.ts +0 -2
- package/dist/routes/shared-world/participant-specs.js +1 -20
- package/dist/routes/shared-world/plan.d.ts +3 -0
- package/dist/routes/shared-world/plan.js +25 -4
- package/dist/routes/shared-world/setup.js +2 -2
- package/dist/routes/terminal/ledger.d.ts +9 -9
- package/dist/routes/terminal/ledger.js +63 -40
- package/dist/routes/terminal/live-finish.js +7 -10
- package/dist/routes/terminal/live-sandbox.d.ts +1 -1
- package/dist/routes/terminal/live-sandbox.js +14 -11
- package/dist/routes/terminal/plan.d.ts +3 -0
- package/dist/routes/terminal/plan.js +8 -5
- package/dist/routes/terminal/sandbox.d.ts +4 -3
- package/dist/routes/terminal/sandbox.js +5 -5
- package/dist/routes/terminal/token-usage.d.ts +1 -2
- package/dist/routes/terminal/token-usage.js +4 -6
- package/dist/routes/terminal/types.d.ts +9 -1
- package/dist/run/bundle-shape.js +6 -1
- package/dist/run/bundle.d.ts +5 -3
- package/dist/run/contained-output.d.ts +53 -5
- package/dist/run/contained-output.js +141 -42
- package/dist/run/detail.d.ts +2 -1
- package/dist/run/detail.js +12 -7
- package/dist/run/escape-sequences.d.ts +11 -0
- package/dist/run/escape-sequences.js +93 -0
- package/dist/run/evidence-files.d.ts +8 -8
- package/dist/run/evidence-files.js +19 -23
- package/dist/run/locate.d.ts +32 -3
- package/dist/run/locate.js +45 -40
- package/dist/run/notes.d.ts +0 -8
- package/dist/run/notes.js +5 -51
- package/dist/run/outcomes.d.ts +24 -0
- package/dist/run/outcomes.js +19 -0
- package/dist/run/paths.d.ts +2 -0
- package/dist/run/paths.js +5 -1
- package/dist/run/preflight-receipts.js +13 -8
- package/dist/run/project.js +7 -4
- package/dist/run/reclaim-outcomes.d.ts +8 -6
- package/dist/run/reclaim-outcomes.js +10 -6
- package/dist/run/reclaim.d.ts +5 -0
- package/dist/run/reclaim.js +43 -16
- package/dist/run/run-clock.d.ts +50 -0
- package/dist/run/run-clock.js +66 -0
- package/dist/run/run.js +5 -2
- package/dist/run/sandbox-ids.d.ts +7 -2
- package/dist/run/sandbox-ids.js +27 -11
- package/dist/run/secrets.d.ts +2 -1
- package/dist/run/secrets.js +18 -22
- package/dist/run/stored-runs.js +14 -7
- package/dist/run/transient-comms-secrets.js +6 -17
- package/dist/run-study.js +22 -13
- package/dist/study/adapter-scorer-loader.js +6 -4
- package/dist/study/config.js +4 -1
- package/dist/study/discover.js +9 -6
- package/dist/study/init-paths.js +7 -5
- package/dist/study/keys.d.ts +2 -0
- package/dist/study/keys.js +2 -0
- package/dist/study/migrate/v2.js +2 -1
- package/dist/study/parse/actors.js +8 -0
- package/dist/study/persona-resolve.d.ts +2 -2
- package/dist/study/persona-resolve.js +9 -5
- package/dist/study/plan-participants.d.ts +4 -0
- package/dist/study/plan-participants.js +4 -0
- package/dist/study/plan-types.d.ts +2 -2
- package/dist/study/plan.d.ts +5 -3
- package/dist/study/plan.js +18 -9
- package/dist/study/preflight-probes.js +11 -3
- package/dist/study/preflight.js +9 -5
- package/dist/study/summary.d.ts +8 -2
- package/dist/study/summary.js +20 -22
- package/dist/study/types.d.ts +14 -0
- package/dist/study/validation.d.ts +5 -0
- package/dist/study/validation.js +8 -0
- package/dist/study/warnings.d.ts +19 -12
- package/dist/study/warnings.js +50 -17
- package/dist/subject/serve.d.ts +3 -0
- package/dist/subject/serve.js +4 -1
- package/dist/substrates/detached.js +14 -7
- package/dist/substrates/e2b/connection.d.ts +12 -0
- package/dist/substrates/e2b/connection.js +35 -0
- package/dist/substrates/e2b/desktop-executor.d.ts +1 -5
- package/dist/substrates/e2b/desktop-executor.js +45 -12
- package/dist/substrates/e2b/lifetime.d.ts +24 -4
- package/dist/substrates/e2b/lifetime.js +41 -4
- package/dist/substrates/e2b/sandbox.d.ts +4 -3
- package/dist/substrates/e2b/sandbox.js +13 -18
- package/dist/substrates/e2b/sdk.d.ts +20 -9
- package/dist/substrates/local/capacity.d.ts +18 -2
- package/dist/substrates/local/capacity.js +21 -2
- package/dist/substrates/local/runtime-host.js +21 -14
- package/dist/substrates/local/runtime.js +3 -11
- package/dist/tui/contract.d.ts +30 -5
- package/dist/tui/contract.js +4 -1
- package/dist/tui-app.js +150 -147
- package/dist/verify/artifacts.d.ts +9 -2
- package/dist/verify/artifacts.js +37 -13
- package/dist/verify/liveness.js +19 -5
- package/dist/verify/verify.js +54 -27
- package/docs/architecture/browser-control.md +10 -4
- package/docs/architecture/guest-desktop.md +7 -2
- package/docs/architecture/state-driven-executor.md +4 -2
- package/docs/contracts/run-bundle.md +18 -10
- package/docs/contracts/schemas.md +46 -29
- package/docs/contracts/study-analysis.md +10 -1
- package/docs/product/automatic-analysis.md +7 -1
- package/package.json +2 -2
package/dist/run/notes.d.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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 =
|
|
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);
|
package/dist/run/outcomes.d.ts
CHANGED
|
@@ -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. */
|
package/dist/run/outcomes.js
CHANGED
|
@@ -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)
|
package/dist/run/paths.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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()}`;
|
package/dist/run/project.js
CHANGED
|
@@ -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
|
|
65
|
-
if (
|
|
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 {
|
|
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:
|
|
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:
|
|
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;
|
|
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;
|
|
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
|
-
|
|
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 [];
|
package/dist/run/reclaim.d.ts
CHANGED
|
@@ -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;
|
package/dist/run/reclaim.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
|
109
|
-
const
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
134
|
+
const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT, RUN_ARTIFACT_MAX_BYTES);
|
|
135
|
+
if (journal.status === "missing")
|
|
127
136
|
return reason;
|
|
128
|
-
|
|
129
|
-
|
|
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: ${
|
|
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
|
|
250
|
-
if (
|
|
251
|
-
return {
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|