@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 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
- while (true) {
122
- const study = await client.get(`/studies/${opts.studyId}`, undefined, { timeout: 60_000 });
123
- const isMedia = isMediaModality(study.modality);
124
- let iterations = study.iterations;
125
- if (opts.iterationId) {
126
- iterations = (iterations ?? []).filter((it) => it.id === opts.iterationId);
127
- }
128
- const rows = flattenTesterStatuses(iterations, opts.testerIds);
129
- const total = rows.length;
130
- const done = rows.filter((r) => TERMINAL_STATUSES.has(r.status)).length;
131
- const errored = rows.filter((r) => r.status === "errored" || r.status === "failed").length;
132
- const summary = `${done}/${total} done${errored > 0 ? `, ${errored} errored/failed` : ""}`;
133
- if (!opts.quiet && summary !== lastReported) {
134
- process.stderr.write(` ${summary}\n`);
135
- lastReported = summary;
136
- }
137
- if (total > 0 && done === total) {
138
- return { rows, isMedia };
139
- }
140
- if (Date.now() - start > opts.timeoutMs) {
141
- throw new WaitTimeoutError(`Timed out after ${Math.round(opts.timeoutMs / 1000)}s waiting for simulations. ` +
142
- `${done}/${total} done. Run \`ish study poll --study ${opts.studyId}\` to check status.`, {
143
- study_id: opts.studyId,
144
- ...(opts.iterationId && { iteration_id: opts.iterationId }),
145
- timeout_seconds: Math.round(opts.timeoutMs / 1000),
146
- done,
147
- total,
148
- pending: total - done,
149
- rows,
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
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
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=private.
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: "private",
222
+ visibility: "workspace",
222
223
  type: "ai",
223
224
  limit: "1",
224
225
  offset: "0",
@@ -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>;
@@ -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|public)");
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|private\`
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 };
@@ -82,7 +82,6 @@ export interface Study {
82
82
  source_study_id?: string;
83
83
  assignments?: Assignment[];
84
84
  interview_questions?: InterviewQuestion[];
85
- frames?: unknown[];
86
85
  iterations?: Iteration[];
87
86
  created_at: string;
88
87
  updated_at: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {