@ishlabs/cli 0.9.0 → 0.10.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 -5
- package/dist/commands/ask.d.ts +12 -0
- package/dist/commands/ask.js +127 -2
- package/dist/commands/chat.d.ts +17 -0
- package/dist/commands/chat.js +589 -0
- package/dist/commands/iteration.js +134 -14
- package/dist/commands/secret.d.ts +20 -0
- package/dist/commands/secret.js +246 -0
- package/dist/commands/study-run.d.ts +38 -0
- package/dist/commands/study-run.js +199 -80
- package/dist/commands/study-tester.js +17 -2
- package/dist/commands/study.js +309 -37
- package/dist/commands/workspace.js +81 -0
- package/dist/config.d.ts +3 -0
- package/dist/connect.d.ts +3 -0
- package/dist/connect.js +346 -22
- package/dist/index.js +64 -6
- package/dist/lib/alias-hydrate.d.ts +42 -0
- package/dist/lib/alias-hydrate.js +175 -0
- package/dist/lib/alias-store.d.ts +1 -0
- package/dist/lib/alias-store.js +28 -1
- package/dist/lib/auth.js +4 -2
- package/dist/lib/chat-endpoint-formatters.d.ts +39 -0
- package/dist/lib/chat-endpoint-formatters.js +104 -0
- package/dist/lib/command-helpers.d.ts +18 -0
- package/dist/lib/command-helpers.js +105 -3
- package/dist/lib/docs.js +542 -17
- package/dist/lib/modality.d.ts +42 -0
- package/dist/lib/modality.js +192 -0
- package/dist/lib/output.d.ts +41 -0
- package/dist/lib/output.js +453 -19
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +182 -12
- package/dist/lib/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import * as readline from "node:readline/promises";
|
|
|
11
11
|
import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolveAudienceProfileIds, addAudienceFilterFlags, hasAudienceFlags, } from "../lib/command-helpers.js";
|
|
12
12
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
13
13
|
import { output, formatSimulationPoll } from "../lib/output.js";
|
|
14
|
-
import {
|
|
14
|
+
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, } from "../lib/modality.js";
|
|
15
15
|
import { runLocalSimulations } from "../lib/local-sim/loop.js";
|
|
16
16
|
import { ensureBrowser } from "../lib/local-sim/install.js";
|
|
17
17
|
function parseMaxInteractions(value) {
|
|
@@ -26,45 +26,53 @@ function parseSlowMo(value) {
|
|
|
26
26
|
throw new Error(`Invalid --slow-mo value: ${value}`);
|
|
27
27
|
return n;
|
|
28
28
|
}
|
|
29
|
-
function isMediaModality(modality) {
|
|
30
|
-
return !!modality && MEDIA_MODALITIES.includes(modality);
|
|
31
|
-
}
|
|
32
29
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
30
|
+
* M8 / M9 / Pattern G: typed wait-timeout error so `--wait` paths can
|
|
31
|
+
* produce a structured envelope (`error_code: "wait_timeout"`, exit 5
|
|
32
|
+
* transient) distinct from the generic timeout/network/server errors
|
|
33
|
+
* that the api-client wrapper produces. Carries the in-flight progress
|
|
34
|
+
* (testers done / total) so `study wait` always emits final state JSON
|
|
35
|
+
* even when it bails on the timer.
|
|
38
36
|
*/
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
if (modality === "image") {
|
|
49
|
-
const urls = details.image_urls;
|
|
50
|
-
if (Array.isArray(urls))
|
|
51
|
-
return urls.length > 0;
|
|
52
|
-
if (typeof urls === "string")
|
|
53
|
-
return urls.length > 0;
|
|
54
|
-
return false;
|
|
37
|
+
export class WaitTimeoutError extends Error {
|
|
38
|
+
progress;
|
|
39
|
+
error_code = "wait_timeout";
|
|
40
|
+
retryable = true;
|
|
41
|
+
constructor(message, progress) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.progress = progress;
|
|
44
|
+
this.name = "WaitTimeoutError";
|
|
55
45
|
}
|
|
56
|
-
// interactive (default)
|
|
57
|
-
return typeof details.url === "string" && details.url.length > 0;
|
|
58
46
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
47
|
+
/**
|
|
48
|
+
* M13 / Pattern J: collapse the N near-duplicate `simulations[]` rows the
|
|
49
|
+
* batch start endpoint returns (one per tester, all sharing study_id) into a
|
|
50
|
+
* single batch entry with `tester_ids[]` + `tester_aliases[]` + `job_ids[]`.
|
|
51
|
+
* Agents always need the per-tester ids, but the surrounding scaffolding
|
|
52
|
+
* (study_id, message) is shared so repeating it N times costs context for no
|
|
53
|
+
* extra signal. Falls back to the raw rows if the response shape is unfamiliar.
|
|
54
|
+
*/
|
|
55
|
+
function dedupeSimulations(simResults) {
|
|
56
|
+
if (!Array.isArray(simResults) || simResults.length === 0)
|
|
57
|
+
return [];
|
|
58
|
+
const studyIds = new Set(simResults.map((r) => r.study_id));
|
|
59
|
+
if (studyIds.size !== 1) {
|
|
60
|
+
// Mixed batches aren't expected today, but if the backend ever returns
|
|
61
|
+
// them, project per-row to keep the data lossless rather than silently
|
|
62
|
+
// collapsing across studies.
|
|
63
|
+
return simResults.map((r) => ({ ...r }));
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
const studyId = simResults[0].study_id;
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
study_id: studyId,
|
|
69
|
+
tester_ids: simResults.map((r) => r.tester_id),
|
|
70
|
+
tester_aliases: simResults.map((r) => tagAlias(ALIAS_PREFIX.tester, String(r.tester_id))),
|
|
71
|
+
job_ids: simResults.map((r) => r.job_id ?? null),
|
|
72
|
+
count: simResults.length,
|
|
73
|
+
message: simResults[0].message,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
68
76
|
}
|
|
69
77
|
const POLL_INTERVAL_MS = 5_000;
|
|
70
78
|
const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
|
|
@@ -74,13 +82,16 @@ function flattenTesterStatuses(iterations, only) {
|
|
|
74
82
|
for (const t of iteration.testers ?? []) {
|
|
75
83
|
if (only && !only.has(t.id))
|
|
76
84
|
continue;
|
|
85
|
+
// Pattern A (cli half): backend now reports per-tester crash detail at
|
|
86
|
+
// `error_message`. Keep `error` / `failure_reason` as legacy fallbacks
|
|
87
|
+
// until every backend deploy is on the new contract.
|
|
88
|
+
const errorMessage = t.error_message || t.error || t.failure_reason || null;
|
|
77
89
|
rows.push({
|
|
78
90
|
id: t.id,
|
|
79
91
|
status: t.status,
|
|
80
92
|
tester_name: t.tester_profile?.name || "Unknown",
|
|
81
93
|
interaction_count: Array.isArray(t.interactions) ? t.interactions.length : 0,
|
|
82
|
-
...(
|
|
83
|
-
...(t.failure_reason && { error: t.failure_reason }),
|
|
94
|
+
...(errorMessage && { error_message: errorMessage }),
|
|
84
95
|
});
|
|
85
96
|
}
|
|
86
97
|
}
|
|
@@ -109,8 +120,16 @@ async function pollStudyUntilDone(client, opts) {
|
|
|
109
120
|
return { rows, isMedia };
|
|
110
121
|
}
|
|
111
122
|
if (Date.now() - start > opts.timeoutMs) {
|
|
112
|
-
throw new
|
|
113
|
-
|
|
123
|
+
throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
|
|
124
|
+
`${done}/${total} done. Run \`ish study poll --study ${opts.studyId}\` to check status.`, {
|
|
125
|
+
study_id: opts.studyId,
|
|
126
|
+
...(opts.iterationId && { iteration_id: opts.iterationId }),
|
|
127
|
+
timeout_seconds: Math.round(opts.timeoutMs / 1000),
|
|
128
|
+
done,
|
|
129
|
+
total,
|
|
130
|
+
pending: total - done,
|
|
131
|
+
rows,
|
|
132
|
+
});
|
|
114
133
|
}
|
|
115
134
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
116
135
|
}
|
|
@@ -143,9 +162,12 @@ export function attachStudyRunCommands(study) {
|
|
|
143
162
|
})
|
|
144
163
|
.option("--config <id>", "Simulation config ID (required for media unless every profile has one)")
|
|
145
164
|
.option("--max-interactions <n>", "Max interactions per tester")
|
|
165
|
+
.option("--max-turns <n>", "Max conversation turns per tester (chat studies only)")
|
|
166
|
+
.option("--early-termination", "Allow chat agent to end the conversation early when goals are met (chat studies only)")
|
|
146
167
|
.option("--language <lang>", "Language code (e.g. en, sv)")
|
|
147
168
|
.option("--wait", "Wait for all simulations to reach a terminal state before returning")
|
|
148
169
|
.option("--timeout <s>", "Wait timeout in seconds (default 300; only with --wait)")
|
|
170
|
+
.option("--dispatch-timeout <s>", "Per-POST timeout in seconds for the create-testers + dispatch calls (default 120). Bump if `study run` times out client-side after seeding testers but before dispatch — those testers exist server-side and are surfaced under `seeded_but_not_dispatched_*` in the error envelope so the agent can resume.")
|
|
149
171
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
150
172
|
// Local simulation options
|
|
151
173
|
.option("--local", "Run simulation with local browser (Playwright) instead of remote")
|
|
@@ -197,11 +219,49 @@ Examples:
|
|
|
197
219
|
const log = (msg) => { if (!globals.quiet)
|
|
198
220
|
console.error(msg); };
|
|
199
221
|
const resolvedWorkspace = resolveWorkspace(opts.workspace);
|
|
200
|
-
|
|
222
|
+
// Pattern A (Sprint 2): when `--iteration` is passed without
|
|
223
|
+
// `--study`, derive the parent study from the iteration itself
|
|
224
|
+
// (mirroring `study wait`). The iteration's `study_id` is
|
|
225
|
+
// authoritative — falling back to the active study config here
|
|
226
|
+
// would silently target the wrong study when the active context
|
|
227
|
+
// has drifted, surfacing as the misleading "Iteration X not found
|
|
228
|
+
// on this study" error N1 hit. If `--study` is also passed, they
|
|
229
|
+
// must agree.
|
|
230
|
+
let resolvedStudy;
|
|
231
|
+
if (opts.iteration && !opts.study) {
|
|
232
|
+
// The iteration's `study_id` is authoritative. Falling back to
|
|
233
|
+
// the active study config here would silently target the wrong
|
|
234
|
+
// study when the active context has drifted (Pattern A from N1)
|
|
235
|
+
// and surface as the misleading "Iteration X not found on this
|
|
236
|
+
// study" error. If the lookup fails (404, transport), surface
|
|
237
|
+
// the lookup error directly rather than masking with a stale
|
|
238
|
+
// active-study read.
|
|
239
|
+
const iterId = resolveId(opts.iteration);
|
|
240
|
+
const iter = await client.get(`/iterations/${iterId}`);
|
|
241
|
+
if (!iter.study_id) {
|
|
242
|
+
throw new Error(`Iteration ${opts.iteration} has no study_id.`);
|
|
243
|
+
}
|
|
244
|
+
resolvedStudy = iter.study_id;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
resolvedStudy = resolveStudy(opts.study);
|
|
248
|
+
}
|
|
249
|
+
// B7: per-POST dispatch timeout (default 120s — long enough to survive
|
|
250
|
+
// a slow-cold backend, short enough that an agent doesn't sit blind
|
|
251
|
+
// for many minutes). Applied to both the testers/batch POST AND the
|
|
252
|
+
// simulation start POST so the seed + dispatch budget is the same.
|
|
253
|
+
const dispatchTimeoutMs = opts.dispatchTimeout
|
|
254
|
+
? Math.max(1, parseInt(opts.dispatchTimeout, 10)) * 1000
|
|
255
|
+
: 120_000;
|
|
256
|
+
if (opts.dispatchTimeout && (Number.isNaN(parseInt(opts.dispatchTimeout, 10))
|
|
257
|
+
|| parseInt(opts.dispatchTimeout, 10) < 1)) {
|
|
258
|
+
throw new Error(`--dispatch-timeout must be a positive integer (seconds), got "${opts.dispatchTimeout}".`);
|
|
259
|
+
}
|
|
201
260
|
// Step 0: Fetch study (with its iterations + their existing testers)
|
|
202
261
|
const study = await client.get(`/studies/${resolvedStudy}`);
|
|
203
262
|
const modality = study.modality || "interactive";
|
|
204
263
|
const isMedia = isMediaModality(modality);
|
|
264
|
+
const isChat = isChatModality(modality);
|
|
205
265
|
if (!study.assignments || study.assignments.length === 0) {
|
|
206
266
|
throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `ish study generate`.");
|
|
207
267
|
}
|
|
@@ -267,10 +327,11 @@ Examples:
|
|
|
267
327
|
throw new Error(`Iteration "${iterationLabel}" has no testers and no audience flags were given. ` +
|
|
268
328
|
"Pass --profile <ids>, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility) with --sample <N> or --all.");
|
|
269
329
|
}
|
|
270
|
-
// Step 3: Resolve simulation config (per-profile fallback for
|
|
330
|
+
// Step 3: Resolve simulation config (per-profile fallback for
|
|
331
|
+
// media + chat, both of which require a config_id per batch item)
|
|
271
332
|
const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
|
|
272
333
|
const profileConfigMap = new Map();
|
|
273
|
-
if (isMedia && !resolvedConfigOverride) {
|
|
334
|
+
if ((isMedia || isChat) && !resolvedConfigOverride) {
|
|
274
335
|
for (const pid of profileIds) {
|
|
275
336
|
const profile = await client.get(`/tester-profiles/${pid}`);
|
|
276
337
|
if (profile.simulation_config_id) {
|
|
@@ -291,7 +352,17 @@ Examples:
|
|
|
291
352
|
log(` Modality: ${modality}`);
|
|
292
353
|
if (study.content_type)
|
|
293
354
|
log(` Content type: ${study.content_type}`);
|
|
294
|
-
if (
|
|
355
|
+
if (isChat) {
|
|
356
|
+
const epId = typeof iteration.details?.chatbot_endpoint_id === "string"
|
|
357
|
+
? iteration.details.chatbot_endpoint_id : undefined;
|
|
358
|
+
if (epId)
|
|
359
|
+
log(` Endpoint: ${epId}`);
|
|
360
|
+
if (opts.maxTurns)
|
|
361
|
+
log(` Max turns: ${opts.maxTurns}`);
|
|
362
|
+
if (opts.earlyTermination)
|
|
363
|
+
log(` Early term: enabled`);
|
|
364
|
+
}
|
|
365
|
+
else if (!isMedia) {
|
|
295
366
|
log(` Platform: ${detailsView.platform || "browser"}`);
|
|
296
367
|
log(` Screen format: ${detailsView.screenFormat || "desktop"}`);
|
|
297
368
|
if (detailsView.url)
|
|
@@ -334,16 +405,16 @@ Examples:
|
|
|
334
405
|
tester_type: "ai",
|
|
335
406
|
status: "draft",
|
|
336
407
|
...(opts.language && { language: opts.language }),
|
|
337
|
-
...(!isMedia && { platform: detailsView.platform || "browser" }),
|
|
408
|
+
...(!isMedia && !isChat && { platform: detailsView.platform || "browser" }),
|
|
338
409
|
}));
|
|
339
410
|
log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
|
|
340
|
-
const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
|
|
411
|
+
const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs }, { timeout: dispatchTimeoutMs });
|
|
341
412
|
createdTesters = batchResult.testers;
|
|
342
413
|
log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
|
|
343
414
|
}
|
|
344
415
|
// Step 6: Dispatch
|
|
345
416
|
if (opts.local) {
|
|
346
|
-
if (isMedia) {
|
|
417
|
+
if (isMedia || isChat) {
|
|
347
418
|
throw new Error("Local mode is only supported for interactive simulations.");
|
|
348
419
|
}
|
|
349
420
|
const testerNameMap = new Map();
|
|
@@ -386,18 +457,58 @@ Examples:
|
|
|
386
457
|
}
|
|
387
458
|
log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
|
|
388
459
|
let simResults;
|
|
389
|
-
|
|
460
|
+
// B7 / Pattern G: tag any failure during the dispatch POST with the
|
|
461
|
+
// testers that already exist server-side. The client doesn't know
|
|
462
|
+
// whether the backend processed our POST or never received it, so
|
|
463
|
+
// re-running `study run` would double-seed (we'd create another
|
|
464
|
+
// batch of testers on top of these). Instead, surface the seeded
|
|
465
|
+
// ids in the error envelope under `seeded_but_not_dispatched_ids`
|
|
466
|
+
// / `_aliases` so the agent can resume by polling them or calling
|
|
467
|
+
// `study tester delete <id>` then retrying.
|
|
468
|
+
const dispatchAttempt = async (fn) => {
|
|
469
|
+
try {
|
|
470
|
+
return await fn();
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
if (err instanceof Error) {
|
|
474
|
+
const tagged = err;
|
|
475
|
+
tagged.seeded_but_not_dispatched_ids = createdTesters.map((t) => t.id);
|
|
476
|
+
tagged.seeded_but_not_dispatched_aliases = createdTesters.map((t) => tagAlias(ALIAS_PREFIX.tester, String(t.id)));
|
|
477
|
+
}
|
|
478
|
+
throw err;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
if (isChat) {
|
|
482
|
+
const chatBatchItems = createdTesters.map((t, i) => ({
|
|
483
|
+
study_id: resolvedStudy,
|
|
484
|
+
tester_id: t.id,
|
|
485
|
+
config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
|
|
486
|
+
...(opts.language && { language: opts.language }),
|
|
487
|
+
}));
|
|
488
|
+
const maxTurns = opts.maxTurns ? parseInt(opts.maxTurns, 10) : undefined;
|
|
489
|
+
if (opts.maxTurns !== undefined && (Number.isNaN(maxTurns) || maxTurns < 1)) {
|
|
490
|
+
throw new Error(`Invalid --max-turns value: ${opts.maxTurns}`);
|
|
491
|
+
}
|
|
492
|
+
const simResult = await dispatchAttempt(() => client.post("/simulation/chat/start/batch", {
|
|
493
|
+
product_id: resolvedWorkspace,
|
|
494
|
+
simulations: chatBatchItems,
|
|
495
|
+
...(maxTurns !== undefined && { max_turns: maxTurns }),
|
|
496
|
+
...(opts.earlyTermination && { early_termination: true }),
|
|
497
|
+
}, { timeout: dispatchTimeoutMs }));
|
|
498
|
+
simResults = simResult.results;
|
|
499
|
+
}
|
|
500
|
+
else if (isMedia) {
|
|
390
501
|
const mediaBatchItems = createdTesters.map((t, i) => ({
|
|
391
502
|
study_id: resolvedStudy,
|
|
392
503
|
tester_id: t.id,
|
|
393
504
|
config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
|
|
394
505
|
...(opts.language && { language: opts.language }),
|
|
395
506
|
}));
|
|
396
|
-
const simResult = await client.post("/simulation/media/start/batch", {
|
|
507
|
+
const simResult = await dispatchAttempt(() => client.post("/simulation/media/start/batch", {
|
|
397
508
|
product_id: resolvedWorkspace,
|
|
398
509
|
simulations: mediaBatchItems,
|
|
399
510
|
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
400
|
-
}, { timeout:
|
|
511
|
+
}, { timeout: dispatchTimeoutMs }));
|
|
401
512
|
simResults = simResult.results;
|
|
402
513
|
}
|
|
403
514
|
else {
|
|
@@ -408,14 +519,14 @@ Examples:
|
|
|
408
519
|
...(opts.language && { language: opts.language }),
|
|
409
520
|
...(detailsView.locale && { locale: detailsView.locale }),
|
|
410
521
|
}));
|
|
411
|
-
const simResult = await client.post("/simulation/interactive/start/batch", {
|
|
522
|
+
const simResult = await dispatchAttempt(() => client.post("/simulation/interactive/start/batch", {
|
|
412
523
|
product_id: resolvedWorkspace,
|
|
413
524
|
simulations: simItems,
|
|
414
525
|
platform: detailsView.platform || "browser",
|
|
415
526
|
...(detailsView.url && { url: detailsView.url }),
|
|
416
527
|
screen_format: detailsView.screenFormat || "desktop",
|
|
417
528
|
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
418
|
-
}, { timeout:
|
|
529
|
+
}, { timeout: dispatchTimeoutMs }));
|
|
419
530
|
simResults = simResult.results;
|
|
420
531
|
}
|
|
421
532
|
if (!opts.wait) {
|
|
@@ -430,7 +541,7 @@ Examples:
|
|
|
430
541
|
testers: testersOut,
|
|
431
542
|
tester_ids: testersOut.map((t) => t.id),
|
|
432
543
|
tester_aliases: testersOut.map((t) => t.alias),
|
|
433
|
-
simulations: simResults,
|
|
544
|
+
simulations: dedupeSimulations(simResults),
|
|
434
545
|
}, true);
|
|
435
546
|
}
|
|
436
547
|
else {
|
|
@@ -466,7 +577,7 @@ Examples:
|
|
|
466
577
|
testers: testersOut,
|
|
467
578
|
tester_ids: testersOut.map((t) => t.id),
|
|
468
579
|
tester_aliases: testersOut.map((t) => t.alias),
|
|
469
|
-
simulations: simResults,
|
|
580
|
+
simulations: dedupeSimulations(simResults),
|
|
470
581
|
results: rows,
|
|
471
582
|
}, true);
|
|
472
583
|
}
|
|
@@ -490,32 +601,22 @@ Examples:
|
|
|
490
601
|
if (testerId) {
|
|
491
602
|
const data = await client.get(`/simulation/status/${resolveId(testerId)}`);
|
|
492
603
|
output(data, globals.json);
|
|
604
|
+
return;
|
|
493
605
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
formatSimulationPoll(allTesters, globals.json, isMedia);
|
|
512
|
-
if (!globals.json && study.product_id) {
|
|
513
|
-
const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
|
|
514
|
-
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
else {
|
|
518
|
-
throw new Error("Provide a tester_id argument or --study flag");
|
|
606
|
+
// M7: fall back to the active study (set by `ish study use`) when
|
|
607
|
+
// neither a tester_id nor an explicit --study is passed. This brings
|
|
608
|
+
// poll into parity with `study results` / `study wait` / `study run`,
|
|
609
|
+
// all of which already honor the active study. Without the fallback
|
|
610
|
+
// an agent that ran `study use s-...` then `study poll` would get a
|
|
611
|
+
// confusing "Provide a tester_id argument or --study flag" error.
|
|
612
|
+
const rid = resolveStudy(opts.study);
|
|
613
|
+
const study = await client.get(`/studies/${rid}`);
|
|
614
|
+
const isMedia = isMediaModality(study.modality);
|
|
615
|
+
const allTesters = flattenTesterStatuses(study.iterations);
|
|
616
|
+
formatSimulationPoll(allTesters, globals.json, isMedia);
|
|
617
|
+
if (!globals.json && study.product_id) {
|
|
618
|
+
const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
|
|
619
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
519
620
|
}
|
|
520
621
|
});
|
|
521
622
|
});
|
|
@@ -535,8 +636,9 @@ Examples:
|
|
|
535
636
|
if (testerId) {
|
|
536
637
|
const start = Date.now();
|
|
537
638
|
let lastStatus = "";
|
|
639
|
+
const resolvedTester = resolveId(testerId);
|
|
538
640
|
while (true) {
|
|
539
|
-
const data = await client.get(`/simulation/status/${
|
|
641
|
+
const data = await client.get(`/simulation/status/${resolvedTester}`, undefined, { timeout: 60_000 });
|
|
540
642
|
const status = String(data.status ?? "unknown");
|
|
541
643
|
if (!globals.quiet && status !== lastStatus) {
|
|
542
644
|
process.stderr.write(` ${status}\n`);
|
|
@@ -547,7 +649,24 @@ Examples:
|
|
|
547
649
|
return;
|
|
548
650
|
}
|
|
549
651
|
if (Date.now() - start > timeoutMs) {
|
|
550
|
-
|
|
652
|
+
// M8 + M9 (per-tester wait): structured wait_timeout with the
|
|
653
|
+
// current status as `progress.rows[0]` so `study wait <id>`
|
|
654
|
+
// always emits machine-readable final state.
|
|
655
|
+
throw new WaitTimeoutError(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for tester ${testerId}. Last status: ${status}.`, {
|
|
656
|
+
study_id: resolvedTester,
|
|
657
|
+
timeout_seconds: Math.round(timeoutMs / 1000),
|
|
658
|
+
done: 0,
|
|
659
|
+
total: 1,
|
|
660
|
+
pending: 1,
|
|
661
|
+
rows: [
|
|
662
|
+
{
|
|
663
|
+
id: resolvedTester,
|
|
664
|
+
status,
|
|
665
|
+
tester_name: String(data.tester_name ?? "Unknown"),
|
|
666
|
+
interaction_count: 0,
|
|
667
|
+
},
|
|
668
|
+
],
|
|
669
|
+
});
|
|
551
670
|
}
|
|
552
671
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
553
672
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { withClient, readJsonFileOrStdin, resolveWorkspace } from "../lib/command-helpers.js";
|
|
8
8
|
import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
|
|
9
|
-
import { formatTesterDetail, output } from "../lib/output.js";
|
|
9
|
+
import { formatTesterDetail, buildTesterSummary, output } from "../lib/output.js";
|
|
10
10
|
/** Pick the latest iteration on a study (highest order_index, falling back to last). */
|
|
11
11
|
async function latestIterationForStudy(client, studyId) {
|
|
12
12
|
const study = await client.get(`/studies/${studyId}`);
|
|
@@ -53,7 +53,18 @@ export function attachStudyTesterCommands(study) {
|
|
|
53
53
|
.description("Inspect or manage testers (low-level; usually created via `study run`)")
|
|
54
54
|
.argument("[id]", "Tester ID — pass directly to view tester details and results")
|
|
55
55
|
.option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the tester)")
|
|
56
|
-
.
|
|
56
|
+
.option("--summary", "Lean projection: alias + status + sentiment + comment + error_message. Drops the action timeline / interactions array.")
|
|
57
|
+
.addHelpText("after", `
|
|
58
|
+
Examples:
|
|
59
|
+
$ ish study tester t-d4e # show tester details
|
|
60
|
+
$ ish study tester t-d4e --summary --json # headline only (sentiment + comment)
|
|
61
|
+
$ ish study tester create --iteration <id> --profile <id>
|
|
62
|
+
$ ish study tester batch-create --iteration <id> --file testers.json
|
|
63
|
+
$ ish study tester delete t-d4e
|
|
64
|
+
|
|
65
|
+
Tips:
|
|
66
|
+
Use \`--get <path>\` to grab one value (e.g. \`--get sentiment\`),
|
|
67
|
+
\`--fields a,b,c\` to project further.`)
|
|
57
68
|
.action(async (id, opts, cmd) => {
|
|
58
69
|
if (!id) {
|
|
59
70
|
cmd.help();
|
|
@@ -65,6 +76,10 @@ export function attachStudyTesterCommands(study) {
|
|
|
65
76
|
const result = data;
|
|
66
77
|
if (result.id)
|
|
67
78
|
result.alias = tagAlias(ALIAS_PREFIX.tester, String(result.id));
|
|
79
|
+
if (opts.summary) {
|
|
80
|
+
output(buildTesterSummary(result), globals.json, { preProjected: true });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
68
83
|
formatTesterDetail(result, globals.json);
|
|
69
84
|
});
|
|
70
85
|
});
|