@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
@@ -0,0 +1,130 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * What a reload has to remember BESIDES the key: which MODE started the run.
4
+ *
5
+ * A `runId` names a run for as long as something holds it, and the page holds it
6
+ * in React state — so a refresh loses it while the fan-out carries on
7
+ * transcribing. `useWorkflowSubmit({ key, recover: true })` is the fix; the key
8
+ * itself is `useRunKey()` (`@alexkroman1/aai-ui`), which owns the minting, the
9
+ * storage and the argument for both. What is left here is the value that is
10
+ * this PAGE's own concept — the mode — plus the sentence the page says while a
11
+ * run it did not start is arriving.
12
+ *
13
+ * It is its own module for two reasons, and only the second is about keeping
14
+ * `client.tsx` down to the page's shape:
15
+ *
16
+ * - **A template's spec is what makes its exemplar code true**, and none of this
17
+ * can be tested from a `client.tsx`. That file ends in a `page()` call and
18
+ * imports a stylesheet, and this package's suites have no DOM at all: the
19
+ * vitest `include` matches `.test.ts` and not `.test.tsx`, and the scaffold
20
+ * declares no React testing library — so a `client.test.tsx` would be
21
+ * collected by nothing here AND would break `aai test` in a scaffolded
22
+ * project. Here the decisions are ordinary functions, so `recover.test.ts`
23
+ * pins them; what only a DOM can show (that the hook adopts the run, once,
24
+ * and never over a submit) is `aai-ui`'s own suite.
25
+ * - The mode recall has a REAL branch in it: what comes back out of storage is
26
+ * an untrusted string, and a page that trusted it would index `WORKFLOWS`
27
+ * with it and start a run by the name `undefined` — a 400 from a value nobody
28
+ * typed.
29
+ *
30
+ * ## The two values keep the same lifetime, and it is not a coincidence
31
+ *
32
+ * The mode lives in `sessionStorage`, which is the store `useRunKey()` defaults
33
+ * to and the same lifetime as the SDK's own upload recall (`useWorkflowSubmit`
34
+ * remembers the id it minted, so picking the same file again sends only the
35
+ * windows that did not land). All three halves of a reload therefore make the
36
+ * same promise: a mode remembered longer than the key that finds the run would
37
+ * open the desk on a flow with nothing behind it.
38
+ */
39
+
40
+ /** Where the mode that last submitted lives between loads. */
41
+ const MODE_STORAGE = "transcription-workflow:mode";
42
+
43
+ /**
44
+ * Remember which flow the reader picked, so the next load shows the run it
45
+ * started.
46
+ *
47
+ * Without this the recovery is half-done in a way that reads as broken: the
48
+ * mode resets to the default on a reload, so a recovered classic run sits
49
+ * behind a radio nobody pressed while the page in front of the reader is an
50
+ * empty form — which is the "start a second run" invitation the key exists to
51
+ * remove.
52
+ *
53
+ * @param mode - The mode that is about to submit.
54
+ */
55
+ export function rememberMode(mode: string): void {
56
+ try {
57
+ globalThis.sessionStorage?.setItem(MODE_STORAGE, mode);
58
+ } catch {
59
+ // A desk that cannot remember its mode still transcribes; it just opens on
60
+ // the default next time.
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The mode the last load submitted with, if it is still one of the modes.
66
+ *
67
+ * The validation is the point. Storage hands back a string this page wrote
68
+ * SOME version ago — a renamed mode, a hand-edited value, a key another app on
69
+ * the origin happens to share — and the page turns a mode into a workflow NAME.
70
+ * An unchecked value would start a run called `undefined` and answer a 400
71
+ * nobody can explain, so anything not in `valid` falls back.
72
+ *
73
+ * @param valid - The modes this page offers, which is the page's own list
74
+ * rather than a copy of it.
75
+ * @param fallback - The mode to open on when there is nothing to recall.
76
+ * @returns One of `valid`, always.
77
+ */
78
+ export function recalledMode<M extends string>(valid: readonly M[], fallback: M): M {
79
+ try {
80
+ const stored = globalThis.sessionStorage?.getItem(MODE_STORAGE);
81
+ // `find` rather than a cast plus `includes`: the narrowing is then the
82
+ // lookup's own, so there is nothing to keep in step by hand.
83
+ return valid.find((mode) => mode === stored) ?? fallback;
84
+ } catch {
85
+ return fallback;
86
+ }
87
+ }
88
+
89
+ /** What {@link pendingNote} needs to know about the run in flight. */
90
+ export type PendingNoteInput = {
91
+ /**
92
+ * Whether this flow's run can be picked up after a reload at all.
93
+ *
94
+ * False for the streaming flow, and the sentence has to say so — see
95
+ * {@link pendingNote}.
96
+ */
97
+ recoverable: boolean;
98
+ /** Whether THIS load pressed Transcribe. A reload cannot have. */
99
+ startedHere: boolean;
100
+ /** Whether a run has arrived yet, which on a reload means the lookup landed. */
101
+ found: boolean;
102
+ };
103
+
104
+ /**
105
+ * What the desk says while something is in flight — four situations, one line
106
+ * each.
107
+ *
108
+ * The one that earns this function is the first: the streaming flow's run reads
109
+ * the recording as this page sends it, so a reload does not orphan that run, it
110
+ * ENDS it — `workflows/stream.ts` gives an upload that stops growing five
111
+ * minutes (`MAX_IDLE_POLLS`) before it calls the uploader gone and fails the
112
+ * run. A page telling a reader they can close the tab would be wrong in the
113
+ * mode this desk opens in.
114
+ *
115
+ * The other three are the ordinary recovery copy, and the reload case gets its
116
+ * own words deliberately: somebody who did not press the button is owed an
117
+ * explanation for a transcript appearing in front of them, and it is the line
118
+ * that stops them sending the recording again.
119
+ *
120
+ * @param input - See {@link PendingNoteInput}.
121
+ * @returns One sentence, always.
122
+ */
123
+ export function pendingNote(input: PendingNoteInput): string {
124
+ const { recoverable, startedHere, found } = input;
125
+ if (!recoverable)
126
+ return "Keep this tab open — the run is reading the recording as this page sends it, so a reload ends the run.";
127
+ if (startedHere) return "Reloading is safe — this page will find the run again.";
128
+ if (!found) return "Looking for a transcript this tab started earlier…";
129
+ return "Still transcribing a recording this tab sent earlier — no need to send it again.";
130
+ }
@@ -0,0 +1,203 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * What a run LOOKS like — one in flight, and every one before it.
4
+ *
5
+ * `client.tsx` owns the page's shape: the form, the three hooks, the mode, and
6
+ * which of them is active. This module owns the readouts, which are the same
7
+ * whichever hook produced the run — a `WorkflowRun<Transcript>` is a
8
+ * `WorkflowRun<Transcript>` whether it arrived from `useWorkflowStream`,
9
+ * `useWorkflowSubmit` or the history listing, which is exactly why `<RunPanel>`
10
+ * serves both the live run and an expanded past one.
11
+ *
12
+ * Three components, in the order a reader meets them:
13
+ *
14
+ * - **`<RunPanel>`** — the status, the narration, and the transcript once there
15
+ * is one.
16
+ * - **`<LiveTranscript>`** — the transcript as it ARRIVES, which is what makes a
17
+ * sixty-segment fan-out watchable.
18
+ * - **`<History>`** — every recent run, newest first.
19
+ */
20
+
21
+ import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
22
+ import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
23
+ import {
24
+ isTerminal,
25
+ useWorkflowProgress,
26
+ WORKFLOW_STATUS_LABELS,
27
+ WorkflowProgress,
28
+ type WorkflowRun,
29
+ } from "@alexkroman1/aai-ui";
30
+ import { useMemo } from "react";
31
+ import type { transcribe } from "./agent.ts";
32
+ import { stitchChunks, TRANSCRIPT_STREAM, type TranscriptChunk } from "./workflows/stitch.ts";
33
+
34
+ /**
35
+ * What a finished run reports.
36
+ *
37
+ * Derived from the workflow declaration rather than restated — `import type` is
38
+ * erased, so naming `transcribe` here bundles none of the agent, the SDK, or the
39
+ * workflow body into this page.
40
+ */
41
+ export type Transcript = WorkflowOutputOf<typeof transcribe>;
42
+
43
+ /** Most past runs the history list shows. */
44
+ export const HISTORY_LIMIT = 10;
45
+
46
+ /**
47
+ * One line describing where a run has got to.
48
+ *
49
+ * `WORKFLOW_STATUS_LABELS` is the SDK's neutral map — a `Record` keyed by the
50
+ * status union rather than a switch, so a status added upstream is a compile
51
+ * error in one place every page inherits, and spreading a complete record cannot
52
+ * drop a key. Two of these keys are really this desk's: a page knows what its
53
+ * workflow does and the SDK does not.
54
+ */
55
+ const STATUS_LINE = {
56
+ ...WORKFLOW_STATUS_LABELS,
57
+ running: "Transcribing…",
58
+ completed: "Transcript ready",
59
+ };
60
+
61
+ /**
62
+ * Every recent run, newest first, with its transcript one click away.
63
+ *
64
+ * This is what a durable workflow with an HTTP API is FOR, and the page used to
65
+ * squander it: a run id is the whole handle — no session, no cookie — so
66
+ * `GET /workflows/runs` can answer "what has this desk transcribed" for any tab,
67
+ * any machine, days later. What stood here instead was a text box asking the
68
+ * reader to paste an id they would have had to write down, which is the same
69
+ * information behind a worse door.
70
+ */
71
+ export function History({
72
+ runs,
73
+ error,
74
+ openId,
75
+ onOpen,
76
+ }: {
77
+ runs: WorkflowRun<Transcript>[];
78
+ error: string | undefined;
79
+ openId: string | undefined;
80
+ onOpen: (runId: string) => void;
81
+ }) {
82
+ return (
83
+ <section className="flex flex-col gap-3 border-t pt-6">
84
+ <h2 className="text-sm font-medium uppercase tracking-[1.2px]">Previous runs</h2>
85
+ {error !== undefined && <p className="text-sm text-red-600">{error}</p>}
86
+ {runs.length === 0 && error === undefined && (
87
+ <p className="text-sm opacity-60">Nothing transcribed yet.</p>
88
+ )}
89
+ <ul className="flex flex-col">
90
+ {runs.map((entry) => (
91
+ <li key={entry.runId} className="border-b last:border-b-0">
92
+ <button
93
+ type="button"
94
+ onClick={() => onOpen(entry.runId)}
95
+ className="flex w-full items-baseline justify-between gap-4 py-2 text-left text-sm"
96
+ >
97
+ <span className="truncate">{title(entry)}</span>
98
+ <span className="shrink-0 text-xs opacity-60">{STATUS_LINE[entry.status]}</span>
99
+ </button>
100
+ {openId === entry.runId && <RunPanel run={entry} />}
101
+ </li>
102
+ ))}
103
+ </ul>
104
+ </section>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * One line naming a past run.
110
+ *
111
+ * The FILE where there is one — `mergeTranscript` puts the recording's own name
112
+ * in the output for exactly this — falling back to the id, which is all a run
113
+ * that failed before it read the upload ever had.
114
+ */
115
+ function title(run: WorkflowRun<Transcript>): string {
116
+ if (run.status === "completed") return run.output.source;
117
+ return run.runId;
118
+ }
119
+
120
+ /** The run's status, its narration, and its transcript once there is one. */
121
+ export function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onClear?: () => void }) {
122
+ return (
123
+ <section className="flex flex-col gap-3 rounded-md border p-5">
124
+ <div className="flex items-baseline justify-between gap-4">
125
+ <h2 className="text-sm font-medium uppercase tracking-[1.2px]">
126
+ {STATUS_LINE[run.status]}
127
+ </h2>
128
+ {onClear && (
129
+ <button type="button" onClick={onClear} className="text-xs underline opacity-60">
130
+ Clear
131
+ </button>
132
+ )}
133
+ </div>
134
+
135
+ {/* The run's own narration, oldest first — the complement of `STATUS_LINE`
136
+ above, and the reason both exist: the status is `running` for the whole
137
+ fan-out, so a sixty-segment recording and a one-segment recording look
138
+ identical while they run. These lines come from the run itself
139
+ (`report()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
140
+ finished run up in the panel below shows how it got there. */}
141
+ <WorkflowProgress runId={run.runId} />
142
+
143
+ {/* While it runs, the transcript so far. Unguarded on the run's status
144
+ beyond this: the component renders nothing until a segment has landed,
145
+ and stops the moment there is an `output` to render instead. */}
146
+ {!isTerminal(run) && <LiveTranscript runId={run.runId} />}
147
+
148
+ {/* Discriminated on `status`, so `output` and `error` are reachable
149
+ without a cast — the reason a snapshot is a union rather than a flat
150
+ object with optional fields. */}
151
+ {run.status === "completed" && (
152
+ <>
153
+ <p className="text-xs opacity-60">
154
+ {run.output.segments} {plural(run.output.segments, "segment")} ·{" "}
155
+ {formatDuration(run.output.durationMs)} of audio · took{" "}
156
+ {formatDuration(run.output.elapsedMs)} · {run.output.words} words
157
+ </p>
158
+ <pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
159
+ </>
160
+ )}
161
+ {run.status === "failed" && <p className="text-red-600">{run.error}</p>}
162
+ </section>
163
+ );
164
+ }
165
+
166
+ /**
167
+ * The transcript as it arrives, stitched from the segments that have landed.
168
+ *
169
+ * The other half of `<WorkflowProgress>` above it: that one renders what the run
170
+ * SAYS about itself, this one renders what it has produced. Both are the same
171
+ * mechanism — a run's output stream — separated by the namespace, which is what
172
+ * lets this one be typed.
173
+ *
174
+ * It renders NOTHING until a segment lands, so a page can mount it unguarded:
175
+ * before the first chunk there is nothing to say that the progress log is not
176
+ * already saying better.
177
+ *
178
+ * The count is derived from the stitched text rather than summed per chunk,
179
+ * because the seams overlap — adding up the segments would over-count every one
180
+ * of them by a couple of seconds' worth of words.
181
+ */
182
+ function LiveTranscript({ runId }: { runId: string }) {
183
+ const { progress } = useWorkflowProgress<TranscriptChunk>(runId, {
184
+ namespace: TRANSCRIPT_STREAM,
185
+ });
186
+ // Memoized on the ARRAY, which the hook appends to per read: stitching is a
187
+ // seam search per segment, and a fan-out re-renders this panel on every
188
+ // progress poll whether or not anything arrived.
189
+ const transcript = useMemo(() => stitchChunks(progress), [progress]);
190
+ if (progress.length === 0) return null;
191
+
192
+ // The furthest point reached, not the count: segments land out of order, so
193
+ // "6 segments" says nothing about how much of the recording is covered.
194
+ const covered = Math.max(...progress.map((chunk) => chunk.endMs));
195
+ return (
196
+ <div className="flex flex-col gap-2">
197
+ <p className="text-xs opacity-60">
198
+ {countWords(transcript)} words so far · through {formatDuration(covered)}
199
+ </p>
200
+ <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,144 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Press to transcript, which is the one number neither bar can give.
4
+ *
5
+ * The upload bar and the progress log describe the two stretches separately, and
6
+ * neither answers the question a reader comparing the three modes is actually
7
+ * asking. Nothing on the server can answer it either — `output.elapsedMs` is the
8
+ * RUN's own wall clock, so in the two modes that store the file first it begins
9
+ * after the upload and misses most of the wait. Only the browser holds both
10
+ * ends.
11
+ *
12
+ * Its own module because it is a self-contained instrument — a stopwatch and the
13
+ * one section that prints it — and `client.tsx` is the file a reader goes to for
14
+ * the page's SHAPE.
15
+ */
16
+
17
+ import { formatDuration } from "@alexkroman1/aai/utils";
18
+ import { useCallback, useEffect, useRef, useState } from "react";
19
+
20
+ /**
21
+ * How often the running stopwatch re-renders.
22
+ *
23
+ * Under a second, so the displayed seconds turn over promptly rather than up to a
24
+ * second late; nothing reads this value, since the elapsed time is measured from
25
+ * the clock at render (see {@link useTotalLatency}).
26
+ */
27
+ const STOPWATCH_TICK_MS = 250;
28
+
29
+ /** What {@link useTotalLatency} reports. */
30
+ export type TotalLatency = {
31
+ /**
32
+ * Milliseconds since the submit — ticking while the submission is in flight,
33
+ * frozen at the finish, and undefined before the first one.
34
+ */
35
+ elapsedMs: number | undefined;
36
+ /** Whether the clock is still running, which is what makes the label honest. */
37
+ running: boolean;
38
+ /** Start (or restart) the clock. Called from the form's own submit handler. */
39
+ start: () => void;
40
+ /** Drop it, for a panel that no longer describes the submission it timed. */
41
+ clear: () => void;
42
+ };
43
+
44
+ /**
45
+ * Wall clock from the submit to the finish, across both waits.
46
+ *
47
+ * `inFlight` is the submission's own `pending` — true from `submit()` until the run
48
+ * reaches a terminal status — so the clock covers the upload, the run, and the
49
+ * gap between them, which is the whole of what a reader waits for and is the one
50
+ * measurement no server-side number can make.
51
+ *
52
+ * Two details it would be easy to get wrong:
53
+ *
54
+ * - **The interval re-renders; it does not accumulate.** The elapsed time is read
55
+ * from the clock at render, so a tick the tab throttled or dropped cannot make
56
+ * the number lag behind real time.
57
+ * - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
58
+ * correction (NTP, a laptop waking up) cannot make a transcription look
59
+ * instant — or negative.
60
+ */
61
+ export function useTotalLatency(inFlight: boolean): TotalLatency {
62
+ const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
63
+ const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
64
+ // Re-render trigger only — see the doc above.
65
+ const [, tick] = useState(0);
66
+ // Whether `inFlight` has been seen true since the last `start()`. Without it,
67
+ // a start that lands one render before the submission reports itself in flight
68
+ // would freeze the clock at zero instead of running it.
69
+ const began = useRef(false);
70
+
71
+ useEffect(() => {
72
+ if (startedAt === undefined || frozenMs !== undefined) return;
73
+ if (inFlight) {
74
+ began.current = true;
75
+ const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
76
+ return () => clearInterval(id);
77
+ }
78
+ // Measured here rather than at render, so the frozen number is the one at the
79
+ // moment the run settled rather than whenever this page next drew.
80
+ if (began.current) setFrozenMs(performance.now() - startedAt);
81
+ }, [startedAt, frozenMs, inFlight]);
82
+
83
+ const start = useCallback(() => {
84
+ began.current = false;
85
+ setFrozenMs(undefined);
86
+ setStartedAt(performance.now());
87
+ }, []);
88
+
89
+ const clear = useCallback(() => {
90
+ began.current = false;
91
+ setStartedAt(undefined);
92
+ setFrozenMs(undefined);
93
+ }, []);
94
+
95
+ return {
96
+ elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
97
+ running: startedAt !== undefined && frozenMs === undefined,
98
+ start,
99
+ clear,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * The one number the two bars cannot give: click to transcript.
105
+ *
106
+ * Rendered above the run panel rather than inside it, because the stretch it
107
+ * covers starts before there IS a run — in two of the three modes the run does
108
+ * not exist until the upload finishes, so a clock living in the panel would
109
+ * appear only after the wait it is supposed to be timing.
110
+ *
111
+ * `runMs` is the run's own elapsed, once it reports one. The remainder is
112
+ * everything the run could not see: storing the file (or, in streaming mode,
113
+ * minting the upload id), the `POST` that starts the run, and the poll that
114
+ * notices it finished. Clamped at zero, because the two numbers come from two
115
+ * different clocks on two different machines and a few milliseconds the wrong way
116
+ * would otherwise print a negative.
117
+ */
118
+ export function TotalLatency({
119
+ elapsedMs,
120
+ running,
121
+ runMs,
122
+ }: {
123
+ elapsedMs: number | undefined;
124
+ running: boolean;
125
+ runMs: number | undefined;
126
+ }) {
127
+ if (elapsedMs === undefined) return null;
128
+ const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
129
+ return (
130
+ <section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
131
+ <h2 className="text-sm font-medium uppercase tracking-[1.2px]">
132
+ {running ? "Elapsed" : "Total latency"}
133
+ </h2>
134
+ <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
135
+ <span className="text-sm tabular-nums">{formatDuration(elapsedMs)}</span>
136
+ {runMs !== undefined && outside !== undefined && (
137
+ <span className="text-xs tabular-nums opacity-60">
138
+ {formatDuration(outside)} before the run · {formatDuration(runMs)} inside it
139
+ </span>
140
+ )}
141
+ </span>
142
+ </section>
143
+ );
144
+ }
@@ -33,10 +33,9 @@
33
33
  *
34
34
  * What stays here is what a dependency cannot decide: how many steps to cut the job
35
35
  * into, and therefore what is journaled and what a retry repeats. That is also
36
- * structural rather than stylistic — the Workflow DevKit's builder transforms
37
- * exactly the files under this `workflows/` directory, so a `"use step"` shipped
38
- * inside the SDK would be transformed by nothing and would run inline with no
39
- * journal and no retry, silently.
36
+ * structural rather than stylistic — only the caller with a `ctx` can open a
37
+ * step, so a step boundary shipped inside the SDK would be no boundary at all:
38
+ * it would run inline, with no journal and no retry, silently.
40
39
  *
41
40
  * ## The one thing that makes this a WORKFLOW rather than a request
42
41
  *
@@ -54,6 +53,7 @@
54
53
  * request rather than two and the value journaled by the last poll IS the result.
55
54
  */
56
55
 
56
+ import type { WorkflowCtx } from "@alexkroman1/aai";
57
57
  import { report, TRANSCRIBE_API, uploadInfo } from "@alexkroman1/aai/step";
58
58
  import {
59
59
  stepTranscribePollClassified,
@@ -61,38 +61,44 @@ import {
61
61
  stepTranscribeUploadClassified,
62
62
  } from "@alexkroman1/aai/step-errors";
63
63
  import { countWords, formatBytes } from "@alexkroman1/aai/utils";
64
- import { sleep } from "workflow";
65
64
  import { startClock, type Transcript } from "./transcribe.ts";
66
65
 
67
66
  /** How long between polls of a submitted job. */
68
- const POLL_INTERVAL = "10s";
67
+ const POLL_INTERVAL_MS = 10_000;
69
68
 
70
69
  /**
71
70
  * Polls before the run gives up on a job.
72
71
  *
73
- * At {@link POLL_INTERVAL} this is an hour, well past what the async API takes for
72
+ * At {@link POLL_INTERVAL_MS} this is an hour, well past what the async API takes for
74
73
  * any recording it accepts. Bounded rather than endless because a job that never
75
74
  * leaves `queued` is a run that would otherwise be replayed forever.
76
75
  */
77
76
  const MAX_POLLS = 360;
78
77
 
79
78
  /** Transcribe a recording through the async API. */
80
- export async function transcribeBatchFlow(input: { recording: string }): Promise<Transcript> {
81
- "use workflow";
82
-
79
+ export async function transcribeBatchFlow(
80
+ input: { recording: string },
81
+ ctx: WorkflowCtx,
82
+ ): Promise<Transcript> {
83
83
  // Both at once: the clock does not depend on the upload, and issuing them
84
84
  // together costs one round trip instead of two before a byte moves. Their issue
85
85
  // order is still decided by this line rather than by which lands first.
86
+ // `maxAttempts: 6` was `uploadToProvider.maxRetries = 5` — five retries after
87
+ // the first, so six in all. More than the default 3 because an upload is the
88
+ // one call here worth another attempt: it moves the whole recording, and a lost
89
+ // connection on a file this size is the expected failure.
86
90
  const [startedAt, { audioUrl }] = await Promise.all([
87
- startClock(),
88
- uploadToProvider(input.recording),
91
+ ctx.step("startClock", () => startClock()),
92
+ ctx.step("uploadToProvider", () => uploadToProvider(input.recording), { maxAttempts: 6 }),
89
93
  ]);
90
- const job = await createJob(audioUrl);
94
+ const job = await ctx.step("createJob", () => createJob(audioUrl));
91
95
 
92
96
  for (let poll = 0; poll < MAX_POLLS; poll += 1) {
93
- const progress = await pollTranscript(input.recording, job.id, startedAt);
97
+ const progress = await ctx.step("pollTranscript", () =>
98
+ pollTranscript(input.recording, job.id, startedAt),
99
+ );
94
100
  if (progress.done) return progress.transcript;
95
- await sleep(POLL_INTERVAL);
101
+ await ctx.sleep(POLL_INTERVAL_MS);
96
102
  }
97
103
  // A plain throw: this is the BODY, where the fatal/retryable distinction has
98
104
  // nothing to apply to — see `stream.ts`'s `abandon` for the same reasoning.
@@ -123,8 +129,6 @@ export async function transcribeBatchFlow(input: { recording: string }): Promise
123
129
  * same reason.
124
130
  */
125
131
  export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
126
- "use step";
127
-
128
132
  const stored = await uploadInfo(uploadId);
129
133
  await report(
130
134
  `Uploading ${stored.name || uploadId} (${formatBytes(stored.size)}) to the async API.`,
@@ -132,13 +136,8 @@ export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: st
132
136
  return await stepTranscribeUploadClassified(uploadId);
133
137
  }
134
138
 
135
- /** Retries beyond the default 3: an upload is the one call here worth another attempt. */
136
- uploadToProvider.maxRetries = 5;
137
-
138
139
  /** Create the transcription job, and answer with the id that outlives this run. */
139
140
  export async function createJob(audioUrl: string): Promise<{ id: string }> {
140
- "use step";
141
-
142
141
  const job = await stepTranscribeSubmitClassified(audioUrl);
143
142
  await report(`Submitted — job ${job.id}.`);
144
143
  return job;
@@ -157,8 +156,6 @@ export async function pollTranscript(
157
156
  id: string,
158
157
  startedAt: number,
159
158
  ): Promise<{ done: false } | { done: true; transcript: Transcript }> {
160
- "use step";
161
-
162
159
  const progress = await stepTranscribePollClassified(id);
163
160
  if (!progress.done) {
164
161
  await report(`Transcript ${id} is ${progress.status}.`);