humanish 0.125.0 → 0.126.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 +16 -0
- package/dist/actors/computer-use/actor.d.ts +2 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/clock.d.ts +9 -8
- package/dist/actors/computer-use/loop/clock.js +27 -17
- package/dist/actors/computer-use/loop/debrief.d.ts +1 -1
- package/dist/actors/computer-use/loop/debrief.js +15 -5
- package/dist/actors/computer-use/loop/ending.d.ts +8 -5
- package/dist/actors/computer-use/loop/ending.js +8 -5
- package/dist/actors/computer-use/loop/observation.d.ts +6 -0
- package/dist/actors/computer-use/loop/observation.js +19 -1
- package/dist/actors/computer-use/loop/session.d.ts +4 -2
- package/dist/actors/computer-use/loop/session.js +4 -1
- package/dist/actors/computer-use/loop/trace.js +4 -2
- package/dist/actors/computer-use/loop/types.d.ts +22 -8
- package/dist/actors/computer-use/loop.js +5 -5
- package/dist/actors/contract.d.ts +31 -8
- package/dist/actors/contract.js +3 -0
- package/dist/actors/stop-cause.js +1 -0
- package/dist/cli/commands/study.js +8 -0
- package/dist/evidence/redaction.d.ts +1 -0
- package/dist/evidence/redaction.js +1 -0
- package/dist/evidence/trace-page.d.ts +6 -0
- package/dist/evidence/trace-page.js +90 -0
- package/dist/observer/feed-cursors.d.ts +14 -0
- package/dist/observer/feed-cursors.js +61 -0
- package/dist/observer/participant-items.d.ts +7 -0
- package/dist/observer/pinned-files.d.ts +2 -1
- package/dist/observer/pinned-files.js +2 -2
- package/dist/observer/run-routes.d.ts +1 -1
- package/dist/observer/run-routes.js +38 -17
- package/dist/observer-app.html +7 -7
- package/dist/routes/computer-use/bundle.js +4 -0
- package/dist/routes/computer-use/diagnostics.d.ts +1 -1
- package/dist/routes/computer-use/diagnostics.js +3 -3
- package/dist/routes/computer-use/e2b-desktop/desktop.js +2 -1
- package/dist/routes/computer-use/e2b-desktop/prepare.d.ts +6 -0
- package/dist/routes/computer-use/e2b-desktop/prepare.js +47 -1
- package/dist/routes/computer-use/e2b-desktop/start.d.ts +2 -1
- package/dist/routes/computer-use/e2b-desktop/start.js +15 -3
- package/dist/routes/computer-use/e2b-desktop/state.d.ts +3 -1
- package/dist/routes/computer-use/e2b-desktop/state.js +2 -0
- package/dist/routes/computer-use/fanout-records.js +4 -0
- package/dist/routes/computer-use/participant-deps.d.ts +1 -1
- package/dist/routes/computer-use/participant-desktop.d.ts +1 -1
- package/dist/routes/computer-use/participant-model.js +16 -6
- package/dist/routes/computer-use/participant-runs.d.ts +2 -2
- package/dist/routes/computer-use/participant-runs.js +11 -7
- package/dist/routes/computer-use/plan.js +39 -1
- package/dist/routes/computer-use/setup.d.ts +1 -0
- package/dist/routes/computer-use/setup.js +28 -0
- package/dist/routes/computer-use/single-bundle.d.ts +5 -1
- package/dist/routes/computer-use/single-bundle.js +2 -0
- package/dist/routes/computer-use/types.d.ts +16 -10
- package/dist/routes/shared-world/bundle-records.js +3 -0
- package/dist/routes/shared-world/participant-specs.js +3 -2
- package/dist/routes/shared-world/plan.js +5 -0
- package/dist/run/contained-output.d.ts +7 -3
- package/dist/run/contained-output.js +11 -7
- package/dist/run/judge.d.ts +7 -4
- package/dist/run/judge.js +13 -8
- package/dist/run/live-items.d.ts +19 -0
- package/dist/run/live-items.js +28 -8
- package/dist/run/stream-trace.d.ts +25 -4
- package/dist/run/stream-trace.js +55 -17
- package/dist/run/streams.d.ts +16 -0
- package/dist/study/arrivals.d.ts +32 -14
- package/dist/study/arrivals.js +47 -21
- package/dist/study/bundle-estimate.d.ts +6 -5
- package/dist/study/bundle-estimate.js +12 -7
- package/dist/study/desktop-files.d.ts +37 -0
- package/dist/study/desktop-files.js +174 -0
- package/dist/study/keys.d.ts +8 -0
- package/dist/study/keys.js +3 -0
- package/dist/study/migrate/v2.js +12 -3
- package/dist/study/parse/actors.d.ts +1 -1
- package/dist/study/parse/actors.js +28 -20
- package/dist/study/parse/clock.d.ts +18 -0
- package/dist/study/parse/clock.js +36 -0
- package/dist/study/plan-participants.d.ts +19 -5
- package/dist/study/plan-participants.js +17 -1
- package/dist/study/plan.js +24 -13
- package/dist/study/preflight.js +4 -0
- package/dist/study/types.d.ts +34 -5
- package/dist/study/validation.js +3 -0
- package/dist/study/warnings.d.ts +2 -2
- package/dist/study/warnings.js +7 -0
- package/dist/subject/local-tree-archive.d.ts +7 -0
- package/dist/subject/local-tree-archive.js +12 -0
- package/dist/substrates/e2b/desktop-browser.d.ts +4 -1
- package/dist/substrates/e2b/desktop-browser.js +6 -2
- package/dist/substrates/e2b/desktop-clock.d.ts +10 -0
- package/dist/substrates/e2b/desktop-clock.js +29 -0
- package/dist/substrates/e2b/sdk.d.ts +6 -0
- package/dist/verify/pages.d.ts +7 -0
- package/dist/verify/pages.js +50 -0
- package/dist/verify/verify.js +10 -3
- package/docs/architecture/actor-contract.md +2 -0
- package/docs/architecture/external-public-shared-world.md +4 -1
- package/docs/architecture/observer.md +10 -1
- package/docs/contracts/policy.md +7 -0
- package/docs/contracts/run-bundle.md +31 -1
- package/docs/contracts/schemas.md +72 -3
- package/package.json +2 -2
package/dist/study/arrivals.d.ts
CHANGED
|
@@ -8,41 +8,58 @@ export declare const LATEST_START_AFTER_MS: number;
|
|
|
8
8
|
export declare function isStartAfterMs(value: unknown): value is number;
|
|
9
9
|
/** How long before its end a participant is told the time left, unless it declares `windDownMs`. */
|
|
10
10
|
export declare const DEFAULT_WIND_DOWN_MS: number;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* A participant's declared window: when it starts, and when it ends on the run's clock
|
|
13
|
+
* (`endAfterMs`) or at the latest after its own session started (`maxSessionMs`).
|
|
14
|
+
*/
|
|
12
15
|
export interface ParticipantWindow {
|
|
13
16
|
readonly id?: string;
|
|
14
17
|
readonly startAfterMs?: number;
|
|
15
18
|
readonly endAfterMs?: number;
|
|
19
|
+
readonly maxSessionMs?: number;
|
|
16
20
|
readonly windDownMs?: number;
|
|
17
21
|
}
|
|
18
22
|
/**
|
|
19
23
|
* Why a participant's declared end cannot run, or undefined: an end that is not a whole number of
|
|
20
|
-
* milliseconds up to 24 hours, one at or before its start,
|
|
21
|
-
*
|
|
24
|
+
* milliseconds up to 24 hours, one at or before its start, a cap that is not a whole number of
|
|
25
|
+
* milliseconds from 1 to 24 hours, a cap beside an end, or a wind-down that is not a whole number
|
|
26
|
+
* of milliseconds or has neither. `path` names the entry, as in "participants[1]".
|
|
22
27
|
*/
|
|
23
28
|
export declare function windowReason(entry: {
|
|
24
29
|
readonly id?: string;
|
|
25
30
|
readonly startAfterMs?: number;
|
|
26
31
|
readonly endAfterMs?: unknown;
|
|
32
|
+
readonly maxSessionMs?: unknown;
|
|
27
33
|
readonly windDownMs?: unknown;
|
|
28
34
|
}, path: string): string | undefined;
|
|
29
35
|
/**
|
|
30
|
-
* A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS
|
|
31
|
-
* place of the study's `sessionMs` (execution.timeoutMs
|
|
36
|
+
* A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS, and
|
|
37
|
+
* with a cap, the cap plus the same room, in place of the study's `sessionMs` (execution.timeoutMs
|
|
38
|
+
* or its default); else `sessionMs`.
|
|
32
39
|
*/
|
|
33
40
|
export declare function participantSessionMs(participant: ParticipantWindow, sessionMs: number): number;
|
|
34
41
|
/**
|
|
35
|
-
* When a participant
|
|
36
|
-
*
|
|
37
|
-
* at its declared time.
|
|
42
|
+
* When a participant's session ends: at a time on the run's clock, or at the latest a time after
|
|
43
|
+
* its own session starts, which the route reads when the session starts.
|
|
38
44
|
*/
|
|
39
|
-
export
|
|
45
|
+
export type ParticipantEnd = {
|
|
46
|
+
readonly kind: "scheduled_end";
|
|
40
47
|
readonly atMs: number;
|
|
41
48
|
readonly windDownMs: number;
|
|
42
|
-
} |
|
|
49
|
+
} | {
|
|
50
|
+
readonly kind: "session_cap";
|
|
51
|
+
readonly afterMs: number;
|
|
52
|
+
readonly windDownMs: number;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* How a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, or
|
|
56
|
+
* undefined when it declares neither an end nor a cap. A participant that waited for a slot still
|
|
57
|
+
* ends at its declared time, and still gets its whole cap.
|
|
58
|
+
*/
|
|
59
|
+
export declare function participantEnd(participant: ParticipantWindow, scheduledAt: number): ParticipantEnd | undefined;
|
|
43
60
|
/**
|
|
44
|
-
* Why the sandbox of the first participant whose window passes `ceilingMs` cannot live that
|
|
45
|
-
* or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
|
|
61
|
+
* Why the sandbox of the first participant whose window or cap passes `ceilingMs` cannot live that
|
|
62
|
+
* long, or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
|
|
46
63
|
* route adds to a session for setup and teardown (sandboxHeadroomMs).
|
|
47
64
|
*/
|
|
48
65
|
export declare function windowDeadlineRefusal(participants: readonly ParticipantWindow[], sandbox: {
|
|
@@ -50,8 +67,9 @@ export declare function windowDeadlineRefusal(participants: readonly Participant
|
|
|
50
67
|
readonly headroomMs: number;
|
|
51
68
|
}, ceilingMs: number): string | undefined;
|
|
52
69
|
/**
|
|
53
|
-
* Each participant's window for `study check`, when any participant declares an end: when
|
|
54
|
-
* and ends and when it is first told the time left,
|
|
70
|
+
* Each participant's window for `study check`, when any participant declares an end or a cap: when
|
|
71
|
+
* it starts and ends and when it is first told the time left, when it starts and its cap and how
|
|
72
|
+
* far into its session it is first told the time left, or when it starts and its session budget.
|
|
55
73
|
*/
|
|
56
74
|
export declare function describeWindows(participants: readonly ParticipantWindow[], sessionMs: number): string | undefined;
|
|
57
75
|
/** The refusal of a start offset that is not one, naming its `field`. */
|
package/dist/study/arrivals.js
CHANGED
|
@@ -32,13 +32,18 @@ export const DEFAULT_WIND_DOWN_MS = 10 * 60_000;
|
|
|
32
32
|
const SCHEDULED_END_ROOM_MS = 5 * 60_000;
|
|
33
33
|
/**
|
|
34
34
|
* Why a participant's declared end cannot run, or undefined: an end that is not a whole number of
|
|
35
|
-
* milliseconds up to 24 hours, one at or before its start,
|
|
36
|
-
*
|
|
35
|
+
* milliseconds up to 24 hours, one at or before its start, a cap that is not a whole number of
|
|
36
|
+
* milliseconds from 1 to 24 hours, a cap beside an end, or a wind-down that is not a whole number
|
|
37
|
+
* of milliseconds or has neither. `path` names the entry, as in "participants[1]".
|
|
37
38
|
*/
|
|
38
39
|
export function windowReason(entry, path) {
|
|
39
|
-
const { endAfterMs, windDownMs } = entry;
|
|
40
|
-
if (
|
|
41
|
-
return `${path}
|
|
40
|
+
const { endAfterMs, maxSessionMs, windDownMs } = entry;
|
|
41
|
+
if (maxSessionMs !== undefined && endAfterMs !== undefined)
|
|
42
|
+
return `${path} sets both endAfterMs and maxSessionMs. endAfterMs holds the participant until a set time, and maxSessionMs lets it end its session sooner, up to a cap, so a participant takes one of them. Keep endAfterMs for one that stays until a set time, or maxSessionMs for one that may leave when it is done.`;
|
|
43
|
+
if (maxSessionMs !== undefined && !(isStartAfterMs(maxSessionMs) && maxSessionMs > 0))
|
|
44
|
+
return `${path}.maxSessionMs must be a whole number of milliseconds from 1 to ${LATEST_START_AFTER_MS} (24 hours): the longest the participant's session lasts, counted from its start.`;
|
|
45
|
+
if (windDownMs !== undefined && endAfterMs === undefined && maxSessionMs === undefined)
|
|
46
|
+
return `${path}.windDownMs is how long before its endAfterMs or maxSessionMs a participant is told the time left, so it needs one of them. Add an end, or remove windDownMs.`;
|
|
42
47
|
if (windDownMs !== undefined && !isStartAfterMs(windDownMs))
|
|
43
48
|
return `${path}.windDownMs must be a whole number of milliseconds from 0 to ${LATEST_START_AFTER_MS} (24 hours): how long before its end the participant is told the time left.`;
|
|
44
49
|
if (endAfterMs === undefined)
|
|
@@ -53,54 +58,75 @@ export function windowReason(entry, path) {
|
|
|
53
58
|
return undefined;
|
|
54
59
|
}
|
|
55
60
|
/**
|
|
56
|
-
* A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS
|
|
57
|
-
* place of the study's `sessionMs` (execution.timeoutMs
|
|
61
|
+
* A participant's session budget: with a declared end, its window plus SCHEDULED_END_ROOM_MS, and
|
|
62
|
+
* with a cap, the cap plus the same room, in place of the study's `sessionMs` (execution.timeoutMs
|
|
63
|
+
* or its default); else `sessionMs`.
|
|
58
64
|
*/
|
|
59
65
|
export function participantSessionMs(participant, sessionMs) {
|
|
66
|
+
const windowMs = declaredWindowMs(participant);
|
|
67
|
+
return windowMs === undefined ? sessionMs : windowMs + SCHEDULED_END_ROOM_MS;
|
|
68
|
+
}
|
|
69
|
+
/** How long a participant with a declared end or cap may work, or undefined without either. */
|
|
70
|
+
function declaredWindowMs(participant) {
|
|
71
|
+
if (participant.maxSessionMs !== undefined)
|
|
72
|
+
return participant.maxSessionMs;
|
|
60
73
|
if (participant.endAfterMs === undefined)
|
|
61
|
-
return
|
|
62
|
-
return participant.endAfterMs - (participant.startAfterMs ?? 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
return participant.endAfterMs - (participant.startAfterMs ?? 0);
|
|
63
76
|
}
|
|
64
77
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* at its declared time.
|
|
78
|
+
* How a participant that was due at `scheduledAt` (runOnSchedule's ScheduledStart) ends, or
|
|
79
|
+
* undefined when it declares neither an end nor a cap. A participant that waited for a slot still
|
|
80
|
+
* ends at its declared time, and still gets its whole cap.
|
|
68
81
|
*/
|
|
69
|
-
export function
|
|
82
|
+
export function participantEnd(participant, scheduledAt) {
|
|
83
|
+
const windDownMs = participant.windDownMs ?? DEFAULT_WIND_DOWN_MS;
|
|
84
|
+
if (participant.maxSessionMs !== undefined)
|
|
85
|
+
return { kind: "session_cap", afterMs: participant.maxSessionMs, windDownMs };
|
|
70
86
|
if (participant.endAfterMs === undefined)
|
|
71
87
|
return undefined;
|
|
72
88
|
return {
|
|
89
|
+
kind: "scheduled_end",
|
|
73
90
|
atMs: scheduledAt - (participant.startAfterMs ?? 0) + participant.endAfterMs,
|
|
74
|
-
windDownMs
|
|
91
|
+
windDownMs,
|
|
75
92
|
};
|
|
76
93
|
}
|
|
77
94
|
/**
|
|
78
|
-
* Why the sandbox of the first participant whose window passes `ceilingMs` cannot live that
|
|
79
|
-
* or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
|
|
95
|
+
* Why the sandbox of the first participant whose window or cap passes `ceilingMs` cannot live that
|
|
96
|
+
* long, or undefined. Its deadline is its session (participantSessionMs) plus `headroomMs`, what the
|
|
80
97
|
* route adds to a session for setup and teardown (sandboxHeadroomMs).
|
|
81
98
|
*/
|
|
82
99
|
export function windowDeadlineRefusal(participants, sandbox, ceilingMs) {
|
|
83
100
|
const inMinutes = (ms) => Math.round(ms / 60_000);
|
|
84
101
|
for (const participant of participants) {
|
|
85
|
-
if (participant
|
|
102
|
+
if (declaredWindowMs(participant) === undefined)
|
|
86
103
|
continue;
|
|
87
104
|
const deadlineMs = participantSessionMs(participant, sandbox.sessionMs) + sandbox.headroomMs;
|
|
88
105
|
if (deadlineMs <= ceilingMs)
|
|
89
106
|
continue;
|
|
90
107
|
const name = participant.id === undefined ? "A participant" : `Participant "${participant.id}"`;
|
|
91
|
-
|
|
108
|
+
const works = participant.maxSessionMs === undefined
|
|
109
|
+
? `works from +${formatDuration(participant.startAfterMs ?? 0)} to +${formatDuration(participant.endAfterMs ?? 0)}`
|
|
110
|
+
: `may work for up to ${formatDuration(participant.maxSessionMs)}`;
|
|
111
|
+
const window = participant.maxSessionMs === undefined ? "window" : "cap";
|
|
112
|
+
return `${name} ${works}, so its sandbox needs a ${inMinutes(deadlineMs)}m deadline: the ${window}, ${inMinutes(SCHEDULED_END_ROOM_MS)}m for its closing report and ${inMinutes(sandbox.headroomMs)}m to set up and tear down. A sandbox may not live longer than ${ceilingMs / 60_000}m. Shorten its ${window}.${ceilingAdvice(deadlineMs)}`;
|
|
92
113
|
}
|
|
93
114
|
return undefined;
|
|
94
115
|
}
|
|
95
116
|
/**
|
|
96
|
-
* Each participant's window for `study check`, when any participant declares an end: when
|
|
97
|
-
* and ends and when it is first told the time left,
|
|
117
|
+
* Each participant's window for `study check`, when any participant declares an end or a cap: when
|
|
118
|
+
* it starts and ends and when it is first told the time left, when it starts and its cap and how
|
|
119
|
+
* far into its session it is first told the time left, or when it starts and its session budget.
|
|
98
120
|
*/
|
|
99
121
|
export function describeWindows(participants, sessionMs) {
|
|
100
|
-
if (!participants.some((participant) => participant
|
|
122
|
+
if (!participants.some((participant) => declaredWindowMs(participant) !== undefined))
|
|
101
123
|
return undefined;
|
|
102
124
|
const windows = participants.map((participant) => {
|
|
103
125
|
const start = `${participant.id ?? "participant"} from +${formatDuration(participant.startAfterMs ?? 0)}`;
|
|
126
|
+
if (participant.maxSessionMs !== undefined) {
|
|
127
|
+
const told = Math.max(0, participant.maxSessionMs - (participant.windDownMs ?? DEFAULT_WIND_DOWN_MS));
|
|
128
|
+
return `${start} until it ends itself or for up to ${formatDuration(participant.maxSessionMs)}, told the time left from ${formatDuration(told)} into its session`;
|
|
129
|
+
}
|
|
104
130
|
if (participant.endAfterMs === undefined)
|
|
105
131
|
return `${start} for up to ${formatDuration(sessionMs)}`;
|
|
106
132
|
const told = Math.max(participant.startAfterMs ?? 0, participant.endAfterMs - (participant.windDownMs ?? DEFAULT_WIND_DOWN_MS));
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { DwellWindow } from "../actors/stop-conditions.js";
|
|
2
|
-
/** One participant as the plan has it: when it starts and ends, and its dwell window. */
|
|
2
|
+
/** One participant as the plan has it: when it starts and ends, its cap, and its dwell window. */
|
|
3
3
|
export interface EstimatedParticipant {
|
|
4
4
|
readonly startAfterMs?: number;
|
|
5
5
|
readonly endAfterMs?: number;
|
|
6
|
+
readonly maxSessionMs?: number;
|
|
6
7
|
readonly limits: {
|
|
7
8
|
readonly dwell?: DwellWindow;
|
|
8
9
|
};
|
|
@@ -33,10 +34,10 @@ export interface BundleEstimate {
|
|
|
33
34
|
}
|
|
34
35
|
/**
|
|
35
36
|
* The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
|
|
36
|
-
* declares an end. A participant with an end works its whole window; one without
|
|
37
|
-
* the measured open session above, or its budget
|
|
38
|
-
*
|
|
39
|
-
* session, and the frame after it.
|
|
37
|
+
* declares an end or a cap. A participant with an end works its whole window; one without ends
|
|
38
|
+
* itself, so it is counted for the measured open session above, or its budget (its cap, else
|
|
39
|
+
* `sessionMs`) when that is shorter. Each takes a model turn at the measured pace, and a dwell
|
|
40
|
+
* window adds a frame every `everyMs` for its length, cut to the session, and the frame after it.
|
|
40
41
|
*/
|
|
41
42
|
export declare function estimateBundle(participants: readonly EstimatedParticipant[], sessionMs: number): BundleEstimate;
|
|
42
43
|
/**
|
|
@@ -48,26 +48,31 @@ const RATES = Object.freeze({
|
|
|
48
48
|
const MIB = 1024 * 1024;
|
|
49
49
|
/**
|
|
50
50
|
* The bundle a live run of `participants` leaves, each in its session of `sessionMs` unless it
|
|
51
|
-
* declares an end. A participant with an end works its whole window; one without
|
|
52
|
-
* the measured open session above, or its budget
|
|
53
|
-
*
|
|
54
|
-
* session, and the frame after it.
|
|
51
|
+
* declares an end or a cap. A participant with an end works its whole window; one without ends
|
|
52
|
+
* itself, so it is counted for the measured open session above, or its budget (its cap, else
|
|
53
|
+
* `sessionMs`) when that is shorter. Each takes a model turn at the measured pace, and a dwell
|
|
54
|
+
* window adds a frame every `everyMs` for its length, cut to the session, and the frame after it.
|
|
55
55
|
*/
|
|
56
56
|
export function estimateBundle(participants, sessionMs) {
|
|
57
57
|
let turns = 0;
|
|
58
58
|
let frames = 0;
|
|
59
59
|
let longestWindowMs = 0;
|
|
60
|
-
|
|
60
|
+
// The longest any participant without an end is counted, for the messages.
|
|
61
|
+
let openMs = Math.min(sessionMs, RATES.openSessionMs);
|
|
61
62
|
for (const participant of participants) {
|
|
62
63
|
const windowMs = participant.endAfterMs === undefined
|
|
63
64
|
? undefined
|
|
64
65
|
: participant.endAfterMs - (participant.startAfterMs ?? 0);
|
|
66
|
+
const budgetMs = windowMs ?? participant.maxSessionMs ?? sessionMs;
|
|
67
|
+
const countedMs = windowMs ?? Math.min(budgetMs, RATES.openSessionMs);
|
|
65
68
|
if (windowMs !== undefined)
|
|
66
69
|
longestWindowMs = Math.max(longestWindowMs, windowMs);
|
|
67
|
-
|
|
70
|
+
else
|
|
71
|
+
openMs = Math.max(openMs, countedMs);
|
|
72
|
+
turns += Math.ceil(countedMs / (RATES.secondsPerTurn * 1000));
|
|
68
73
|
const dwell = participant.limits.dwell;
|
|
69
74
|
if (dwell !== undefined)
|
|
70
|
-
frames += Math.floor(Math.min(dwell.ms,
|
|
75
|
+
frames += Math.floor(Math.min(dwell.ms, budgetMs) / dwell.everyMs) + 1;
|
|
71
76
|
}
|
|
72
77
|
const count = participants.length;
|
|
73
78
|
const runFileBytes = RATES.runBytes +
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { StudyParseFailure, StudyParticipantFile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where `participants[].files` act: the hosted desktop of an independent computer-use participant.
|
|
4
|
+
* The parser's unread-field check and the route planners give this reason.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DESKTOP_FILES_ROUTE_REASON = "files are copied onto a hosted computer-use desktop; needs route: computer-use with execution.target: e2b-desktop";
|
|
7
|
+
/** A participant's `files` list: each entry's `from` and `as`. */
|
|
8
|
+
export declare function parseParticipantFiles(raw: unknown, field: string): {
|
|
9
|
+
ok: true;
|
|
10
|
+
value: StudyParticipantFile[] | undefined;
|
|
11
|
+
} | StudyParseFailure;
|
|
12
|
+
/** One file read on this machine for a participant's desktop. */
|
|
13
|
+
export interface DesktopFile {
|
|
14
|
+
/** Where it lands, relative to the desktop user's home folder. */
|
|
15
|
+
readonly as: string;
|
|
16
|
+
/** The file's bytes, shared by every participant that receives it and uploaded as they are. */
|
|
17
|
+
readonly data: ArrayBuffer;
|
|
18
|
+
readonly size: number;
|
|
19
|
+
/** The sha256 of `data`, 64 hex characters. */
|
|
20
|
+
readonly sha256: string;
|
|
21
|
+
}
|
|
22
|
+
type DesktopFilesRead = {
|
|
23
|
+
readonly ok: true;
|
|
24
|
+
readonly files: ReadonlyMap<string, readonly DesktopFile[]>;
|
|
25
|
+
} | {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Read every participant's declared files from `folder`, the folder that holds the study file, and
|
|
31
|
+
* return them by participant id. Runs before any sandbox exists, so a refusal costs nothing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readDesktopFiles(folder: string, participants: readonly {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly files?: readonly StudyParticipantFile[];
|
|
36
|
+
}[]): Promise<DesktopFilesRead>;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Files a participant's hosted desktop receives before it starts (`participants[].files`), so a
|
|
2
|
+
// browser upload dialog can find them. The parser checks the declared paths; readDesktopFiles reads
|
|
3
|
+
// the files from the study's folder on this machine before any sandbox exists.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { stat } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { scanEncodedText } from "../evidence/encoded-text.js";
|
|
8
|
+
import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
|
|
9
|
+
import { isRecord } from "../run/type-guards.js";
|
|
10
|
+
import { isLocalTreeDenylisted } from "../subject/local-tree-archive.js";
|
|
11
|
+
import { SUBJECT_DIR } from "../subject/steps.js";
|
|
12
|
+
import { invalid, str } from "./parse/values.js";
|
|
13
|
+
/**
|
|
14
|
+
* Where `participants[].files` act: the hosted desktop of an independent computer-use participant.
|
|
15
|
+
* The parser's unread-field check and the route planners give this reason.
|
|
16
|
+
*/
|
|
17
|
+
export const DESKTOP_FILES_ROUTE_REASON = "files are copied onto a hosted computer-use desktop; needs route: computer-use with execution.target: e2b-desktop";
|
|
18
|
+
/** The most files one participant's desktop receives. */
|
|
19
|
+
const MAX_FILES = 20;
|
|
20
|
+
/** A participant's `files` list: each entry's `from` and `as`. */
|
|
21
|
+
export function parseParticipantFiles(raw, field) {
|
|
22
|
+
if (raw === undefined)
|
|
23
|
+
return { ok: true, value: undefined };
|
|
24
|
+
const listReason = `\`${field}\` must be a list of 1 to ${MAX_FILES} { from, as } entries, one per file the participant's desktop receives.`;
|
|
25
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_FILES)
|
|
26
|
+
return invalid(listReason);
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const [index, entry] of raw.entries()) {
|
|
29
|
+
const at = `${field}[${index}]`;
|
|
30
|
+
const from = isRecord(entry) ? str(entry.from) : undefined;
|
|
31
|
+
const as = isRecord(entry) ? str(entry.as) : undefined;
|
|
32
|
+
if (from === undefined || as === undefined)
|
|
33
|
+
return invalid(listReason);
|
|
34
|
+
if (!isPlainRelativePath(from))
|
|
35
|
+
return invalid(`\`${at}.from\` must be a path inside the folder that holds the study file, written relative to it, such as fixtures/insurance-card.png, with / between folders and no empty, . or .. part.`);
|
|
36
|
+
if (isLocalTreeDenylisted(from))
|
|
37
|
+
return invalid(`\`${at}.from\` names a file humanish keeps on this machine: a .env file, a key, a credential file, or one under .git, node_modules or .humanish. The local-tree archive leaves the same files out. Copy a synthetic file instead.`);
|
|
38
|
+
if (!isPlainRelativePath(as) || as.startsWith("~"))
|
|
39
|
+
return invalid(`\`${at}.as\` must be a path relative to the desktop user's home folder, such as Downloads/insurance-card.png, with / between folders, no empty, . or .. part, and no leading ~.`);
|
|
40
|
+
if (isHumanishDesktopPath(as))
|
|
41
|
+
return invalid(`\`${at}.as\` is a path humanish uses on the desktop: ${SUBJECT_FOLDER}/ holds the cloned or unpacked app, and names that start with .humanish are humanish's own files. Pick another path, such as Downloads/insurance-card.png.`);
|
|
42
|
+
// The run records `as`, and redacting it would record a different path. Encoded text the
|
|
43
|
+
// public-safety scan cannot read would leave run.json unscanned.
|
|
44
|
+
const scanned = scanEncodedText(as);
|
|
45
|
+
if (scanned.sensitive || scanned.opaque)
|
|
46
|
+
return invalid(`\`${at}.as\` looks like a secret, a local path or encoded text, and the run records it. Name the file plainly, such as Downloads/insurance-card.png.`);
|
|
47
|
+
const clash = files.find((earlier) => earlier.as === as || as.startsWith(`${earlier.as}/`) || earlier.as.startsWith(`${as}/`));
|
|
48
|
+
if (clash !== undefined)
|
|
49
|
+
return invalid(clash.as === as
|
|
50
|
+
? `\`${at}.as\` names ${as}, which another file in this list already uses. Give each file its own path.`
|
|
51
|
+
: `\`${at}.as\` names ${as}, and another file in this list uses ${clash.as}, so one would have to be both a file and a folder. Give each file its own path.`);
|
|
52
|
+
files.push({ from, as });
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, value: files };
|
|
55
|
+
}
|
|
56
|
+
/** The subject's folder under the sandbox user's home, where a clone or local tree is put. */
|
|
57
|
+
const SUBJECT_FOLDER = path.posix.relative("/home/user", SUBJECT_DIR);
|
|
58
|
+
/**
|
|
59
|
+
* Whether `as` lands where humanish puts its own files: the subject's folder, which provisioning
|
|
60
|
+
* replaces, or a name starting with .humanish, such as the local-tree archive.
|
|
61
|
+
*/
|
|
62
|
+
function isHumanishDesktopPath(as) {
|
|
63
|
+
const first = as.split("/")[0] ?? "";
|
|
64
|
+
return first === SUBJECT_FOLDER || first.startsWith(".humanish");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A relative path of plain names joined by `/`. A backslash is refused because the desktop is
|
|
68
|
+
* Linux, where it is part of a name, and on Windows it would be a separator.
|
|
69
|
+
*/
|
|
70
|
+
function isPlainRelativePath(value) {
|
|
71
|
+
if (value.includes("\0") || value.includes("\\") || value.startsWith("/"))
|
|
72
|
+
return false;
|
|
73
|
+
return value.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
74
|
+
}
|
|
75
|
+
const MIB = 1024 * 1024;
|
|
76
|
+
/** The most bytes humanish copies for one file. */
|
|
77
|
+
const FILE_MAX_BYTES = 10 * MIB;
|
|
78
|
+
/** The most bytes one participant's desktop receives, a file counted each time it is placed. */
|
|
79
|
+
const PARTICIPANT_MAX_BYTES = 25 * MIB;
|
|
80
|
+
/** The most bytes of distinct files a study names: humanish holds them in memory for the run. */
|
|
81
|
+
const STUDY_MAX_BYTES = 100 * MIB;
|
|
82
|
+
/**
|
|
83
|
+
* Read every participant's declared files from `folder`, the folder that holds the study file, and
|
|
84
|
+
* return them by participant id. Runs before any sandbox exists, so a refusal costs nothing.
|
|
85
|
+
*/
|
|
86
|
+
export async function readDesktopFiles(folder, participants) {
|
|
87
|
+
// A library caller's config skips the parser, so its rules run again here.
|
|
88
|
+
for (const participant of participants) {
|
|
89
|
+
const checked = parseParticipantFiles(participant.files, `Participant ${participant.id}'s files`);
|
|
90
|
+
if (!checked.ok)
|
|
91
|
+
return { ok: false, message: checked.error.message };
|
|
92
|
+
}
|
|
93
|
+
const files = new Map();
|
|
94
|
+
if (!participants.some((participant) => participant.files !== undefined))
|
|
95
|
+
return { ok: true, files };
|
|
96
|
+
const root = await bindFolder(folder);
|
|
97
|
+
if (root === undefined)
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
message: "The folder that holds the study file could not be read as a folder, so its participants' files cannot be copied. Run the study from its file, with the folder in place.",
|
|
101
|
+
};
|
|
102
|
+
// One read per distinct `from`: a group of participants shares one file's bytes.
|
|
103
|
+
const sources = new Map();
|
|
104
|
+
let studyBytes = 0;
|
|
105
|
+
for (const participant of participants) {
|
|
106
|
+
const placed = [];
|
|
107
|
+
let participantBytes = 0;
|
|
108
|
+
for (const [index, file] of (participant.files ?? []).entries()) {
|
|
109
|
+
let source = sources.get(file.from);
|
|
110
|
+
if (source === undefined) {
|
|
111
|
+
// A file past what the study has left is refused unread, so memory stays under the total.
|
|
112
|
+
const left = STUDY_MAX_BYTES - studyBytes;
|
|
113
|
+
const read = await readContainedRegularFile(root, file.from, Math.min(FILE_MAX_BYTES, left));
|
|
114
|
+
if (read.status === "refused" && read.reason === "too-large" && left < FILE_MAX_BYTES)
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
message: `The files this study's participants receive total more than ${STUDY_MAX_BYTES / MIB} MiB, the most humanish holds in memory for one run. Use smaller files, or give more participants the same file.`,
|
|
118
|
+
};
|
|
119
|
+
if (read.status !== "read")
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
message: `Participant ${participant.id}'s files[${index}].from, ${file.from}, ${unreadReason(read)}. Paths in from are relative to the folder that holds the study file.`,
|
|
123
|
+
};
|
|
124
|
+
const sha256 = createHash("sha256").update(read.bytes).digest("hex");
|
|
125
|
+
source = { data: new Uint8Array(read.bytes).buffer, size: read.bytes.length, sha256 };
|
|
126
|
+
sources.set(file.from, source);
|
|
127
|
+
studyBytes += source.size;
|
|
128
|
+
}
|
|
129
|
+
participantBytes += source.size;
|
|
130
|
+
if (participantBytes > PARTICIPANT_MAX_BYTES)
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
message: `Participant ${participant.id}'s files total more than ${PARTICIPANT_MAX_BYTES / MIB} MiB, the most humanish copies onto one desktop. Use smaller or fewer files.`,
|
|
134
|
+
};
|
|
135
|
+
placed.push({ as: file.as, ...source });
|
|
136
|
+
}
|
|
137
|
+
if (placed.length > 0)
|
|
138
|
+
files.set(participant.id, placed);
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, files };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The study's folder as a contained-read root, or undefined when it is not an existing folder.
|
|
144
|
+
* prepareSelectedOutputDirectory would create a missing one.
|
|
145
|
+
*/
|
|
146
|
+
async function bindFolder(folder) {
|
|
147
|
+
try {
|
|
148
|
+
if (!(await stat(folder)).isDirectory())
|
|
149
|
+
return undefined;
|
|
150
|
+
return await prepareSelectedOutputDirectory(folder, folder);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Why a `from` could not be read, as a clause after its path. */
|
|
157
|
+
function unreadReason(read) {
|
|
158
|
+
if (read.status === "missing")
|
|
159
|
+
return "does not exist in the folder that holds the study file";
|
|
160
|
+
switch (read.reason) {
|
|
161
|
+
case "too-large":
|
|
162
|
+
return `is larger than ${FILE_MAX_BYTES / MIB} MiB, the most humanish copies for one file`;
|
|
163
|
+
case "directory":
|
|
164
|
+
return "is a folder; name a file";
|
|
165
|
+
case "not-regular":
|
|
166
|
+
return "is a symbolic link, a hard link or another kind of file humanish does not copy; put a plain copy of the file in the folder";
|
|
167
|
+
case "unsafe-path":
|
|
168
|
+
return "leaves the folder that holds the study file or passes through a symbolic link; put a plain copy of the file in the folder";
|
|
169
|
+
case "changed":
|
|
170
|
+
return "changed while humanish read it; run the study again";
|
|
171
|
+
case "unreadable":
|
|
172
|
+
return "could not be read; check its permissions";
|
|
173
|
+
}
|
|
174
|
+
}
|
package/dist/study/keys.d.ts
CHANGED
|
@@ -16,12 +16,20 @@ export declare const PARTICIPANT_ENTRY: {
|
|
|
16
16
|
device: true;
|
|
17
17
|
instruction: true;
|
|
18
18
|
target: true;
|
|
19
|
+
files: {
|
|
20
|
+
from: true;
|
|
21
|
+
as: true;
|
|
22
|
+
};
|
|
19
23
|
entry: true;
|
|
20
24
|
host: true;
|
|
21
25
|
startAfterMs: true;
|
|
22
26
|
endAfterMs: true;
|
|
27
|
+
maxSessionMs: true;
|
|
23
28
|
windDownMs: true;
|
|
24
29
|
idleWaitMs: true;
|
|
30
|
+
clock: {
|
|
31
|
+
timeZone: true;
|
|
32
|
+
};
|
|
25
33
|
reasoningEffort: true;
|
|
26
34
|
stopWhen: {
|
|
27
35
|
any: {
|
package/dist/study/keys.js
CHANGED
|
@@ -37,12 +37,15 @@ export const PARTICIPANT_ENTRY = {
|
|
|
37
37
|
device: true,
|
|
38
38
|
instruction: true,
|
|
39
39
|
target: true,
|
|
40
|
+
files: { from: true, as: true },
|
|
40
41
|
entry: true,
|
|
41
42
|
host: true,
|
|
42
43
|
startAfterMs: true,
|
|
43
44
|
endAfterMs: true,
|
|
45
|
+
maxSessionMs: true,
|
|
44
46
|
windDownMs: true,
|
|
45
47
|
idleWaitMs: true,
|
|
48
|
+
clock: { timeZone: true },
|
|
46
49
|
reasoningEffort: true,
|
|
47
50
|
stopWhen: STOP_WHEN,
|
|
48
51
|
dwell: DWELL,
|
package/dist/study/migrate/v2.js
CHANGED
|
@@ -80,6 +80,16 @@ export function movedV2KeyReason(raw) {
|
|
|
80
80
|
}
|
|
81
81
|
return undefined;
|
|
82
82
|
}
|
|
83
|
+
// Participant keys that arrived after v2, so a v2 participant cannot carry them.
|
|
84
|
+
const AFTER_V2_PARTICIPANT_KEYS = [
|
|
85
|
+
"startAfterMs",
|
|
86
|
+
"endAfterMs",
|
|
87
|
+
"windDownMs",
|
|
88
|
+
"idleWaitMs",
|
|
89
|
+
"files",
|
|
90
|
+
"maxSessionMs",
|
|
91
|
+
"clock",
|
|
92
|
+
];
|
|
83
93
|
// The keys of a humanish.lab.v2 file, in the order its parser listed them: the v3 sections with
|
|
84
94
|
// `subject.topology`, the actor list with its participant keys, `execution.caps`, `personas` and
|
|
85
95
|
// the `scenario` object.
|
|
@@ -92,11 +102,10 @@ const V2_KEYS = {
|
|
|
92
102
|
actors: {
|
|
93
103
|
type: true,
|
|
94
104
|
count: true,
|
|
95
|
-
|
|
96
|
-
lanes: without(PARTICIPANT_ENTRY, "startAfterMs", "endAfterMs", "windDownMs", "idleWaitMs"),
|
|
105
|
+
lanes: without(PARTICIPANT_ENTRY, ...AFTER_V2_PARTICIPANT_KEYS),
|
|
97
106
|
// Roster groups are participants with a count; the parser expanded them into `lanes[]`.
|
|
98
107
|
roster: {
|
|
99
|
-
...without(PARTICIPANT_ENTRY,
|
|
108
|
+
...without(PARTICIPANT_ENTRY, ...AFTER_V2_PARTICIPANT_KEYS),
|
|
100
109
|
count: true,
|
|
101
110
|
},
|
|
102
111
|
// maxWaitMs, idleWaitMs and clock arrived after v2, so a v2 file cannot carry them.
|
|
@@ -25,7 +25,7 @@ export interface ParticipantPaths {
|
|
|
25
25
|
* Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
|
|
26
26
|
* study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
|
|
27
27
|
* Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
|
|
28
|
-
* instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, reasoningEffort?, stopWhen?, dwell? }`.
|
|
28
|
+
* instruction?, target?, entry?, host?, startAfterMs?, endAfterMs?, windDownMs?, idleWaitMs?, maxSessionMs?, clock?, reasoningEffort?, stopWhen?, dwell?, files? }`.
|
|
29
29
|
* Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
|
|
30
30
|
* must be public-safe tokens; a device must be a known preset name. The
|
|
31
31
|
* route-scoped cross-validation (device XOR raw resolution, targets, the participant bound) runs in parseStudy
|