@alexkroman1/aai-cli 9.2.0 → 10.0.1

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.
Files changed (81) hide show
  1. package/dist/{_bundler-CDuYl5Gb.mjs → _bundler-B31MqYaD.mjs} +7 -33
  2. package/dist/_bundler.d.ts +3 -41
  3. package/dist/{_dev-server-BAR8Ya43.mjs → _dev-server-Bo5dSBzQ.mjs} +140 -44
  4. package/dist/_dev-server.d.ts +1 -8
  5. package/dist/_dev-vite-config.d.ts +21 -0
  6. package/dist/{_init-BNotRgV1.mjs → _init-BURbdHNM.mjs} +1 -1
  7. package/dist/{_slug-api--ZBmIGH-.mjs → _slug-api-CfGQq1Tt.mjs} +1 -1
  8. package/dist/{_templates-4WcKOjS5.mjs → _templates-0Zr2z6yA.mjs} +37 -2
  9. package/dist/_templates.d.ts +29 -0
  10. package/dist/{build-DvDdSwAS.mjs → build-DKC2e9o_.mjs} +1 -2
  11. package/dist/cli.mjs +46 -22
  12. package/dist/{delete-8pjH8n8n.mjs → delete-X_CRgCf6.mjs} +1 -1
  13. package/dist/{deploy-CjU3e-3P.mjs → deploy-CchW5pmw.mjs} +2 -2
  14. package/dist/{dev-dFzdvwa7.mjs → dev-BfVl1uBA.mjs} +1 -1
  15. package/dist/{init-DK7GpIF9.mjs → init-BRfoc2EK.mjs} +2 -2
  16. package/dist/{logs-D2Yhqe2w.mjs → logs-hWnJ66Bl.mjs} +2 -2
  17. package/dist/scaffold/CLAUDE.md +162 -54
  18. package/dist/scaffold/package.json +4 -6
  19. package/dist/scaffold/server.mjs +12 -23
  20. package/dist/{secret-CwgDLpSy.mjs → secret-B-3T5tvr.mjs} +2 -2
  21. package/dist/{studio-D3-HWIfk.mjs → studio-wVWNLREn.mjs} +4 -3
  22. package/dist/templates/call-audit/agent.eval.test.ts +1 -1
  23. package/dist/templates/call-audit/agent.test.ts +80 -8
  24. package/dist/templates/call-audit/client.tsx +87 -3
  25. package/dist/templates/call-audit/workflows/audit.ts +52 -32
  26. package/dist/templates/call-audit/workflows/ingest.ts +15 -15
  27. package/dist/templates/call-audit/workflows/media.ts +1 -1
  28. package/dist/templates/call-audit/workflows/summarize.ts +0 -4
  29. package/dist/templates/link-digest/agent.eval.test.ts +4 -2
  30. package/dist/templates/link-digest/agent.test.ts +25 -5
  31. package/dist/templates/link-digest/client.tsx +65 -7
  32. package/dist/templates/link-digest/workflows/digest.ts +39 -29
  33. package/dist/templates/podcast-digest/agent.eval.test.ts +5 -5
  34. package/dist/templates/podcast-digest/agent.test.ts +108 -2
  35. package/dist/templates/podcast-digest/client.tsx +89 -8
  36. package/dist/templates/podcast-digest/workflows/digest.ts +67 -48
  37. package/dist/templates/podcast-digest/workflows/feeds.ts +1 -4
  38. package/dist/templates/podcast-digest/workflows/slack.ts +5 -6
  39. package/dist/templates/recap-workflow/agent.eval.test.ts +72 -8
  40. package/dist/templates/recap-workflow/agent.test.ts +323 -83
  41. package/dist/templates/recap-workflow/shared.ts +4 -3
  42. package/dist/templates/recap-workflow/workflows/recap.ts +322 -86
  43. package/dist/templates/recap-workflow/workflows/tokens.ts +21 -0
  44. package/dist/templates/redline/agent.eval.test.ts +1 -1
  45. package/dist/templates/redline/agent.test.ts +1 -1
  46. package/dist/templates/redline/client.tsx +83 -3
  47. package/dist/templates/redline/workflows/redline.ts +26 -23
  48. package/dist/templates/research-workflow/agent.eval.test.ts +9 -8
  49. package/dist/templates/research-workflow/agent.test.ts +32 -6
  50. package/dist/templates/research-workflow/agent.ts +4 -4
  51. package/dist/templates/research-workflow/shared.ts +4 -3
  52. package/dist/templates/research-workflow/workflows/prompts.ts +1 -1
  53. package/dist/templates/research-workflow/workflows/research.ts +35 -38
  54. package/dist/templates/spoken-summary/agent.eval.test.ts +3 -3
  55. package/dist/templates/spoken-summary/agent.test.ts +10 -8
  56. package/dist/templates/spoken-summary/client.tsx +72 -3
  57. package/dist/templates/spoken-summary/workflows/summarize.ts +25 -21
  58. package/dist/templates/spoken-summary/workflows/transcribe.ts +15 -18
  59. package/dist/templates/transcription-workflow/agent.eval.test.ts +1 -1
  60. package/dist/templates/transcription-workflow/agent.test.ts +626 -14
  61. package/dist/templates/transcription-workflow/agent.ts +6 -4
  62. package/dist/templates/transcription-workflow/client.tsx +106 -308
  63. package/dist/templates/transcription-workflow/recover.test.ts +141 -0
  64. package/dist/templates/transcription-workflow/recover.ts +130 -0
  65. package/dist/templates/transcription-workflow/run-panel.tsx +203 -0
  66. package/dist/templates/transcription-workflow/total-latency.tsx +144 -0
  67. package/dist/templates/transcription-workflow/workflows/batch.ts +21 -24
  68. package/dist/templates/transcription-workflow/workflows/downsample.ts +273 -0
  69. package/dist/templates/transcription-workflow/workflows/normalize.ts +17 -40
  70. package/dist/templates/transcription-workflow/workflows/stream.ts +177 -24
  71. package/dist/templates/transcription-workflow/workflows/transcribe.ts +69 -32
  72. package/dist/templates/transcription-workflow/workflows/wav.ts +15 -4
  73. package/dist/worker-bundler.d.ts +0 -14
  74. package/dist/worker-bundler.mjs +149 -1
  75. package/dist/{workflow-BflATMmU.mjs → workflow-BBu5oEDw.mjs} +84 -10
  76. package/dist/workflow.d.ts +1 -0
  77. package/package.json +4 -6
  78. package/dist/_workflow-scan.d.ts +0 -108
  79. package/dist/worker-bundler-CCVEDjm1.mjs +0 -650
  80. package/dist/workflow-bundler.d.ts +0 -138
  81. package/dist/{_studio-CKrsixd0.mjs → _studio-B1waXMuR.mjs} +1 -1
@@ -17,10 +17,12 @@
17
17
  * `aai secret put ASSEMBLYAI_API_KEY` once deployed. `requiredEnv` below is
18
18
  * what makes a deploy check for it rather than letting the first run find out.
19
19
  * A step reads it with `requireStepEnv`; see `@alexkroman1/aai/step`.
20
- * - **A `DATABASE_URL`** a secret when deployed, `.env` under `aai dev` — and
21
- * the platform provisions none, so it is a Postgres you bring. REQUIRED here,
22
- * unlike most workflow apps: runs are durable on the platform either way, but
23
- * an UPLOAD's record is a row, so the form below refuses by name without one.
20
+ * - **No database.** This used to ask for a `DATABASE_URL` and no longer does:
21
+ * an upload's record is a platform row and its bytes are platform storage, so
22
+ * the form below and every step that reads back what it stored work on a
23
+ * deployed app with nothing provisioned and under `aai dev` they go in the
24
+ * project's `.workflow-data` directory beside the runs. Bring a database only
25
+ * for data of your OWN that has to outlive a run.
24
26
  * - **ffmpeg, under `aai dev` only.** A deployed guest's image installs it; on a
25
27
  * laptop it is whatever is on `PATH` (or `AAI_FFMPEG_PATH`). The `transcribe`
26
28
  * flow needs it for anything that is not already a linear-PCM WAV, because the
@@ -141,45 +141,74 @@
141
141
  * either — `output.elapsedMs` is the RUN's own wall clock, so it starts after the
142
142
  * bytes are stored in two of the three modes and misses the whole upload, which is
143
143
  * most of the wait on a long file over a slow link. Only the browser holds both
144
- * ends, so `useTotalLatency` is a stopwatch here: started by the submit, ticking
145
- * across the upload and the run alike, and frozen the moment the run settles.
144
+ * ends, so `total-latency.tsx` is a stopwatch: `useTotalLatency` is started by
145
+ * the submit, ticks across the upload and the run alike, and freezes the moment
146
+ * the run settles.
146
147
  *
147
148
  * `<TotalLatency>` also prints the SPLIT once the run reports its own elapsed —
148
149
  * before the run and inside it — because the two numbers on screen otherwise
149
150
  * disagree with no way to see why, and their difference is exactly what picking a
150
151
  * mode or unchecking `parallel` moves.
152
+ *
153
+ * ## A reload keeps two of the three runs, and the third CANNOT be kept
154
+ *
155
+ * The run id lives in React state, so a refresh loses it while the fan-out
156
+ * carries on. `key` is the handle that survives that and `recover: true` is what
157
+ * reads it back — and here it is a decision PER MODE rather than per page:
158
+ *
159
+ * - **"After it uploads"** and **"Let the provider do it"** recover. Their input
160
+ * names a recording that is already stored, so a later load adopting the run
161
+ * is adopting something complete: the transcript arrives, the progress log
162
+ * replays, and nobody is asked to send a 600 MB file a second time.
163
+ * - **"While it uploads" does not, and the hook REFUSES the option rather than
164
+ * ignoring it.** That run's input names an upload id this page load minted and
165
+ * is still filling, so a later load could only adopt a run waiting for bytes
166
+ * nobody is sending — and it is worse than useless: `workflows/stream.ts`
167
+ * fails a run whose upload stops growing (`MAX_IDLE_POLLS`), so the reload
168
+ * that "recovered" it would be watching it die. Streaming runs are still in
169
+ * Previous runs below, which is where a run this page cannot hold belongs.
170
+ *
171
+ * The MODE is remembered too, and that is not decoration: without it a reload
172
+ * opens on the default flow while the recovered run sits behind a radio nobody
173
+ * pressed, so the reader sees an empty form and starts a second run — the exact
174
+ * thing the key exists to prevent. The KEY is `useRunKey()`, which owns the
175
+ * minting, the storage and the argument for the key being opaque rather than a
176
+ * `?key=` parameter; `recover.ts` owns the mode, which is this page's own
177
+ * concept, and the validation on the way back out of storage that turning a
178
+ * stored string into a workflow name obliges.
179
+ *
180
+ * Two smaller consequences worth knowing. Both recovering hooks look up on
181
+ * mount, so a load costs two `find` requests on a page that was already reading
182
+ * a run listing — cheap, and the alternative (arming the lookup when a mode is
183
+ * picked) would re-adopt a run the reader had just cleared, because the lookup
184
+ * is deliberately a mount-time act. And `<TotalLatency>` shows nothing for a
185
+ * recovered run: the stopwatch is a browser clock and the browser it was
186
+ * running on is gone, which is more honest than a total measured from the reload.
151
187
  */
152
188
 
153
189
  import "@alexkroman1/aai-ui/styles.css";
154
- import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
155
- import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
156
190
  import {
157
191
  Form,
158
192
  isTerminal,
159
193
  page,
160
194
  SubmitButton,
161
195
  UploadProgressBar,
162
- useWorkflowProgress,
196
+ useRunKey,
163
197
  useWorkflowRuns,
164
198
  useWorkflowStream,
165
199
  useWorkflowSubmit,
166
- WORKFLOW_STATUS_LABELS,
167
200
  WorkflowFields,
168
- WorkflowProgress,
169
- type WorkflowRun,
170
201
  } from "@alexkroman1/aai-ui";
171
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
202
+ import { useEffect, useState } from "react";
172
203
  import type { transcribe } from "./agent.ts";
173
- import { stitchChunks, TRANSCRIPT_STREAM, type TranscriptChunk } from "./workflows/stitch.ts";
174
-
175
- /**
176
- * What a finished run reports.
177
- *
178
- * Derived from the workflow declaration rather than restated `import type` is
179
- * erased, so naming `transcribe` here bundles none of the agent, the SDK, or the
180
- * workflow body into this page.
181
- */
182
- type Transcript = WorkflowOutputOf<typeof transcribe>;
204
+ import { pendingNote, recalledMode, rememberMode } from "./recover.ts";
205
+ // The readouts — one run in flight, and every one before it. Their own module
206
+ // because they are the same whichever of the three hooks produced the run; this
207
+ // file owns the page's shape.
208
+ import { HISTORY_LIMIT, History, RunPanel, type Transcript } from "./run-panel.tsx";
209
+ // The stopwatch and the section that prints it. See its module doc for why the
210
+ // one number a reader wants can only be measured here.
211
+ import { TotalLatency, useTotalLatency } from "./total-latency.tsx";
183
212
 
184
213
  /**
185
214
  * The three workflows this page drives, keyed by the mode that picks one.
@@ -207,7 +236,7 @@ const MODES: readonly { mode: Mode; label: string; note: string }[] = [
207
236
  {
208
237
  mode: "streaming",
209
238
  label: "While it uploads",
210
- note: "Sync API. The run starts first and transcribes each segment as its bytes land, so progress is visible while the file is still moving.",
239
+ note: "Sync API. The run starts first and transcribes each segment as its bytes land, so progress is visible while the file is still moving — but the run is reading the file from this page, so it cannot survive a reload.",
211
240
  },
212
241
  {
213
242
  mode: "classic",
@@ -221,147 +250,51 @@ const MODES: readonly { mode: Mode; label: string; note: string }[] = [
221
250
  },
222
251
  ];
223
252
 
224
- /** Most past runs the history list shows. */
225
- const HISTORY_LIMIT = 10;
226
-
227
- /**
228
- * How often the running stopwatch re-renders.
229
- *
230
- * Under a second, so the displayed seconds turn over promptly rather than up to a
231
- * second late; nothing reads this value, since the elapsed time is measured from
232
- * the clock at render (see {@link useTotalLatency}).
233
- */
234
- const STOPWATCH_TICK_MS = 250;
235
-
236
- /** What {@link useTotalLatency} reports. */
237
- type TotalLatency = {
238
- /**
239
- * Milliseconds since the submit — ticking while the submission is in flight,
240
- * frozen at the finish, and undefined before the first one.
241
- */
242
- elapsedMs: number | undefined;
243
- /** Whether the clock is still running, which is what makes the label honest. */
244
- running: boolean;
245
- /** Start (or restart) the clock. Called from the form's own submit handler. */
246
- start: () => void;
247
- /** Drop it, for a panel that no longer describes the submission it timed. */
248
- clear: () => void;
249
- };
250
-
251
253
  /**
252
- * Wall clock from the submit to the finish, across both waits.
253
- *
254
- * `inFlight` is the submission's own `pending` — true from `submit()` until the run
255
- * reaches a terminal status — so the clock covers the upload, the run, and the
256
- * gap between them, which is the whole of what a reader waits for and is the one
257
- * measurement no server-side number can make.
254
+ * Just the mode names, for the recall to check a stored value against.
258
255
  *
259
- * Two details it would be easy to get wrong:
260
- *
261
- * - **The interval re-renders; it does not accumulate.** The elapsed time is read
262
- * from the clock at render, so a tick the tab throttled or dropped cannot make
263
- * the number lag behind real time.
264
- * - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
265
- * correction (NTP, a laptop waking up) cannot make a transcription look
266
- * instant — or negative.
267
- */
268
- function useTotalLatency(inFlight: boolean): TotalLatency {
269
- const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
270
- const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
271
- // Re-render trigger only — see the doc above.
272
- const [, tick] = useState(0);
273
- // Whether `inFlight` has been seen true since the last `start()`. Without it,
274
- // a start that lands one render before the submission reports itself in flight
275
- // would freeze the clock at zero instead of running it.
276
- const began = useRef(false);
277
-
278
- useEffect(() => {
279
- if (startedAt === undefined || frozenMs !== undefined) return;
280
- if (inFlight) {
281
- began.current = true;
282
- const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
283
- return () => clearInterval(id);
284
- }
285
- // Measured here rather than at render, so the frozen number is the one at the
286
- // moment the run settled rather than whenever this page next drew.
287
- if (began.current) setFrozenMs(performance.now() - startedAt);
288
- }, [startedAt, frozenMs, inFlight]);
289
-
290
- const start = useCallback(() => {
291
- began.current = false;
292
- setFrozenMs(undefined);
293
- setStartedAt(performance.now());
294
- }, []);
295
-
296
- const clear = useCallback(() => {
297
- began.current = false;
298
- setStartedAt(undefined);
299
- setFrozenMs(undefined);
300
- }, []);
301
-
302
- return {
303
- elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
304
- running: startedAt !== undefined && frozenMs === undefined,
305
- start,
306
- clear,
307
- };
308
- }
309
-
310
- /**
311
- * The one number the two bars cannot give: click to transcript.
312
- *
313
- * Rendered above the run panel rather than inside it, because the stretch it
314
- * covers starts before there IS a run — in two of the three modes the run does
315
- * not exist until the upload finishes, so a clock living in the panel would
316
- * appear only after the wait it is supposed to be timing.
317
- *
318
- * `runMs` is the run's own elapsed, once it reports one. The remainder is
319
- * everything the run could not see: storing the file (or, in streaming mode,
320
- * minting the upload id), the `POST` that starts the run, and the poll that
321
- * notices it finished. Clamped at zero, because the two numbers come from two
322
- * different clocks on two different machines and a few milliseconds the wrong way
323
- * would otherwise print a negative.
256
+ * Derived from `MODES` rather than written out again: a fourth flow then joins
257
+ * the recall by joining that list, and the two cannot disagree about what a
258
+ * mode is.
324
259
  */
325
- function TotalLatency({
326
- elapsedMs,
327
- running,
328
- runMs,
329
- }: {
330
- elapsedMs: number | undefined;
331
- running: boolean;
332
- runMs: number | undefined;
333
- }) {
334
- if (elapsedMs === undefined) return null;
335
- const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
336
- return (
337
- <section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
338
- <h2 className="text-sm font-medium uppercase tracking-[1.2px]">
339
- {running ? "Elapsed" : "Total latency"}
340
- </h2>
341
- <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
342
- <span className="text-sm tabular-nums">{formatDuration(elapsedMs)}</span>
343
- {runMs !== undefined && outside !== undefined && (
344
- <span className="text-xs tabular-nums opacity-60">
345
- {formatDuration(outside)} before the run · {formatDuration(runMs)} inside it
346
- </span>
347
- )}
348
- </span>
349
- </section>
350
- );
351
- }
260
+ const MODE_NAMES: readonly Mode[] = MODES.map((option) => option.mode);
352
261
 
353
262
  function TranscriptionDesk() {
354
- const [mode, setMode] = useState<Mode>("streaming");
263
+ // The mode the last submission used, so a recovered run is in front of the
264
+ // reader rather than behind a radio nobody pressed. Lazy, and validated on the
265
+ // way out of storage — see `recalledMode`.
266
+ const [mode, setMode] = useState<Mode>(() => recalledMode(MODE_NAMES, "streaming"));
355
267
  // Whether the browser cuts the recording up and sends the pieces at once. One
356
268
  // piece of state for all three hooks, because it describes the UPLOAD and every
357
269
  // mode has one — see the module doc.
358
270
  const [parallel, setParallel] = useState(true);
271
+ // This tab's handle on its own runs — minted once and remembered, which is
272
+ // what a later load produces to find the run again.
273
+ const key = useRunKey();
274
+ // Did THIS load press Transcribe? A reload cannot have, and it is the only way
275
+ // the page can tell "working on what you just sent" from "picking up where you
276
+ // left off" — the hooks report the run, not who asked for it.
277
+ const [startedHere, setStartedHere] = useState(false);
359
278
  // ALL THREE hooks are called every render, because a hook may not be conditional —
360
279
  // and that costs nothing here: none of them does anything until its `submit` is
361
280
  // called, and `useWorkflowRun` underneath them holds no id until then either.
281
+ //
282
+ // `recover` is a constant `true` on the two that take it rather than
283
+ // `mode === …`: the lookup is a MOUNT-time act, so arming it when a mode is
284
+ // picked would re-adopt a run the reader had just cleared. The streaming hook
285
+ // takes neither half — it refuses `recover` by type, and recording a key it
286
+ // will never read back would be config nothing uses.
362
287
  const streamed = useWorkflowStream<typeof transcribe>(WORKFLOWS.streaming, { parallel });
363
- const stored = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.classic, { parallel });
364
- const batched = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.batch, { parallel });
288
+ const stored = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.classic, {
289
+ parallel,
290
+ key,
291
+ recover: true,
292
+ });
293
+ const batched = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.batch, {
294
+ parallel,
295
+ key,
296
+ recover: true,
297
+ });
365
298
  // The batch flow uploads the same way the classic one does — the id comes from the
366
299
  // store — so it is the SAME hook against a different workflow. Only the streaming
367
300
  // mode needs the other one, because only it needs the id before the bytes.
@@ -394,7 +327,8 @@ function TranscriptionDesk() {
394
327
  <h1 className="text-2xl font-medium">Transcription Desk</h1>
395
328
  <p className="text-sm opacity-70">
396
329
  Upload a WAV recording. It is split into chunks, transcribed chunk by chunk, and stitched
397
- back together by a durable workflow — so you can close this tab and come back to it.
330
+ back together by a durable workflow — which outlives this page, and which two of the three
331
+ flows below can pick up again after a reload.
398
332
  </p>
399
333
  </header>
400
334
 
@@ -422,6 +356,11 @@ function TranscriptionDesk() {
422
356
  <Form
423
357
  onSubmit={(values) => {
424
358
  total.start();
359
+ setStartedHere(true);
360
+ // Written at SUBMIT rather than on the radio, so the remembered mode
361
+ // is always the mode a run exists under — which is the only thing the
362
+ // next load can use it for.
363
+ rememberMode(mode);
425
364
  return submitForm(values);
426
365
  }}
427
366
  error={error}
@@ -442,10 +381,28 @@ function TranscriptionDesk() {
442
381
  runMs={run?.status === "completed" ? run.output.elapsedMs : undefined}
443
382
  />
444
383
 
384
+ {/* One sentence about the wait, and the only place the three modes differ
385
+ in what a reader may DO: `pending` is also true on a reload while the
386
+ run is being looked up by key, which is the stretch where an empty form
387
+ would invite a second upload of the same recording. */}
388
+ {pending && (
389
+ <p className="text-sm opacity-70">
390
+ {pendingNote({
391
+ recoverable: mode !== "streaming",
392
+ startedHere,
393
+ found: run !== undefined,
394
+ })}
395
+ </p>
396
+ )}
397
+
445
398
  {run && (
446
399
  <RunPanel
447
400
  run={run}
448
401
  onClear={() => {
402
+ // A recovered run is dismissed as deliberately as one this load
403
+ // started: the lookup is a mount-time act, so `reset()` is not
404
+ // undone by a second one and Clear really does clear.
405
+ setStartedHere(false);
449
406
  reset();
450
407
  total.clear();
451
408
  }}
@@ -550,163 +507,4 @@ function UploadPicker({
550
507
  );
551
508
  }
552
509
 
553
- /**
554
- * Every recent run, newest first, with its transcript one click away.
555
- *
556
- * This is what a durable workflow with an HTTP API is FOR, and the page used to
557
- * squander it: a run id is the whole handle — no session, no cookie — so
558
- * `GET /workflows/runs` can answer "what has this desk transcribed" for any tab,
559
- * any machine, days later. What stood here instead was a text box asking the
560
- * reader to paste an id they would have had to write down, which is the same
561
- * information behind a worse door.
562
- */
563
- function History({
564
- runs,
565
- error,
566
- openId,
567
- onOpen,
568
- }: {
569
- runs: WorkflowRun<Transcript>[];
570
- error: string | undefined;
571
- openId: string | undefined;
572
- onOpen: (runId: string) => void;
573
- }) {
574
- return (
575
- <section className="flex flex-col gap-3 border-t pt-6">
576
- <h2 className="text-sm font-medium uppercase tracking-[1.2px]">Previous runs</h2>
577
- {error !== undefined && <p className="text-sm text-red-600">{error}</p>}
578
- {runs.length === 0 && error === undefined && (
579
- <p className="text-sm opacity-60">Nothing transcribed yet.</p>
580
- )}
581
- <ul className="flex flex-col">
582
- {runs.map((entry) => (
583
- <li key={entry.runId} className="border-b last:border-b-0">
584
- <button
585
- type="button"
586
- onClick={() => onOpen(entry.runId)}
587
- className="flex w-full items-baseline justify-between gap-4 py-2 text-left text-sm"
588
- >
589
- <span className="truncate">{title(entry)}</span>
590
- <span className="shrink-0 text-xs opacity-60">{STATUS_LINE[entry.status]}</span>
591
- </button>
592
- {openId === entry.runId && <RunPanel run={entry} />}
593
- </li>
594
- ))}
595
- </ul>
596
- </section>
597
- );
598
- }
599
-
600
- /**
601
- * One line naming a past run.
602
- *
603
- * The FILE where there is one — `mergeTranscript` puts the recording's own name
604
- * in the output for exactly this — falling back to the id, which is all a run
605
- * that failed before it read the upload ever had.
606
- */
607
- function title(run: WorkflowRun<Transcript>): string {
608
- if (run.status === "completed") return run.output.source;
609
- return run.runId;
610
- }
611
-
612
- /** The run's status, its narration, and its transcript once there is one. */
613
- function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: () => void }) {
614
- return (
615
- <section className="flex flex-col gap-3 rounded-md border p-5">
616
- <div className="flex items-baseline justify-between gap-4">
617
- <h2 className="text-sm font-medium uppercase tracking-[1.2px]">
618
- {STATUS_LINE[run.status]}
619
- </h2>
620
- {onClear && (
621
- <button type="button" onClick={onClear} className="text-xs underline opacity-60">
622
- Clear
623
- </button>
624
- )}
625
- </div>
626
-
627
- {/* The run's own narration, oldest first — the complement of `STATUS_LINE`
628
- below, and the reason both exist: the status is `running` for the whole
629
- fan-out, so a sixty-segment recording and a one-segment recording look
630
- identical while they run. These lines come from the run itself
631
- (`report()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
632
- finished run up in the panel below shows how it got there. */}
633
- <WorkflowProgress runId={run.runId} />
634
-
635
- {/* While it runs, the transcript so far. Unguarded on the run's status
636
- beyond this: the component renders nothing until a segment has landed,
637
- and stops the moment there is an `output` to render instead. */}
638
- {!isTerminal(run) && <LiveTranscript runId={run.runId} />}
639
-
640
- {/* Discriminated on `status`, so `output` and `error` are reachable
641
- without a cast — the reason a snapshot is a union rather than a flat
642
- object with optional fields. */}
643
- {run.status === "completed" && (
644
- <>
645
- <p className="text-xs opacity-60">
646
- {run.output.segments} {plural(run.output.segments, "segment")} ·{" "}
647
- {formatDuration(run.output.durationMs)} of audio · took{" "}
648
- {formatDuration(run.output.elapsedMs)} · {run.output.words} words
649
- </p>
650
- <pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
651
- </>
652
- )}
653
- {run.status === "failed" && <p className="text-red-600">{run.error}</p>}
654
- </section>
655
- );
656
- }
657
-
658
- /**
659
- * The transcript as it arrives, stitched from the segments that have landed.
660
- *
661
- * The other half of `<WorkflowProgress>` above it: that one renders what the run
662
- * SAYS about itself, this one renders what it has produced. Both are the same
663
- * mechanism — a run's output stream — separated by the namespace, which is what
664
- * lets this one be typed.
665
- *
666
- * It renders NOTHING until a segment lands, so a page can mount it unguarded:
667
- * before the first chunk there is nothing to say that the progress log is not
668
- * already saying better.
669
- *
670
- * The count is derived from the stitched text rather than summed per chunk,
671
- * because the seams overlap — adding up the segments would over-count every one
672
- * of them by a couple of seconds' worth of words.
673
- */
674
- function LiveTranscript({ runId }: { runId: string }) {
675
- const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
676
- namespace: TRANSCRIPT_STREAM,
677
- });
678
- // Memoized on the ARRAY, which the hook appends to per read: stitching is a
679
- // seam search per segment, and a fan-out re-renders this panel on every
680
- // progress poll whether or not anything arrived.
681
- const transcript = useMemo(() => stitchChunks(progress), [progress]);
682
- if (progress.length === 0) return null;
683
-
684
- // The furthest point reached, not the count: segments land out of order, so
685
- // "6 segments" says nothing about how much of the recording is covered.
686
- const covered = Math.max(...progress.map((chunk) => chunk.endMs));
687
- return (
688
- <div className="flex flex-col gap-2">
689
- <p className="text-xs opacity-60">
690
- {countWords(transcript)} words so far · through {formatDuration(covered)}
691
- </p>
692
- <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
693
- </div>
694
- );
695
- }
696
-
697
- /**
698
- * One line describing where a run has got to.
699
- *
700
- * `WORKFLOW_STATUS_LABELS` is the SDK's neutral map — a `Record` keyed by the
701
- * status union rather than a switch, so a status added upstream is a compile
702
- * error in one place every page inherits, and spreading a complete record cannot
703
- * drop a key. Two of these keys are really this desk's: a page knows what its
704
- * workflow does and the SDK does not.
705
- */
706
- const STATUS_LINE = {
707
- ...WORKFLOW_STATUS_LABELS,
708
- running: "Transcribing…",
709
- completed: "Transcript ready",
710
- };
711
-
712
510
  page({ name: "Transcription Desk", component: TranscriptionDesk });
@@ -0,0 +1,141 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Specs for what a reload remembers — `recover.ts`.
4
+ *
5
+ * This is the testable half of the page's recovery, and the split is worth
6
+ * knowing before adding to either side. What lives HERE is the decisions that
7
+ * are ordinary functions: handing back a mode that may not be a mode any more,
8
+ * and which sentence a reader gets. The KEY is not one of them any more — it is
9
+ * `useRunKey()` (`@alexkroman1/aai-ui`), specced there, over the hook that mints
10
+ * it. What CANNOT live here is the other hook — `useWorkflowSubmit({ key,
11
+ * recover: true })` adopting the key's newest run, once per mount, and never
12
+ * over a run the person has already started — because this package's suites
13
+ * have no DOM (the vitest `include`
14
+ * matches `.test.ts` and not `.test.tsx`, and the scaffold declares no React
15
+ * testing library, so a `client.test.tsx` would be collected by nothing here
16
+ * and would break `aai test` in a scaffolded project). That half is specced in
17
+ * `@alexkroman1/aai-ui`, over the hook itself.
18
+ *
19
+ * Storage is a FAKE rather than jsdom's, for the same reason: `sessionStorage`
20
+ * does not exist in Node, which is also the environment in which every guard in
21
+ * `recover.ts` matters — a page that cannot reach storage still has to render.
22
+ */
23
+
24
+ import { afterEach, describe, expect, test, vi } from "vitest";
25
+ import { pendingNote, recalledMode, rememberMode } from "./recover.ts";
26
+
27
+ /** The modes the page offers, in the page's own order. */
28
+ const MODES = ["streaming", "classic", "batch"] as const;
29
+
30
+ /**
31
+ * Just enough `Storage` for the two recall functions, backed by a map a spec
32
+ * can read.
33
+ *
34
+ * `vi.stubGlobal` rather than an injected parameter, because the guarded
35
+ * `globalThis.sessionStorage?.…` access IS the thing under test — a version
36
+ * taking a store would test a shape the template does not ship.
37
+ */
38
+ function fakeStorage(seed: Record<string, string> = {}) {
39
+ const store = new Map(Object.entries(seed));
40
+ vi.stubGlobal("sessionStorage", {
41
+ getItem: (k: string) => store.get(k) ?? null,
42
+ setItem: (k: string, v: string) => void store.set(k, v),
43
+ });
44
+ return store;
45
+ }
46
+
47
+ /** Storage that THROWS on both halves — Safari private mode, a sandboxed frame. */
48
+ function refusingStorage() {
49
+ vi.stubGlobal("sessionStorage", {
50
+ getItem: () => {
51
+ throw new DOMException("denied");
52
+ },
53
+ setItem: () => {
54
+ throw new DOMException("denied");
55
+ },
56
+ });
57
+ }
58
+
59
+ afterEach(() => {
60
+ // `restoreMocks` covers `vi.spyOn` and `unstubEnvs` covers `vi.stubEnv`;
61
+ // neither covers a stubbed global, so this is the one teardown these specs
62
+ // owe. Without it the first file to stub storage decides every later one.
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ describe("recalledMode", () => {
67
+ test("opens on the mode the last submission used", () => {
68
+ fakeStorage({ "transcription-workflow:mode": "batch" });
69
+ expect(recalledMode(MODES, "streaming")).toBe("batch");
70
+ });
71
+
72
+ test("falls back for a stored value that is not a mode any more", () => {
73
+ // The branch this function exists for. The page turns a mode into a
74
+ // workflow NAME, so an unchecked value here starts a run called `undefined`
75
+ // and answers a 400 nobody typed.
76
+ fakeStorage({ "transcription-workflow:mode": "webhook" });
77
+ expect(recalledMode(MODES, "streaming")).toBe("streaming");
78
+ });
79
+
80
+ test("falls back when nothing has been stored yet", () => {
81
+ fakeStorage();
82
+ expect(recalledMode(MODES, "streaming")).toBe("streaming");
83
+ });
84
+
85
+ test("falls back when storage refuses", () => {
86
+ refusingStorage();
87
+ expect(recalledMode(MODES, "classic")).toBe("classic");
88
+ });
89
+
90
+ test("round-trips what rememberMode wrote", () => {
91
+ fakeStorage();
92
+ rememberMode("classic");
93
+ expect(recalledMode(MODES, "streaming")).toBe("classic");
94
+ });
95
+
96
+ test("rememberMode is silent when storage refuses", () => {
97
+ refusingStorage();
98
+ expect(() => rememberMode("classic")).not.toThrow();
99
+ });
100
+ });
101
+
102
+ describe("pendingNote", () => {
103
+ test("tells a streaming reader to keep the tab open, whoever started the run", () => {
104
+ // First and unconditional: that run is reading the file from this page, so
105
+ // a reload does not orphan it, it ends it. A page promising otherwise in
106
+ // the mode it OPENS in would be the worst copy on the desk.
107
+ const started = pendingNote({ recoverable: false, startedHere: true, found: true });
108
+ expect(started).toMatch(/keep this tab open/i);
109
+ expect(pendingNote({ recoverable: false, startedHere: false, found: false })).toBe(started);
110
+ });
111
+
112
+ test("promises the reload back to whoever pressed the button", () => {
113
+ const note = pendingNote({ recoverable: true, startedHere: true, found: true });
114
+ expect(note).toMatch(/reloading is safe/i);
115
+ });
116
+
117
+ test("says it is LOOKING while the lookup is still out", () => {
118
+ const note = pendingNote({ recoverable: true, startedHere: false, found: false });
119
+ expect(note).toMatch(/looking for/i);
120
+ });
121
+
122
+ test("explains a run the reader did not start, and says not to send it again", () => {
123
+ // The line that stops a second 600 MB upload of the same recording, which
124
+ // is what the key is for.
125
+ const note = pendingNote({ recoverable: true, startedHere: false, found: true });
126
+ expect(note).toMatch(/earlier/i);
127
+ expect(note).toMatch(/no need to send it again/i);
128
+ });
129
+
130
+ test("says something different in each of its four situations", () => {
131
+ const notes = [
132
+ pendingNote({ recoverable: false, startedHere: true, found: true }),
133
+ pendingNote({ recoverable: true, startedHere: true, found: true }),
134
+ pendingNote({ recoverable: true, startedHere: false, found: false }),
135
+ pendingNote({ recoverable: true, startedHere: false, found: true }),
136
+ ];
137
+ // A branch that duplicates its neighbour's sentence is a branch nobody can
138
+ // see, and the four are the whole of what this page says about the wait.
139
+ expect(new Set(notes).size).toBe(notes.length);
140
+ });
141
+ });