humanish 0.110.4 → 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 +15 -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/cli/commands/study.js +2 -4
- package/dist/routes/computer-use/participant-model.js +1 -1
- package/dist/routes/computer-use/plan.js +4 -6
- 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 +1 -1
- package/dist/routes/computer-use/types.d.ts +4 -4
- package/dist/routes/scripted/route.js +1 -1
- package/dist/routes/scripted/scenario.d.ts +1 -1
- package/dist/routes/scripted/scenario.js +3 -3
- 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 +1 -1
- package/dist/routes/shared-world/plan.js +3 -4
- 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 +1 -1
- package/dist/routes/terminal/route.js +1 -1
- package/dist/routes/terminal/types.d.ts +1 -1
- package/dist/run/results.d.ts +1 -1
- 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 +21 -11
- package/dist/study/config.d.ts +29 -13
- package/dist/study/config.js +174 -157
- package/dist/study/discover.d.ts +1 -1
- package/dist/study/discover.js +1 -1
- package/dist/study/keys.d.ts +250 -2
- package/dist/study/keys.js +32 -62
- package/dist/study/migrate/convert.js +3 -4
- package/dist/study/migrate/v2.d.ts +17 -7
- package/dist/study/migrate/v2.js +150 -23
- package/dist/study/parse/actors.d.ts +11 -32
- package/dist/study/parse/actors.js +11 -156
- package/dist/study/parse/comms.js +6 -2
- package/dist/study/parse/execution.d.ts +9 -4
- package/dist/study/parse/execution.js +4 -39
- package/dist/study/parse/{study-v3.d.ts → front.d.ts} +6 -17
- package/dist/study/parse/{study-v3.js → front.js} +22 -91
- package/dist/study/parse/subject.d.ts +2 -0
- package/dist/study/parse/subject.js +15 -18
- package/dist/study/persona-resolve.js +7 -9
- package/dist/study/plan-base.d.ts +1 -1
- package/dist/study/plan-base.js +3 -4
- package/dist/study/plan-participants.d.ts +2 -2
- package/dist/study/plan-types.d.ts +5 -5
- package/dist/study/plan.js +1 -1
- package/dist/study/preflight-probes.js +2 -2
- package/dist/study/preflight.d.ts +1 -1
- package/dist/study/preflight.js +1 -1
- package/dist/study/routing.d.ts +5 -5
- package/dist/study/routing.js +5 -5
- package/dist/study/run-study-options.d.ts +1 -1
- package/dist/study/study-deps.d.ts +1 -1
- package/dist/study/study-fields.d.ts +12 -21
- package/dist/study/study-fields.js +29 -34
- package/dist/study/summary.js +9 -4
- package/dist/study/types.d.ts +50 -128
- package/dist/study/types.js +0 -1
- package/dist/study/url-credentials.js +3 -4
- package/dist/study/validation.d.ts +2 -10
- package/dist/study/validation.js +16 -46
- package/dist/study/warnings.d.ts +3 -3
- package/dist/study/warnings.js +40 -109
- package/dist/substrates/local/runtime-config.js +9 -11
- package/docs/contracts/schemas.md +23 -4
- package/package.json +1 -1
- package/dist/study/parse/study.d.ts +0 -38
- package/dist/study/parse/study.js +0 -225
package/dist/study/migrate/v2.js
CHANGED
|
@@ -4,20 +4,90 @@
|
|
|
4
4
|
// rules the two formats share. The converter (convert.ts) rewrites the YAML document separately and
|
|
5
5
|
// compares what the two rewrites parse to.
|
|
6
6
|
//
|
|
7
|
-
// The v2
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
10
|
import { isRecord } from "../../run/type-guards.js";
|
|
11
|
-
import {
|
|
11
|
+
import { ACTOR, CAPS, EXECUTION, findUnknownKey, PARTICIPANT_ENTRY, SHARED_SECTIONS, SUBJECT, } from "../keys.js";
|
|
12
12
|
import { parseActorFields, parseActorType, parseParticipantEntries, PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN, } from "../parse/actors.js";
|
|
13
13
|
import { parseComms } from "../parse/comms.js";
|
|
14
|
-
import { parseExecution, parsePolicies, parseReview } from "../parse/execution.js";
|
|
15
|
-
import {
|
|
16
|
-
import { parseSubject } from "../parse/subject.js";
|
|
14
|
+
import { parseCaps, parseExecution, parsePolicies, parseReview } from "../parse/execution.js";
|
|
15
|
+
import { hasSubjectSource, parseSubject } from "../parse/subject.js";
|
|
17
16
|
import { posInt, str } from "../parse/values.js";
|
|
18
17
|
import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, routeOf, } from "../routing.js";
|
|
19
18
|
import { desktopMediaValidationReason, outputTokenLimitValidationReason, smtpValidationReason, } from "../validation.js";
|
|
20
|
-
import { ID_PATTERN, STUDY_SCHEMA,
|
|
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
|
+
}
|
|
21
91
|
function refused(message) {
|
|
22
92
|
return { ok: false, message };
|
|
23
93
|
}
|
|
@@ -32,22 +102,26 @@ const ENTRY_PATHS = {
|
|
|
32
102
|
* message.
|
|
33
103
|
*/
|
|
34
104
|
export function checkV2(raw) {
|
|
35
|
-
const unknownKey =
|
|
105
|
+
const unknownKey = findUnknownKey(raw, V2_KEYS);
|
|
36
106
|
if (unknownKey)
|
|
37
107
|
return refused(unknownKey);
|
|
38
108
|
const id = str(raw.id);
|
|
39
109
|
if (!id || !ID_PATTERN.test(id)) {
|
|
40
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_.-]*$/).");
|
|
41
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;
|
|
42
116
|
const subject = parseSubject(raw.subject);
|
|
43
117
|
if (!subject.ok)
|
|
44
118
|
return refused(subject.error.message);
|
|
45
119
|
const actor = readActor(raw.actors);
|
|
46
120
|
if (!actor.ok)
|
|
47
121
|
return actor;
|
|
48
|
-
const execution =
|
|
122
|
+
const execution = readExecution(raw.execution);
|
|
49
123
|
if (!execution.ok)
|
|
50
|
-
return
|
|
124
|
+
return execution;
|
|
51
125
|
const scenario = readScenario(raw.scenario);
|
|
52
126
|
if (!scenario.ok)
|
|
53
127
|
return scenario;
|
|
@@ -71,22 +145,26 @@ export function checkV2(raw) {
|
|
|
71
145
|
if (!comms.ok)
|
|
72
146
|
return refused(comms.error.message);
|
|
73
147
|
const composition = {
|
|
74
|
-
schema:
|
|
148
|
+
schema: STUDY_SCHEMA,
|
|
75
149
|
id,
|
|
150
|
+
// A shared world is declared; any other route is derived below.
|
|
151
|
+
route: topology?.value === "shared-world" ? "shared-world" : "computer-use",
|
|
76
152
|
subject: subject.value,
|
|
77
|
-
|
|
78
|
-
...(execution.value === undefined ? {} : { execution: execution.value }),
|
|
153
|
+
actor: { type: actor.value.type, ...actor.value.fields },
|
|
154
|
+
...(execution.value.execution === undefined ? {} : { execution: execution.value.execution }),
|
|
79
155
|
...(policies.value === undefined ? {} : { policies: policies.value }),
|
|
80
156
|
...(comms.value === undefined ? {} : { comms: comms.value }),
|
|
81
157
|
};
|
|
82
158
|
const route = v2RouteOf(composition);
|
|
159
|
+
composition.route = route;
|
|
83
160
|
const study = {
|
|
84
161
|
route,
|
|
85
162
|
composition,
|
|
163
|
+
topology: topology?.value,
|
|
86
164
|
count: actor.value.count,
|
|
87
165
|
entries: actor.value.entries,
|
|
88
166
|
focus: actor.value.focus,
|
|
89
|
-
executionCaps: execution.value
|
|
167
|
+
executionCaps: execution.value.caps,
|
|
90
168
|
scenario: scenario.value,
|
|
91
169
|
personas: Array.isArray(raw.personas) && raw.personas.some(isRecord),
|
|
92
170
|
terminalTimeout: route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined,
|
|
@@ -107,7 +185,37 @@ export function checkV2(raw) {
|
|
|
107
185
|
* the shared-world checks' refusal, as it does from the v2 parser.
|
|
108
186
|
*/
|
|
109
187
|
function v2RouteOf(composition) {
|
|
110
|
-
return 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 } };
|
|
111
219
|
}
|
|
112
220
|
// parse/actors.ts parseActors, for the one actor it accepts.
|
|
113
221
|
function readActor(raw) {
|
|
@@ -252,7 +360,7 @@ export function v2ShapeReason(study) {
|
|
|
252
360
|
const { route, composition, count, entries, focus } = study;
|
|
253
361
|
if (route === "scripted") {
|
|
254
362
|
// composition-rules.ts scriptedBrowserValidationReason, in its order.
|
|
255
|
-
if (composition.subject.source === "clone" &&
|
|
363
|
+
if (composition.subject.source === "clone" && study.topology !== undefined) {
|
|
256
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`.";
|
|
257
365
|
}
|
|
258
366
|
if ((count ?? 1) > 2) {
|
|
@@ -280,13 +388,13 @@ export function v2ShapeReason(study) {
|
|
|
280
388
|
const caps = study.scenario?.caps;
|
|
281
389
|
if (composition.subject.source === "app-url" &&
|
|
282
390
|
composition.execution?.target === "local" &&
|
|
283
|
-
composition.
|
|
391
|
+
composition.actor.type === "local-agent" &&
|
|
284
392
|
(caps?.maxUsd !== undefined || caps?.maxTotalUsd !== undefined)) {
|
|
285
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.";
|
|
286
394
|
}
|
|
287
395
|
return undefined;
|
|
288
396
|
}
|
|
289
|
-
// The caps keys each route reads, as
|
|
397
|
+
// The caps keys each route reads, as parse/front.ts CAPS_KEYS has them.
|
|
290
398
|
const CAPS_READ = {
|
|
291
399
|
preview: [],
|
|
292
400
|
"computer-use": ["maxUsd", "maxTotalUsd"],
|
|
@@ -394,7 +502,7 @@ const V2_DROP_ROWS = [
|
|
|
394
502
|
},
|
|
395
503
|
{
|
|
396
504
|
path: "subject.topology",
|
|
397
|
-
applies: (study, routes) => !routes.shared && study.
|
|
505
|
+
applies: (study, routes) => !routes.shared && study.topology !== undefined,
|
|
398
506
|
},
|
|
399
507
|
{
|
|
400
508
|
path: "execution.caps",
|
|
@@ -458,15 +566,34 @@ const DROP_ORDER = [
|
|
|
458
566
|
"review.scorer",
|
|
459
567
|
"personas",
|
|
460
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
|
+
}
|
|
461
587
|
/**
|
|
462
588
|
* The keys migrate drops: the v2-only rows above and `inert`, the fields parseStudyV3 reports the
|
|
463
|
-
* route does not read, then terminal `execution.timeoutMs`,
|
|
464
|
-
* come before their children, so a child under a dropped
|
|
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.
|
|
465
592
|
*/
|
|
466
593
|
export function droppedPaths(study, inert) {
|
|
467
594
|
const routes = routesOf(study);
|
|
468
595
|
const found = new Set([
|
|
469
|
-
...inert,
|
|
596
|
+
...inert.map(v2PathOf),
|
|
470
597
|
...V2_DROP_ROWS.filter((row) => row.applies(study, routes)).map((row) => row.path),
|
|
471
598
|
]);
|
|
472
599
|
const paths = [
|
|
@@ -1,56 +1,35 @@
|
|
|
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
|
-
/** The participant entries an actor declares, after roster groups expand into them. The manifest
|
|
5
|
-
* spells them `actors[].lanes`. Undefined when the actor declares none. */
|
|
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): {
|
|
12
|
-
ok: true;
|
|
13
|
-
value: StudyActor[];
|
|
14
|
-
} | StudyParseFailure;
|
|
15
4
|
/** An actor's `type`, which a study route must run. `path` names the actor in messages. */
|
|
16
5
|
export declare function parseActorType(entry: Record<string, unknown>, path: string): {
|
|
17
6
|
ok: true;
|
|
18
7
|
value: string;
|
|
19
8
|
} | StudyParseFailure;
|
|
20
|
-
/** An actor's keys other than `type
|
|
21
|
-
type ActorFields = Omit<
|
|
9
|
+
/** An actor's keys other than `type`. */
|
|
10
|
+
type ActorFields = Omit<StudyActor, "type">;
|
|
22
11
|
/**
|
|
23
|
-
* Every actor key other than `type
|
|
24
|
-
*
|
|
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.
|
|
25
14
|
*/
|
|
26
15
|
export declare function parseActorFields(entry: Record<string, unknown>, path: string): {
|
|
27
16
|
ok: true;
|
|
28
17
|
value: ActorFields;
|
|
29
18
|
} | StudyParseFailure;
|
|
30
|
-
/** The participants a v2 actor declares, which a v3 study declares outside its actor. */
|
|
31
|
-
interface ActorParticipants {
|
|
32
|
-
readonly count?: number | undefined;
|
|
33
|
-
readonly entries?: StudyParticipantEntry[] | undefined;
|
|
34
|
-
readonly instruction?: string | undefined;
|
|
35
|
-
}
|
|
36
|
-
/** A v3 study's actor in the v2 spelling: the actor holds its participants. */
|
|
37
|
-
export declare function withParticipants(actor: StudyV3["actor"], { count, entries, instruction }: ActorParticipants): StudyActor;
|
|
38
|
-
/** A v2 actor without the keys that declare its participants: the actor of a v3 study. */
|
|
39
|
-
export declare function withoutParticipants(actor: StudyActor): StudyV3["actor"];
|
|
40
19
|
/** How a participant list's messages name the list and each entry in it. */
|
|
41
20
|
export interface ParticipantPaths {
|
|
42
21
|
readonly list: string;
|
|
43
22
|
readonly entry: (index: number) => string;
|
|
44
23
|
}
|
|
45
24
|
/**
|
|
46
|
-
* Parse a participant list into a fan-out roster (computer-use E2B route): `
|
|
47
|
-
*
|
|
48
|
-
* each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
|
|
49
|
-
* entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
|
|
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? }`.
|
|
50
29
|
* Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
|
|
51
30
|
* must be public-safe tokens; a device must be a known preset name. The
|
|
52
|
-
* route-scoped cross-validation (
|
|
53
|
-
*
|
|
31
|
+
* route-scoped cross-validation (device XOR raw resolution, targets, cap 16) runs in parseStudy
|
|
32
|
+
* where the route is known.
|
|
54
33
|
*/
|
|
55
34
|
export declare function parseParticipantEntries(raw: unknown, paths: ParticipantPaths): {
|
|
56
35
|
ok: true;
|
|
@@ -4,24 +4,15 @@ import { isMaxOutputTokens } from "../../actors/output-token-limit.js";
|
|
|
4
4
|
import { isHttpUrl } from "./subject.js";
|
|
5
5
|
import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js";
|
|
6
6
|
import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
|
|
7
|
-
import { invalid,
|
|
7
|
+
import { invalid, str } from "./values.js";
|
|
8
8
|
import { isRecord } from "../../run/type-guards.js";
|
|
9
9
|
// A participant id interpolates into its evidence paths (screenshots/<id>/, actors/<id>.json), so
|
|
10
10
|
// it must be a public-safe path token, same shape as a study id.
|
|
11
11
|
export const PARTICIPANT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
12
12
|
export const PARTICIPANT_ID_MAX_CHARS = 40;
|
|
13
13
|
const METADATA_MAX_CHARS = 80;
|
|
14
|
-
/** The participant entries an actor declares, after roster groups expand into them. The manifest
|
|
15
|
-
* spells them `actors[].lanes`. Undefined when the actor declares none. */
|
|
16
|
-
export function rosterOf(actor) {
|
|
17
|
-
return actor?.lanes;
|
|
18
|
-
}
|
|
19
|
-
/** An actor's per-participant focus on the app-url route. The manifest spells it `laneFocus`. */
|
|
20
|
-
export function focusOf(actor) {
|
|
21
|
-
return actor?.laneFocus;
|
|
22
|
-
}
|
|
23
14
|
// Actor ids humanish no longer registers. Rejecting them at parse keeps a study that names one
|
|
24
|
-
// from running on a route that ignores
|
|
15
|
+
// from running on a route that ignores actor.type, such as a this-repo dry run.
|
|
25
16
|
const REMOVED_ACTOR_TYPES = new Set(["pi-agent-core", "claude-agent-sdk"]);
|
|
26
17
|
// Only actors of a run kind a route dispatches. codex-app-server is registered but declares only
|
|
27
18
|
// the "code" run kind, which no study route runs, so naming it would lead to the same dead end.
|
|
@@ -32,56 +23,6 @@ function routableActorTypes() {
|
|
|
32
23
|
...registeredTerminalActors(),
|
|
33
24
|
];
|
|
34
25
|
}
|
|
35
|
-
export function parseActors(raw) {
|
|
36
|
-
if (!Array.isArray(raw) || raw.length === 0) {
|
|
37
|
-
return invalid("`actors` must be a non-empty array.");
|
|
38
|
-
}
|
|
39
|
-
// Multi-actor fan-out is not wired yet (only actors[0] is consumed). Fail closed rather than
|
|
40
|
-
// silently ignore actors[1..]; multi-actor support lands in a later slice.
|
|
41
|
-
if (raw.length > 1) {
|
|
42
|
-
return invalid("Multiple actors are not supported yet (only the first actor runs); declare a single actor.");
|
|
43
|
-
}
|
|
44
|
-
const actors = [];
|
|
45
|
-
for (const [index, entry] of raw.entries()) {
|
|
46
|
-
if (!isRecord(entry)) {
|
|
47
|
-
return invalid(`actors[${index}] must be an object.`);
|
|
48
|
-
}
|
|
49
|
-
const path = `actors[${index}]`;
|
|
50
|
-
const type = parseActorType(entry, path);
|
|
51
|
-
if (!type.ok)
|
|
52
|
-
return type;
|
|
53
|
-
const actor = { type: type.value };
|
|
54
|
-
const count = posInt(entry.count);
|
|
55
|
-
if (count !== undefined)
|
|
56
|
-
actor.count = count;
|
|
57
|
-
if (entry.lanes !== undefined && entry.roster !== undefined) {
|
|
58
|
-
return invalid(`Set either actors[${index}].lanes or actors[${index}].roster, not both: list participants one by one in \`lanes\`, or in groups in \`roster\`.`);
|
|
59
|
-
}
|
|
60
|
-
if (entry.roster !== undefined && count !== undefined) {
|
|
61
|
-
return invalid(`Set either actors[${index}].roster or actors[${index}].count, not both: \`roster\` declares groups of distinct participants, and \`count\` declares identical ones.`);
|
|
62
|
-
}
|
|
63
|
-
if (entry.roster !== undefined && entry.laneFocus !== undefined) {
|
|
64
|
-
return invalid(`actors[${index}].roster and actors[${index}].laneFocus are mutually exclusive: a roster group's instruction is each participant's steer.`);
|
|
65
|
-
}
|
|
66
|
-
const rosterResult = entry.roster !== undefined
|
|
67
|
-
? parseRosterGroups(entry.roster, index)
|
|
68
|
-
: parseParticipantEntries(entry.lanes, lanePaths(index));
|
|
69
|
-
if (!rosterResult.ok) {
|
|
70
|
-
return rosterResult;
|
|
71
|
-
}
|
|
72
|
-
if (rosterResult.value)
|
|
73
|
-
actor.lanes = rosterResult.value;
|
|
74
|
-
const fields = parseActorFields(entry, path);
|
|
75
|
-
if (!fields.ok)
|
|
76
|
-
return fields;
|
|
77
|
-
Object.assign(actor, fields.value);
|
|
78
|
-
const focus = parseFocus(entry.laneFocus);
|
|
79
|
-
if (focus)
|
|
80
|
-
actor.laneFocus = focus;
|
|
81
|
-
actors.push(actor);
|
|
82
|
-
}
|
|
83
|
-
return { ok: true, value: actors };
|
|
84
|
-
}
|
|
85
26
|
/** An actor's `type`, which a study route must run. `path` names the actor in messages. */
|
|
86
27
|
export function parseActorType(entry, path) {
|
|
87
28
|
const type = str(entry.type);
|
|
@@ -94,8 +35,8 @@ export function parseActorType(entry, path) {
|
|
|
94
35
|
return { ok: true, value: type };
|
|
95
36
|
}
|
|
96
37
|
/**
|
|
97
|
-
* Every actor key other than `type
|
|
98
|
-
*
|
|
38
|
+
* Every actor key other than `type`. The parser reads them after the participant entries, so a file
|
|
39
|
+
* with errors in both reports the entry first.
|
|
99
40
|
*/
|
|
100
41
|
export function parseActorFields(entry, path) {
|
|
101
42
|
const actor = {};
|
|
@@ -143,101 +84,15 @@ export function parseActorFields(entry, path) {
|
|
|
143
84
|
actor.tasks = tasksResult.value;
|
|
144
85
|
return { ok: true, value: actor };
|
|
145
86
|
}
|
|
146
|
-
/** A v3 study's actor in the v2 spelling: the actor holds its participants. */
|
|
147
|
-
export function withParticipants(actor, { count, entries, instruction }) {
|
|
148
|
-
const { type, ...fields } = actor;
|
|
149
|
-
return {
|
|
150
|
-
type,
|
|
151
|
-
...(count === undefined ? {} : { count }),
|
|
152
|
-
...(entries === undefined ? {} : { lanes: entries }),
|
|
153
|
-
...fields,
|
|
154
|
-
...(instruction === undefined ? {} : { laneFocus: { instruction } }),
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
/** A v2 actor without the keys that declare its participants: the actor of a v3 study. */
|
|
158
|
-
export function withoutParticipants(actor) {
|
|
159
|
-
const fields = { ...actor };
|
|
160
|
-
delete fields.count;
|
|
161
|
-
delete fields.lanes;
|
|
162
|
-
delete fields.laneFocus;
|
|
163
|
-
return fields;
|
|
164
|
-
}
|
|
165
|
-
function parseFocus(raw) {
|
|
166
|
-
if (!isRecord(raw)) {
|
|
167
|
-
return undefined;
|
|
168
|
-
}
|
|
169
|
-
const focus = {};
|
|
170
|
-
const id = str(raw.id);
|
|
171
|
-
if (id)
|
|
172
|
-
focus.id = id;
|
|
173
|
-
const label = str(raw.label);
|
|
174
|
-
if (label)
|
|
175
|
-
focus.label = label;
|
|
176
|
-
const instruction = str(raw.instruction);
|
|
177
|
-
if (instruction)
|
|
178
|
-
focus.instruction = instruction;
|
|
179
|
-
return Object.keys(focus).length > 0 ? focus : undefined;
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Parse `actors[index].roster` compact groups into concrete `lanes[]` entries. This is authoring sugar for
|
|
183
|
-
* "N users of M adapter-owned types across S surfaces"; the runtime receives only `lanes[]`.
|
|
184
|
-
*/
|
|
185
|
-
function parseRosterGroups(raw, actorIndex) {
|
|
186
|
-
if (raw === undefined) {
|
|
187
|
-
return { ok: true, value: undefined };
|
|
188
|
-
}
|
|
189
|
-
if (!Array.isArray(raw) || raw.length === 0) {
|
|
190
|
-
return invalid(`actors[${actorIndex}].roster must be a non-empty array of group objects ({ id, count, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }) when set.`);
|
|
191
|
-
}
|
|
192
|
-
const expanded = [];
|
|
193
|
-
const seenGroupIds = new Set();
|
|
194
|
-
for (const [groupIndex, entry] of raw.entries()) {
|
|
195
|
-
if (!isRecord(entry)) {
|
|
196
|
-
return invalid(`actors[${actorIndex}].roster[${groupIndex}] must be an object ({ id, count, actorType?, surface?, caseGroup?, persona?, device?, instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }).`);
|
|
197
|
-
}
|
|
198
|
-
const groupId = str(entry.id);
|
|
199
|
-
if (groupId === undefined) {
|
|
200
|
-
return invalid(`actors[${actorIndex}].roster[${groupIndex}].id is required and must be a public-safe token matching ${PARTICIPANT_ID_PATTERN}.`);
|
|
201
|
-
}
|
|
202
|
-
if (!PARTICIPANT_ID_PATTERN.test(groupId) || groupId.length > PARTICIPANT_ID_MAX_CHARS - 3) {
|
|
203
|
-
return invalid(`actors[${actorIndex}].roster[${groupIndex}].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"; "${groupId}" is not.`);
|
|
204
|
-
}
|
|
205
|
-
if (seenGroupIds.has(groupId)) {
|
|
206
|
-
return invalid(`actors[${actorIndex}].roster group ids must be unique (duplicate "${groupId}").`);
|
|
207
|
-
}
|
|
208
|
-
seenGroupIds.add(groupId);
|
|
209
|
-
const count = posInt(entry.count);
|
|
210
|
-
if (count === undefined) {
|
|
211
|
-
return invalid(`actors[${actorIndex}].roster[${groupIndex}].count is required and must be a positive integer.`);
|
|
212
|
-
}
|
|
213
|
-
const groupEntryInput = { ...entry };
|
|
214
|
-
delete groupEntryInput.id;
|
|
215
|
-
delete groupEntryInput.count;
|
|
216
|
-
for (let i = 1; i <= count; i += 1) {
|
|
217
|
-
expanded.push({
|
|
218
|
-
...groupEntryInput,
|
|
219
|
-
id: `${groupId}-${String(i).padStart(2, "0")}`,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return parseParticipantEntries(expanded, lanePaths(actorIndex));
|
|
224
|
-
}
|
|
225
|
-
// The v2 spelling, `actors[0].lanes[3]`. A roster group's participants are named the same way.
|
|
226
|
-
function lanePaths(actorIndex) {
|
|
227
|
-
return {
|
|
228
|
-
list: `actors[${actorIndex}].lanes`,
|
|
229
|
-
entry: (index) => `actors[${actorIndex}].lanes[${index}]`,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
87
|
/**
|
|
233
|
-
* Parse a participant list into a fan-out roster (computer-use E2B route): `
|
|
234
|
-
*
|
|
235
|
-
* each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
|
|
236
|
-
* entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
|
|
88
|
+
* Parse a participant list into a fan-out roster (computer-use E2B route): `participants` in a
|
|
89
|
+
* study, `actors[0].lanes` in the v2 file migrate reads, each named in messages by `paths`.
|
|
90
|
+
* Structural only: each entry is `{ id?, actorType?, surface?, caseGroup?, persona?, device?,
|
|
91
|
+
* instruction?, target?, entry?, host?, reasoningEffort?, stopWhen?, dwell? }`.
|
|
237
92
|
* Participant ids (when declared) must be public-safe path tokens and unique; grouping metadata
|
|
238
93
|
* must be public-safe tokens; a device must be a known preset name. The
|
|
239
|
-
* route-scoped cross-validation (
|
|
240
|
-
*
|
|
94
|
+
* route-scoped cross-validation (device XOR raw resolution, targets, cap 16) runs in parseStudy
|
|
95
|
+
* where the route is known.
|
|
241
96
|
*/
|
|
242
97
|
export function parseParticipantEntries(raw, paths) {
|
|
243
98
|
if (raw === undefined) {
|
|
@@ -415,7 +270,7 @@ function parseDwell(raw, field) {
|
|
|
415
270
|
...(whenResult.value === undefined ? {} : { when: whenResult.value }),
|
|
416
271
|
ms,
|
|
417
272
|
everyMs,
|
|
418
|
-
// oxlint-disable-next-line unicorn/no-thenable -- `then` is the documented dwell field of
|
|
273
|
+
// oxlint-disable-next-line unicorn/no-thenable -- `then` is the documented dwell field of a study
|
|
419
274
|
then,
|
|
420
275
|
},
|
|
421
276
|
};
|
|
@@ -76,9 +76,13 @@ function parseCommsEmail(raw) {
|
|
|
76
76
|
}
|
|
77
77
|
return { ok: true, value: email };
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* A real inbox: a saved connection, with optional link and allowed origins and nothing else. The
|
|
81
|
+
* parsed config adds `kind: real`, so a config parseStudy returned parses again with it.
|
|
82
|
+
*/
|
|
80
83
|
function parseEmailConnection(raw) {
|
|
81
|
-
const unsupported = Object.keys(raw).filter((key) => !["connection", "linkOrigin", "allowedOrigins"].includes(key)
|
|
84
|
+
const unsupported = Object.keys(raw).filter((key) => !["connection", "linkOrigin", "allowedOrigins"].includes(key) &&
|
|
85
|
+
!(key === "kind" && raw.kind === "real"));
|
|
82
86
|
if (unsupported.length)
|
|
83
87
|
return invalid("An email connection cannot be mixed with capture settings, kind, recipients, or provider options. Use only connection, optional linkOrigin and allowedOrigins.");
|
|
84
88
|
if (typeof raw.connection !== "string" || !/^[a-z][a-z0-9-]{0,47}$/.test(raw.connection))
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import type { StudyParseFailure, StudyDefaults, StudyExecution, StudyPolicies, StudyReview
|
|
1
|
+
import type { StudyCaps, StudyParseFailure, StudyDefaults, StudyExecution, StudyPolicies, StudyReview } from "../types.js";
|
|
2
2
|
export declare function parseExecution(raw: unknown): {
|
|
3
3
|
ok: true;
|
|
4
4
|
value: StudyExecution | undefined;
|
|
5
5
|
} | StudyParseFailure;
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Parse a `caps` block's values. Returns a parse failure on a malformed value rather than silently
|
|
8
|
+
* dropping a budget declaration (a cap that silently does nothing would claim protection it
|
|
9
|
+
* does not give). Each cap must be a non-negative finite number. readStudyFront has checked the
|
|
10
|
+
* keys against the route; migrate checks a v2 file's two caps blocks with it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseCaps(raw: unknown): {
|
|
8
13
|
ok: true;
|
|
9
|
-
value:
|
|
14
|
+
value: StudyCaps | undefined;
|
|
10
15
|
} | StudyParseFailure;
|
|
11
16
|
export declare function parsePolicies(raw: unknown): {
|
|
12
17
|
ok: true;
|
|
@@ -33,14 +33,6 @@ export function parseExecution(raw) {
|
|
|
33
33
|
}
|
|
34
34
|
if (desktopResult.value)
|
|
35
35
|
execution.desktop = desktopResult.value;
|
|
36
|
-
// Reuse the terminal route's caps parser (same shape, not a fork); a malformed budget is a hard
|
|
37
|
-
// error, never silently dropped (a cap that silently does nothing would be a safety lie).
|
|
38
|
-
const capsResult = parseCaps(raw.caps);
|
|
39
|
-
if (!capsResult.ok) {
|
|
40
|
-
return capsResult;
|
|
41
|
-
}
|
|
42
|
-
if (capsResult.value)
|
|
43
|
-
execution.caps = capsResult.value;
|
|
44
36
|
const terminalResult = parseTerminal(raw.terminal);
|
|
45
37
|
if (!terminalResult.ok) {
|
|
46
38
|
return terminalResult;
|
|
@@ -217,40 +209,13 @@ function parseDesktop(raw) {
|
|
|
217
209
|
}
|
|
218
210
|
return { ok: true, value: Object.keys(desktop).length > 0 ? desktop : undefined };
|
|
219
211
|
}
|
|
220
|
-
export function parsePersonas(raw) {
|
|
221
|
-
if (!Array.isArray(raw)) {
|
|
222
|
-
return undefined;
|
|
223
|
-
}
|
|
224
|
-
const personas = raw.filter(isRecord);
|
|
225
|
-
return personas.length > 0 ? personas : undefined;
|
|
226
|
-
}
|
|
227
|
-
export function parseScenario(raw) {
|
|
228
|
-
if (!isRecord(raw)) {
|
|
229
|
-
return { ok: true, value: undefined };
|
|
230
|
-
}
|
|
231
|
-
const scenario = {};
|
|
232
|
-
const ref = str(raw.ref);
|
|
233
|
-
if (ref)
|
|
234
|
-
scenario.ref = ref;
|
|
235
|
-
if (isRecord(raw.inline))
|
|
236
|
-
scenario.inline = raw.inline;
|
|
237
|
-
const mode = str(raw.mode);
|
|
238
|
-
if (mode === "dry-run" || mode === "live")
|
|
239
|
-
scenario.mode = mode;
|
|
240
|
-
const capsResult = parseCaps(raw.caps);
|
|
241
|
-
if (!capsResult.ok) {
|
|
242
|
-
return capsResult;
|
|
243
|
-
}
|
|
244
|
-
if (capsResult.value)
|
|
245
|
-
scenario.caps = capsResult.value;
|
|
246
|
-
return { ok: true, value: Object.keys(scenario).length > 0 ? scenario : undefined };
|
|
247
|
-
}
|
|
248
212
|
/**
|
|
249
|
-
* Parse `
|
|
213
|
+
* Parse a `caps` block's values. Returns a parse failure on a malformed value rather than silently
|
|
250
214
|
* dropping a budget declaration (a cap that silently does nothing would claim protection it
|
|
251
|
-
* does not give). Each cap must be a non-negative finite number.
|
|
215
|
+
* does not give). Each cap must be a non-negative finite number. readStudyFront has checked the
|
|
216
|
+
* keys against the route; migrate checks a v2 file's two caps blocks with it.
|
|
252
217
|
*/
|
|
253
|
-
function parseCaps(raw) {
|
|
218
|
+
export function parseCaps(raw) {
|
|
254
219
|
if (raw === undefined) {
|
|
255
220
|
return { ok: true, value: undefined };
|
|
256
221
|
}
|