@ishlabs/cli 0.15.0 → 0.16.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 +10 -0
- package/dist/commands/study-run.js +89 -31
- package/dist/commands/workspace.js +3 -2
- package/dist/lib/api-client.d.ts +7 -0
- package/dist/lib/api-client.js +9 -0
- package/dist/lib/command-helpers.js +1 -1
- package/dist/lib/docs.js +6 -1
- package/dist/lib/study-events.d.ts +46 -0
- package/dist/lib/study-events.js +126 -0
- package/dist/lib/types.d.ts +0 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -308,6 +308,16 @@ ish ask run --new --name "newsletter" \
|
|
|
308
308
|
|
|
309
309
|
**Audience flags** (`--profile`, `--sample`, `--all-simulatable`, `--country`, `--gender`, `--min-age`, `--max-age`, `--search`, `--visibility`, `--name`, `--description`, `--workspace`) only apply with `--new` — the audience is fixed at ask creation.
|
|
310
310
|
|
|
311
|
+
**`--visibility` values** (same set everywhere it's accepted):
|
|
312
|
+
|
|
313
|
+
| Value | Selects |
|
|
314
|
+
|---|---|
|
|
315
|
+
| `workspace` | Profiles owned by your workspace (default scope). |
|
|
316
|
+
| `shared` | Community-published profiles visible across workspaces. |
|
|
317
|
+
| `platform` | Admin-curated profiles from the platform pool. |
|
|
318
|
+
|
|
319
|
+
Old values `private` (now `workspace`) and `public` (now `platform`) keep working until the next release; the server logs a deprecation warning and maps them to the new vocabulary.
|
|
320
|
+
|
|
311
321
|
**Other ask verbs:**
|
|
312
322
|
|
|
313
323
|
```bash
|
|
@@ -11,6 +11,7 @@ import * as readline from "node:readline/promises";
|
|
|
11
11
|
import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy, parseWaitTimeout, resolveAudienceProfileIds, addAudienceFilterFlags, hasAudienceFlags, readFileOrStdin, } 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 { streamStudyEvents } from "../lib/study-events.js";
|
|
14
15
|
import { isMediaModality, isChatModality, iterationHasContent, describeRequiredContentFlag, readChatMode, readTesterPairConfig, summarizeRoleCriteria, } from "../lib/modality.js";
|
|
15
16
|
import { runLocalSimulations } from "../lib/local-sim/loop.js";
|
|
16
17
|
import { ensureBrowser } from "../lib/local-sim/install.js";
|
|
@@ -93,6 +94,11 @@ function dedupeSimulations(simResults) {
|
|
|
93
94
|
];
|
|
94
95
|
}
|
|
95
96
|
const POLL_INTERVAL_MS = 5_000;
|
|
97
|
+
// When the backend SSE stream is connected we drop to a slow backstop —
|
|
98
|
+
// events wake us up promptly, so re-fetching every 5s is wasteful. If the
|
|
99
|
+
// stream dies (older backend, broker offline, token mint failure) the loop
|
|
100
|
+
// transparently reverts to POLL_INTERVAL_MS.
|
|
101
|
+
const SSE_BACKSTOP_INTERVAL_MS = 30_000;
|
|
96
102
|
const TERMINAL_STATUSES = new Set(["completed", "errored", "failed", "cancelled", "canceled"]);
|
|
97
103
|
function flattenTesterStatuses(iterations, only) {
|
|
98
104
|
const rows = [];
|
|
@@ -118,38 +124,90 @@ function flattenTesterStatuses(iterations, only) {
|
|
|
118
124
|
async function pollStudyUntilDone(client, opts) {
|
|
119
125
|
const start = Date.now();
|
|
120
126
|
let lastReported = "";
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
127
|
+
// Open the SSE event stream as a wake source. When the backend supports
|
|
128
|
+
// it (enable_realtime=true + broker live), tester status changes wake
|
|
129
|
+
// the loop the moment they happen — no need to wait for the next poll
|
|
130
|
+
// tick. When it doesn't, the iterator exits silently on the first read
|
|
131
|
+
// and we fall through to pure polling at POLL_INTERVAL_MS.
|
|
132
|
+
const ac = new AbortController();
|
|
133
|
+
const eventIter = streamStudyEvents(client, opts.studyId, ac.signal);
|
|
134
|
+
// Optimistic: we assume the stream will deliver until the first read
|
|
135
|
+
// confirms otherwise. The first `await pendingEvent` settles within
|
|
136
|
+
// milliseconds for an unavailable stream (token mint 503 / endpoint 503)
|
|
137
|
+
// and is essentially a no-op for the latency budget.
|
|
138
|
+
let sseAlive = true;
|
|
139
|
+
let pendingEvent = eventIter.next();
|
|
140
|
+
try {
|
|
141
|
+
while (true) {
|
|
142
|
+
const study = await client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 });
|
|
143
|
+
const isMedia = isMediaModality(study.modality);
|
|
144
|
+
let iterations = study.iterations;
|
|
145
|
+
if (opts.iterationId) {
|
|
146
|
+
iterations = (iterations ?? []).filter((it) => it.id === opts.iterationId);
|
|
147
|
+
}
|
|
148
|
+
const rows = flattenTesterStatuses(iterations, opts.testerIds);
|
|
149
|
+
const total = rows.length;
|
|
150
|
+
const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
|
|
151
|
+
const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
|
|
152
|
+
const summary = `${done}/${total} done${errored > 0 ? `, ${errored} errored/failed` : ""}`;
|
|
153
|
+
if (!opts.quiet && summary !== lastReported) {
|
|
154
|
+
process.stderr.write(` ${summary}\n`);
|
|
155
|
+
lastReported = summary;
|
|
156
|
+
}
|
|
157
|
+
if (total > 0 && done === total) {
|
|
158
|
+
return { rows, isMedia };
|
|
159
|
+
}
|
|
160
|
+
if (Date.now() - start > opts.timeoutMs) {
|
|
161
|
+
throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
|
|
162
|
+
`${done}/${total} done. Run \`ish study poll --study ${opts.studyId}\` to check status.`, {
|
|
163
|
+
study_id: opts.studyId,
|
|
164
|
+
...(opts.iterationId && { iteration_id: opts.iterationId }),
|
|
165
|
+
timeout_seconds: Math.round(opts.timeoutMs / 1000),
|
|
166
|
+
done,
|
|
167
|
+
total,
|
|
168
|
+
pending: total - done,
|
|
169
|
+
rows,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Wait for either the next backend event or the next tick. Use the
|
|
173
|
+
// backstop interval while the stream is delivering (events are the
|
|
174
|
+
// primary wake source); fall back to POLL_INTERVAL_MS once the
|
|
175
|
+
// stream is dead (older backend or broker dropped mid-run).
|
|
176
|
+
const interval = sseAlive ? SSE_BACKSTOP_INTERVAL_MS : POLL_INTERVAL_MS;
|
|
177
|
+
if (sseAlive && pendingEvent !== null) {
|
|
178
|
+
let timerHandle;
|
|
179
|
+
const timer = new Promise((resolve) => {
|
|
180
|
+
timerHandle = setTimeout(() => resolve({ kind: "timer" }), interval);
|
|
181
|
+
});
|
|
182
|
+
const eventOrEnd = pendingEvent.then((r) => ({ kind: "event", result: r }), () => ({ kind: "event", result: { value: undefined, done: true } }));
|
|
183
|
+
const winner = await Promise.race([eventOrEnd, timer]);
|
|
184
|
+
if (timerHandle !== undefined)
|
|
185
|
+
clearTimeout(timerHandle);
|
|
186
|
+
if (winner.kind === "event") {
|
|
187
|
+
if (winner.result.done) {
|
|
188
|
+
// Stream ended; revert to pure polling for the rest of the run.
|
|
189
|
+
sseAlive = false;
|
|
190
|
+
pendingEvent = null;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// Re-arm for the next event. We deliberately don't act on
|
|
194
|
+
// the event payload here — the next loop iteration re-fetches
|
|
195
|
+
// status and that's the truth source.
|
|
196
|
+
pendingEvent = eventIter.next();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Timer winning doesn't replace pendingEvent — it stays parked
|
|
200
|
+
// and resolves on whichever event arrives next.
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
204
|
+
}
|
|
151
205
|
}
|
|
152
|
-
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
// Stop the SSE fetch + reader so we don't leave a dangling HTTP
|
|
209
|
+
// connection to the backend after returning / throwing.
|
|
210
|
+
ac.abort();
|
|
153
211
|
}
|
|
154
212
|
}
|
|
155
213
|
function readIterationDetails(details) {
|
|
@@ -214,11 +214,12 @@ async function collectWorkspaceUsage(client, workspaceId) {
|
|
|
214
214
|
.get(`/products/${workspaceId}/studies`)
|
|
215
215
|
.catch(() => []);
|
|
216
216
|
// testers_used: paginated list returns { total, items, ... }. Backend
|
|
217
|
-
// gates `maxCustomTesterProfiles` on visibility=
|
|
217
|
+
// gates `maxCustomTesterProfiles` on visibility=workspace (rows owned by
|
|
218
|
+
// this workspace; was `private` before the visibility rename).
|
|
218
219
|
const testersPromise = client
|
|
219
220
|
.get("/tester-profiles", {
|
|
220
221
|
product_id: workspaceId,
|
|
221
|
-
visibility: "
|
|
222
|
+
visibility: "workspace",
|
|
222
223
|
type: "ai",
|
|
223
224
|
limit: "1",
|
|
224
225
|
offset: "0",
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export declare class ApiClient {
|
|
|
20
20
|
token: string;
|
|
21
21
|
});
|
|
22
22
|
get accessToken(): string;
|
|
23
|
+
/** Resolved base URL including the ``/api/v1`` prefix.
|
|
24
|
+
*
|
|
25
|
+
* Exposed so helpers that bypass the JSON-decoding ``fetch`` wrappers
|
|
26
|
+
* (e.g. the SSE stream in ``lib/study-events.ts``) can build their own
|
|
27
|
+
* requests against the same backend.
|
|
28
|
+
*/
|
|
29
|
+
get apiBase(): string;
|
|
23
30
|
private headers;
|
|
24
31
|
get<T = unknown>(path: string, params?: Record<string, string | string[]>, opts?: RequestOpts): Promise<T>;
|
|
25
32
|
post<T = unknown>(path: string, body?: unknown, opts?: RequestOpts): Promise<T>;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -83,6 +83,15 @@ export class ApiClient {
|
|
|
83
83
|
get accessToken() {
|
|
84
84
|
return this.token;
|
|
85
85
|
}
|
|
86
|
+
/** Resolved base URL including the ``/api/v1`` prefix.
|
|
87
|
+
*
|
|
88
|
+
* Exposed so helpers that bypass the JSON-decoding ``fetch`` wrappers
|
|
89
|
+
* (e.g. the SSE stream in ``lib/study-events.ts``) can build their own
|
|
90
|
+
* requests against the same backend.
|
|
91
|
+
*/
|
|
92
|
+
get apiBase() {
|
|
93
|
+
return this.baseUrl;
|
|
94
|
+
}
|
|
86
95
|
headers() {
|
|
87
96
|
return {
|
|
88
97
|
Authorization: `Bearer ${this.token}`,
|
|
@@ -245,7 +245,7 @@ export function addAudienceFilterFlags(cmd, opts = {}) {
|
|
|
245
245
|
.option("--country <code>", "Filter by 2-letter country code (repeatable)", collectRepeatable, [])
|
|
246
246
|
.option("--min-age <n>", "Minimum age (inclusive)")
|
|
247
247
|
.option("--max-age <n>", "Maximum age (inclusive)")
|
|
248
|
-
.option("--visibility <v>", "Filter by visibility (private
|
|
248
|
+
.option("--visibility <v>", "Filter by visibility: workspace (your workspace), shared (community-published), platform (admin-curated). Old values `private` / `public` still accepted as aliases for `workspace` / `platform` until the next release; server logs a deprecation warning.");
|
|
249
249
|
}
|
|
250
250
|
export function getGlobals(cmd) {
|
|
251
251
|
let globals;
|
package/dist/lib/docs.js
CHANGED
|
@@ -1268,7 +1268,12 @@ flags. Two ways to select:
|
|
|
1268
1268
|
- \`--min-age 25\`
|
|
1269
1269
|
- \`--max-age 50\`
|
|
1270
1270
|
- \`--search "early adopter"\`
|
|
1271
|
-
- \`--visibility shared|
|
|
1271
|
+
- \`--visibility workspace|shared|platform\` (filter by where the
|
|
1272
|
+
profile lives: your workspace, the community-published pool, or
|
|
1273
|
+
the admin-curated platform pool; old values \`private\` /
|
|
1274
|
+
\`public\` still accepted as aliases for \`workspace\` /
|
|
1275
|
+
\`platform\` until the next release with a server-side
|
|
1276
|
+
deprecation warning)
|
|
1272
1277
|
|
|
1273
1278
|
The two modes are **mutually exclusive** — pass either \`--profile\` or
|
|
1274
1279
|
the filter set, not both.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE consumer for the backend's per-study event stream.
|
|
3
|
+
*
|
|
4
|
+
* Used by `study run --wait` to wake up the poll loop as soon as a tester
|
|
5
|
+
* status / interaction event arrives, instead of waiting for the next poll
|
|
6
|
+
* tick. The canonical truth source remains `GET /studies/{id}` — SSE here
|
|
7
|
+
* only shortens the latency between a backend event and the next status
|
|
8
|
+
* fetch; the poll fallback still runs on a slow timer in case events are
|
|
9
|
+
* missed.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort:
|
|
12
|
+
* - Mints a short-lived stream token (POST /auth/stream-token).
|
|
13
|
+
* - Opens `GET /studies/{id}/events?token=…` via `fetch` and streams the
|
|
14
|
+
* response body.
|
|
15
|
+
* - Returns (silently exits the iterator) on any failure — token mint
|
|
16
|
+
* 503 (server not configured), endpoint 503 (broker offline on this
|
|
17
|
+
* instance), network error, abort. The caller's polling rhythm is the
|
|
18
|
+
* safety net; we never raise.
|
|
19
|
+
*
|
|
20
|
+
* Stream-token TTL is 1h on the backend. For runs longer than that the
|
|
21
|
+
* fetch will end (server closes); the caller falls back to pure polling
|
|
22
|
+
* for the remainder.
|
|
23
|
+
*/
|
|
24
|
+
import { ApiClient, ApiError } from "./api-client.js";
|
|
25
|
+
export interface StudyEvent {
|
|
26
|
+
type: string;
|
|
27
|
+
study_id: string;
|
|
28
|
+
iteration_id?: string | null;
|
|
29
|
+
tester_id?: string | null;
|
|
30
|
+
interaction_id?: string | null;
|
|
31
|
+
frame_id?: string | null;
|
|
32
|
+
frame_version_id?: string | null;
|
|
33
|
+
tester_status?: string | null;
|
|
34
|
+
ts: string;
|
|
35
|
+
seq: number;
|
|
36
|
+
payload?: unknown;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Async generator that yields parsed StudyEvents from the backend SSE
|
|
40
|
+
* stream. Exits silently (without throwing) on failure or abort — callers
|
|
41
|
+
* MUST have a polling fallback that drives correctness.
|
|
42
|
+
*/
|
|
43
|
+
export declare function streamStudyEvents(client: ApiClient, studyId: string, signal: AbortSignal): AsyncGenerator<StudyEvent, void, void>;
|
|
44
|
+
/** Type narrower used by callers to skip the synthetic LAG marker. */
|
|
45
|
+
export declare function isLagEvent(event: StudyEvent): boolean;
|
|
46
|
+
export { ApiError };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE consumer for the backend's per-study event stream.
|
|
3
|
+
*
|
|
4
|
+
* Used by `study run --wait` to wake up the poll loop as soon as a tester
|
|
5
|
+
* status / interaction event arrives, instead of waiting for the next poll
|
|
6
|
+
* tick. The canonical truth source remains `GET /studies/{id}` — SSE here
|
|
7
|
+
* only shortens the latency between a backend event and the next status
|
|
8
|
+
* fetch; the poll fallback still runs on a slow timer in case events are
|
|
9
|
+
* missed.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort:
|
|
12
|
+
* - Mints a short-lived stream token (POST /auth/stream-token).
|
|
13
|
+
* - Opens `GET /studies/{id}/events?token=…` via `fetch` and streams the
|
|
14
|
+
* response body.
|
|
15
|
+
* - Returns (silently exits the iterator) on any failure — token mint
|
|
16
|
+
* 503 (server not configured), endpoint 503 (broker offline on this
|
|
17
|
+
* instance), network error, abort. The caller's polling rhythm is the
|
|
18
|
+
* safety net; we never raise.
|
|
19
|
+
*
|
|
20
|
+
* Stream-token TTL is 1h on the backend. For runs longer than that the
|
|
21
|
+
* fetch will end (server closes); the caller falls back to pure polling
|
|
22
|
+
* for the remainder.
|
|
23
|
+
*/
|
|
24
|
+
import { ApiError } from "./api-client.js";
|
|
25
|
+
/**
|
|
26
|
+
* Async generator that yields parsed StudyEvents from the backend SSE
|
|
27
|
+
* stream. Exits silently (without throwing) on failure or abort — callers
|
|
28
|
+
* MUST have a polling fallback that drives correctness.
|
|
29
|
+
*/
|
|
30
|
+
export async function* streamStudyEvents(client, studyId, signal) {
|
|
31
|
+
let token;
|
|
32
|
+
try {
|
|
33
|
+
const minted = await client.post("/auth/stream-token", { scope: `study:${studyId}:read` });
|
|
34
|
+
token = minted.token;
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// 503 = server not configured for realtime; anything else = transient.
|
|
38
|
+
// Either way, fall back silently.
|
|
39
|
+
void err;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const url = `${client.apiBase}/studies/${studyId}/events?token=${encodeURIComponent(token)}`;
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetch(url, {
|
|
46
|
+
headers: { Accept: "text/event-stream" },
|
|
47
|
+
signal,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
void err;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok || response.body === null) {
|
|
55
|
+
// 503 = broker not running on this instance; anything else = unexpected.
|
|
56
|
+
// Caller's polling rhythm handles it.
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const reader = response.body.getReader();
|
|
60
|
+
const decoder = new TextDecoder("utf-8");
|
|
61
|
+
let buffer = "";
|
|
62
|
+
try {
|
|
63
|
+
while (true) {
|
|
64
|
+
const { value, done } = await reader.read();
|
|
65
|
+
if (done)
|
|
66
|
+
return;
|
|
67
|
+
buffer += decoder.decode(value, { stream: true });
|
|
68
|
+
// SSE framing: events are separated by blank lines. A line beginning
|
|
69
|
+
// with ":" is a comment (heartbeat) and is ignored.
|
|
70
|
+
let sep = buffer.indexOf("\n\n");
|
|
71
|
+
while (sep !== -1) {
|
|
72
|
+
const block = buffer.slice(0, sep);
|
|
73
|
+
buffer = buffer.slice(sep + 2);
|
|
74
|
+
const event = parseEventBlock(block);
|
|
75
|
+
if (event !== null)
|
|
76
|
+
yield event;
|
|
77
|
+
sep = buffer.indexOf("\n\n");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
void err;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
// Best-effort cancel; ignore errors if the connection is already gone.
|
|
87
|
+
try {
|
|
88
|
+
await reader.cancel();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function parseEventBlock(block) {
|
|
96
|
+
// Skip comment-only blocks (e.g. `: ping - …`).
|
|
97
|
+
const lines = block.split(/\r?\n/);
|
|
98
|
+
let data = null;
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
if (line.startsWith(":") || line.length === 0)
|
|
101
|
+
continue;
|
|
102
|
+
if (line.startsWith("data:")) {
|
|
103
|
+
// Per SSE spec the value starts after the optional single space.
|
|
104
|
+
const value = line.slice(5).replace(/^ /, "");
|
|
105
|
+
data = data === null ? value : data + "\n" + value;
|
|
106
|
+
}
|
|
107
|
+
// We intentionally ignore `event:` and `id:` here — the type and seq
|
|
108
|
+
// are also present inside the JSON payload, which is the canonical
|
|
109
|
+
// form. Reading them from the framing too would just be redundant.
|
|
110
|
+
}
|
|
111
|
+
if (data === null)
|
|
112
|
+
return null;
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(data);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Type narrower used by callers to skip the synthetic LAG marker. */
|
|
121
|
+
export function isLagEvent(event) {
|
|
122
|
+
return event.type === "stream.lag";
|
|
123
|
+
}
|
|
124
|
+
// Re-export ApiError so callers don't need a separate import path when
|
|
125
|
+
// branching on whether the stream is available.
|
|
126
|
+
export { ApiError };
|
package/dist/lib/types.d.ts
CHANGED