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