humanish 0.110.3 → 0.111.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 +29 -0
- package/CONTEXT.md +2 -2
- package/dist/actors/local-agent/claude-session.js +1 -1
- package/dist/actors/local-agent/cli.js +1 -1
- package/dist/actors/scripted-browser/actor.d.ts +2 -2
- package/dist/actors/scripted-browser/actor.js +17 -8
- package/dist/actors/scripted-browser/launch-env.d.ts +12 -0
- package/dist/actors/scripted-browser/launch-env.js +50 -0
- package/dist/analysis/automatic-completion.d.ts +2 -1
- package/dist/analysis/automatic-completion.js +7 -2
- package/dist/cli/commands/migrate.js +1 -1
- package/dist/cli/commands/runs.js +5 -2
- package/dist/cli/commands/study-route-computer-use.js +3 -2
- package/dist/cli/commands/study-route-preview.js +2 -1
- package/dist/cli/commands/study-route-shared-world.js +3 -3
- package/dist/cli/commands/study.js +2 -1
- package/dist/routes/computer-use/live-phase.js +8 -1
- package/dist/routes/computer-use/local-vm.js +2 -1
- package/dist/routes/computer-use/participant-model.js +1 -1
- package/dist/routes/computer-use/plan.js +9 -10
- package/dist/routes/computer-use/preflight.js +1 -1
- package/dist/routes/computer-use/result.js +1 -1
- package/dist/routes/computer-use/route.js +3 -2
- package/dist/routes/computer-use/setup.js +2 -1
- package/dist/routes/computer-use/types.d.ts +4 -4
- package/dist/routes/scripted/plan.js +5 -4
- package/dist/routes/scripted/route.js +6 -2
- package/dist/routes/scripted/scenario.d.ts +1 -1
- package/dist/routes/scripted/scenario.js +3 -3
- package/dist/routes/scripted/surface-sessions.d.ts +2 -0
- package/dist/routes/scripted/surface-sessions.js +2 -1
- package/dist/routes/scripted/types.d.ts +2 -2
- package/dist/routes/shared-world/handoff.js +1 -1
- package/dist/routes/shared-world/participant-specs.js +6 -2
- package/dist/routes/shared-world/plan.js +8 -8
- package/dist/routes/shared-world/route.js +3 -3
- package/dist/routes/shared-world/types.d.ts +1 -1
- package/dist/routes/terminal/bundle.d.ts +3 -3
- package/dist/routes/terminal/ledger.d.ts +2 -2
- package/dist/routes/terminal/lifetime.js +1 -1
- package/dist/routes/terminal/live-finish.js +1 -1
- package/dist/routes/terminal/plan.js +4 -3
- package/dist/routes/terminal/route.js +3 -2
- package/dist/routes/terminal/session.js +1 -0
- package/dist/routes/terminal/types.d.ts +1 -1
- package/dist/run/display.d.ts +10 -0
- package/dist/run/display.js +22 -0
- package/dist/run/reclaim.d.ts +4 -2
- package/dist/run/reclaim.js +61 -18
- package/dist/run/results.d.ts +1 -1
- package/dist/run/run.d.ts +11 -1
- package/dist/run/run.js +24 -5
- package/dist/run/status.d.ts +6 -0
- package/dist/run/status.js +1 -0
- package/dist/run/stored-runs.js +26 -11
- package/dist/run-study.d.ts +5 -3
- package/dist/run-study.js +7 -11
- package/dist/study/adapter-scorer-loader.d.ts +1 -1
- package/dist/study/adapter-scorer-loader.js +3 -3
- package/dist/study/composition-rules.js +49 -39
- package/dist/study/config.d.ts +31 -8
- package/dist/study/config.js +181 -155
- package/dist/study/discover.d.ts +1 -1
- package/dist/study/discover.js +1 -1
- package/dist/study/doctor.js +5 -4
- package/dist/study/keys.d.ts +250 -2
- package/dist/study/keys.js +32 -62
- package/dist/study/{convert.d.ts → migrate/convert.d.ts} +1 -1
- package/dist/study/{convert.js → migrate/convert.js} +28 -31
- package/dist/study/{migrate.d.ts → migrate/migrate.d.ts} +1 -1
- package/dist/study/{migrate.js → migrate/migrate.js} +2 -2
- package/dist/study/{owned-files.js → migrate/owned-files.js} +1 -1
- package/dist/study/migrate/v2.d.ts +78 -0
- package/dist/study/migrate/v2.js +607 -0
- package/dist/study/parse/actors.d.ts +34 -10
- package/dist/study/parse/actors.js +76 -174
- package/dist/study/parse/comms.js +6 -2
- package/dist/study/parse/execution.d.ts +9 -4
- package/dist/study/parse/execution.js +6 -41
- package/dist/study/parse/front.d.ts +36 -0
- package/dist/study/parse/{study-v3.js → front.js} +37 -88
- package/dist/study/parse/subject.d.ts +2 -0
- package/dist/study/parse/subject.js +15 -18
- package/dist/study/persona-resolve.d.ts +2 -4
- package/dist/study/persona-resolve.js +7 -8
- package/dist/study/plan-base.d.ts +1 -1
- package/dist/study/plan-base.js +6 -6
- package/dist/study/plan-participants.d.ts +2 -2
- package/dist/study/plan-participants.js +12 -11
- package/dist/study/plan-types.d.ts +5 -5
- package/dist/study/plan.js +5 -4
- package/dist/study/preflight-probes.js +4 -4
- package/dist/study/preflight.d.ts +1 -1
- package/dist/study/preflight.js +3 -3
- package/dist/study/routing.d.ts +5 -5
- package/dist/study/routing.js +17 -20
- package/dist/study/run-study-options.d.ts +1 -1
- package/dist/study/run-study-options.js +3 -3
- package/dist/study/study-deps.d.ts +1 -1
- package/dist/study/study-fields.d.ts +19 -0
- package/dist/study/study-fields.js +46 -0
- package/dist/study/summary.js +19 -18
- package/dist/study/types.d.ts +66 -104
- package/dist/study/types.js +0 -1
- package/dist/study/url-credentials.js +3 -3
- package/dist/study/validation.d.ts +4 -10
- package/dist/study/validation.js +54 -79
- package/dist/study/warnings.d.ts +3 -3
- package/dist/study/warnings.js +42 -110
- package/dist/substrates/local/runtime-config.js +14 -16
- package/dist/tui-app.js +3 -3
- package/docs/contracts/schemas.md +36 -8
- package/package.json +1 -1
- package/dist/study/parse/study-v3.d.ts +0 -21
- /package/dist/study/{owned-files.d.ts → migrate/owned-files.d.ts} +0 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// humanish.lab.v2, read for `humanish migrate`. checkV2 applies the v2 parser's section checks in
|
|
2
|
+
// its order and with its messages, and the rules on v2 keys that a humanish.study.v3 file has no
|
|
3
|
+
// form for. v2ToV3Raw rewrites the file as a plain v3 record, which parseStudyV3 checks against the
|
|
4
|
+
// rules the two formats share. The converter (convert.ts) rewrites the YAML document separately and
|
|
5
|
+
// compares what the two rewrites parse to.
|
|
6
|
+
//
|
|
7
|
+
// The v2 parser itself is gone. The rules here on keys v3 does not have (the actor list and roster,
|
|
8
|
+
// `laneFocus`, the `scenario` object, `subject.topology`, `execution.caps`, and the checks on their
|
|
9
|
+
// combinations) are kept with that parser's messages, so migrate refuses what it refused.
|
|
10
|
+
import { isRecord } from "../../run/type-guards.js";
|
|
11
|
+
import { ACTOR, CAPS, EXECUTION, findUnknownKey, PARTICIPANT_ENTRY, SHARED_SECTIONS, SUBJECT, } from "../keys.js";
|
|
12
|
+
import { parseActorFields, parseActorType, parseParticipantEntries, PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN, } from "../parse/actors.js";
|
|
13
|
+
import { parseComms } from "../parse/comms.js";
|
|
14
|
+
import { parseCaps, parseExecution, parsePolicies, parseReview } from "../parse/execution.js";
|
|
15
|
+
import { hasSubjectSource, parseSubject } from "../parse/subject.js";
|
|
16
|
+
import { posInt, str } from "../parse/values.js";
|
|
17
|
+
import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, routeOf, } from "../routing.js";
|
|
18
|
+
import { desktopMediaValidationReason, outputTokenLimitValidationReason, smtpValidationReason, } from "../validation.js";
|
|
19
|
+
import { ID_PATTERN, STUDY_SCHEMA, } from "../types.js";
|
|
20
|
+
/** The schema of the study format before humanish.study.v3. */
|
|
21
|
+
export const V2_SCHEMA = "humanish.lab.v2";
|
|
22
|
+
/** The message for a humanish.lab.v2 document, which the package and the CLI no longer run. */
|
|
23
|
+
export const V2_UNSUPPORTED_MESSAGE = "This is a humanish.lab.v2 study, which humanish no longer reads. Convert its file with humanish migrate <path>.";
|
|
24
|
+
// The StudyConfig fields of humanish 0.110 and earlier, which a humanish.study.v3 config does not
|
|
25
|
+
// have, each with where its value goes now. A library caller may still build a config with them.
|
|
26
|
+
const V2_FIELDS = [
|
|
27
|
+
{
|
|
28
|
+
present: (config) => config.actors !== undefined,
|
|
29
|
+
move: "`actors[0]` is `actor`, and its `count`, `lanes`, `roster` and `laneFocus` are `participants` (`surfaces` on the scripted route)",
|
|
30
|
+
},
|
|
31
|
+
// A caller that moved `actors[0]` to `actor` as it was.
|
|
32
|
+
{
|
|
33
|
+
present: (config) => isRecord(config.actor) &&
|
|
34
|
+
["count", "lanes", "roster", "laneFocus"].some((key) => config.actor[key] !== undefined),
|
|
35
|
+
move: "`actor.count`, `actor.lanes`, `actor.roster` and `actor.laneFocus` are `participants` (`surfaces` on the scripted route)",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
present: (config) => isRecord(config.scenario),
|
|
39
|
+
move: "`scenario.ref` is `scenario`, a string, `scenario.mode` is `mode` and `scenario.caps` is `caps`",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
present: (config) => isRecord(config.execution) && config.execution.caps !== undefined,
|
|
43
|
+
move: "`execution.caps` is `caps`",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
present: (config) => isRecord(config.subject) && config.subject.topology !== undefined,
|
|
47
|
+
move: "`subject.topology: shared-world` is `route: shared-world`",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
present: (config) => config.personas !== undefined,
|
|
51
|
+
move: "`personas` has no field now; remove it",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
/** Where each humanish 0.110 StudyConfig field a config still sets went; empty when it sets none. */
|
|
55
|
+
export function v2FieldMoves(config) {
|
|
56
|
+
if (!isRecord(config))
|
|
57
|
+
return [];
|
|
58
|
+
return V2_FIELDS.filter((field) => field.present(config)).map((field) => field.move);
|
|
59
|
+
}
|
|
60
|
+
// The keys of a humanish.lab.v2 file, in the order its parser listed them: the v3 sections with
|
|
61
|
+
// `subject.topology`, the actor list with its participant keys, `execution.caps`, `personas` and
|
|
62
|
+
// the `scenario` object.
|
|
63
|
+
const V2_KEYS = {
|
|
64
|
+
schema: true,
|
|
65
|
+
id: true,
|
|
66
|
+
title: true,
|
|
67
|
+
description: true,
|
|
68
|
+
subject: { source: true, topology: true, ...without(SUBJECT, "source") },
|
|
69
|
+
actors: {
|
|
70
|
+
type: true,
|
|
71
|
+
count: true,
|
|
72
|
+
lanes: PARTICIPANT_ENTRY,
|
|
73
|
+
// Roster groups are participants with a count; the parser expanded them into `lanes[]`.
|
|
74
|
+
roster: { ...PARTICIPANT_ENTRY, count: true },
|
|
75
|
+
...without(ACTOR, "type"),
|
|
76
|
+
laneFocus: { id: true, label: true, instruction: true },
|
|
77
|
+
},
|
|
78
|
+
execution: withAfter(EXECUTION, "egressAllow", { caps: CAPS }),
|
|
79
|
+
// Inline personas were validated by the persona resolver.
|
|
80
|
+
personas: true,
|
|
81
|
+
scenario: { ref: true, mode: true, inline: true, caps: CAPS },
|
|
82
|
+
...SHARED_SECTIONS,
|
|
83
|
+
};
|
|
84
|
+
function without(shape, ...keys) {
|
|
85
|
+
return Object.fromEntries(Object.entries(shape).filter(([key]) => !keys.includes(key)));
|
|
86
|
+
}
|
|
87
|
+
// `shape` with `added` inserted after the key `after`.
|
|
88
|
+
function withAfter(shape, after, added) {
|
|
89
|
+
return Object.fromEntries(Object.entries(shape).flatMap((entry) => entry[0] === after ? [entry, ...Object.entries(added)] : [entry]));
|
|
90
|
+
}
|
|
91
|
+
function refused(message) {
|
|
92
|
+
return { ok: false, message };
|
|
93
|
+
}
|
|
94
|
+
// The v2 spelling of a participant list and its entries, `actors[0].lanes[3]`.
|
|
95
|
+
const ENTRY_PATHS = {
|
|
96
|
+
list: "actors[0].lanes",
|
|
97
|
+
entry: (index) => `actors[0].lanes[${index}]`,
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Check a humanish.lab.v2 file the way the v2 parser does, up to the checks that read more than
|
|
101
|
+
* one section, and then the budget rule those checks apply first. Each refusal is that parser's
|
|
102
|
+
* message.
|
|
103
|
+
*/
|
|
104
|
+
export function checkV2(raw) {
|
|
105
|
+
const unknownKey = findUnknownKey(raw, V2_KEYS);
|
|
106
|
+
if (unknownKey)
|
|
107
|
+
return refused(unknownKey);
|
|
108
|
+
const id = str(raw.id);
|
|
109
|
+
if (!id || !ID_PATTERN.test(id)) {
|
|
110
|
+
return refused("The study id must be a public-safe token starting with a letter or digit (/^[A-Za-z0-9][A-Za-z0-9_.-]*$/).");
|
|
111
|
+
}
|
|
112
|
+
// The v2 parser checked `subject.topology` right after `subject.source`.
|
|
113
|
+
const topology = hasSubjectSource(raw.subject) ? readTopology(raw.subject) : undefined;
|
|
114
|
+
if (topology?.ok === false)
|
|
115
|
+
return topology;
|
|
116
|
+
const subject = parseSubject(raw.subject);
|
|
117
|
+
if (!subject.ok)
|
|
118
|
+
return refused(subject.error.message);
|
|
119
|
+
const actor = readActor(raw.actors);
|
|
120
|
+
if (!actor.ok)
|
|
121
|
+
return actor;
|
|
122
|
+
const execution = readExecution(raw.execution);
|
|
123
|
+
if (!execution.ok)
|
|
124
|
+
return execution;
|
|
125
|
+
const scenario = readScenario(raw.scenario);
|
|
126
|
+
if (!scenario.ok)
|
|
127
|
+
return scenario;
|
|
128
|
+
if (isRecord(raw.policies) &&
|
|
129
|
+
raw.policies.mediaPermission !== undefined &&
|
|
130
|
+
raw.policies.mediaPermission !== "prompt" &&
|
|
131
|
+
raw.policies.mediaPermission !== "granted") {
|
|
132
|
+
return refused("`policies.mediaPermission` must be `prompt` (the participant answers the browser's own dialog) or `granted`.");
|
|
133
|
+
}
|
|
134
|
+
const policies = parsePolicies(raw.policies);
|
|
135
|
+
if (!policies.ok)
|
|
136
|
+
return refused(policies.error.message);
|
|
137
|
+
const review = parseReview(raw.review);
|
|
138
|
+
if (!review.ok)
|
|
139
|
+
return refused(review.error.message);
|
|
140
|
+
if (isRecord(raw.defaults) && raw.defaults.open !== undefined) {
|
|
141
|
+
if (typeof raw.defaults.open !== "boolean")
|
|
142
|
+
return refused("`defaults.open` must be true or false.");
|
|
143
|
+
}
|
|
144
|
+
const comms = parseComms(raw.comms);
|
|
145
|
+
if (!comms.ok)
|
|
146
|
+
return refused(comms.error.message);
|
|
147
|
+
const composition = {
|
|
148
|
+
schema: STUDY_SCHEMA,
|
|
149
|
+
id,
|
|
150
|
+
// A shared world is declared; any other route is derived below.
|
|
151
|
+
route: topology?.value === "shared-world" ? "shared-world" : "computer-use",
|
|
152
|
+
subject: subject.value,
|
|
153
|
+
actor: { type: actor.value.type, ...actor.value.fields },
|
|
154
|
+
...(execution.value.execution === undefined ? {} : { execution: execution.value.execution }),
|
|
155
|
+
...(policies.value === undefined ? {} : { policies: policies.value }),
|
|
156
|
+
...(comms.value === undefined ? {} : { comms: comms.value }),
|
|
157
|
+
};
|
|
158
|
+
const route = v2RouteOf(composition);
|
|
159
|
+
composition.route = route;
|
|
160
|
+
const study = {
|
|
161
|
+
route,
|
|
162
|
+
composition,
|
|
163
|
+
topology: topology?.value,
|
|
164
|
+
count: actor.value.count,
|
|
165
|
+
entries: actor.value.entries,
|
|
166
|
+
focus: actor.value.focus,
|
|
167
|
+
executionCaps: execution.value.caps,
|
|
168
|
+
scenario: scenario.value,
|
|
169
|
+
personas: Array.isArray(raw.personas) && raw.personas.some(isRecord),
|
|
170
|
+
terminalTimeout: route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined,
|
|
171
|
+
};
|
|
172
|
+
const budget = scenarioBudgetReason(study);
|
|
173
|
+
if (!budget)
|
|
174
|
+
return { ok: true, value: study };
|
|
175
|
+
// The checks the v2 parser runs before the budget rule, which are the first it runs across
|
|
176
|
+
// sections, so a file that breaks one of them and the budget rule gets the same refusal.
|
|
177
|
+
return refused(smtpValidationReason(composition) ??
|
|
178
|
+
desktopMediaValidationReason(composition) ??
|
|
179
|
+
outputTokenLimitValidationReason(composition) ??
|
|
180
|
+
budget);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The route a v2 file takes. `subject.topology: shared-world` declares a shared world, as
|
|
184
|
+
* `route: shared-world` does in v3, so a file that declares one its composition does not take gets
|
|
185
|
+
* the shared-world checks' refusal, as it does from the v2 parser.
|
|
186
|
+
*/
|
|
187
|
+
function v2RouteOf(composition) {
|
|
188
|
+
return composition.route === "shared-world" ? "shared-world" : routeOf(composition);
|
|
189
|
+
}
|
|
190
|
+
// parse/subject.ts read `subject.topology` as one of two values.
|
|
191
|
+
function readTopology(subject) {
|
|
192
|
+
if (!isRecord(subject) || subject.topology === undefined)
|
|
193
|
+
return { ok: true, value: undefined };
|
|
194
|
+
const topology = str(subject.topology);
|
|
195
|
+
if (topology !== "per-lane-worlds" && topology !== "shared-world") {
|
|
196
|
+
return refused("`subject.topology` must be per-lane-worlds (the default) or shared-world.");
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, value: topology };
|
|
199
|
+
}
|
|
200
|
+
// parse/execution.ts parseExecution, which read `execution.caps` after `execution.desktop` and
|
|
201
|
+
// before `execution.terminal`.
|
|
202
|
+
function readExecution(raw) {
|
|
203
|
+
let caps;
|
|
204
|
+
if (isRecord(raw)) {
|
|
205
|
+
const { target, timeoutMs, completionTimeoutMs, concurrency, desktop } = raw;
|
|
206
|
+
const head = parseExecution({ target, timeoutMs, completionTimeoutMs, concurrency, desktop });
|
|
207
|
+
if (!head.ok)
|
|
208
|
+
return refused(head.error.message);
|
|
209
|
+
const parsed = parseCaps(raw.caps);
|
|
210
|
+
if (!parsed.ok)
|
|
211
|
+
return refused(parsed.error.message);
|
|
212
|
+
caps = parsed.value;
|
|
213
|
+
}
|
|
214
|
+
// parseExecution reads every key but `caps`.
|
|
215
|
+
const execution = parseExecution(raw);
|
|
216
|
+
if (!execution.ok)
|
|
217
|
+
return refused(execution.error.message);
|
|
218
|
+
return { ok: true, value: { execution: execution.value, caps } };
|
|
219
|
+
}
|
|
220
|
+
// parse/actors.ts parseActors, for the one actor it accepts.
|
|
221
|
+
function readActor(raw) {
|
|
222
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
223
|
+
return refused("`actors` must be a non-empty array.");
|
|
224
|
+
}
|
|
225
|
+
if (raw.length > 1) {
|
|
226
|
+
return refused("Multiple actors are not supported yet (only the first actor runs); declare a single actor.");
|
|
227
|
+
}
|
|
228
|
+
const entry = raw[0];
|
|
229
|
+
if (!isRecord(entry))
|
|
230
|
+
return refused("actors[0] must be an object.");
|
|
231
|
+
const type = parseActorType(entry, "actors[0]");
|
|
232
|
+
if (!type.ok)
|
|
233
|
+
return refused(type.error.message);
|
|
234
|
+
const count = posInt(entry.count);
|
|
235
|
+
if (entry.lanes !== undefined && entry.roster !== undefined) {
|
|
236
|
+
return refused("Set either actors[0].lanes or actors[0].roster, not both: list participants one by one in `lanes`, or in groups in `roster`.");
|
|
237
|
+
}
|
|
238
|
+
if (entry.roster !== undefined && count !== undefined) {
|
|
239
|
+
return refused("Set either actors[0].roster or actors[0].count, not both: `roster` declares groups of distinct participants, and `count` declares identical ones.");
|
|
240
|
+
}
|
|
241
|
+
if (entry.roster !== undefined && entry.laneFocus !== undefined) {
|
|
242
|
+
return refused("actors[0].roster and actors[0].laneFocus are mutually exclusive: a roster group's instruction is each participant's steer.");
|
|
243
|
+
}
|
|
244
|
+
let listed = entry.lanes;
|
|
245
|
+
if (entry.roster !== undefined) {
|
|
246
|
+
const expanded = rosterEntries(entry.roster);
|
|
247
|
+
if (!expanded.ok)
|
|
248
|
+
return expanded;
|
|
249
|
+
listed = expanded.value;
|
|
250
|
+
}
|
|
251
|
+
const entries = parseParticipantEntries(listed, ENTRY_PATHS);
|
|
252
|
+
if (!entries.ok)
|
|
253
|
+
return refused(entries.error.message);
|
|
254
|
+
const fields = parseActorFields(entry, "actors[0]");
|
|
255
|
+
if (!fields.ok)
|
|
256
|
+
return refused(fields.error.message);
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
value: {
|
|
260
|
+
type: type.value,
|
|
261
|
+
fields: fields.value,
|
|
262
|
+
count,
|
|
263
|
+
entries: entries.value,
|
|
264
|
+
focus: readFocus(entry.laneFocus),
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const GROUP_KEYS = "{ id, count, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }";
|
|
269
|
+
/**
|
|
270
|
+
* `actors[0].roster` groups, each expanded into its participants `<id>-01` to `<id>-NN`, as written:
|
|
271
|
+
* the entries parse after. parse/actors.ts parseRosterGroups.
|
|
272
|
+
*/
|
|
273
|
+
function rosterEntries(raw) {
|
|
274
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
275
|
+
return refused(`actors[0].roster must be a non-empty array of group objects (${GROUP_KEYS}) when set.`);
|
|
276
|
+
}
|
|
277
|
+
const expanded = [];
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
for (const [index, group] of raw.entries()) {
|
|
280
|
+
if (!isRecord(group))
|
|
281
|
+
return refused(`actors[0].roster[${index}] must be an object (${GROUP_KEYS}).`);
|
|
282
|
+
const id = str(group.id);
|
|
283
|
+
if (id === undefined) {
|
|
284
|
+
return refused(`actors[0].roster[${index}].id is required and must be a public-safe token matching ${PARTICIPANT_ID_PATTERN}.`);
|
|
285
|
+
}
|
|
286
|
+
if (!PARTICIPANT_ID_PATTERN.test(id) || id.length > PARTICIPANT_ID_MAX_CHARS - 3) {
|
|
287
|
+
return refused(`actors[0].roster[${index}].id must match ${PARTICIPANT_ID_PATTERN} and be at most ${PARTICIPANT_ID_MAX_CHARS - 3} characters, because each generated participant id adds a suffix like "-01"; "${id}" is not.`);
|
|
288
|
+
}
|
|
289
|
+
if (seen.has(id))
|
|
290
|
+
return refused(`actors[0].roster group ids must be unique (duplicate "${id}").`);
|
|
291
|
+
seen.add(id);
|
|
292
|
+
const count = posInt(group.count);
|
|
293
|
+
if (count === undefined) {
|
|
294
|
+
return refused(`actors[0].roster[${index}].count is required and must be a positive integer.`);
|
|
295
|
+
}
|
|
296
|
+
const shared = { ...group };
|
|
297
|
+
delete shared.id;
|
|
298
|
+
delete shared.count;
|
|
299
|
+
for (let n = 1; n <= count; n += 1)
|
|
300
|
+
expanded.push({ ...shared, id: `${id}-${String(n).padStart(2, "0")}` });
|
|
301
|
+
}
|
|
302
|
+
return { ok: true, value: expanded };
|
|
303
|
+
}
|
|
304
|
+
// The fields of `laneFocus` that are non-empty strings; undefined when none is. parse/actors.ts
|
|
305
|
+
// parseFocus.
|
|
306
|
+
function readFocus(raw) {
|
|
307
|
+
if (!isRecord(raw))
|
|
308
|
+
return undefined;
|
|
309
|
+
const focus = {};
|
|
310
|
+
const id = str(raw.id);
|
|
311
|
+
if (id)
|
|
312
|
+
focus.id = id;
|
|
313
|
+
const label = str(raw.label);
|
|
314
|
+
if (label)
|
|
315
|
+
focus.label = label;
|
|
316
|
+
const instruction = str(raw.instruction);
|
|
317
|
+
if (instruction)
|
|
318
|
+
focus.instruction = instruction;
|
|
319
|
+
return Object.keys(focus).length > 0 ? focus : undefined;
|
|
320
|
+
}
|
|
321
|
+
// parse/execution.ts parseScenario: a `mode` or `ref` it cannot read is read as unset.
|
|
322
|
+
function readScenario(raw) {
|
|
323
|
+
if (!isRecord(raw))
|
|
324
|
+
return { ok: true, value: undefined };
|
|
325
|
+
const scenario = {};
|
|
326
|
+
const ref = str(raw.ref);
|
|
327
|
+
if (ref)
|
|
328
|
+
scenario.ref = ref;
|
|
329
|
+
if (isRecord(raw.inline))
|
|
330
|
+
scenario.inline = raw.inline;
|
|
331
|
+
const mode = str(raw.mode);
|
|
332
|
+
if (mode === "dry-run" || mode === "live")
|
|
333
|
+
scenario.mode = mode;
|
|
334
|
+
const caps = parseCaps(raw.caps);
|
|
335
|
+
if (!caps.ok)
|
|
336
|
+
return refused(caps.error.message);
|
|
337
|
+
if (caps.value)
|
|
338
|
+
scenario.caps = caps.value;
|
|
339
|
+
return { ok: true, value: Object.keys(scenario).length > 0 ? scenario : undefined };
|
|
340
|
+
}
|
|
341
|
+
// validation.ts scenarioCapsValidationReason. The v2 parser applies it after the SMTP, media and
|
|
342
|
+
// output-limit checks and before the composition checks, so checkV2 runs it with the first three.
|
|
343
|
+
function scenarioBudgetReason(study) {
|
|
344
|
+
if (!isComputerUseComposition(study.composition))
|
|
345
|
+
return undefined;
|
|
346
|
+
for (const key of ["maxUsd", "maxTotalUsd"]) {
|
|
347
|
+
const value = study.scenario?.caps?.[key];
|
|
348
|
+
if (value === undefined || value <= 0)
|
|
349
|
+
continue;
|
|
350
|
+
return `scenario.caps.${key} (${value}) does not cap a computer-use study: this route stops on execution.caps.${key}. Move the value to execution.caps.${key}; scenario.caps applies only to terminal-product studies.`;
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The refusals the v2 parser gives for shapes a v3 file cannot express, once the checks both
|
|
356
|
+
* formats share have passed on the study without them. A file that also breaks a shared check
|
|
357
|
+
* gets that check's message.
|
|
358
|
+
*/
|
|
359
|
+
export function v2ShapeReason(study) {
|
|
360
|
+
const { route, composition, count, entries, focus } = study;
|
|
361
|
+
if (route === "scripted") {
|
|
362
|
+
// composition-rules.ts scriptedBrowserValidationReason, in its order.
|
|
363
|
+
if (composition.subject.source === "clone" && study.topology !== undefined) {
|
|
364
|
+
return "A clone scripted-browser study does not support `subject.topology` yet: it runs one synthetic subject for its scripted actor. Remove `subject.topology`.";
|
|
365
|
+
}
|
|
366
|
+
if ((count ?? 1) > 2) {
|
|
367
|
+
return "A scripted-browser study takes `surfaces` 1 (desktop) or 2 (desktop and mobile). Higher counts are not supported yet.";
|
|
368
|
+
}
|
|
369
|
+
if (composition.subject.source === "clone" && entries !== undefined) {
|
|
370
|
+
return "`participants` is not supported on the scripted-browser route yet. Use `surfaces` to choose the desktop and mobile surfaces.";
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// validation.ts computerUseValidationReason.
|
|
374
|
+
if (isComputerUseComposition(composition) && entries !== undefined) {
|
|
375
|
+
if (count !== undefined) {
|
|
376
|
+
return "Set either `actors[0].count` (identical participants) or `actors[0].lanes` (a roster of distinct participants), not both.";
|
|
377
|
+
}
|
|
378
|
+
if (focus !== undefined) {
|
|
379
|
+
return "actors[0].laneFocus and actors[0].lanes are mutually exclusive: each roster entry's `instruction` is the fan-out steer; laneFocus is the steer for a single participant.";
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// composition-rules.ts terminalValidationReason.
|
|
383
|
+
if (route === "terminal" && (count ?? 1) > 1) {
|
|
384
|
+
return "Terminal fan-out to more than one participant is not supported yet; set participants to 1.";
|
|
385
|
+
}
|
|
386
|
+
// substrates/local/runtime-config.ts localBrowserUnsupportedReason: a local Codex participant
|
|
387
|
+
// takes no dollar cap, and the v2 parser counts one in scenario.caps too.
|
|
388
|
+
const caps = study.scenario?.caps;
|
|
389
|
+
if (composition.subject.source === "app-url" &&
|
|
390
|
+
composition.execution?.target === "local" &&
|
|
391
|
+
composition.actor.type === "local-agent" &&
|
|
392
|
+
(caps?.maxUsd !== undefined || caps?.maxTotalUsd !== undefined)) {
|
|
393
|
+
return "Local Codex participants currently use gpt-6-astra at low effort. Account dollar/output-token caps are unavailable; use API participants for those controls.";
|
|
394
|
+
}
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
// The caps keys each route reads, as parse/front.ts CAPS_KEYS has them.
|
|
398
|
+
const CAPS_READ = {
|
|
399
|
+
preview: [],
|
|
400
|
+
"computer-use": ["maxUsd", "maxTotalUsd"],
|
|
401
|
+
"shared-world": ["maxUsd", "maxTotalUsd"],
|
|
402
|
+
terminal: ["maxUsd", "maxJobs", "maxMinutes"],
|
|
403
|
+
scripted: [],
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* A v2 file as a plain humanish.study.v3 record, for parseStudyV3 and parseStudy. It reads each key
|
|
407
|
+
* it moves the way the v2 parser reads it (`count` as a positive integer, `laneFocus` through its
|
|
408
|
+
* string fields, `roster` groups expanded, `scenario.ref` and `scenario.mode` as strings), and
|
|
409
|
+
* leaves out every key no v3 file can hold, so it parses whenever the v2 parser would. A caps
|
|
410
|
+
* block keeps the keys its route reads. A shared world keeps a count or no list, which only
|
|
411
|
+
* parseStudyV3's report mode takes.
|
|
412
|
+
*/
|
|
413
|
+
export function v2ToV3Raw(raw, route) {
|
|
414
|
+
const listed = Array.isArray(raw.actors) && isRecord(raw.actors[0]) ? raw.actors[0] : {};
|
|
415
|
+
const { count: rawCount, lanes, roster, laneFocus, ...actor } = listed;
|
|
416
|
+
const count = posInt(rawCount);
|
|
417
|
+
const expanded = roster === undefined ? undefined : rosterEntries(roster);
|
|
418
|
+
const entries = expanded?.ok ? expanded.value : Array.isArray(lanes) ? lanes : undefined;
|
|
419
|
+
const instruction = readFocus(laneFocus)?.instruction;
|
|
420
|
+
const scenario = isRecord(raw.scenario) ? raw.scenario : {};
|
|
421
|
+
const mode = str(scenario.mode);
|
|
422
|
+
const ref = str(scenario.ref);
|
|
423
|
+
let participants;
|
|
424
|
+
if (route === "preview")
|
|
425
|
+
participants = count;
|
|
426
|
+
else if (route === "computer-use" || route === "shared-world") {
|
|
427
|
+
participants =
|
|
428
|
+
entries ??
|
|
429
|
+
(instruction === undefined
|
|
430
|
+
? count
|
|
431
|
+
: { ...(count === undefined ? {} : { count }), instruction });
|
|
432
|
+
}
|
|
433
|
+
const surfaces = route !== "scripted"
|
|
434
|
+
? undefined
|
|
435
|
+
: count === 2
|
|
436
|
+
? ["desktop", "mobile"]
|
|
437
|
+
: count === 1
|
|
438
|
+
? ["desktop"]
|
|
439
|
+
: undefined;
|
|
440
|
+
const block = route === "terminal" ? scenario.caps : isRecord(raw.execution) ? raw.execution.caps : undefined;
|
|
441
|
+
const caps = isRecord(block)
|
|
442
|
+
? Object.fromEntries(Object.entries(block).filter(([key]) => CAPS_READ[route].includes(key)))
|
|
443
|
+
: {};
|
|
444
|
+
const execution = isRecord(raw.execution) ? { ...raw.execution } : undefined;
|
|
445
|
+
delete execution?.caps;
|
|
446
|
+
if (route === "terminal")
|
|
447
|
+
delete execution?.timeoutMs;
|
|
448
|
+
const subject = isRecord(raw.subject) ? { ...raw.subject } : raw.subject;
|
|
449
|
+
if (isRecord(subject))
|
|
450
|
+
delete subject.topology;
|
|
451
|
+
const v3 = { schema: STUDY_SCHEMA };
|
|
452
|
+
const fields = [
|
|
453
|
+
["id", raw.id],
|
|
454
|
+
["title", raw.title],
|
|
455
|
+
["description", raw.description],
|
|
456
|
+
["route", route],
|
|
457
|
+
["mode", mode === "dry-run" || mode === "live" ? mode : undefined],
|
|
458
|
+
["subject", subject],
|
|
459
|
+
["actor", actor],
|
|
460
|
+
["participants", participants],
|
|
461
|
+
["surfaces", surfaces],
|
|
462
|
+
["caps", Object.keys(caps).length > 0 ? caps : undefined],
|
|
463
|
+
["execution", execution],
|
|
464
|
+
["scenario", ref],
|
|
465
|
+
["policies", raw.policies],
|
|
466
|
+
["review", raw.review],
|
|
467
|
+
["defaults", raw.defaults],
|
|
468
|
+
["comms", raw.comms],
|
|
469
|
+
];
|
|
470
|
+
for (const [key, value] of fields)
|
|
471
|
+
if (value !== undefined)
|
|
472
|
+
v3[key] = value;
|
|
473
|
+
return v3;
|
|
474
|
+
}
|
|
475
|
+
function routesOf({ composition }) {
|
|
476
|
+
return {
|
|
477
|
+
cua: isComputerUseComposition(composition),
|
|
478
|
+
terminal: isTerminalProductComposition(composition),
|
|
479
|
+
scripted: isScriptedBrowserComposition(composition),
|
|
480
|
+
shared: isSharedWorldComposition(composition),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
// The keys that have no v3 form on the file's route, which the v2 parser's inert-field table
|
|
484
|
+
// (warnings.ts) lists. The computer-use and terminal routes read the `laneFocus` instruction, and
|
|
485
|
+
// only the computer-use routes read a list.
|
|
486
|
+
const V2_DROP_ROWS = [
|
|
487
|
+
{
|
|
488
|
+
path: "actors[0].laneFocus.id",
|
|
489
|
+
applies: (study, routes) => (routes.cua || routes.terminal) && study.focus?.id !== undefined,
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
path: "actors[0].laneFocus.label",
|
|
493
|
+
applies: (study, routes) => (routes.cua || routes.terminal) && study.focus?.label !== undefined,
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
path: "actors[0].laneFocus",
|
|
497
|
+
applies: (study, routes) => !routes.cua && !routes.terminal && study.focus !== undefined,
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
path: "actors[0].lanes",
|
|
501
|
+
applies: (study, routes) => !routes.cua && study.entries !== undefined,
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
path: "subject.topology",
|
|
505
|
+
applies: (study, routes) => !routes.shared && study.topology !== undefined,
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
path: "execution.caps",
|
|
509
|
+
applies: (study, routes) => !routes.cua && study.executionCaps !== undefined,
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
path: "scenario.caps",
|
|
513
|
+
applies: (study, routes) => !routes.terminal && study.scenario?.caps !== undefined,
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
path: "scenario.caps.maxTotalUsd",
|
|
517
|
+
applies: (study, routes) => routes.terminal && study.scenario?.caps?.maxTotalUsd !== undefined,
|
|
518
|
+
},
|
|
519
|
+
{ path: "scenario.inline", applies: (study) => study.scenario?.inline !== undefined },
|
|
520
|
+
{ path: "personas", applies: (study) => study.personas },
|
|
521
|
+
];
|
|
522
|
+
// Every key migrate may drop, in the order the inert-field table lists them, which is the order
|
|
523
|
+
// migrate drops and reports them.
|
|
524
|
+
const DROP_ORDER = [
|
|
525
|
+
"actors[0].lanes[].entry",
|
|
526
|
+
"actors[0].lanes[].host",
|
|
527
|
+
"actors[0].laneFocus.id",
|
|
528
|
+
"actors[0].laneFocus.label",
|
|
529
|
+
"actors[0].mission",
|
|
530
|
+
"actors[0].laneFocus",
|
|
531
|
+
"actors[0].persona",
|
|
532
|
+
"actors[0].model",
|
|
533
|
+
"actors[0].lanes",
|
|
534
|
+
"subject.clone.depth",
|
|
535
|
+
"subject.serve",
|
|
536
|
+
"subject.env",
|
|
537
|
+
"subject.state",
|
|
538
|
+
"subject.topology",
|
|
539
|
+
"subject.state.checkpoint",
|
|
540
|
+
"subject.publicTarget",
|
|
541
|
+
"subject.exposure",
|
|
542
|
+
"comms.email",
|
|
543
|
+
"comms.email.external",
|
|
544
|
+
"execution.timeoutMs",
|
|
545
|
+
"execution.completionTimeoutMs",
|
|
546
|
+
"execution.concurrency",
|
|
547
|
+
"execution.caps",
|
|
548
|
+
"subject.product",
|
|
549
|
+
"scenario.caps",
|
|
550
|
+
"scenario.caps.maxTotalUsd",
|
|
551
|
+
"execution.terminal",
|
|
552
|
+
"execution.runtimeAuth",
|
|
553
|
+
"execution.runtime",
|
|
554
|
+
"execution.desktop.resolution",
|
|
555
|
+
"execution.desktop.device",
|
|
556
|
+
"execution.desktop.browser",
|
|
557
|
+
"execution.desktop.fidelity",
|
|
558
|
+
"execution.desktop.sandboxTimeoutMs",
|
|
559
|
+
"execution.desktop.template",
|
|
560
|
+
"execution.desktop.codexAppServer",
|
|
561
|
+
"scenario.ref",
|
|
562
|
+
"scenario.inline",
|
|
563
|
+
"review.scoring",
|
|
564
|
+
"review.milestones",
|
|
565
|
+
"review.vocabulary",
|
|
566
|
+
"review.scorer",
|
|
567
|
+
"personas",
|
|
568
|
+
];
|
|
569
|
+
/**
|
|
570
|
+
* The v2 path of a field parseStudyV3 reports in report mode. Each of these keys exists in both
|
|
571
|
+
* formats: `actor.<key>` is `actors[0].<key>`, a `participants` entry's key is
|
|
572
|
+
* `actors[0].lanes[].<key>` (field-paths.ts finds it under `roster` when the file has one), `mode`
|
|
573
|
+
* is `scenario.mode` and `scenario` is `scenario.ref`. Every other path is the same.
|
|
574
|
+
*/
|
|
575
|
+
function v2PathOf(path) {
|
|
576
|
+
if (path.startsWith("actor."))
|
|
577
|
+
return `actors[0].${path.slice("actor.".length)}`;
|
|
578
|
+
if (path.startsWith("participants[]")) {
|
|
579
|
+
return `actors[0].lanes[]${path.slice("participants[]".length)}`;
|
|
580
|
+
}
|
|
581
|
+
if (path === "mode")
|
|
582
|
+
return "scenario.mode";
|
|
583
|
+
if (path === "scenario")
|
|
584
|
+
return "scenario.ref";
|
|
585
|
+
return path;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The keys migrate drops: the v2-only rows above and `inert`, the fields parseStudyV3 reports the
|
|
589
|
+
* route does not read (v3 paths, mapped back to the file's), then terminal `execution.timeoutMs`,
|
|
590
|
+
* which no terminal code reads. Parents come before their children, so a child under a dropped
|
|
591
|
+
* parent is reported with the parent.
|
|
592
|
+
*/
|
|
593
|
+
export function droppedPaths(study, inert) {
|
|
594
|
+
const routes = routesOf(study);
|
|
595
|
+
const found = new Set([
|
|
596
|
+
...inert.map(v2PathOf),
|
|
597
|
+
...V2_DROP_ROWS.filter((row) => row.applies(study, routes)).map((row) => row.path),
|
|
598
|
+
]);
|
|
599
|
+
const paths = [
|
|
600
|
+
...DROP_ORDER.filter((path) => found.has(path)),
|
|
601
|
+
...[...found].filter((path) => !DROP_ORDER.includes(path)),
|
|
602
|
+
];
|
|
603
|
+
if (study.terminalTimeout)
|
|
604
|
+
paths.push("execution.timeoutMs");
|
|
605
|
+
const depth = (path) => path.split(".").length;
|
|
606
|
+
return [...new Set(paths)].sort((left, right) => depth(left) - depth(right));
|
|
607
|
+
}
|
|
@@ -1,14 +1,38 @@
|
|
|
1
|
-
import type { StudyActor, StudyParticipantEntry,
|
|
1
|
+
import type { StudyActor, StudyParticipantEntry, StudyParseFailure } from "../types.js";
|
|
2
2
|
export declare const PARTICIPANT_ID_PATTERN: RegExp;
|
|
3
3
|
export declare const PARTICIPANT_ID_MAX_CHARS = 40;
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
export declare function rosterOf<Entry = StudyParticipantEntry>(actor: {
|
|
7
|
-
readonly lanes?: readonly Entry[];
|
|
8
|
-
} | undefined): readonly Entry[] | undefined;
|
|
9
|
-
/** An actor's per-participant focus on the app-url route. The manifest spells it `laneFocus`. */
|
|
10
|
-
export declare function focusOf(actor: Pick<StudyActor, "laneFocus"> | undefined): StudyParticipantFocus | undefined;
|
|
11
|
-
export declare function parseActors(raw: unknown): {
|
|
4
|
+
/** An actor's `type`, which a study route must run. `path` names the actor in messages. */
|
|
5
|
+
export declare function parseActorType(entry: Record<string, unknown>, path: string): {
|
|
12
6
|
ok: true;
|
|
13
|
-
value:
|
|
7
|
+
value: string;
|
|
14
8
|
} | StudyParseFailure;
|
|
9
|
+
/** An actor's keys other than `type`. */
|
|
10
|
+
type ActorFields = Omit<StudyActor, "type">;
|
|
11
|
+
/**
|
|
12
|
+
* Every actor key other than `type`. The parser reads them after the participant entries, so a file
|
|
13
|
+
* with errors in both reports the entry first.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseActorFields(entry: Record<string, unknown>, path: string): {
|
|
16
|
+
ok: true;
|
|
17
|
+
value: ActorFields;
|
|
18
|
+
} | StudyParseFailure;
|
|
19
|
+
/** How a participant list's messages name the list and each entry in it. */
|
|
20
|
+
export interface ParticipantPaths {
|
|
21
|
+
readonly list: string;
|
|
22
|
+
readonly entry: (index: number) => string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
|
|
26
|
+
* study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
|
|
27
|
+
* Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
|
|
28
|
+
* instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
|
|
29
|
+
* Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
|
|
30
|
+
* must be public-safe tokens; a device must be a known preset name. The
|
|
31
|
+
* route-scoped cross-validation (device XOR raw resolution, targets, cap 16) runs in parseStudy
|
|
32
|
+
* where the route is known.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseParticipantEntries(raw: unknown, paths: ParticipantPaths): {
|
|
35
|
+
ok: true;
|
|
36
|
+
value: StudyParticipantEntry[] | undefined;
|
|
37
|
+
} | StudyParseFailure;
|
|
38
|
+
export {};
|