humanish 0.140.0 → 0.141.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 +12 -0
- package/dist/actors/codex/restricted-participant.js +1 -1
- package/dist/actors/computer-use/external-handoff.d.ts +22 -0
- package/dist/actors/computer-use/external-handoff.js +83 -0
- package/dist/actors/computer-use/loop/observation.d.ts +7 -0
- package/dist/actors/computer-use/loop/observation.js +17 -1
- package/dist/actors/computer-use/loop/types.d.ts +6 -0
- package/dist/analysis/analysis-limits.d.ts +6 -0
- package/dist/analysis/analysis-limits.js +6 -0
- package/dist/analysis/analysis-lock.d.ts +8 -0
- package/dist/analysis/analysis-lock.js +129 -0
- package/dist/analysis/automatic-completion.js +7 -0
- package/dist/analysis/execute.js +1 -1
- package/dist/analysis/job.d.ts +1 -0
- package/dist/analysis/job.js +6 -2
- package/dist/analysis/recording-ended.d.ts +16 -0
- package/dist/analysis/recording-ended.js +29 -0
- package/dist/analysis/service.d.ts +1 -3
- package/dist/analysis/service.js +23 -50
- package/dist/analysis/source.d.ts +1 -1
- package/dist/browser-control/protocol.d.ts +8 -0
- package/dist/browser-control/protocol.js +21 -0
- package/dist/cli/commands/analysis-signals.d.ts +10 -4
- package/dist/cli/commands/analysis-signals.js +28 -6
- package/dist/cli/commands/runs.js +8 -2
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +2 -2
- package/dist/cli/commands/study-route-shared-world.js +2 -2
- package/dist/cli/commands/study-route-terminal.js +2 -2
- package/dist/cli/findings.d.ts +23 -0
- package/dist/cli/findings.js +80 -4
- package/dist/cli/io.js +1 -0
- package/dist/evidence/browser-hygiene.d.ts +12 -1
- package/dist/evidence/browser-hygiene.js +45 -2
- package/dist/feedback/export-bundle.js +1 -1
- package/dist/guest/external-links.d.ts +14 -0
- package/dist/guest/external-links.js +48 -0
- package/dist/guest/runtime-desktop.js +5 -2
- package/dist/observer/data.d.ts +4 -0
- package/dist/observer/run-routes.js +12 -3
- package/dist/observer-app.html +2 -2
- package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
- package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
- package/dist/routes/computer-use/in-process-desktop.js +2 -2
- package/dist/routes/computer-use/live-phase.js +1 -1
- package/dist/routes/computer-use/setup.js +2 -2
- package/dist/routes/shared-world/route.js +1 -1
- package/dist/run/display.d.ts +6 -1
- package/dist/run/display.js +11 -0
- package/dist/run/host-suspension.js +4 -1
- package/dist/run/preflight-receipts.d.ts +3 -12
- package/dist/run/preflight-receipts.js +7 -67
- package/dist/run/process-owner.d.ts +28 -0
- package/dist/run/process-owner.js +83 -0
- package/dist/run/projection.js +1 -1
- package/dist/run/reclaim.d.ts +6 -0
- package/dist/run/reclaim.js +32 -2
- package/dist/run/route-shell.js +1 -1
- package/dist/run/run-index.d.ts +3 -1
- package/dist/run/run-index.js +23 -11
- package/dist/run/run.d.ts +16 -5
- package/dist/run/run.js +25 -10
- package/dist/run/status.d.ts +57 -2
- package/dist/run/status.js +87 -1
- package/dist/run/stored-runs.d.ts +3 -0
- package/dist/run/stored-runs.js +9 -4
- package/dist/study/run-study-events.d.ts +7 -1
- package/dist/substrates/e2b/desktop-browser.js +3 -1
- package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
- package/dist/substrates/e2b/desktop-executor.js +3 -0
- package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
- package/dist/substrates/e2b/desktop-handoff.js +86 -0
- package/dist/substrates/local/runtime-release.js +8 -8
- package/dist/tui-app.js +115 -115
- package/docs/architecture/observer.md +7 -3
- package/docs/contracts/schemas.md +24 -3
- package/docs/contracts/study-analysis.md +1 -1
- package/docs/product/automatic-analysis.md +29 -7
- package/package.json +1 -1
- package/skills/humanish/SKILL.md +108 -5
package/dist/run/run.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type RunBundle } from "./bundle.js";
|
|
|
3
3
|
import { type ExecutionFailure, type ExecutionOutcome, type OutcomePolicy } from "./judge.js";
|
|
4
4
|
import { type PreparedRunArtifactPaths, type RunIdInUse } from "./paths.js";
|
|
5
5
|
import { RunSecrets } from "./secrets.js";
|
|
6
|
+
import { type RunStatusHandle } from "./status.js";
|
|
6
7
|
import { type HostClock, type HostSuspension } from "./host-suspension.js";
|
|
7
8
|
import type { RunStudyProvenance } from "./study-provenance.js";
|
|
8
9
|
interface StartRunOptions {
|
|
@@ -170,7 +171,7 @@ export declare class FinishedRun {
|
|
|
170
171
|
readonly runId: string;
|
|
171
172
|
/** The paths created by startRun and validated at the start of `finish`. */
|
|
172
173
|
readonly paths: PreparedRunArtifactPaths;
|
|
173
|
-
constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>,
|
|
174
|
+
constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>, status: Pick<RunStatusHandle, "interrupted" | "beginAnalysis">, participantsRan: () => boolean);
|
|
174
175
|
/**
|
|
175
176
|
* A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
|
|
176
177
|
* when asked, since the signal can arrive after the bundle was published.
|
|
@@ -178,6 +179,12 @@ export declare class FinishedRun {
|
|
|
178
179
|
get interrupted(): boolean;
|
|
179
180
|
/** The route reported a participant's session started (Run.participantStarted). */
|
|
180
181
|
get participantsRan(): boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Automatic analysis of this run starts in this process: status.json records when, and its
|
|
184
|
+
* heartbeat runs again, so every surface can say the run is analyzing. Call the returned
|
|
185
|
+
* function with the analysis outcome's state when it ends. After an interrupt both do nothing.
|
|
186
|
+
*/
|
|
187
|
+
beginAnalysis(): ReturnType<RunStatusHandle["beginAnalysis"]>;
|
|
181
188
|
static isIssued(value: unknown): value is FinishedRun;
|
|
182
189
|
/**
|
|
183
190
|
* The run's ok and execution outcome as run.json records them: what the route passed to
|
|
@@ -195,12 +202,16 @@ export declare class FinishedRun {
|
|
|
195
202
|
/**
|
|
196
203
|
* Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
|
|
197
204
|
* so the scope and its run admit no further calls, even through references `fn` leaked into
|
|
198
|
-
* timers or callbacks. It then waits for the calls it admitted and, unless the run
|
|
199
|
-
*
|
|
200
|
-
*
|
|
205
|
+
* timers or callbacks. It then waits for the calls it admitted and, unless the run published,
|
|
206
|
+
* records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
|
|
207
|
+
* not finish, so no surface shows it as passed or counts it in a study's durations. The close cannot replace `fn`'s exception: admitted calls
|
|
208
|
+
* are awaited settled and the status write swallows its own errors. `finished` is set only
|
|
201
209
|
* when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
|
|
202
210
|
*/
|
|
203
|
-
export declare function runScope<T>(fn: (scope: RunScope) => Promise<T
|
|
211
|
+
export declare function runScope<T>(fn: (scope: RunScope) => Promise<T>, hooks?: {
|
|
212
|
+
/** The run exists: its directory and first status.json write landed. A throw is ignored. */
|
|
213
|
+
onStart?: (runId: string) => void;
|
|
214
|
+
}): Promise<{
|
|
204
215
|
result: T;
|
|
205
216
|
finished: FinishedRun | undefined;
|
|
206
217
|
}>;
|
package/dist/run/run.js
CHANGED
|
@@ -35,34 +35,42 @@ const issueKey = Symbol("FinishedRun");
|
|
|
35
35
|
export class FinishedRun {
|
|
36
36
|
#observer;
|
|
37
37
|
#recordFailure;
|
|
38
|
-
#interrupted;
|
|
39
38
|
#outcome;
|
|
40
39
|
runId;
|
|
41
40
|
/** The paths created by startRun and validated at the start of `finish`. */
|
|
42
41
|
paths;
|
|
43
42
|
#participantsRan;
|
|
44
|
-
|
|
43
|
+
#status;
|
|
44
|
+
constructor(key, runId, paths, observer, outcome, recordFailure, status, participantsRan) {
|
|
45
45
|
if (key !== issueKey)
|
|
46
46
|
throw new Error("Only Run.finish issues a FinishedRun.");
|
|
47
47
|
this.runId = runId;
|
|
48
48
|
this.paths = paths;
|
|
49
49
|
this.#participantsRan = participantsRan;
|
|
50
|
+
this.#status = status;
|
|
50
51
|
this.#observer = observer;
|
|
51
52
|
this.#outcome = outcome;
|
|
52
53
|
this.#recordFailure = recordFailure;
|
|
53
|
-
this.#interrupted = interrupted;
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
56
|
* A signal stopped this run's process: status.json and run.json record it `interrupted`. Read
|
|
57
57
|
* when asked, since the signal can arrive after the bundle was published.
|
|
58
58
|
*/
|
|
59
59
|
get interrupted() {
|
|
60
|
-
return this.#interrupted
|
|
60
|
+
return this.#status.interrupted;
|
|
61
61
|
}
|
|
62
62
|
/** The route reported a participant's session started (Run.participantStarted). */
|
|
63
63
|
get participantsRan() {
|
|
64
64
|
return this.#participantsRan();
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Automatic analysis of this run starts in this process: status.json records when, and its
|
|
68
|
+
* heartbeat runs again, so every surface can say the run is analyzing. Call the returned
|
|
69
|
+
* function with the analysis outcome's state when it ends. After an interrupt both do nothing.
|
|
70
|
+
*/
|
|
71
|
+
beginAnalysis() {
|
|
72
|
+
return this.#status.beginAnalysis();
|
|
73
|
+
}
|
|
66
74
|
static isIssued(value) {
|
|
67
75
|
return typeof value === "object" && value !== null && #observer in value;
|
|
68
76
|
}
|
|
@@ -262,7 +270,7 @@ function runPublisher(args) {
|
|
|
262
270
|
await writeBundleFiles(bundle, finishedOutcome(current), (publicBundle) => runStatus.restate(runStatusOutcome(publicBundle))).catch(() => undefined);
|
|
263
271
|
return current;
|
|
264
272
|
}));
|
|
265
|
-
return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure,
|
|
273
|
+
return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure, runStatus, participantsRan);
|
|
266
274
|
};
|
|
267
275
|
const interrupt = async (signal) => {
|
|
268
276
|
interrupted = { state: "interrupted", ok: false, signal, at: new Date(now()).toISOString() };
|
|
@@ -287,12 +295,13 @@ function runPublisher(args) {
|
|
|
287
295
|
/**
|
|
288
296
|
* Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
|
|
289
297
|
* so the scope and its run admit no further calls, even through references `fn` leaked into
|
|
290
|
-
* timers or callbacks. It then waits for the calls it admitted and, unless the run
|
|
291
|
-
*
|
|
292
|
-
*
|
|
298
|
+
* timers or callbacks. It then waits for the calls it admitted and, unless the run published,
|
|
299
|
+
* records status.json `interrupted` with reason `no-publish`: a run with no published bundle did
|
|
300
|
+
* not finish, so no surface shows it as passed or counts it in a study's durations. The close cannot replace `fn`'s exception: admitted calls
|
|
301
|
+
* are awaited settled and the status write swallows its own errors. `finished` is set only
|
|
293
302
|
* when `Run.finish` resolved inside the scope, so a refusal cannot hand analysis another run.
|
|
294
303
|
*/
|
|
295
|
-
export async function runScope(fn) {
|
|
304
|
+
export async function runScope(fn, hooks = {}) {
|
|
296
305
|
let closed = false;
|
|
297
306
|
let started = false;
|
|
298
307
|
let status;
|
|
@@ -339,6 +348,12 @@ export async function runScope(fn) {
|
|
|
339
348
|
},
|
|
340
349
|
});
|
|
341
350
|
await runStatus.started;
|
|
351
|
+
try {
|
|
352
|
+
hooks.onStart?.(runId);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// An announcement cannot stop the run it announces.
|
|
356
|
+
}
|
|
342
357
|
const run = openRun(options, runId, created.paths, runStatus);
|
|
343
358
|
interruptBundle = run.interrupt;
|
|
344
359
|
return { ok: true, run: run.run };
|
|
@@ -414,7 +429,7 @@ export async function runScope(fn) {
|
|
|
414
429
|
}
|
|
415
430
|
finally {
|
|
416
431
|
if (finished === undefined)
|
|
417
|
-
await status?.
|
|
432
|
+
await status?.abandon();
|
|
418
433
|
unregister?.();
|
|
419
434
|
}
|
|
420
435
|
}
|
package/dist/run/status.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AutomaticAnalysisOutcome } from "../analysis/job.js";
|
|
1
2
|
import type { RunBundle } from "./bundle.js";
|
|
2
3
|
import { type RunStudyProvenance } from "./study-provenance.js";
|
|
3
4
|
import { type PreparedOutputRoot } from "./contained-output.js";
|
|
@@ -10,11 +11,26 @@ export declare const RUN_STATUS_FILE = "status.json";
|
|
|
10
11
|
export declare const RUN_STATUS_TOUCH_MS = 5000;
|
|
11
12
|
/**
|
|
12
13
|
* A `running` record whose `updatedAt` is older than this is interrupted, not alive: the process
|
|
13
|
-
* died
|
|
14
|
+
* died before the run ended (a dropped SSH, a killed terminal, a crash). Three touch intervals of
|
|
14
15
|
* slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
|
|
15
16
|
*/
|
|
16
17
|
export declare const RUN_STATUS_STALE_MS: number;
|
|
17
18
|
type RunStatusState = "running" | "finished" | "interrupted";
|
|
19
|
+
/**
|
|
20
|
+
* Why a run is `interrupted` without a signal. `no-publish`: its process left the run without
|
|
21
|
+
* publishing a bundle, as a route that throws or returns a refusal after the run started does.
|
|
22
|
+
* `reclaimed`: `humanish reclaim` found its heartbeat stale and stopped its sandboxes.
|
|
23
|
+
*/
|
|
24
|
+
type RunInterruptReason = "no-publish" | "reclaimed";
|
|
25
|
+
/** How automatic analysis ended, as its job records it. */
|
|
26
|
+
type RunAnalysisEndState = AutomaticAnalysisOutcome["state"];
|
|
27
|
+
/** Automatic analysis in the run's own process, after the run ended. */
|
|
28
|
+
interface RunStatusAnalysis {
|
|
29
|
+
startedAt: string;
|
|
30
|
+
/** Absent while it runs, and on the record of a process that stopped during it. */
|
|
31
|
+
endedAt?: string;
|
|
32
|
+
state?: RunAnalysisEndState;
|
|
33
|
+
}
|
|
18
34
|
/** The signals the CLI's run handler records when it stops a run. */
|
|
19
35
|
export type RunInterruptSignal = "SIGINT" | "SIGTERM" | "SIGHUP";
|
|
20
36
|
/** The outcome summary a finalized record carries. Derived from the bundle; never authoritative. */
|
|
@@ -67,7 +83,14 @@ export interface RunStatusRecord {
|
|
|
67
83
|
completedAt?: string;
|
|
68
84
|
/** The signal that stopped the run, on an `interrupted` record. */
|
|
69
85
|
signal?: RunInterruptSignal;
|
|
86
|
+
/** Why an `interrupted` record has no signal. */
|
|
87
|
+
reason?: RunInterruptReason;
|
|
70
88
|
outcome?: RunStatusOutcome;
|
|
89
|
+
/**
|
|
90
|
+
* Set on a `finished` record when automatic analysis starts in the same process. While it
|
|
91
|
+
* runs, the heartbeat keeps `updatedAt` fresh; `completedAt` stays when the participants ended.
|
|
92
|
+
*/
|
|
93
|
+
analysis?: RunStatusAnalysis;
|
|
71
94
|
}
|
|
72
95
|
export interface RunStatusHandle {
|
|
73
96
|
/** Resolves once the initial record has landed on disk, and never rejects: a failed write is
|
|
@@ -75,7 +98,8 @@ export interface RunStatusHandle {
|
|
|
75
98
|
* it before a route can acquire a sandbox, so a run killed after its sandbox receipt lands
|
|
76
99
|
* still has a record to classify. */
|
|
77
100
|
readonly started: Promise<void>;
|
|
78
|
-
/**
|
|
101
|
+
/** The run ended with a published bundle: state `finished`, `completedAt`, and the derived
|
|
102
|
+
* outcome. Stops the cadence.
|
|
79
103
|
* Idempotent: a second call is a no-op, so a route with several exit paths is safe. */
|
|
80
104
|
finish(outcome?: RunStatusOutcome): Promise<void>;
|
|
81
105
|
/** After finish, replace the finished record's outcome with one read from a rewritten run.json,
|
|
@@ -88,6 +112,15 @@ export interface RunStatusHandle {
|
|
|
88
112
|
interrupt(signal: RunInterruptSignal): Promise<boolean>;
|
|
89
113
|
/** True from the moment `interrupt` accepts a signal, before its write lands. */
|
|
90
114
|
readonly interrupted: boolean;
|
|
115
|
+
/** The run's scope closed without a publish: state `interrupted` with reason `no-publish`. A
|
|
116
|
+
* no-op after finish or an interrupt, so it never overwrites a record that says more. */
|
|
117
|
+
abandon(): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* After finish, automatic analysis starts: `analysis.startedAt` is written and the heartbeat
|
|
120
|
+
* runs again until the returned function records the end and its state. Before finish or after
|
|
121
|
+
* an interrupt it writes nothing, and neither does the returned function.
|
|
122
|
+
*/
|
|
123
|
+
beginAnalysis(): Promise<(state: RunAnalysisEndState) => Promise<void>>;
|
|
91
124
|
/** The host suspensions the cadence saw until now, or until it stopped. */
|
|
92
125
|
suspensions(): readonly HostSuspension[];
|
|
93
126
|
}
|
|
@@ -114,6 +147,28 @@ export type RunLiveness = "running" | "interrupted" | "finished";
|
|
|
114
147
|
* `nowMs` is passed in rather than read, so a classification is reproducible.
|
|
115
148
|
*/
|
|
116
149
|
export declare function classifyRunStatus(record: Pick<RunStatusRecord, "state" | "updatedAt">, nowMs: number, staleMs?: number): RunLiveness;
|
|
150
|
+
/** Automatic analysis as a status record shows it: running, ended, or stopped with its process. */
|
|
151
|
+
export type RunAnalysisPhase = {
|
|
152
|
+
state: "running" | "stopped";
|
|
153
|
+
startedAt: string;
|
|
154
|
+
} | {
|
|
155
|
+
state: "ended";
|
|
156
|
+
startedAt: string;
|
|
157
|
+
endedAt: string;
|
|
158
|
+
outcome?: RunAnalysisEndState;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* The record's automatic analysis. `stopped` is an analysis with no end whose heartbeat went
|
|
162
|
+
* stale: the process ended during it. Undefined when the record has none, or one this version
|
|
163
|
+
* cannot read.
|
|
164
|
+
*/
|
|
165
|
+
export declare function runAnalysisPhase(record: Pick<RunStatusRecord, "updatedAt" | "analysis">, nowMs: number): RunAnalysisPhase | undefined;
|
|
166
|
+
/**
|
|
167
|
+
* A `running` record whose heartbeat is stale and whose pid no process on this host holds: its
|
|
168
|
+
* owner is gone, so the run cannot still be writing. A live pid proves nothing either way (pids
|
|
169
|
+
* are reused), so such a record stays unconfirmed.
|
|
170
|
+
*/
|
|
171
|
+
export declare function runOwnerGone(record: Pick<RunStatusRecord, "state" | "pid" | "updatedAt">, nowMs: number, running?: (pid: number) => boolean): boolean;
|
|
117
172
|
/** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
|
|
118
173
|
export declare function isRunStatusRecord(value: unknown): value is RunStatusRecord;
|
|
119
174
|
export {};
|
package/dist/run/status.js
CHANGED
|
@@ -20,7 +20,7 @@ export const RUN_STATUS_FILE = "status.json";
|
|
|
20
20
|
export const RUN_STATUS_TOUCH_MS = 5_000;
|
|
21
21
|
/**
|
|
22
22
|
* A `running` record whose `updatedAt` is older than this is interrupted, not alive: the process
|
|
23
|
-
* died
|
|
23
|
+
* died before the run ended (a dropped SSH, a killed terminal, a crash). Three touch intervals of
|
|
24
24
|
* slack so an ordinary scheduling hiccup or a slow disk never mislabels a healthy run.
|
|
25
25
|
*/
|
|
26
26
|
export const RUN_STATUS_STALE_MS = RUN_STATUS_TOUCH_MS * 3;
|
|
@@ -132,6 +132,48 @@ export function beginRunStatus(runPaths, options) {
|
|
|
132
132
|
get interrupted() {
|
|
133
133
|
return interrupted;
|
|
134
134
|
},
|
|
135
|
+
async abandon() {
|
|
136
|
+
if (finished)
|
|
137
|
+
return;
|
|
138
|
+
finished = true;
|
|
139
|
+
heartbeat.stop();
|
|
140
|
+
const completedAt = iso();
|
|
141
|
+
await write({
|
|
142
|
+
...base,
|
|
143
|
+
state: "interrupted",
|
|
144
|
+
updatedAt: completedAt,
|
|
145
|
+
completedAt,
|
|
146
|
+
reason: "no-publish",
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
async beginAnalysis() {
|
|
150
|
+
const ended = async () => { };
|
|
151
|
+
if (finishedRecord === undefined || interrupted)
|
|
152
|
+
return ended;
|
|
153
|
+
const startedAt = iso();
|
|
154
|
+
finishedRecord = { ...finishedRecord, updatedAt: startedAt, analysis: { startedAt } };
|
|
155
|
+
await write(finishedRecord);
|
|
156
|
+
const analysisHeartbeat = startHeartbeat(clock, RUN_STATUS_TOUCH_MS, () => {
|
|
157
|
+
if (finishedRecord === undefined)
|
|
158
|
+
return;
|
|
159
|
+
finishedRecord = { ...finishedRecord, updatedAt: iso() };
|
|
160
|
+
void write(finishedRecord);
|
|
161
|
+
});
|
|
162
|
+
let done = false;
|
|
163
|
+
return async (state) => {
|
|
164
|
+
if (done || finishedRecord?.analysis === undefined)
|
|
165
|
+
return;
|
|
166
|
+
done = true;
|
|
167
|
+
analysisHeartbeat.stop();
|
|
168
|
+
const endedAt = iso();
|
|
169
|
+
finishedRecord = {
|
|
170
|
+
...finishedRecord,
|
|
171
|
+
updatedAt: endedAt,
|
|
172
|
+
analysis: { ...finishedRecord.analysis, endedAt, state },
|
|
173
|
+
};
|
|
174
|
+
await write(finishedRecord);
|
|
175
|
+
};
|
|
176
|
+
},
|
|
135
177
|
suspensions: () => heartbeat.suspensions(),
|
|
136
178
|
};
|
|
137
179
|
return handle;
|
|
@@ -150,6 +192,50 @@ export function classifyRunStatus(record, nowMs, staleMs = RUN_STATUS_STALE_MS)
|
|
|
150
192
|
return "interrupted";
|
|
151
193
|
return nowMs - updated <= staleMs ? "running" : "interrupted";
|
|
152
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* The record's automatic analysis. `stopped` is an analysis with no end whose heartbeat went
|
|
197
|
+
* stale: the process ended during it. Undefined when the record has none, or one this version
|
|
198
|
+
* cannot read.
|
|
199
|
+
*/
|
|
200
|
+
export function runAnalysisPhase(record, nowMs) {
|
|
201
|
+
const analysis = record.analysis;
|
|
202
|
+
if (analysis === null || typeof analysis !== "object")
|
|
203
|
+
return undefined;
|
|
204
|
+
const { startedAt, endedAt, state } = analysis;
|
|
205
|
+
if (typeof startedAt !== "string" || !Number.isFinite(Date.parse(startedAt)))
|
|
206
|
+
return undefined;
|
|
207
|
+
if (typeof endedAt === "string")
|
|
208
|
+
return {
|
|
209
|
+
state: "ended",
|
|
210
|
+
startedAt,
|
|
211
|
+
endedAt,
|
|
212
|
+
...(typeof state === "string" ? { outcome: state } : {}),
|
|
213
|
+
};
|
|
214
|
+
const updated = Date.parse(record.updatedAt);
|
|
215
|
+
return Number.isFinite(updated) && nowMs - updated <= RUN_STATUS_STALE_MS
|
|
216
|
+
? { state: "running", startedAt }
|
|
217
|
+
: { state: "stopped", startedAt };
|
|
218
|
+
}
|
|
219
|
+
/** Whether `pid` names a process on this host. EPERM means one exists that this user cannot signal. */
|
|
220
|
+
function processRunning(pid) {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(pid, 0);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
return error.code === "EPERM";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* A `running` record whose heartbeat is stale and whose pid no process on this host holds: its
|
|
231
|
+
* owner is gone, so the run cannot still be writing. A live pid proves nothing either way (pids
|
|
232
|
+
* are reused), so such a record stays unconfirmed.
|
|
233
|
+
*/
|
|
234
|
+
export function runOwnerGone(record, nowMs, running = processRunning) {
|
|
235
|
+
if (record.state !== "running" || classifyRunStatus(record, nowMs) === "running")
|
|
236
|
+
return false;
|
|
237
|
+
return record.pid !== process.pid && !running(record.pid);
|
|
238
|
+
}
|
|
153
239
|
/** Shape guard for a record read off disk. Unknown extra fields are tolerated (additive contract). */
|
|
154
240
|
export function isRunStatusRecord(value) {
|
|
155
241
|
if (value === null || typeof value !== "object")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReviewSummary } from "./bundle.js";
|
|
2
2
|
import { type RunDisplay } from "./display.js";
|
|
3
|
+
import { type RunAnalysisPhase } from "./status.js";
|
|
3
4
|
import { type VerifyResult } from "../verify/verify.js";
|
|
4
5
|
declare const RUNS_SCHEMA = "humanish.runs-result.v1";
|
|
5
6
|
export interface RunsResult {
|
|
@@ -13,6 +14,8 @@ export interface RunsResult {
|
|
|
13
14
|
path: string;
|
|
14
15
|
/** How the run reads (runDisplay in src/run/display.ts); absent when it has no readable run.json. */
|
|
15
16
|
display?: RunDisplay;
|
|
17
|
+
/** The automatic analysis its own process started after the run ended, from status.json. */
|
|
18
|
+
analysis?: RunAnalysisPhase;
|
|
16
19
|
}>;
|
|
17
20
|
latest: string | null;
|
|
18
21
|
error?: {
|
package/dist/run/stored-runs.js
CHANGED
|
@@ -6,7 +6,7 @@ import { assertPreparedSelectedOutputDirectory, bindExistingManagedHumanishOutpu
|
|
|
6
6
|
import { RUN_BUNDLE_FILE } from "./bundle.js";
|
|
7
7
|
import { isReviewSummary, isRunBundle, isRunOutcome } from "./bundle-shape.js";
|
|
8
8
|
import { bundleDisplayFacts, displayedReview, runDisplay, } from "./display.js";
|
|
9
|
-
import { RUN_STATUS_FILE } from "./status.js";
|
|
9
|
+
import { isRunStatusRecord, RUN_STATUS_FILE, runAnalysisPhase, } from "./status.js";
|
|
10
10
|
import { readLatest, readRunJsonIfExists, resolveRunPath, runJsonValue } from "./locate.js";
|
|
11
11
|
import { withCuaReviewProvenance } from "./outcomes.js";
|
|
12
12
|
import { displayedTraces, loadStreamTraces } from "./stream-trace.js";
|
|
@@ -106,10 +106,15 @@ async function listedRunDisplay(runPaths, bundle) {
|
|
|
106
106
|
const displayed = displayedBundle(bundle);
|
|
107
107
|
if (displayed === undefined)
|
|
108
108
|
return {};
|
|
109
|
-
const status =
|
|
110
|
-
|
|
109
|
+
const status = runJsonValue(await readRunJsonIfExists(runPaths, RUN_STATUS_FILE));
|
|
110
|
+
const analysis = isRunStatusRecord(status) && status.runId === displayed.runId
|
|
111
|
+
? runAnalysisPhase(status, Date.now())
|
|
111
112
|
: undefined;
|
|
112
|
-
return {
|
|
113
|
+
return {
|
|
114
|
+
// A bundle with an outcome decides how the run ended; status.json only adds its analysis.
|
|
115
|
+
display: runDisplay(bundleDisplayFacts(displayed, displayed.outcome === undefined ? status : undefined)),
|
|
116
|
+
...(analysis === undefined ? {} : { analysis }),
|
|
117
|
+
};
|
|
113
118
|
}
|
|
114
119
|
/** The fields runDisplay reads from an unchecked run.json; none when it does not name a run. */
|
|
115
120
|
function displayedBundle(bundle) {
|
|
@@ -19,7 +19,8 @@ export type SetupTarget = {
|
|
|
19
19
|
readonly participant: ParticipantRef;
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
|
-
* What a run reports while it runs. `
|
|
22
|
+
* What a run reports while it runs. `run-started` comes from every route that records a run, once
|
|
23
|
+
* its run id exists. `plan` comes from computer use only; the other routes run from
|
|
23
24
|
* the study plan but do not emit it. `subject-phase` comes from computer use (participant target)
|
|
24
25
|
* and shared world (subject target).
|
|
25
26
|
*/
|
|
@@ -40,6 +41,11 @@ export type StudyEvent = {
|
|
|
40
41
|
at: string;
|
|
41
42
|
ok?: boolean;
|
|
42
43
|
durationMs?: number;
|
|
44
|
+
}
|
|
45
|
+
/** The run's directory and status.json exist: `runId` names it from here on. */
|
|
46
|
+
| {
|
|
47
|
+
type: "run-started";
|
|
48
|
+
runId: string;
|
|
43
49
|
} | {
|
|
44
50
|
type: "analysis-started";
|
|
45
51
|
} | {
|
|
@@ -5,6 +5,7 @@ import { failureTail } from "../../evidence/redaction.js";
|
|
|
5
5
|
import { isHttpUrl } from "../../study/parse/subject.js";
|
|
6
6
|
import { runDetachedStep } from "../detached.js";
|
|
7
7
|
import { shellQuote } from "../shell.js";
|
|
8
|
+
import { externalLinkHandlerSetup } from "./desktop-handoff.js";
|
|
8
9
|
import { e2bShell } from "./shell.js";
|
|
9
10
|
// Settle after opening the browser or the terminal, before the first screenshot: long enough for
|
|
10
11
|
// a cold browser and page load to paint (2 s captured a blank desktop; the render needs 6-9 s).
|
|
@@ -210,7 +211,8 @@ extraChromiumFlags = [], environment) {
|
|
|
210
211
|
`target_url=${shellQuote(targetUrl)}`,
|
|
211
212
|
`browser_preference=${shellQuote(requestedBrowser)}`,
|
|
212
213
|
"profile_dir=",
|
|
213
|
-
`chrome_preferences_json=${shellQuote(chromiumEvidenceProfilePreferencesJson())}`,
|
|
214
|
+
`chrome_preferences_json=${shellQuote(chromiumEvidenceProfilePreferencesJson(targetUrl))}`,
|
|
215
|
+
externalLinkHandlerSetup(),
|
|
214
216
|
"prepare_profile() {",
|
|
215
217
|
' profile_dir="$(mktemp -d /tmp/humanish-chrome-profile.XXXXXX)"',
|
|
216
218
|
' mkdir -p "$profile_dir/Default"',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CuaExecutor, CuaObservation } from "../../actors/computer-use/loop.js";
|
|
2
|
+
import type { ExternalHandoff } from "../../actors/computer-use/external-handoff.js";
|
|
2
3
|
/**
|
|
3
4
|
* The minimal slice of the @e2b/desktop Sandbox the executor depends on: a structural subset of
|
|
4
5
|
* the SDK class (checked against 2.4.0), so a Sandbox satisfies it with no adapter. Methods are typed to return `Promise<void> | void` (and the
|
|
@@ -65,6 +66,12 @@ export interface E2BDesktopExecutorOptions {
|
|
|
65
66
|
* never persists raw URL/title/text; it only uses them in memory to decide whether to stop.
|
|
66
67
|
*/
|
|
67
68
|
observeBrowserState?: () => Promise<Pick<CuaObservation, "url" | "title" | "text" | "scrollY">>;
|
|
69
|
+
/**
|
|
70
|
+
* Optional read of the links the browser handed to another app since the last read
|
|
71
|
+
* (desktop-handoff.ts). It runs beside the browser probe and the screenshot; a read that fails
|
|
72
|
+
* leaves its links for the next one.
|
|
73
|
+
*/
|
|
74
|
+
readExternalHandoffs?: () => Promise<readonly ExternalHandoff[]>;
|
|
68
75
|
}
|
|
69
76
|
/** The stage of a `type` action that failed. */
|
|
70
77
|
export type CuaTypePhase = "desktop-write" | "text-tempfile" | "text-command";
|
|
@@ -151,10 +151,13 @@ export function createE2BDesktopExecutor(desktop, options = {}) {
|
|
|
151
151
|
// Probe browser state before the screenshot so a deterministic stopWhen match
|
|
152
152
|
// and its persisted frame describe the same settled surface as closely as the
|
|
153
153
|
// desktop substrate allows.
|
|
154
|
+
const handoffs = options.readExternalHandoffs?.().catch(() => []);
|
|
154
155
|
const browserState = await options.observeBrowserState?.().catch(() => undefined);
|
|
155
156
|
const raw = await desktop.screenshot();
|
|
156
157
|
const screenshot = toBuffer(raw);
|
|
158
|
+
const externalHandoffs = (await handoffs) ?? [];
|
|
157
159
|
return {
|
|
160
|
+
...(externalHandoffs.length === 0 ? {} : { externalHandoffs }),
|
|
158
161
|
screenshot,
|
|
159
162
|
stateSignature: perceptualSignature(screenshot),
|
|
160
163
|
...(browserState?.url === undefined ? {} : { url: browserState.url }),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ExternalHandoff } from "../../actors/computer-use/external-handoff.js";
|
|
2
|
+
import { type Shell } from "../shell.js";
|
|
3
|
+
/**
|
|
4
|
+
* One stand-in, installed in `bin`, appending to `log` (a shell word). `$0` names the command it
|
|
5
|
+
* stands in for. The local guest image ships the same script
|
|
6
|
+
* (runtime/browser-guest/control/root/opt/humanish/control/bin/xdg-open).
|
|
7
|
+
*/
|
|
8
|
+
export declare function externalLinkStandIn(bin: string, log: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Shell lines for the browser launch script, run before the browser starts: they install the
|
|
11
|
+
* stand-ins and put them first on the search path for every command the script starts after them.
|
|
12
|
+
*/
|
|
13
|
+
export declare function externalLinkHandlerSetup(dir?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Returns a read of the links handed off since the previous read, oldest first and at most
|
|
16
|
+
* EXTERNAL_HANDOFF_LIMITS.perObservation at a time. Reads run one at a time, so a retried
|
|
17
|
+
* observation cannot return a link twice. A line still being written is left for the next read.
|
|
18
|
+
*/
|
|
19
|
+
export declare function externalHandoffReader(shell: Pick<Shell, "run">, options: {
|
|
20
|
+
requestTimeoutMs: number;
|
|
21
|
+
dir?: string;
|
|
22
|
+
}): () => Promise<ExternalHandoff[]>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Links the participant's browser hands to another app (mailto:, tel:, sms:) on a hosted desktop.
|
|
2
|
+
//
|
|
3
|
+
// Chrome on Linux opens such a link by running `xdg-email` (mailto) or `xdg-open` (the rest) by
|
|
4
|
+
// name, unless an XDG desktop portal answers on the session bus; the stock desktop runs none.
|
|
5
|
+
// The stock desktop has no mail, phone or messaging app, so `xdg-open` ended in XFCE's "Choose
|
|
6
|
+
// Preferred Application" dialog, which participants reported as a fault of the app under test.
|
|
7
|
+
// The browser launch puts stand-ins for both commands first on the browser's search path. A stand-in
|
|
8
|
+
// appends the link to a log and exits; a web or file link goes on to the desktop's own command.
|
|
9
|
+
// The executor reads the log on each observation, and the participant loop records each link
|
|
10
|
+
// and tells the participant what happened. Chrome's own "Open xdg-open?" prompt for tel: and
|
|
11
|
+
// the other non-mailto schemes is answered by the profile preference in
|
|
12
|
+
// src/evidence/browser-hygiene.ts; Chrome never prompts for mailto.
|
|
13
|
+
import { EXTERNAL_HANDOFF_LIMITS, externalHandoffOf, } from "../../actors/computer-use/external-handoff.js";
|
|
14
|
+
import { shellQuote } from "../shell.js";
|
|
15
|
+
/** Where the stand-ins and their log live on the desktop. */
|
|
16
|
+
const EXTERNAL_LINK_DIR = "/tmp/humanish-external-links";
|
|
17
|
+
const LINK_LENGTH = 4096;
|
|
18
|
+
/**
|
|
19
|
+
* One stand-in, installed in `bin`, appending to `log` (a shell word). `$0` names the command it
|
|
20
|
+
* stands in for. The local guest image ships the same script
|
|
21
|
+
* (runtime/browser-guest/control/root/opt/humanish/control/bin/xdg-open).
|
|
22
|
+
*/
|
|
23
|
+
export function externalLinkStandIn(bin, log) {
|
|
24
|
+
return [
|
|
25
|
+
"#!/bin/sh",
|
|
26
|
+
"# Installed by humanish for a study participant's browser: this desktop has no app for this",
|
|
27
|
+
"# link, so the link is written down for the study instead of opening a chooser dialog.",
|
|
28
|
+
`bin=${shellQuote(bin)}`,
|
|
29
|
+
"url=",
|
|
30
|
+
'for arg in "$@"; do case "$arg" in -*) ;; *) url=$arg; break ;; esac; done',
|
|
31
|
+
'case "$url" in',
|
|
32
|
+
" [Hh][Tt][Tt][Pp]:*|[Hh][Tt][Tt][Pp][Ss]:*|[Ff][Ii][Ll][Ee]:*) ;;",
|
|
33
|
+
" [A-Za-z]*:*)",
|
|
34
|
+
` printf '%s\\n' "$(printf '%s' "$url" | tr -d '\\r\\n' | cut -c 1-${LINK_LENGTH})" >> ${log}`,
|
|
35
|
+
" exit 0 ;;",
|
|
36
|
+
"esac",
|
|
37
|
+
// Anything else goes to the desktop's own command, found on the search path without this
|
|
38
|
+
// directory.
|
|
39
|
+
"set -f",
|
|
40
|
+
"real=",
|
|
41
|
+
"IFS=:",
|
|
42
|
+
'for entry in $PATH; do [ "$entry" = "$bin" ] || real=${real:+$real:}$entry; done',
|
|
43
|
+
'PATH=$real exec "${0##*/}" "$@"',
|
|
44
|
+
"",
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Shell lines for the browser launch script, run before the browser starts: they install the
|
|
49
|
+
* stand-ins and put them first on the search path for every command the script starts after them.
|
|
50
|
+
*/
|
|
51
|
+
export function externalLinkHandlerSetup(dir = EXTERNAL_LINK_DIR) {
|
|
52
|
+
const bin = `${dir}/bin`;
|
|
53
|
+
return [
|
|
54
|
+
`mkdir -p ${shellQuote(bin)}`,
|
|
55
|
+
`touch ${shellQuote(`${dir}/links.log`)}`,
|
|
56
|
+
`printf '%s' ${shellQuote(externalLinkStandIn(bin, shellQuote(`${dir}/links.log`)))} > ${shellQuote(`${bin}/xdg-open`)}`,
|
|
57
|
+
`cp ${shellQuote(`${bin}/xdg-open`)} ${shellQuote(`${bin}/xdg-email`)}`,
|
|
58
|
+
`chmod 755 ${shellQuote(`${bin}/xdg-open`)} ${shellQuote(`${bin}/xdg-email`)}`,
|
|
59
|
+
`export PATH=${shellQuote(bin)}:"$PATH"`,
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Returns a read of the links handed off since the previous read, oldest first and at most
|
|
64
|
+
* EXTERNAL_HANDOFF_LIMITS.perObservation at a time. Reads run one at a time, so a retried
|
|
65
|
+
* observation cannot return a link twice. A line still being written is left for the next read.
|
|
66
|
+
*/
|
|
67
|
+
export function externalHandoffReader(shell, options) {
|
|
68
|
+
const log = `${options.dir ?? EXTERNAL_LINK_DIR}/links.log`;
|
|
69
|
+
let consumed = 0;
|
|
70
|
+
let previous = Promise.resolve();
|
|
71
|
+
const read = async () => {
|
|
72
|
+
const result = await shell.run(`tail -n +${consumed + 1} ${shellQuote(log)} 2>/dev/null | head -n ${EXTERNAL_HANDOFF_LIMITS.perObservation}`, { requestTimeoutMs: options.requestTimeoutMs, timeoutMs: 10_000 });
|
|
73
|
+
const lines = result.stdout.split("\n");
|
|
74
|
+
lines.pop();
|
|
75
|
+
consumed += lines.length;
|
|
76
|
+
return lines.flatMap((line) => {
|
|
77
|
+
const handoff = externalHandoffOf(line);
|
|
78
|
+
return handoff === undefined ? [] : [handoff];
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
return () => {
|
|
82
|
+
const next = previous.then(read, read);
|
|
83
|
+
previous = next.catch(() => undefined);
|
|
84
|
+
return next;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/** Updated with the verified, public runtime artifact before release. */
|
|
2
2
|
const LOCAL_RUNTIME_RELEASE = {
|
|
3
|
-
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.
|
|
4
|
-
sha256: "
|
|
5
|
-
bytes:
|
|
6
|
-
image: "sha256:
|
|
3
|
+
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.3/runtime-linux-amd64.tar.gz",
|
|
4
|
+
sha256: "6fc19792e9b8f414165a73470526cc414c98d0ad368210c5e95daf29469b8dbd",
|
|
5
|
+
bytes: 597049520,
|
|
6
|
+
image: "sha256:4672388737bd7d136910fca6f8b6d99e09fbe96c13e1b100491c05444e7d267a",
|
|
7
7
|
};
|
|
8
8
|
/** Architecture-specific artifacts; each retains matching sources and notices. */
|
|
9
9
|
export const LOCAL_RUNTIME_RELEASES = {
|
|
10
10
|
amd64: LOCAL_RUNTIME_RELEASE,
|
|
11
11
|
arm64: {
|
|
12
|
-
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.
|
|
13
|
-
sha256: "
|
|
14
|
-
bytes:
|
|
15
|
-
image: "sha256:
|
|
12
|
+
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.4/runtime-linux-arm64.tar.gz",
|
|
13
|
+
sha256: "d590de49a86eabe1cd09d7556aa75b2ebd62bc2c88fe37122e2af315c478c901",
|
|
14
|
+
bytes: 583551803,
|
|
15
|
+
image: "sha256:4606122520504f7f587f52b872165e6ab900be0ba3eca933b2f6dd3b9dcde723",
|
|
16
16
|
},
|
|
17
17
|
};
|
|
18
18
|
/** Media assets are separate so ordinary browser studies keep the smaller download. */
|