@ishlabs/cli 0.17.7 → 0.19.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/README.md +54 -54
- package/dist/commands/ask.d.ts +4 -4
- package/dist/commands/ask.js +66 -66
- package/dist/commands/chat.js +10 -10
- package/dist/commands/config.js +1 -1
- package/dist/commands/docs.js +1 -1
- package/dist/commands/iteration.js +57 -57
- package/dist/commands/mcp.d.ts +23 -0
- package/dist/commands/mcp.js +676 -0
- package/dist/commands/person.d.ts +5 -0
- package/dist/commands/{profile.js → person.js} +197 -162
- package/dist/commands/source.d.ts +6 -2
- package/dist/commands/source.js +35 -30
- package/dist/commands/study-analyze.d.ts +1 -1
- package/dist/commands/study-analyze.js +3 -3
- package/dist/commands/study-participant.d.ts +8 -0
- package/dist/commands/{study-tester.js → study-participant.js} +50 -50
- package/dist/commands/study-run.d.ts +6 -6
- package/dist/commands/study-run.js +341 -290
- package/dist/commands/study.js +106 -72
- package/dist/commands/workspace.js +13 -13
- package/dist/connect.js +5 -5
- package/dist/index.js +6 -4
- package/dist/lib/accessibility-profile.d.ts +1 -1
- package/dist/lib/accessibility-profile.js +1 -1
- package/dist/lib/alias-hydrate.js +4 -4
- package/dist/lib/alias-store.d.ts +5 -5
- package/dist/lib/alias-store.js +8 -8
- package/dist/lib/api-client.d.ts +1 -1
- package/dist/lib/api-client.js +1 -1
- package/dist/lib/billing.d.ts +11 -11
- package/dist/lib/billing.js +16 -16
- package/dist/lib/chat-endpoint-templates.js +1 -1
- package/dist/lib/command-helpers.d.ts +18 -18
- package/dist/lib/command-helpers.js +49 -37
- package/dist/lib/docs.js +570 -387
- package/dist/lib/enums.d.ts +2 -2
- package/dist/lib/enums.js +2 -2
- package/dist/lib/local-sim/browser.d.ts +1 -1
- package/dist/lib/local-sim/browser.js +1 -1
- package/dist/lib/local-sim/debug-report.d.ts +2 -2
- package/dist/lib/local-sim/debug-report.js +3 -3
- package/dist/lib/local-sim/loop.d.ts +5 -5
- package/dist/lib/local-sim/loop.js +38 -38
- package/dist/lib/local-sim/types.d.ts +12 -12
- package/dist/lib/mcp-clients.d.ts +51 -0
- package/dist/lib/mcp-clients.js +175 -0
- package/dist/lib/modality.d.ts +10 -10
- package/dist/lib/modality.js +46 -46
- package/dist/lib/output.d.ts +16 -15
- package/dist/lib/output.js +291 -226
- package/dist/lib/profile-sources.d.ts +64 -16
- package/dist/lib/profile-sources.js +91 -30
- package/dist/lib/skill-content.js +216 -168
- package/dist/lib/study-events.d.ts +3 -3
- package/dist/lib/study-events.js +1 -1
- package/dist/lib/study-inputs.d.ts +11 -1
- package/dist/lib/study-inputs.js +68 -17
- package/dist/lib/study-participants.d.ts +32 -0
- package/dist/lib/study-participants.js +12 -0
- package/dist/lib/types.d.ts +104 -34
- package/package.json +1 -1
- package/dist/commands/profile.d.ts +0 -5
- package/dist/commands/study-tester.d.ts +0 -8
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ish study run — Run, monitor, and cancel simulations of a study.
|
|
3
3
|
*
|
|
4
|
-
* `ish study run` creates
|
|
4
|
+
* `ish study run` creates participants for the latest (or specified) iteration
|
|
5
5
|
* and dispatches simulations. Iterations are created separately via
|
|
6
6
|
* `ish iteration create`, which carries the URL/content details.
|
|
7
7
|
*
|
|
8
8
|
* Lower-level: `study poll`, `study cancel`.
|
|
9
9
|
*/
|
|
10
|
+
import { Option } from "commander";
|
|
10
11
|
import * as readline from "node:readline/promises";
|
|
11
|
-
import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout,
|
|
12
|
+
import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolvePersonIds, addPersonFilterFlags, hasPersonFlags, readFileOrStdin, } from "../lib/command-helpers.js";
|
|
12
13
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
13
14
|
import { output, formatSimulationPoll } from "../lib/output.js";
|
|
15
|
+
import { fetchStudyParticipants } from "../lib/study-participants.js";
|
|
14
16
|
import { streamStudyEvents } from "../lib/study-events.js";
|
|
15
|
-
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode,
|
|
17
|
+
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readParticipantPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
|
|
16
18
|
// NOTE: local-sim modules are loaded via dynamic import at the `--local`
|
|
17
19
|
// branch below, NOT statically here. `local-sim/install.ts` deep-imports
|
|
18
20
|
// `playwright-core/lib/server/registry/index`, which is not exposed by
|
|
@@ -33,7 +35,7 @@ function parseMaxInteractions(value) {
|
|
|
33
35
|
* iteration carries its own value. Picked to match the frontend's
|
|
34
36
|
* conservative interactive launchers and to prevent runaway spend when an
|
|
35
37
|
* iteration runs against a broken or non-responsive surface — without a
|
|
36
|
-
* cap, a stuck
|
|
38
|
+
* cap, a stuck participant can rack up hundreds of steps before the SDK gives
|
|
37
39
|
* up.
|
|
38
40
|
*/
|
|
39
41
|
const DEFAULT_MAX_INTERACTIONS = 20;
|
|
@@ -56,7 +58,7 @@ function parseSlowMo(value) {
|
|
|
56
58
|
* produce a structured envelope (`error_code: "wait_timeout"`, exit 5
|
|
57
59
|
* transient) distinct from the generic timeout/network/server errors
|
|
58
60
|
* that the api-client wrapper produces. Carries the in-flight progress
|
|
59
|
-
* (
|
|
61
|
+
* (participants done / total) so `study wait` always emits final state JSON
|
|
60
62
|
* even when it bails on the timer.
|
|
61
63
|
*/
|
|
62
64
|
export class WaitTimeoutError extends Error {
|
|
@@ -71,9 +73,9 @@ export class WaitTimeoutError extends Error {
|
|
|
71
73
|
}
|
|
72
74
|
/**
|
|
73
75
|
* M13 / Pattern J: collapse the N near-duplicate `simulations[]` rows the
|
|
74
|
-
* batch start endpoint returns (one per
|
|
75
|
-
* single batch entry with `
|
|
76
|
-
* Agents always need the per-
|
|
76
|
+
* batch start endpoint returns (one per participant, all sharing study_id) into a
|
|
77
|
+
* single batch entry with `participant_ids[]` + `participant_aliases[]` + `job_ids[]`.
|
|
78
|
+
* Agents always need the per-participant ids, but the surrounding scaffolding
|
|
77
79
|
* (study_id, message) is shared so repeating it N times costs context for no
|
|
78
80
|
* extra signal. Falls back to the raw rows if the response shape is unfamiliar.
|
|
79
81
|
*/
|
|
@@ -91,8 +93,8 @@ function dedupeSimulations(simResults) {
|
|
|
91
93
|
return [
|
|
92
94
|
{
|
|
93
95
|
study_id: studyId,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
participant_ids: simResults.map((r) => r.participant_id),
|
|
97
|
+
participant_aliases: simResults.map((r) => tagAlias(ALIAS_PREFIX.participant, String(r.participant_id))),
|
|
96
98
|
job_ids: simResults.map((r) => r.job_id ?? null),
|
|
97
99
|
count: simResults.length,
|
|
98
100
|
message: simResults[0].message,
|
|
@@ -106,24 +108,27 @@ const POLL_INTERVAL_MS = 5_000;
|
|
|
106
108
|
// transparently reverts to POLL_INTERVAL_MS.
|
|
107
109
|
const SSE_BACKSTOP_INTERVAL_MS = 30_000;
|
|
108
110
|
const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
|
|
109
|
-
function
|
|
111
|
+
function flattenParticipantStatuses(participants, opts = {}) {
|
|
110
112
|
const rows = [];
|
|
111
|
-
for (const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
113
|
+
for (const t of participants ?? []) {
|
|
114
|
+
if (opts.iterationId && t.iteration_id !== opts.iterationId)
|
|
115
|
+
continue;
|
|
116
|
+
if (opts.only && !opts.only.has(t.id))
|
|
117
|
+
continue;
|
|
118
|
+
// Pattern A (cli half): backend now reports per-participant crash detail at
|
|
119
|
+
// `error_message`. Keep `error` / `failure_reason` as legacy fallbacks
|
|
120
|
+
// until every backend deploy is on the new contract.
|
|
121
|
+
const errorMessage = t.error_message ||
|
|
122
|
+
t.error ||
|
|
123
|
+
t.failure_reason ||
|
|
124
|
+
null;
|
|
125
|
+
rows.push({
|
|
126
|
+
id: t.id,
|
|
127
|
+
status: t.status,
|
|
128
|
+
participant_name: t.person?.name || "Unknown",
|
|
129
|
+
interaction_count: Array.isArray(t.interactions) ? t.interactions.length : 0,
|
|
130
|
+
...(errorMessage && { error_message: String(errorMessage) }),
|
|
131
|
+
});
|
|
127
132
|
}
|
|
128
133
|
return rows;
|
|
129
134
|
}
|
|
@@ -131,7 +136,7 @@ async function pollStudyUntilDone(client, opts) {
|
|
|
131
136
|
const start = Date.now();
|
|
132
137
|
let lastReported = "";
|
|
133
138
|
// Open the SSE event stream as a wake source. When the backend supports
|
|
134
|
-
// it (enable_realtime=true + broker live),
|
|
139
|
+
// it (enable_realtime=true + broker live), participant status changes wake
|
|
135
140
|
// the loop the moment they happen — no need to wait for the next poll
|
|
136
141
|
// tick. When it doesn't, the iterator exits silently on the first read
|
|
137
142
|
// and we fall through to pure polling at POLL_INTERVAL_MS.
|
|
@@ -145,13 +150,15 @@ async function pollStudyUntilDone(client, opts) {
|
|
|
145
150
|
let pendingEvent = eventIter.next();
|
|
146
151
|
try {
|
|
147
152
|
while (true) {
|
|
148
|
-
const study = await
|
|
153
|
+
const [study, participants] = await Promise.all([
|
|
154
|
+
client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 }),
|
|
155
|
+
fetchStudyParticipants(client, opts.studyId, { timeout: 60_000 }),
|
|
156
|
+
]);
|
|
149
157
|
const isMedia = isMediaModality(study.modality);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
const rows = flattenTesterStatuses(iterations, opts.testerIds);
|
|
158
|
+
const rows = flattenParticipantStatuses(participants, {
|
|
159
|
+
iterationId: opts.iterationId,
|
|
160
|
+
only: opts.participantIds,
|
|
161
|
+
});
|
|
155
162
|
const total = rows.length;
|
|
156
163
|
const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
|
|
157
164
|
const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
|
|
@@ -234,22 +241,24 @@ export function attachStudyRunCommands(study) {
|
|
|
234
241
|
// --- Primary: `study run` ---
|
|
235
242
|
const studyRun = study
|
|
236
243
|
.command("run")
|
|
237
|
-
.description("Run a study (creates
|
|
244
|
+
.description("Run a study (creates participants for the latest iteration and dispatches simulations)")
|
|
238
245
|
.option("--workspace <id>", "Workspace ID")
|
|
239
246
|
.option("--study <id>", "Study ID")
|
|
240
247
|
.option("--iteration <id>", "Iteration to run (defaults to latest on the study)");
|
|
241
|
-
|
|
248
|
+
addPersonFilterFlags(studyRun, {
|
|
242
249
|
allFlagName: "--all",
|
|
243
|
-
allFlagDescription: "Use every AI
|
|
250
|
+
allFlagDescription: "Use every AI person matching the filters (workspace-wide if no filters set)",
|
|
244
251
|
})
|
|
245
|
-
.option("--config <id>", "Simulation config ID (required for media unless every
|
|
246
|
-
.
|
|
247
|
-
.
|
|
252
|
+
.option("--config <id>", "Simulation config ID (required for media unless every person has one)")
|
|
253
|
+
.addOption(new Option("--sim-config <json|@file>", "Dev: per-run config overrides (ConfigOverrides JSON, or @path to a file). Layers on top of --config. "
|
|
254
|
+
+ "e.g. --sim-config '{\"model_settings\":{\"agent\":{\"model\":\"gemini-3.5-flash\"}}}'").hideHelp())
|
|
255
|
+
.option("--max-interactions <n>", `Max interactions per participant (interactive / media only). Precedence: flag > iteration's stored value > CLI default (${DEFAULT_MAX_INTERACTIONS}).`)
|
|
256
|
+
.option("--max-turns <n>", "Max conversation turns per participant (chat studies only)")
|
|
248
257
|
.option("--early-termination", "Allow chat agent to end the conversation early when goals are met (chat studies only)")
|
|
249
258
|
.option("--language <lang>", "Language code (e.g. en, sv)")
|
|
250
259
|
.option("--wait", "Wait for all simulations to reach a terminal state before returning")
|
|
251
260
|
.option("--timeout <s>", "Wait timeout in seconds (default 300; only with --wait)")
|
|
252
|
-
.option("--dispatch-timeout <s>", "Per-POST timeout in seconds for the create-
|
|
261
|
+
.option("--dispatch-timeout <s>", "Per-POST timeout in seconds for the create-participants + dispatch calls (default 120). Bump if `study run` times out client-side after seeding participants but before dispatch — those participants exist server-side and are surfaced under `seeded_but_not_dispatched_*` in the error envelope so the agent can resume.")
|
|
253
262
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
254
263
|
// Local simulation options
|
|
255
264
|
.option("--local", "Run simulation with local browser (Playwright) instead of remote")
|
|
@@ -257,7 +266,7 @@ export function attachStudyRunCommands(study) {
|
|
|
257
266
|
.option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
|
|
258
267
|
.option("--devtools", "Open Chrome DevTools (local mode only)")
|
|
259
268
|
.option("--debug", "Enable detailed debug logging to stderr and ~/.ish/local-sim.log")
|
|
260
|
-
.option("--parallel <n>", "Run N
|
|
269
|
+
.option("--parallel <n>", "Run N participants in parallel (local mode only, default: all)")
|
|
261
270
|
.addHelpText("after", `
|
|
262
271
|
Note: --workspace and --study are optional if you have set active context
|
|
263
272
|
via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
|
|
@@ -265,23 +274,22 @@ Note: --workspace and --study are optional if you have set active context
|
|
|
265
274
|
Iterations carry the URL/content. If the study has none, create one
|
|
266
275
|
first with \`ish iteration create\`.
|
|
267
276
|
|
|
268
|
-
|
|
269
|
-
--
|
|
277
|
+
People: pass nothing to reuse the iteration's existing participants. Pass
|
|
278
|
+
--person to use specific people, or filter flags (--bio, --country,
|
|
270
279
|
--gender, --min-age, --max-age, --occupation, --search, --visibility)
|
|
271
|
-
with --sample <N> or --all to seed
|
|
272
|
-
pool.
|
|
280
|
+
with --sample <N> or --all to seed from the workspace pool.
|
|
273
281
|
|
|
274
282
|
Examples:
|
|
275
|
-
# Run the latest iteration, reusing its
|
|
283
|
+
# Run the latest iteration, reusing its participants:
|
|
276
284
|
$ ish study run -y
|
|
277
285
|
|
|
278
|
-
# Run with
|
|
279
|
-
$ ish study run --
|
|
286
|
+
# Run with explicit people:
|
|
287
|
+
$ ish study run --person p-795,p-af2
|
|
280
288
|
|
|
281
|
-
# Run with a demographic-filtered sample (3 Swedish
|
|
289
|
+
# Run with a demographic-filtered sample (3 Swedish people aged 35–50):
|
|
282
290
|
$ ish study run --country SE --min-age 35 --max-age 50 --sample 3
|
|
283
291
|
|
|
284
|
-
# Run with every female
|
|
292
|
+
# Run with every female person in the workspace:
|
|
285
293
|
$ ish study run --gender female --all
|
|
286
294
|
|
|
287
295
|
# Run a specific iteration:
|
|
@@ -290,7 +298,7 @@ Examples:
|
|
|
290
298
|
# Override the simulation config (e.g. for a media study):
|
|
291
299
|
$ ish study run --config c-c3c
|
|
292
300
|
|
|
293
|
-
# Cap interactions per
|
|
301
|
+
# Cap interactions per participant (default 20 — pass higher to allow deeper
|
|
294
302
|
# exploration, lower to cap spend on a known-broken surface):
|
|
295
303
|
$ ish study run --max-interactions 30
|
|
296
304
|
|
|
@@ -305,6 +313,26 @@ Examples:
|
|
|
305
313
|
await withClient(cmd, async (client, globals) => {
|
|
306
314
|
const log = (msg) => { if (!globals.quiet)
|
|
307
315
|
console.error(msg); };
|
|
316
|
+
// Internal-only: per-run ConfigOverrides (model/prompt/sim-settings) layered
|
|
317
|
+
// on top of the base sim config by the backend's merge_config_overrides.
|
|
318
|
+
// Gated behind ISH_INTERNAL (defense-in-depth only — the real authorization
|
|
319
|
+
// belongs on the backend; the API currently accepts config_overrides from any
|
|
320
|
+
// authed caller). Parsed up front so a bad payload fails before seeding participants.
|
|
321
|
+
let configOverrides;
|
|
322
|
+
if (opts.simConfig) {
|
|
323
|
+
if (!process.env.ISH_INTERNAL) {
|
|
324
|
+
throw new Error("--sim-config is an internal-only flag. Set ISH_INTERNAL=1 to use it.");
|
|
325
|
+
}
|
|
326
|
+
const raw = opts.simConfig.startsWith("@")
|
|
327
|
+
? await readFileOrStdin(opts.simConfig.slice(1))
|
|
328
|
+
: opts.simConfig;
|
|
329
|
+
try {
|
|
330
|
+
configOverrides = JSON.parse(raw);
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
throw new Error(`--sim-config is not valid JSON: ${e.message}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
308
336
|
const resolvedWorkspace = resolveWorkspace(opts.workspace);
|
|
309
337
|
// Pattern A (Sprint 2): when `--iteration` is passed without
|
|
310
338
|
// `--study`, derive the parent study from the iteration itself
|
|
@@ -335,7 +363,7 @@ Examples:
|
|
|
335
363
|
}
|
|
336
364
|
// B7: per-POST dispatch timeout (default 120s — long enough to survive
|
|
337
365
|
// a slow-cold backend, short enough that an agent doesn't sit blind
|
|
338
|
-
// for many minutes). Applied to both the
|
|
366
|
+
// for many minutes). Applied to both the participants/batch POST AND the
|
|
339
367
|
// simulation start POST so the seed + dispatch budget is the same.
|
|
340
368
|
const dispatchTimeoutMs = opts.dispatchTimeout
|
|
341
369
|
? Math.max(1, parseInt(opts.dispatchTimeout, 10)) * 1000
|
|
@@ -344,12 +372,13 @@ Examples:
|
|
|
344
372
|
|| parseInt(opts.dispatchTimeout, 10) < 1)) {
|
|
345
373
|
throw new Error(`--dispatch-timeout must be a positive integer (seconds), got "${opts.dispatchTimeout}".`);
|
|
346
374
|
}
|
|
347
|
-
// Step 0: Fetch study (
|
|
375
|
+
// Step 0: Fetch study metadata (lite — participants live on a
|
|
376
|
+
// separate endpoint; we fetch them below only when reuse is possible).
|
|
348
377
|
const study = await client.get(`/studies/${resolvedStudy}`);
|
|
349
378
|
const modality = study.modality || "interactive";
|
|
350
379
|
const isMedia = isMediaModality(modality);
|
|
351
380
|
const isChat = isChatModality(modality);
|
|
352
|
-
// Pair-mode (
|
|
381
|
+
// Pair-mode (participant_pair) is read off the iteration once we've
|
|
353
382
|
// resolved it below; set defaults here so the value is in scope.
|
|
354
383
|
let chatMode = "external_chatbot";
|
|
355
384
|
let isPair = false;
|
|
@@ -381,108 +410,115 @@ Examples:
|
|
|
381
410
|
// a clear suggestion rather than masking the problem.
|
|
382
411
|
if (isChat) {
|
|
383
412
|
chatMode = readChatMode(iteration.details);
|
|
384
|
-
isPair = chatMode === "
|
|
413
|
+
isPair = chatMode === "participant_pair";
|
|
385
414
|
}
|
|
386
415
|
if (!iterationHasContent(iteration.details, modality)) {
|
|
387
|
-
const flagHint = describeRequiredContentFlag(modality, isPair ? "
|
|
416
|
+
const flagHint = describeRequiredContentFlag(modality, isPair ? "participant_pair" : undefined);
|
|
388
417
|
const iterAlias = tagAlias(ALIAS_PREFIX.iteration, iterationId);
|
|
389
|
-
throw new Error(`Iteration "${iterationLabel}" (${iterAlias}) has no ${isMedia ? "content" : isPair ? "
|
|
418
|
+
throw new Error(`Iteration "${iterationLabel}" (${iterAlias}) has no ${isMedia ? "content" : isPair ? "people/scenarios" : isChat ? "endpoint" : "URL"} configured yet. ` +
|
|
390
419
|
`Add ${isMedia ? "content" : isPair ? "the pair-mode payload" : isChat ? "an endpoint" : "a URL"} with ` +
|
|
391
420
|
`\`ish iteration create --study ${resolvedStudy} ${flagHint}\` ` +
|
|
392
421
|
`(or update the existing iteration via \`ish iteration update ${iterAlias} --details-json '{...}'\`), then retry.`);
|
|
393
422
|
}
|
|
394
423
|
const detailsView = readIterationDetails(iteration.details);
|
|
395
|
-
const pairConfig = isPair ?
|
|
396
|
-
// Step 2: Resolve
|
|
397
|
-
// - If any
|
|
424
|
+
const pairConfig = isPair ? readParticipantPairConfig(iteration.details) : undefined;
|
|
425
|
+
// Step 2: Resolve people.
|
|
426
|
+
// - If any person flag is set (--person / --sample / --all / filter flags),
|
|
398
427
|
// resolve a fresh ID list from the workspace pool via the shared helper.
|
|
399
|
-
// - Otherwise reuse the iteration's existing
|
|
400
|
-
// - For chat
|
|
428
|
+
// - Otherwise reuse the iteration's existing participants.
|
|
429
|
+
// - For chat participant_pair iterations, people live inside the
|
|
401
430
|
// iteration's mode_details and are authoritative; run-time
|
|
402
431
|
// overrides are refused.
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
const
|
|
432
|
+
const personNames = new Map();
|
|
433
|
+
const personIds = [];
|
|
434
|
+
const existingParticipants = [];
|
|
435
|
+
const personSet = hasPersonFlags(opts);
|
|
407
436
|
if (isPair) {
|
|
408
|
-
if (
|
|
409
|
-
throw new Error("
|
|
410
|
-
"To change the
|
|
437
|
+
if (personSet) {
|
|
438
|
+
throw new Error("participant_pair chat iterations carry their own people inside mode_details; run-time overrides (--person / --sample / --all / --country / --gender / --min-age / --max-age / --search / --visibility) are not supported. " +
|
|
439
|
+
"To change the people, update the iteration via `ish iteration update <id> --details-json '{...}'`.");
|
|
411
440
|
}
|
|
412
441
|
if (!pairConfig) {
|
|
413
442
|
throw new Error("Pair-mode iteration is missing mode_details; cannot dispatch.");
|
|
414
443
|
}
|
|
415
|
-
// Surface a flat
|
|
444
|
+
// Surface a flat personIds[] (a then b) so downstream
|
|
416
445
|
// bookkeeping (config resolution, output) still has something to
|
|
417
|
-
// chew on. The pair-batch
|
|
446
|
+
// chew on. The pair-batch participant-provisioning POST below uses
|
|
418
447
|
// the split lists, not this flat one.
|
|
419
|
-
for (const pid of pairConfig.
|
|
420
|
-
if (!
|
|
421
|
-
|
|
422
|
-
|
|
448
|
+
for (const pid of pairConfig.group_a) {
|
|
449
|
+
if (!personNames.has(pid)) {
|
|
450
|
+
personNames.set(pid, "");
|
|
451
|
+
personIds.push(pid);
|
|
423
452
|
}
|
|
424
453
|
}
|
|
425
|
-
for (const pid of pairConfig.
|
|
426
|
-
if (!
|
|
427
|
-
|
|
428
|
-
|
|
454
|
+
for (const pid of pairConfig.group_b) {
|
|
455
|
+
if (!personNames.has(pid)) {
|
|
456
|
+
personNames.set(pid, "");
|
|
457
|
+
personIds.push(pid);
|
|
429
458
|
}
|
|
430
459
|
}
|
|
431
460
|
}
|
|
432
|
-
else if (
|
|
433
|
-
const resolved = await
|
|
434
|
-
|
|
461
|
+
else if (personSet) {
|
|
462
|
+
const resolved = await resolvePersonIds(client, resolvedWorkspace, opts, { requireSimulatable: false, allFlagName: "--all" });
|
|
463
|
+
personIds.push(...resolved);
|
|
435
464
|
}
|
|
436
|
-
else if (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
465
|
+
else if (!isPair) {
|
|
466
|
+
// Reuse-existing path: no person flags, non-pair iteration. Fetch
|
|
467
|
+
// participants from the dedicated endpoint and filter to the
|
|
468
|
+
// current iteration. (Pair iterations don't reuse participant rows
|
|
469
|
+
// — they reuse Conversation refs above.)
|
|
470
|
+
const studyParticipants = await fetchStudyParticipants(client, resolvedStudy);
|
|
471
|
+
for (const t of studyParticipants) {
|
|
472
|
+
if (t.iteration_id !== iteration.id)
|
|
473
|
+
continue;
|
|
474
|
+
const pid = t.person_id || t.person?.id;
|
|
475
|
+
const name = t.person?.name;
|
|
476
|
+
if (pid && !personNames.has(pid)) {
|
|
477
|
+
personNames.set(pid, name || "");
|
|
478
|
+
personIds.push(pid);
|
|
443
479
|
}
|
|
444
480
|
if (t.id) {
|
|
445
|
-
|
|
481
|
+
existingParticipants.push({ id: t.id, person: { name: name || "Unknown" } });
|
|
446
482
|
}
|
|
447
483
|
}
|
|
448
484
|
}
|
|
449
|
-
// Pair iterations always seed fresh
|
|
450
|
-
// endpoint; never reuse a stale
|
|
451
|
-
const
|
|
452
|
-
// Pair iterations with criteria-only
|
|
453
|
-
//
|
|
485
|
+
// Pair iterations always seed fresh participants via the pair-batch
|
|
486
|
+
// endpoint; never reuse a stale participant roster from a prior run.
|
|
487
|
+
const reuseExistingParticipants = !isPair && !personSet && existingParticipants.length > 0;
|
|
488
|
+
// Pair iterations with criteria-only groups will have empty
|
|
489
|
+
// personIds at this stage if the backend deferred resolution past
|
|
454
490
|
// iteration create. That's a valid state — skip the
|
|
455
|
-
// "no
|
|
491
|
+
// "no people flags" guard for them and let dispatch surface any
|
|
456
492
|
// backend-side resolution errors (e.g. pool too small).
|
|
457
|
-
const pairCriteriaOnly = isPair && !!pairConfig &&
|
|
493
|
+
const pairCriteriaOnly = isPair && !!pairConfig && personIds.length === 0
|
|
458
494
|
&& (!!pairConfig.role_criteria_a || !!pairConfig.role_criteria_b);
|
|
459
|
-
if (
|
|
460
|
-
throw new Error(`Iteration "${iterationLabel}" has no
|
|
461
|
-
"Pass --
|
|
495
|
+
if (personIds.length === 0 && !pairCriteriaOnly) {
|
|
496
|
+
throw new Error(`Iteration "${iterationLabel}" has no participants and no person flags were given. ` +
|
|
497
|
+
"Pass --person <ids>, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility) with --sample <N> or --all.");
|
|
462
498
|
}
|
|
463
|
-
// Step 3: Resolve simulation config. Always pre-flight every
|
|
499
|
+
// Step 3: Resolve simulation config. Always pre-flight every person
|
|
464
500
|
// when no --config override is given: missing simulation_config_id
|
|
465
501
|
// is fatal across all modalities (media + chat batch dispatch use it
|
|
466
502
|
// per-item; interactive + pair dispatch fail server-side on the
|
|
467
|
-
// first sim start) and creating
|
|
503
|
+
// first sim start) and creating participant rows before discovering it
|
|
468
504
|
// leaves phantom DRAFT rows in the iteration. Pair mode reads
|
|
469
|
-
// pairConfig.
|
|
505
|
+
// pairConfig.group_a; non-pair uses personIds. personConfigMap
|
|
470
506
|
// is consumed by the media branch; other branches just need the
|
|
471
507
|
// validation side effect.
|
|
472
508
|
const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
|
|
473
|
-
const
|
|
509
|
+
const personConfigMap = new Map();
|
|
474
510
|
if (!resolvedConfigOverride) {
|
|
475
511
|
const idsToCheck = isPair && pairConfig
|
|
476
|
-
? [...new Set([...pairConfig.
|
|
477
|
-
:
|
|
512
|
+
? [...new Set([...pairConfig.group_a, ...pairConfig.group_b])]
|
|
513
|
+
: personIds;
|
|
478
514
|
for (const pid of idsToCheck) {
|
|
479
|
-
const profile = await client.get(`/
|
|
515
|
+
const profile = await client.get(`/people/${pid}`);
|
|
480
516
|
if (profile.simulation_config_id) {
|
|
481
|
-
|
|
517
|
+
personConfigMap.set(pid, profile.simulation_config_id);
|
|
482
518
|
}
|
|
483
519
|
else {
|
|
484
|
-
throw new Error(`Profile ${
|
|
485
|
-
"Use --config <id> to specify one, or assign a config to the
|
|
520
|
+
throw new Error(`Profile ${personNames.get(pid) || pid} has no simulation config assigned.\n` +
|
|
521
|
+
"Use --config <id> to specify one, or assign a config to the person.\n" +
|
|
486
522
|
"List configs with: ish config list");
|
|
487
523
|
}
|
|
488
524
|
}
|
|
@@ -496,19 +532,19 @@ Examples:
|
|
|
496
532
|
if (study.content_type)
|
|
497
533
|
log(` Content type: ${study.content_type}`);
|
|
498
534
|
if (isPair && pairConfig) {
|
|
499
|
-
log(` Chat mode:
|
|
500
|
-
//
|
|
535
|
+
log(` Chat mode: participant_pair`);
|
|
536
|
+
// Group description per side: prefer explicit count when
|
|
501
537
|
// present; otherwise show the criteria filter that the backend
|
|
502
538
|
// will resolve into a pool.
|
|
503
|
-
const describeSide = (
|
|
504
|
-
if (
|
|
505
|
-
return `${
|
|
539
|
+
const describeSide = (groupLen, crit) => {
|
|
540
|
+
if (groupLen > 0)
|
|
541
|
+
return `${groupLen} person(s)${crit ? ` (criteria validates list)` : ""}`;
|
|
506
542
|
const summary = summarizeRoleCriteria(crit);
|
|
507
543
|
return summary ? `criteria (${summary}) — pool resolved server-side` : "—";
|
|
508
544
|
};
|
|
509
|
-
log(`
|
|
510
|
-
log(`
|
|
511
|
-
const explicitConvs = Math.min(pairConfig.
|
|
545
|
+
log(` Group A: ${describeSide(pairConfig.group_a.length, pairConfig.role_criteria_a)}`);
|
|
546
|
+
log(` Group B: ${describeSide(pairConfig.group_b.length, pairConfig.role_criteria_b)}`);
|
|
547
|
+
const explicitConvs = Math.min(pairConfig.group_a.length, pairConfig.group_b.length);
|
|
512
548
|
const criteriaResolved = !!pairConfig.role_criteria_a || !!pairConfig.role_criteria_b;
|
|
513
549
|
if (explicitConvs > 0 && !criteriaResolved) {
|
|
514
550
|
log(` Conversations: ${explicitConvs} (1:1 by index)`);
|
|
@@ -573,13 +609,13 @@ Examples:
|
|
|
573
609
|
if (opts.language)
|
|
574
610
|
log(` Language: ${opts.language}`);
|
|
575
611
|
if (!isPair) {
|
|
576
|
-
log(` Profiles (${
|
|
577
|
-
for (const pid of
|
|
578
|
-
const name =
|
|
612
|
+
log(` Profiles (${personIds.length}):`);
|
|
613
|
+
for (const pid of personIds) {
|
|
614
|
+
const name = personNames.get(pid);
|
|
579
615
|
log(` - ${name ? `${name} (${pid})` : pid}`);
|
|
580
616
|
}
|
|
581
|
-
const
|
|
582
|
-
if (
|
|
617
|
+
const participantCount = personIds.length;
|
|
618
|
+
if (participantCount > 0) {
|
|
583
619
|
if (isChat) {
|
|
584
620
|
const turnsForChat = opts.maxTurns
|
|
585
621
|
? parseInt(opts.maxTurns, 10)
|
|
@@ -587,7 +623,7 @@ Examples:
|
|
|
587
623
|
? iteration.details.max_turns
|
|
588
624
|
: 14);
|
|
589
625
|
if (Number.isFinite(turnsForChat)) {
|
|
590
|
-
const est = estimateChatSolo({
|
|
626
|
+
const est = estimateChatSolo({ participantCount, maxTurns: turnsForChat });
|
|
591
627
|
log(` Credits (est): ≈ ${est.upper_bound} credit(s) upper bound — ${est.breakdown}`);
|
|
592
628
|
}
|
|
593
629
|
}
|
|
@@ -600,7 +636,7 @@ Examples:
|
|
|
600
636
|
: `CLI default — pass --max-interactions to override`;
|
|
601
637
|
log(` Max steps: ${stepsForMedia} (${source})`);
|
|
602
638
|
if (Number.isFinite(stepsForMedia)) {
|
|
603
|
-
const est = estimateMediaRun({
|
|
639
|
+
const est = estimateMediaRun({ participantCount, maxInteractions: stepsForMedia });
|
|
604
640
|
log(` Credits (est): ≈ ${est.upper_bound} credit(s) upper bound — ${est.breakdown}`);
|
|
605
641
|
}
|
|
606
642
|
}
|
|
@@ -621,10 +657,10 @@ Examples:
|
|
|
621
657
|
const { ensureBrowser } = await import("../lib/local-sim/install.js");
|
|
622
658
|
await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
|
|
623
659
|
}
|
|
624
|
-
// Step 5: Either reuse the iteration's
|
|
625
|
-
let
|
|
660
|
+
// Step 5: Either reuse the iteration's participants or batch-create new ones
|
|
661
|
+
let createdParticipants;
|
|
626
662
|
// Pair-mode bookkeeping: the dispatch endpoint takes
|
|
627
|
-
// `conversation_ids`, not
|
|
663
|
+
// `conversation_ids`, not participant ids. We populate this list either
|
|
628
664
|
// by reusing the iteration's existing Conversation rows or by
|
|
629
665
|
// calling pair-batch.
|
|
630
666
|
let pairConversationIds = [];
|
|
@@ -633,24 +669,34 @@ Examples:
|
|
|
633
669
|
// 1. If the iteration already carries `conversations[]` from a
|
|
634
670
|
// prior dispatch, reuse them — skip pair-batch entirely.
|
|
635
671
|
// 2. Otherwise call pair-batch with the resolved
|
|
636
|
-
//
|
|
637
|
-
// already have
|
|
672
|
+
// group UUID lists. Criteria-only iterations should
|
|
673
|
+
// already have groups materialised at iteration-create
|
|
638
674
|
// time; if they're still empty here, the backend's
|
|
639
|
-
// `
|
|
675
|
+
// `PairGroupResolutionError` is the authoritative
|
|
640
676
|
// failure mode — refuse before hitting pair-batch.
|
|
641
677
|
//
|
|
642
678
|
// Wire shapes per backend `app/api/iterations/routers`:
|
|
643
|
-
// POST /iterations/{id}/
|
|
679
|
+
// POST /iterations/{id}/participants/pair-batch
|
|
644
680
|
// body : { side_a: UUID[1..20], side_b: UUID[1..20] (equal len),
|
|
645
681
|
// language?: str }
|
|
646
682
|
// reply : { conversations: [{ conversation_id, pair_index,
|
|
647
|
-
//
|
|
683
|
+
// participant_a_id, participant_b_id }] }
|
|
684
|
+
//
|
|
685
|
+
// On the LITE study response, the iteration's conversation refs
|
|
686
|
+
// use the storage-shape field names group_a_participant_id /
|
|
687
|
+
// group_b_participant_id. Map them back to the pair-batch reply
|
|
688
|
+
// shape (participant_a_id / participant_b_id) the rest of this
|
|
689
|
+
// function expects.
|
|
648
690
|
const existingConvs = iteration.conversations ?? [];
|
|
649
691
|
const reusable = [];
|
|
650
692
|
for (const c of existingConvs) {
|
|
651
693
|
const cid = c.conversation_id || c.id;
|
|
652
|
-
if (cid && c.
|
|
653
|
-
reusable.push({
|
|
694
|
+
if (cid && c.group_a_participant_id && c.group_b_participant_id) {
|
|
695
|
+
reusable.push({
|
|
696
|
+
conversation_id: cid,
|
|
697
|
+
participant_a_id: c.group_a_participant_id,
|
|
698
|
+
participant_b_id: c.group_b_participant_id,
|
|
699
|
+
});
|
|
654
700
|
}
|
|
655
701
|
}
|
|
656
702
|
let pairRows;
|
|
@@ -659,79 +705,79 @@ Examples:
|
|
|
659
705
|
log(`Reusing ${reusable.length} existing conversation${reusable.length > 1 ? "s" : ""} on iteration "${iterationLabel}"`);
|
|
660
706
|
}
|
|
661
707
|
else {
|
|
662
|
-
if (pairConfig.
|
|
663
|
-
throw new Error("Pair-mode iteration has empty
|
|
708
|
+
if (pairConfig.group_a.length === 0 || pairConfig.group_b.length === 0) {
|
|
709
|
+
throw new Error("Pair-mode iteration has empty group_a / group_b and no conversations yet. " +
|
|
664
710
|
"If this iteration was created with --role-criteria-a/-b, the backend should have " +
|
|
665
|
-
"resolved a
|
|
666
|
-
"fresh shape, or recreate with explicit --
|
|
711
|
+
"resolved a person pool at create time — try `ish iteration get <id>` to fetch a " +
|
|
712
|
+
"fresh shape, or recreate with explicit --person-a/-b.");
|
|
667
713
|
}
|
|
668
|
-
log(`Provisioning ${pairConfig.
|
|
669
|
-
const pairBatchResult = await client.post(`/iterations/${iterationId}/
|
|
670
|
-
side_a: pairConfig.
|
|
671
|
-
side_b: pairConfig.
|
|
714
|
+
log(`Provisioning ${pairConfig.group_a.length} pair conversation${pairConfig.group_a.length > 1 ? "s" : ""}...`);
|
|
715
|
+
const pairBatchResult = await client.post(`/iterations/${iterationId}/participants/pair-batch`, {
|
|
716
|
+
side_a: pairConfig.group_a,
|
|
717
|
+
side_b: pairConfig.group_b,
|
|
672
718
|
...(opts.language && { language: opts.language }),
|
|
673
719
|
}, { timeout: dispatchTimeoutMs });
|
|
674
720
|
pairRows = (pairBatchResult.conversations ?? []).map((c) => ({
|
|
675
721
|
conversation_id: c.conversation_id,
|
|
676
|
-
|
|
677
|
-
|
|
722
|
+
participant_a_id: c.participant_a_id,
|
|
723
|
+
participant_b_id: c.participant_b_id,
|
|
678
724
|
}));
|
|
679
725
|
if (pairRows.length === 0) {
|
|
680
726
|
throw new Error("Pair-batch returned no conversations. The backend response did not include any conversation IDs.");
|
|
681
727
|
}
|
|
682
|
-
log(`Created ${pairRows.length * 2}
|
|
728
|
+
log(`Created ${pairRows.length * 2} participants (${pairRows.length} conversation${pairRows.length > 1 ? "s" : ""})`);
|
|
683
729
|
}
|
|
684
730
|
pairConversationIds = pairRows.map((r) => r.conversation_id);
|
|
685
|
-
// Flatten both sides'
|
|
731
|
+
// Flatten both sides' participant IDs for downstream bookkeeping:
|
|
686
732
|
// error-tagging (`seeded_but_not_dispatched_ids`), poll filtering,
|
|
687
733
|
// and JSON output. Names aren't returned by pair-batch; agents
|
|
688
734
|
// who care can correlate via `ish iteration get <id>`.
|
|
689
|
-
|
|
735
|
+
createdParticipants = [];
|
|
690
736
|
for (let i = 0; i < pairRows.length; i++) {
|
|
691
737
|
const row = pairRows[i];
|
|
692
|
-
|
|
693
|
-
id: row.
|
|
694
|
-
|
|
738
|
+
createdParticipants.push({
|
|
739
|
+
id: row.participant_a_id,
|
|
740
|
+
person: { name: `pair ${i} side A` },
|
|
695
741
|
});
|
|
696
|
-
|
|
697
|
-
id: row.
|
|
698
|
-
|
|
742
|
+
createdParticipants.push({
|
|
743
|
+
id: row.participant_b_id,
|
|
744
|
+
person: { name: `pair ${i} side B` },
|
|
699
745
|
});
|
|
700
746
|
}
|
|
701
747
|
}
|
|
702
|
-
else if (
|
|
703
|
-
|
|
704
|
-
log(`Reusing ${
|
|
748
|
+
else if (reuseExistingParticipants && existingParticipants.length > 0) {
|
|
749
|
+
createdParticipants = existingParticipants;
|
|
750
|
+
log(`Reusing ${createdParticipants.length} existing participant${createdParticipants.length > 1 ? "s" : ""} from iteration "${iterationLabel}"`);
|
|
705
751
|
}
|
|
706
752
|
else {
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
753
|
+
const participantInputs = personIds.map((profileId) => ({
|
|
754
|
+
person_id: profileId,
|
|
755
|
+
participant_type: "ai",
|
|
710
756
|
status: "draft",
|
|
711
757
|
...(opts.language && { language: opts.language }),
|
|
712
758
|
...(!isMedia && !isChat && { platform: detailsView.platform || "browser" }),
|
|
713
759
|
}));
|
|
714
|
-
log(`Creating ${
|
|
715
|
-
const batchResult = await client.post(`/iterations/${iterationId}/
|
|
716
|
-
|
|
717
|
-
log(`Created ${
|
|
760
|
+
log(`Creating ${participantInputs.length} participant${participantInputs.length > 1 ? "s" : ""}...`);
|
|
761
|
+
const batchResult = await client.post(`/iterations/${iterationId}/participants/batch`, { participants: participantInputs }, { timeout: dispatchTimeoutMs });
|
|
762
|
+
createdParticipants = batchResult.participants;
|
|
763
|
+
log(`Created ${createdParticipants.length} participant${createdParticipants.length > 1 ? "s" : ""}`);
|
|
718
764
|
}
|
|
719
765
|
// Step 6: Dispatch
|
|
720
766
|
if (opts.local) {
|
|
721
767
|
if (isMedia || isChat) {
|
|
722
768
|
throw new Error("Local mode is only supported for interactive simulations.");
|
|
723
769
|
}
|
|
724
|
-
const
|
|
725
|
-
for (const t of
|
|
726
|
-
|
|
770
|
+
const participantNameMap = new Map();
|
|
771
|
+
for (const t of createdParticipants) {
|
|
772
|
+
participantNameMap.set(t.id, t.person?.name ?? "Unknown");
|
|
727
773
|
}
|
|
728
774
|
const { runLocalSimulations } = await import("../lib/local-sim/loop.js");
|
|
729
775
|
await runLocalSimulations(client, {
|
|
730
776
|
workspaceId: resolvedWorkspace,
|
|
731
777
|
studyId: resolvedStudy,
|
|
732
778
|
iterationId,
|
|
733
|
-
|
|
734
|
-
|
|
779
|
+
participantIds: createdParticipants.map((t) => t.id),
|
|
780
|
+
participantNames: participantNameMap,
|
|
735
781
|
url: detailsView.url,
|
|
736
782
|
screenFormat: detailsView.screenFormat,
|
|
737
783
|
locale: detailsView.locale,
|
|
@@ -745,31 +791,31 @@ Examples:
|
|
|
745
791
|
json: globals.json,
|
|
746
792
|
});
|
|
747
793
|
if (globals.json) {
|
|
748
|
-
const
|
|
794
|
+
const participantsOut = createdParticipants.map((t) => ({
|
|
749
795
|
id: t.id,
|
|
750
|
-
alias: tagAlias(ALIAS_PREFIX.
|
|
751
|
-
|
|
796
|
+
alias: tagAlias(ALIAS_PREFIX.participant, String(t.id)),
|
|
797
|
+
person_name: t.person?.name,
|
|
752
798
|
}));
|
|
753
799
|
output({
|
|
754
800
|
iteration_id: iterationId,
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
801
|
+
participants: participantsOut,
|
|
802
|
+
participant_ids: participantsOut.map((t) => t.id),
|
|
803
|
+
participant_aliases: participantsOut.map((t) => t.alias),
|
|
758
804
|
mode: "local",
|
|
759
805
|
}, true);
|
|
760
806
|
}
|
|
761
807
|
return;
|
|
762
808
|
}
|
|
763
|
-
log(`Starting ${
|
|
809
|
+
log(`Starting ${createdParticipants.length} simulation${createdParticipants.length > 1 ? "s" : ""}...`);
|
|
764
810
|
let simResults;
|
|
765
811
|
// B7 / Pattern G: tag any failure during the dispatch POST with the
|
|
766
|
-
//
|
|
812
|
+
// participants that already exist server-side. The client doesn't know
|
|
767
813
|
// whether the backend processed our POST or never received it, so
|
|
768
814
|
// re-running `study run` would double-seed (we'd create another
|
|
769
|
-
// batch of
|
|
815
|
+
// batch of participants on top of these). Instead, surface the seeded
|
|
770
816
|
// ids in the error envelope under `seeded_but_not_dispatched_ids`
|
|
771
817
|
// / `_aliases` so the agent can resume by polling them or calling
|
|
772
|
-
// `study
|
|
818
|
+
// `study participant delete <id>` then retrying.
|
|
773
819
|
const dispatchAttempt = async (fn) => {
|
|
774
820
|
try {
|
|
775
821
|
return await fn();
|
|
@@ -777,8 +823,8 @@ Examples:
|
|
|
777
823
|
catch (err) {
|
|
778
824
|
if (err instanceof Error) {
|
|
779
825
|
const tagged = err;
|
|
780
|
-
tagged.seeded_but_not_dispatched_ids =
|
|
781
|
-
tagged.seeded_but_not_dispatched_aliases =
|
|
826
|
+
tagged.seeded_but_not_dispatched_ids = createdParticipants.map((t) => t.id);
|
|
827
|
+
tagged.seeded_but_not_dispatched_aliases = createdParticipants.map((t) => tagAlias(ALIAS_PREFIX.participant, String(t.id)));
|
|
782
828
|
}
|
|
783
829
|
throw err;
|
|
784
830
|
}
|
|
@@ -803,22 +849,22 @@ Examples:
|
|
|
803
849
|
// chat_credit_cost(max_turns) * 2 * len(conversation_ids).
|
|
804
850
|
let pairConfigId = resolvedConfigOverride;
|
|
805
851
|
if (!pairConfigId) {
|
|
806
|
-
// Fall back to the first
|
|
852
|
+
// Fall back to the first group_a person's
|
|
807
853
|
// simulation_config_id. Pair dispatch takes a single config
|
|
808
|
-
// for the whole batch, so we don't need the per-
|
|
854
|
+
// for the whole batch, so we don't need the per-person map
|
|
809
855
|
// the external_chatbot path builds. Step 3 already populated
|
|
810
|
-
//
|
|
856
|
+
// personConfigMap with every group person's config when
|
|
811
857
|
// --config was not passed, so reuse that.
|
|
812
|
-
const fallbackProfileId = pairConfig.
|
|
858
|
+
const fallbackProfileId = pairConfig.group_a[0];
|
|
813
859
|
if (!fallbackProfileId) {
|
|
814
|
-
throw new Error("Pair-mode dispatch requires --config <id>: the iteration has no
|
|
860
|
+
throw new Error("Pair-mode dispatch requires --config <id>: the iteration has no person to draw a default config_id from.");
|
|
815
861
|
}
|
|
816
|
-
pairConfigId =
|
|
862
|
+
pairConfigId = personConfigMap.get(fallbackProfileId);
|
|
817
863
|
if (!pairConfigId) {
|
|
818
864
|
// Defensive: Step 3 should have either populated the map or
|
|
819
865
|
// thrown. If we land here something upstream changed.
|
|
820
866
|
throw new Error(`Pair-mode dispatch requires a config_id. Profile ${fallbackProfileId} has no simulation config assigned and --config was not passed.\n` +
|
|
821
|
-
"Use --config <id> to specify one, or assign a config to the
|
|
867
|
+
"Use --config <id> to specify one, or assign a config to the person.\n" +
|
|
822
868
|
"List configs with: ish config list");
|
|
823
869
|
}
|
|
824
870
|
}
|
|
@@ -833,10 +879,10 @@ Examples:
|
|
|
833
879
|
simResults = simResult.results;
|
|
834
880
|
}
|
|
835
881
|
else {
|
|
836
|
-
const chatBatchItems =
|
|
882
|
+
const chatBatchItems = createdParticipants.map((t, i) => ({
|
|
837
883
|
study_id: resolvedStudy,
|
|
838
|
-
|
|
839
|
-
config_id: resolvedConfigOverride ||
|
|
884
|
+
participant_id: t.id,
|
|
885
|
+
config_id: resolvedConfigOverride || personConfigMap.get(personIds[i]),
|
|
840
886
|
...(opts.language && { language: opts.language }),
|
|
841
887
|
}));
|
|
842
888
|
const simResult = await dispatchAttempt(() => client.post("/simulation/chat/start/batch", {
|
|
@@ -849,11 +895,12 @@ Examples:
|
|
|
849
895
|
}
|
|
850
896
|
}
|
|
851
897
|
else if (isMedia) {
|
|
852
|
-
const mediaBatchItems =
|
|
898
|
+
const mediaBatchItems = createdParticipants.map((t, i) => ({
|
|
853
899
|
study_id: resolvedStudy,
|
|
854
|
-
|
|
855
|
-
config_id: resolvedConfigOverride ||
|
|
900
|
+
participant_id: t.id,
|
|
901
|
+
config_id: resolvedConfigOverride || personConfigMap.get(personIds[i]),
|
|
856
902
|
...(opts.language && { language: opts.language }),
|
|
903
|
+
...(configOverrides !== undefined && { config_overrides: configOverrides }),
|
|
857
904
|
}));
|
|
858
905
|
const simResult = await dispatchAttempt(() => client.post("/simulation/media/start/batch", {
|
|
859
906
|
product_id: resolvedWorkspace,
|
|
@@ -863,12 +910,13 @@ Examples:
|
|
|
863
910
|
simResults = simResult.results;
|
|
864
911
|
}
|
|
865
912
|
else {
|
|
866
|
-
const simItems =
|
|
913
|
+
const simItems = createdParticipants.map((t) => ({
|
|
867
914
|
study_id: resolvedStudy,
|
|
868
|
-
|
|
915
|
+
participant_id: t.id,
|
|
869
916
|
...(resolvedConfigOverride && { config_id: resolvedConfigOverride }),
|
|
870
917
|
...(opts.language && { language: opts.language }),
|
|
871
918
|
...(detailsView.locale && { locale: detailsView.locale }),
|
|
919
|
+
...(configOverrides !== undefined && { config_overrides: configOverrides }),
|
|
872
920
|
}));
|
|
873
921
|
const simResult = await dispatchAttempt(() => client.post("/simulation/interactive/start/batch", {
|
|
874
922
|
product_id: resolvedWorkspace,
|
|
@@ -880,7 +928,7 @@ Examples:
|
|
|
880
928
|
}, { timeout: dispatchTimeoutMs }));
|
|
881
929
|
simResults = simResult.results;
|
|
882
930
|
}
|
|
883
|
-
// Pair-mode preview block: surface the
|
|
931
|
+
// Pair-mode preview block: surface the group sizes + scenario
|
|
884
932
|
// previews + initiator in the JSON envelope so agents can verify
|
|
885
933
|
// what they just dispatched without needing a follow-up
|
|
886
934
|
// `iteration get`. Mirrors the human confirmation block (which is
|
|
@@ -891,12 +939,12 @@ Examples:
|
|
|
891
939
|
? iteration.details.max_turns
|
|
892
940
|
: 14);
|
|
893
941
|
const pairPreview = isPair && pairConfig ? {
|
|
894
|
-
mode: "
|
|
895
|
-
|
|
896
|
-
|
|
942
|
+
mode: "participant_pair",
|
|
943
|
+
group_a_size: pairConfig.group_a.length,
|
|
944
|
+
group_b_size: pairConfig.group_b.length,
|
|
897
945
|
// Post-dispatch we know the actual conversation count from the
|
|
898
946
|
// pair-batch (or reuse) result. This is the authoritative number
|
|
899
|
-
// — better than guessing from
|
|
947
|
+
// — better than guessing from group length, which may diverge
|
|
900
948
|
// when the backend trims to the smaller side.
|
|
901
949
|
conversation_count: pairConversationIds.length,
|
|
902
950
|
conversation_ids: pairConversationIds,
|
|
@@ -926,12 +974,12 @@ Examples:
|
|
|
926
974
|
// Non-pair credit estimate — surfaced as a top-level field in the
|
|
927
975
|
// JSON envelope alongside `pair_preview.credit_estimate`. Mirrors
|
|
928
976
|
// backend formulas (`media_credit_cost` / `chat_credit_cost`).
|
|
929
|
-
// null when we can't estimate (criteria-only
|
|
977
|
+
// null when we can't estimate (criteria-only group, etc.).
|
|
930
978
|
const nonPairCreditEstimate = (() => {
|
|
931
979
|
if (isPair)
|
|
932
980
|
return null;
|
|
933
|
-
const
|
|
934
|
-
if (
|
|
981
|
+
const participantCount = createdParticipants.length || personIds.length;
|
|
982
|
+
if (participantCount <= 0)
|
|
935
983
|
return null;
|
|
936
984
|
if (isChat) {
|
|
937
985
|
const turns = opts.maxTurns
|
|
@@ -941,25 +989,25 @@ Examples:
|
|
|
941
989
|
: 14);
|
|
942
990
|
if (!Number.isFinite(turns))
|
|
943
991
|
return null;
|
|
944
|
-
return estimateChatSolo({
|
|
992
|
+
return estimateChatSolo({ participantCount, maxTurns: turns });
|
|
945
993
|
}
|
|
946
994
|
const steps = resolveMaxInteractions(opts.maxInteractions, iteration.details);
|
|
947
995
|
if (!Number.isFinite(steps))
|
|
948
996
|
return null;
|
|
949
|
-
return estimateMediaRun({
|
|
997
|
+
return estimateMediaRun({ participantCount, maxInteractions: steps });
|
|
950
998
|
})();
|
|
951
999
|
if (!opts.wait) {
|
|
952
1000
|
if (globals.json) {
|
|
953
|
-
const
|
|
1001
|
+
const participantsOut = createdParticipants.map((t) => ({
|
|
954
1002
|
id: t.id,
|
|
955
|
-
alias: tagAlias(ALIAS_PREFIX.
|
|
956
|
-
|
|
1003
|
+
alias: tagAlias(ALIAS_PREFIX.participant, String(t.id)),
|
|
1004
|
+
person_name: t.person?.name,
|
|
957
1005
|
}));
|
|
958
1006
|
output({
|
|
959
1007
|
iteration_id: iterationId,
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1008
|
+
participants: participantsOut,
|
|
1009
|
+
participant_ids: participantsOut.map((t) => t.id),
|
|
1010
|
+
participant_aliases: participantsOut.map((t) => t.alias),
|
|
963
1011
|
url: getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`),
|
|
964
1012
|
...(pairPreview && { pair_preview: pairPreview }),
|
|
965
1013
|
...(nonPairCreditEstimate && { credit_estimate: nonPairCreditEstimate }),
|
|
@@ -968,9 +1016,9 @@ Examples:
|
|
|
968
1016
|
}
|
|
969
1017
|
else {
|
|
970
1018
|
for (let i = 0; i < simResults.length; i++) {
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
log(` ${
|
|
1019
|
+
const participant = createdParticipants[i];
|
|
1020
|
+
const personName = participant?.person?.name || "Unknown";
|
|
1021
|
+
log(` ${personName.padEnd(24)} QUEUED`);
|
|
974
1022
|
}
|
|
975
1023
|
const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
|
|
976
1024
|
log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
@@ -978,27 +1026,27 @@ Examples:
|
|
|
978
1026
|
}
|
|
979
1027
|
return;
|
|
980
1028
|
}
|
|
981
|
-
// --wait: block until all dispatched
|
|
1029
|
+
// --wait: block until all dispatched participants reach a terminal state
|
|
982
1030
|
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
983
|
-
const dispatchedIds = new Set(
|
|
1031
|
+
const dispatchedIds = new Set(createdParticipants.map((t) => t.id));
|
|
984
1032
|
log(`Waiting for ${dispatchedIds.size} simulation${dispatchedIds.size > 1 ? "s" : ""} to finish...`);
|
|
985
1033
|
const { rows, isMedia: pollIsMedia } = await pollStudyUntilDone(client, {
|
|
986
1034
|
studyId: resolvedStudy,
|
|
987
|
-
|
|
1035
|
+
participantIds: dispatchedIds,
|
|
988
1036
|
timeoutMs,
|
|
989
1037
|
quiet: globals.quiet,
|
|
990
1038
|
});
|
|
991
1039
|
if (globals.json) {
|
|
992
|
-
const
|
|
1040
|
+
const participantsOut = createdParticipants.map((t) => ({
|
|
993
1041
|
id: t.id,
|
|
994
|
-
alias: tagAlias(ALIAS_PREFIX.
|
|
995
|
-
|
|
1042
|
+
alias: tagAlias(ALIAS_PREFIX.participant, String(t.id)),
|
|
1043
|
+
person_name: t.person?.name,
|
|
996
1044
|
}));
|
|
997
1045
|
output({
|
|
998
1046
|
iteration_id: iterationId,
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1047
|
+
participants: participantsOut,
|
|
1048
|
+
participant_ids: participantsOut.map((t) => t.id),
|
|
1049
|
+
participant_aliases: participantsOut.map((t) => t.alias),
|
|
1002
1050
|
url: getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`),
|
|
1003
1051
|
...(pairPreview && { pair_preview: pairPreview }),
|
|
1004
1052
|
...(nonPairCreditEstimate && { credit_estimate: nonPairCreditEstimate }),
|
|
@@ -1017,28 +1065,31 @@ Examples:
|
|
|
1017
1065
|
study
|
|
1018
1066
|
.command("poll")
|
|
1019
1067
|
.description("Check simulation progress for a study")
|
|
1020
|
-
.argument("[
|
|
1068
|
+
.argument("[participant_id]", "Participant ID (alias or UUID; from `ish study run --json`.participant_aliases[])")
|
|
1021
1069
|
.option("--study <id>", "Study ID (poll all simulations for study)")
|
|
1022
|
-
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from --study/--iteration/<
|
|
1023
|
-
.addHelpText("after", "\nExamples:\n $ ish study poll --study <study_id>\n $ ish study poll <
|
|
1024
|
-
.action(async (
|
|
1070
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from --study/--iteration/<participant_id>)")
|
|
1071
|
+
.addHelpText("after", "\nExamples:\n $ ish study poll --study <study_id>\n $ ish study poll <participant_id> --json\n\nGet participant IDs from `ish study run --json` (.participant_aliases[] / .participant_ids[]).")
|
|
1072
|
+
.action(async (participantId, opts, cmd) => {
|
|
1025
1073
|
await withClient(cmd, async (client, globals) => {
|
|
1026
|
-
if (
|
|
1027
|
-
const data = await client.get(`/simulation/status/${resolveId(
|
|
1074
|
+
if (participantId) {
|
|
1075
|
+
const data = await client.get(`/simulation/status/${resolveId(participantId)}`);
|
|
1028
1076
|
output(data, globals.json);
|
|
1029
1077
|
return;
|
|
1030
1078
|
}
|
|
1031
1079
|
// M7: fall back to the active study (set by `ish study use`) when
|
|
1032
|
-
// neither a
|
|
1080
|
+
// neither a participant_id nor an explicit --study is passed. This brings
|
|
1033
1081
|
// poll into parity with `study results` / `study wait` / `study run`,
|
|
1034
1082
|
// all of which already honor the active study. Without the fallback
|
|
1035
1083
|
// an agent that ran `study use s-...` then `study poll` would get a
|
|
1036
|
-
// confusing "Provide a
|
|
1084
|
+
// confusing "Provide a participant_id argument or --study flag" error.
|
|
1037
1085
|
const rid = resolveStudy(opts.study);
|
|
1038
|
-
const study = await
|
|
1086
|
+
const [study, participants] = await Promise.all([
|
|
1087
|
+
client.get(`/studies/${rid}`),
|
|
1088
|
+
fetchStudyParticipants(client, rid),
|
|
1089
|
+
]);
|
|
1039
1090
|
const isMedia = isMediaModality(study.modality);
|
|
1040
|
-
const
|
|
1041
|
-
formatSimulationPoll(
|
|
1091
|
+
const allParticipants = flattenParticipantStatuses(participants);
|
|
1092
|
+
formatSimulationPoll(allParticipants, globals.json, isMedia);
|
|
1042
1093
|
if (!globals.json && study.product_id) {
|
|
1043
1094
|
const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
|
|
1044
1095
|
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
@@ -1049,21 +1100,21 @@ Examples:
|
|
|
1049
1100
|
study
|
|
1050
1101
|
.command("wait")
|
|
1051
1102
|
.description("Poll until simulations reach a terminal state (completed/errored/failed/cancelled)")
|
|
1052
|
-
.argument("[
|
|
1053
|
-
.option("--study <id>", "Study ID (wait for all
|
|
1054
|
-
.option("--iteration <id>", "Iteration ID (wait for
|
|
1103
|
+
.argument("[participant_id]", "Participant ID (alias or UUID; from `ish study run --json`.participant_aliases[])")
|
|
1104
|
+
.option("--study <id>", "Study ID (wait for all participants in the study)")
|
|
1105
|
+
.option("--iteration <id>", "Iteration ID (wait for participants in this iteration only)")
|
|
1055
1106
|
.option("--timeout <s>", "Max seconds to wait (default 300)")
|
|
1056
|
-
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from --study/--iteration/<
|
|
1057
|
-
.addHelpText("after", "\nExamples:\n $ ish study wait # wait on the active study\n $ ish study wait --iteration i-d4e\n $ ish study wait <
|
|
1058
|
-
.action(async (
|
|
1107
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from --study/--iteration/<participant_id>)")
|
|
1108
|
+
.addHelpText("after", "\nExamples:\n $ ish study wait # wait on the active study\n $ ish study wait --iteration i-d4e\n $ ish study wait <participant_id> --timeout 600\n\nGet participant IDs from `ish study run --json` (.participant_aliases[] / .participant_ids[]).")
|
|
1109
|
+
.action(async (participantId, opts, cmd) => {
|
|
1059
1110
|
await withClient(cmd, async (client, globals) => {
|
|
1060
1111
|
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
1061
|
-
if (
|
|
1112
|
+
if (participantId) {
|
|
1062
1113
|
const start = Date.now();
|
|
1063
1114
|
let lastStatus = "";
|
|
1064
|
-
const
|
|
1115
|
+
const resolvedParticipant = resolveId(participantId);
|
|
1065
1116
|
while (true) {
|
|
1066
|
-
const data = await client.get(`/simulation/status/${
|
|
1117
|
+
const data = await client.get(`/simulation/status/${resolvedParticipant}`, undefined, { timeout: 60_000 });
|
|
1067
1118
|
const status = String(data.status ?? "unknown");
|
|
1068
1119
|
if (!globals.quiet && status !== lastStatus) {
|
|
1069
1120
|
process.stderr.write(` ${status}\n`);
|
|
@@ -1074,20 +1125,20 @@ Examples:
|
|
|
1074
1125
|
return;
|
|
1075
1126
|
}
|
|
1076
1127
|
if (Date.now() - start > timeoutMs) {
|
|
1077
|
-
// M8 + M9 (per-
|
|
1128
|
+
// M8 + M9 (per-participant wait): structured wait_timeout with the
|
|
1078
1129
|
// current status as `progress.rows[0]` so `study wait <id>`
|
|
1079
1130
|
// always emits machine-readable final state.
|
|
1080
|
-
throw new WaitTimeoutError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for
|
|
1081
|
-
study_id:
|
|
1131
|
+
throw new WaitTimeoutError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for participant ${participantId}. Last status: ${status}.`, {
|
|
1132
|
+
study_id: resolvedParticipant,
|
|
1082
1133
|
timeout_seconds: Math.round(timeoutMs / 1000),
|
|
1083
1134
|
done: 0,
|
|
1084
1135
|
total: 1,
|
|
1085
1136
|
pending: 1,
|
|
1086
1137
|
rows: [
|
|
1087
1138
|
{
|
|
1088
|
-
id:
|
|
1139
|
+
id: resolvedParticipant,
|
|
1089
1140
|
status,
|
|
1090
|
-
|
|
1141
|
+
participant_name: String(data.participant_name ?? "Unknown"),
|
|
1091
1142
|
interaction_count: 0,
|
|
1092
1143
|
},
|
|
1093
1144
|
],
|
|
@@ -1133,57 +1184,57 @@ Examples:
|
|
|
1133
1184
|
study
|
|
1134
1185
|
.command("cancel")
|
|
1135
1186
|
.description("Cancel a running simulation")
|
|
1136
|
-
.argument("<
|
|
1137
|
-
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from <
|
|
1138
|
-
.addHelpText("after", "\nExamples:\n $ ish study cancel
|
|
1139
|
-
.action(async (
|
|
1187
|
+
.argument("<participant_id>", "Participant ID (alias or UUID; from `ish study run --json`.participant_aliases[])")
|
|
1188
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from <participant_id>)")
|
|
1189
|
+
.addHelpText("after", "\nExamples:\n $ ish study cancel pt-072\n $ ish study cancel <uuid>\n\nGet participant IDs from `ish study run --json` (.participant_aliases[] / .participant_ids[]).")
|
|
1190
|
+
.action(async (participantId, _opts, cmd) => {
|
|
1140
1191
|
await withClient(cmd, async (client, globals) => {
|
|
1141
|
-
const data = await client.post(`/simulation/cancel/${resolveId(
|
|
1192
|
+
const data = await client.post(`/simulation/cancel/${resolveId(participantId)}`);
|
|
1142
1193
|
output(data, globals.json);
|
|
1143
1194
|
});
|
|
1144
1195
|
});
|
|
1145
1196
|
// --- Extend ---
|
|
1146
1197
|
//
|
|
1147
|
-
// Resume a terminal
|
|
1198
|
+
// Resume a terminal participant with `additional_steps` more turns — the
|
|
1148
1199
|
// "start" half of the cancel + extend pair. The backend spawns a NEW
|
|
1149
|
-
//
|
|
1200
|
+
// participant under the same iteration, branched from the source's last
|
|
1150
1201
|
// interaction; the source row is left untouched. When --instruction is
|
|
1151
|
-
// set, the new
|
|
1202
|
+
// set, the new participant treats it as overriding direction (the backend
|
|
1152
1203
|
// surfaces it in a dedicated <user_added_instructions> block on every
|
|
1153
1204
|
// prompt — see app-simulation Fix 1).
|
|
1154
1205
|
study
|
|
1155
1206
|
.command("extend")
|
|
1156
|
-
.description("Extend a terminal
|
|
1157
|
-
.argument("<
|
|
1207
|
+
.description("Extend a terminal participant with more steps (and optionally a mid-run instruction)")
|
|
1208
|
+
.argument("<participant_id>", "Participant to extend (alias or UUID). Must be in a terminal state (completed/failed/cancelled).")
|
|
1158
1209
|
.option("--add-steps <n>", "Extra interactions past the source's original cap (1-50; backend caps server-side)", "10")
|
|
1159
|
-
.option("--instruction <text>", "User message to inject as the new
|
|
1160
|
-
.option("--wait", "Block until the new
|
|
1210
|
+
.option("--instruction <text>", "User message to inject as the new participant resumes. Accepts inline text, `@/path/to/file`, or `-` for stdin.")
|
|
1211
|
+
.option("--wait", "Block until the new participant reaches a terminal state")
|
|
1161
1212
|
.option("--timeout <s>", "Wait timeout in seconds (default 300; only with --wait)")
|
|
1162
1213
|
.option("--dispatch-timeout <s>", "Per-POST timeout in seconds for the dispatch call (default 120)")
|
|
1163
|
-
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from <
|
|
1214
|
+
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from <participant_id>)")
|
|
1164
1215
|
.addHelpText("after", `
|
|
1165
|
-
The source
|
|
1216
|
+
The source participant is left untouched; a new participant is spawned under the
|
|
1166
1217
|
same iteration and branched from the source's last interaction. Get the
|
|
1167
|
-
new
|
|
1218
|
+
new participant ID from \`.participant_id\` / \`.participant_alias\` on the JSON output.
|
|
1168
1219
|
|
|
1169
1220
|
Examples:
|
|
1170
1221
|
# Add 5 more steps to a completed run (no new instruction):
|
|
1171
|
-
$ ish study extend
|
|
1222
|
+
$ ish study extend pt-072 --add-steps 5
|
|
1172
1223
|
|
|
1173
1224
|
# Inject a mid-run instruction and wait for completion:
|
|
1174
|
-
$ ish study extend
|
|
1225
|
+
$ ish study extend pt-072 \\
|
|
1175
1226
|
--instruction "Open the language selector and switch to German." \\
|
|
1176
1227
|
--wait
|
|
1177
1228
|
|
|
1178
1229
|
# Long instruction from a file:
|
|
1179
|
-
$ ish study extend
|
|
1230
|
+
$ ish study extend pt-072 --instruction @/tmp/prompt.txt --wait --timeout 600
|
|
1180
1231
|
|
|
1181
1232
|
# Instruction from stdin (pipe-friendly):
|
|
1182
|
-
$ echo "Try the search bar instead." | ish study extend
|
|
1233
|
+
$ echo "Try the search bar instead." | ish study extend pt-072 --instruction -
|
|
1183
1234
|
|
|
1184
|
-
Get
|
|
1235
|
+
Get participant IDs from \`ish study run --json\` (.participant_aliases[] / .participant_ids[]).
|
|
1185
1236
|
See \`ish docs get-page concepts/extending-a-simulation\` for the full mental model.`)
|
|
1186
|
-
.action(async (
|
|
1237
|
+
.action(async (participantId, opts, cmd) => {
|
|
1187
1238
|
await withClient(cmd, async (client, globals) => {
|
|
1188
1239
|
// --add-steps: client-side parser fails fast before the network
|
|
1189
1240
|
// call. Bound mirrors the backend's `le=50` cap; if the backend
|
|
@@ -1221,30 +1272,30 @@ See \`ish docs get-page concepts/extending-a-simulation\` for the full mental mo
|
|
|
1221
1272
|
parseInt(opts.dispatchTimeout, 10) < 1)) {
|
|
1222
1273
|
throw new Error(`--dispatch-timeout must be a positive integer (seconds), got "${opts.dispatchTimeout}".`);
|
|
1223
1274
|
}
|
|
1224
|
-
const sourceId = resolveId(
|
|
1225
|
-
const sourceAlias = tagAlias(ALIAS_PREFIX.
|
|
1275
|
+
const sourceId = resolveId(participantId);
|
|
1276
|
+
const sourceAlias = tagAlias(ALIAS_PREFIX.participant, sourceId);
|
|
1226
1277
|
if (!globals.quiet) {
|
|
1227
1278
|
const stepNote = `${addSteps} step${addSteps === 1 ? "" : "s"}`;
|
|
1228
1279
|
const instrNote = instruction ? " and a new instruction" : "";
|
|
1229
1280
|
console.error(`Extending ${sourceAlias} with ${stepNote}${instrNote}...`);
|
|
1230
1281
|
}
|
|
1231
1282
|
const body = {
|
|
1232
|
-
|
|
1283
|
+
source_participant_id: sourceId,
|
|
1233
1284
|
additional_steps: addSteps,
|
|
1234
1285
|
};
|
|
1235
1286
|
if (instruction)
|
|
1236
1287
|
body.user_message = instruction;
|
|
1237
1288
|
const data = await client.post("/simulation/interactive/extend", body, { timeout: dispatchTimeoutMs });
|
|
1238
|
-
const
|
|
1239
|
-
const newAlias = tagAlias(ALIAS_PREFIX.
|
|
1289
|
+
const newParticipantId = String(data.participant_id);
|
|
1290
|
+
const newAlias = tagAlias(ALIAS_PREFIX.participant, newParticipantId);
|
|
1240
1291
|
// UUIDs preserved on the output — `study extend` is a write-path
|
|
1241
|
-
// dispatch command and the new `
|
|
1242
|
-
// return value (mirrors how `study run` keeps
|
|
1292
|
+
// dispatch command and the new `participant_id` is the load-bearing
|
|
1293
|
+
// return value (mirrors how `study run` keeps participant_ids in lean
|
|
1243
1294
|
// output via the writePath option).
|
|
1244
1295
|
const baseEnvelope = {
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1296
|
+
participant_id: newParticipantId,
|
|
1297
|
+
participant_alias: newAlias,
|
|
1298
|
+
source_participant_id: sourceId,
|
|
1248
1299
|
source_alias: sourceAlias,
|
|
1249
1300
|
study_id: data.study_id,
|
|
1250
1301
|
job_id: data.job_id,
|
|
@@ -1257,15 +1308,15 @@ See \`ish docs get-page concepts/extending-a-simulation\` for the full mental mo
|
|
|
1257
1308
|
output(baseEnvelope, true, { writePath: true });
|
|
1258
1309
|
}
|
|
1259
1310
|
else {
|
|
1260
|
-
console.error(` New
|
|
1311
|
+
console.error(` New participant: ${newAlias}`);
|
|
1261
1312
|
if (data.message)
|
|
1262
1313
|
console.error(` ${data.message}`);
|
|
1263
1314
|
console.error(` Run \`ish study wait ${newAlias} --timeout 600\` to block until it finishes.`);
|
|
1264
1315
|
}
|
|
1265
1316
|
return;
|
|
1266
1317
|
}
|
|
1267
|
-
// --wait: poll the new
|
|
1268
|
-
// Mirrors the per-
|
|
1318
|
+
// --wait: poll the new participant until it reaches a terminal state.
|
|
1319
|
+
// Mirrors the per-participant wait block in `study wait <participant_id>`
|
|
1269
1320
|
// above — same WaitTimeoutError shape (exit 5, retryable) so the
|
|
1270
1321
|
// failure envelope is consistent across commands.
|
|
1271
1322
|
const timeoutMs = parseWaitTimeout(opts.timeout);
|
|
@@ -1275,7 +1326,7 @@ See \`ish docs get-page concepts/extending-a-simulation\` for the full mental mo
|
|
|
1275
1326
|
const start = Date.now();
|
|
1276
1327
|
let lastStatus = "";
|
|
1277
1328
|
while (true) {
|
|
1278
|
-
const status = await client.get(`/simulation/status/${
|
|
1329
|
+
const status = await client.get(`/simulation/status/${newParticipantId}`, undefined, { timeout: 60_000 });
|
|
1279
1330
|
const s = String(status.status ?? "unknown");
|
|
1280
1331
|
if (!globals.quiet && s !== lastStatus) {
|
|
1281
1332
|
process.stderr.write(` ${s}\n`);
|
|
@@ -1287,7 +1338,7 @@ See \`ish docs get-page concepts/extending-a-simulation\` for the full mental mo
|
|
|
1287
1338
|
...(typeof status.interaction_count === "number" && {
|
|
1288
1339
|
interaction_count: status.interaction_count,
|
|
1289
1340
|
}),
|
|
1290
|
-
...(status.
|
|
1341
|
+
...(status.participant_name && { participant_name: status.participant_name }),
|
|
1291
1342
|
...(status.error && { error: status.error }),
|
|
1292
1343
|
};
|
|
1293
1344
|
if (globals.json) {
|
|
@@ -1301,17 +1352,17 @@ See \`ish docs get-page concepts/extending-a-simulation\` for the full mental mo
|
|
|
1301
1352
|
return;
|
|
1302
1353
|
}
|
|
1303
1354
|
if (Date.now() - start > timeoutMs) {
|
|
1304
|
-
throw new WaitTimeoutError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for
|
|
1305
|
-
study_id:
|
|
1355
|
+
throw new WaitTimeoutError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for participant ${newAlias}. Last status: ${s}.`, {
|
|
1356
|
+
study_id: newParticipantId,
|
|
1306
1357
|
timeout_seconds: Math.round(timeoutMs / 1000),
|
|
1307
1358
|
done: 0,
|
|
1308
1359
|
total: 1,
|
|
1309
1360
|
pending: 1,
|
|
1310
1361
|
rows: [
|
|
1311
1362
|
{
|
|
1312
|
-
id:
|
|
1363
|
+
id: newParticipantId,
|
|
1313
1364
|
status: s,
|
|
1314
|
-
|
|
1365
|
+
participant_name: String(status.participant_name ?? "Unknown"),
|
|
1315
1366
|
interaction_count: typeof status.interaction_count === "number" ? status.interaction_count : 0,
|
|
1316
1367
|
},
|
|
1317
1368
|
],
|