@alexkroman1/aai-cli 13.2.0 → 14.0.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.
Files changed (152) hide show
  1. package/bin.mjs +1 -1
  2. package/dist/{_agent-BTwZJu4P.mjs → _agent-BzUeqOdj.mjs} +33 -10
  3. package/dist/_agent.d.ts +23 -0
  4. package/dist/{_bundler-C31rlcYz.mjs → _bundler-DolUCMxu.mjs} +9 -8
  5. package/dist/_cli-common.d.ts +45 -6
  6. package/dist/{_config-CmJOFsAP.mjs → _config-DYzC6WMD.mjs} +7 -4
  7. package/dist/_config.d.ts +25 -1
  8. package/dist/_dev-env.d.ts +1 -1
  9. package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-CSMqF8PN.mjs} +28 -30
  10. package/dist/_dev-vite-config.d.ts +3 -3
  11. package/dist/_e2e-test-utils.d.ts +15 -7
  12. package/dist/{_init-BURbdHNM.mjs → _init-CQ8idAwo.mjs} +64 -30
  13. package/dist/{_output-CKkmWs7i.mjs → _output-DBP9Op_d.mjs} +43 -3
  14. package/dist/_output.d.ts +24 -7
  15. package/dist/{_preflight-Co1G9Jww.mjs → _preflight-CcAR577V.mjs} +3 -3
  16. package/dist/_resource-commands.d.ts +9 -9
  17. package/dist/{_server-common-6e0QI4mq.mjs → _server-common-vILJp3it.mjs} +3 -3
  18. package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-CGvc2YSQ.mjs} +19 -10
  19. package/dist/_slug-api.d.ts +19 -0
  20. package/dist/{_studio-B1waXMuR.mjs → _studio-DnR_BqFp.mjs} +9 -5
  21. package/dist/_studio-commands.d.ts +13 -13
  22. package/dist/{_templates-0Zr2z6yA.mjs → _templates-CK4oKoeX.mjs} +3 -3
  23. package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-U3jcil8L.mjs} +5 -4
  24. package/dist/_typecheck-gate.d.ts +3 -1
  25. package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
  26. package/dist/{_utils-B8QmtFhK.mjs → _utils-D5JGcjiW.mjs} +44 -2
  27. package/dist/_utils.d.ts +35 -0
  28. package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
  29. package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-CvpqMpdh.mjs} +19 -27
  30. package/dist/_workflow-determinism.d.ts +3 -3
  31. package/dist/build-Mxk8gWvX.mjs +108 -0
  32. package/dist/build.d.ts +5 -0
  33. package/dist/cli.mjs +172 -116
  34. package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-BJgREAh6.mjs} +4 -4
  35. package/dist/client-bundler.mjs +1 -1
  36. package/dist/{delete-X_CRgCf6.mjs → delete-BdFnbnVR.mjs} +6 -6
  37. package/dist/{deploy-BzZ-r4iM.mjs → deploy-uAJ4NukN.mjs} +14 -14
  38. package/dist/{dev-BDykFvcc.mjs → dev-DApPSaE_.mjs} +5 -5
  39. package/dist/{eval-0qEOdm0l.mjs → eval-BK47A_K5.mjs} +27 -11
  40. package/dist/eval.d.ts +2 -0
  41. package/dist/{init-BtJI306C.mjs → init-DukDxECd.mjs} +13 -21
  42. package/dist/{login-DeUETobb.mjs → login-DaLrPOFg.mjs} +6 -8
  43. package/dist/{logs-hWnJ66Bl.mjs → logs-7BRwq0cG.mjs} +22 -15
  44. package/dist/project-config.mjs +1 -1
  45. package/dist/scaffold/.env.example +17 -1
  46. package/dist/scaffold/CLAUDE.md +84 -56
  47. package/dist/scaffold/package.json +6 -5
  48. package/dist/scaffold/server.mjs +129 -25
  49. package/dist/secret-Cjp912yF.mjs +162 -0
  50. package/dist/secret.d.ts +48 -13
  51. package/dist/{studio-BxAS_FQQ.mjs → studio-CpHlNHUZ.mjs} +15 -17
  52. package/dist/templates/briefing-desk/agent.test.ts +7 -3
  53. package/dist/templates/call-audit/agent.test.ts +25 -9
  54. package/dist/templates/call-audit/agent.ts +2 -2
  55. package/dist/templates/call-audit/client.tsx +40 -58
  56. package/dist/templates/call-audit/workflows/audit.ts +7 -5
  57. package/dist/templates/call-audit/workflows/ingest.ts +7 -7
  58. package/dist/templates/call-audit/workflows/media.ts +12 -37
  59. package/dist/templates/call-audit/workflows/summarize.ts +8 -8
  60. package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
  61. package/dist/templates/dispatch-center/agent.test.ts +7 -7
  62. package/dist/templates/dispatch-center/client.tsx +53 -61
  63. package/dist/templates/dispatch-center/shared.ts +0 -8
  64. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
  65. package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
  66. package/dist/templates/embedded-assets/agent.test.ts +7 -1
  67. package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
  68. package/dist/templates/health-assistant/agent.test.ts +8 -4
  69. package/dist/templates/infocom-adventure/client.tsx +40 -36
  70. package/dist/templates/link-digest/agent.test.ts +10 -4
  71. package/dist/templates/link-digest/agent.ts +2 -2
  72. package/dist/templates/link-digest/client.tsx +42 -36
  73. package/dist/templates/link-digest/workflows/digest.ts +13 -16
  74. package/dist/templates/night-owl/agent.eval.test.ts +4 -2
  75. package/dist/templates/night-owl/agent.test.ts +6 -1
  76. package/dist/templates/night-owl/client.tsx +8 -2
  77. package/dist/templates/pipeline-simple/agent.test.ts +33 -16
  78. package/dist/templates/pizza-ordering/client.tsx +5 -4
  79. package/dist/templates/pizza-ordering/shared.ts +3 -8
  80. package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
  81. package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
  82. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
  83. package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
  84. package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
  85. package/dist/templates/plan-and-execute/agent.test.ts +10 -5
  86. package/dist/templates/plan-and-execute/client.tsx +2 -2
  87. package/dist/templates/podcast-digest/agent.test.ts +33 -14
  88. package/dist/templates/podcast-digest/client.tsx +27 -36
  89. package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
  90. package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
  91. package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
  92. package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
  93. package/dist/templates/recap-workflow/agent.test.ts +49 -32
  94. package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
  95. package/dist/templates/redline/agent.eval.test.ts +5 -8
  96. package/dist/templates/redline/agent.test.ts +8 -2
  97. package/dist/templates/redline/client.tsx +47 -48
  98. package/dist/templates/redline/workflows/redline.ts +17 -17
  99. package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
  100. package/dist/templates/research-workflow/agent.test.ts +17 -8
  101. package/dist/templates/research-workflow/workflows/research.ts +28 -30
  102. package/dist/templates/retail/agent.test.ts +17 -15
  103. package/dist/templates/retail/cancel.ts +7 -6
  104. package/dist/templates/retail/client.tsx +39 -58
  105. package/dist/templates/retail/payment.ts +4 -3
  106. package/dist/templates/retail/pending.test.ts +19 -19
  107. package/dist/templates/retail/registry.test.ts +99 -58
  108. package/dist/templates/retail/store.test.ts +5 -5
  109. package/dist/templates/retail/swap.ts +8 -11
  110. package/dist/templates/simple/agent.test.ts +39 -6
  111. package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
  112. package/dist/templates/solo-rpg/agent.test.ts +12 -12
  113. package/dist/templates/solo-rpg/client.tsx +3 -3
  114. package/dist/templates/spoken-summary/agent.test.ts +21 -12
  115. package/dist/templates/spoken-summary/agent.ts +3 -3
  116. package/dist/templates/spoken-summary/client.tsx +36 -47
  117. package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
  118. package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
  119. package/dist/templates/support-line/client.tsx +4 -3
  120. package/dist/templates/support-line/prompts.ts +0 -2
  121. package/dist/templates/transcription-workflow/agent.test.ts +36 -20
  122. package/dist/templates/transcription-workflow/agent.ts +2 -2
  123. package/dist/templates/transcription-workflow/client.tsx +34 -40
  124. package/dist/templates/transcription-workflow/recover.ts +1 -1
  125. package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
  126. package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
  127. package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
  128. package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
  129. package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
  130. package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
  131. package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
  132. package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
  133. package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
  134. package/dist/templates/travel-concierge/agent.test.ts +15 -7
  135. package/dist/templates/travel-concierge/client.tsx +2 -2
  136. package/dist/templates/travel-concierge/shared.ts +10 -14
  137. package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
  138. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
  139. package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
  140. package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
  141. package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
  142. package/dist/test-D032V2cA.mjs +242 -0
  143. package/dist/test.d.ts +97 -31
  144. package/dist/typecheck.mjs +2 -2
  145. package/dist/worker-bundler.mjs +3 -3
  146. package/dist/{workflow-BBu5oEDw.mjs → workflow-CVioyO3V.mjs} +8 -7
  147. package/package.json +10 -10
  148. package/dist/bin.d.mts +0 -2
  149. package/dist/build-BLlLOrng.mjs +0 -66
  150. package/dist/secret-B-3T5tvr.mjs +0 -61
  151. package/dist/test-CiLab-AA.mjs +0 -182
  152. package/dist/tsdown.config.d.ts +0 -2
@@ -20,9 +20,9 @@
20
20
 
21
21
  import { readdir } from "node:fs/promises";
22
22
  import { tmpdir } from "node:os";
23
- import { readUpload, type UploadRange } from "@alexkroman1/aai/step";
23
+ import { stepReadUpload, type UploadRange } from "@alexkroman1/aai/step";
24
24
  import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
25
- import { createWorkflowCtx } from "@alexkroman1/aai/testing";
25
+ import { createWorkflowContext } from "@alexkroman1/aai/testing";
26
26
  import {
27
27
  installStubReporter,
28
28
  installStubStepFetch,
@@ -41,6 +41,7 @@ import {
41
41
  requestFormat,
42
42
  } from "./workflows/downsample.ts";
43
43
  import { cuttable, heavierThanNormalized, normalizeRecording } from "./workflows/normalize.ts";
44
+ import { stitchChunks, stitchTranscript, TRANSCRIPT_STREAM } from "./workflows/stitch.ts";
44
45
  import {
45
46
  expectedSegments,
46
47
  nextPollDelay,
@@ -54,9 +55,6 @@ import {
54
55
  import {
55
56
  mergeTranscript,
56
57
  splitRecording,
57
- stitchChunks,
58
- stitchTranscript,
59
- TRANSCRIPT_STREAM,
60
58
  type Transcript,
61
59
  transcribeFlow,
62
60
  transcribeSegment,
@@ -88,9 +86,9 @@ const UPLOAD_ID = "upl_test";
88
86
  const STARTED_AT = 1_000_000;
89
87
 
90
88
  /**
91
- * Publish one in-memory upload, the way `createServer` publishes a real store.
89
+ * Publish one in-memory upload, the way `createRuntimeServer` publishes a real store.
92
90
  *
93
- * This is the seam that makes a step testable at all: `readUpload` reads a
91
+ * This is the seam that makes a step testable at all: `stepReadUpload` reads a
94
92
  * process-wide slot rather than dialling anything, so a spec supplies its own
95
93
  * bytes with no server, no database and no HTTP.
96
94
  */
@@ -143,15 +141,19 @@ function wavFile(
143
141
  return head;
144
142
  }
145
143
 
146
- describe("the agent declares its three workflows and nothing else", () => {
144
+ describe("the agent declares its three workflows", () => {
147
145
  test("under the names the REST route resolves them by", () => {
148
146
  // The page starts a run by these strings, so a rename is a runtime 400 rather
149
147
  // than a compile error — which is what makes pinning them worth a test.
150
- expect(Object.keys(agentDef.workflows ?? {})).toEqual([
151
- "transcribe",
152
- "transcribeStream",
153
- "transcribeBatch",
154
- ]);
148
+ // `arrayContaining` rather than an exact key list: a fourth desk of your own
149
+ // is an invited edit and must not redden a test you did not write. These
150
+ // three NAMES stay pinned, deliberately — the page starts a run by each
151
+ // string, so renaming one is a runtime 400 rather than a compile error, and
152
+ // this is the only thing that says so. Rename here and in `client.tsx`
153
+ // together.
154
+ expect(Object.keys(agentDef.workflows ?? {})).toEqual(
155
+ expect.arrayContaining(["transcribe", "transcribeStream", "transcribeBatch"]),
156
+ );
155
157
  expect(agentDef.workflows?.transcribe).toBe(transcribe);
156
158
  expect(agentDef.workflows?.transcribeStream).toBe(transcribeStream);
157
159
  expect(agentDef.workflows?.transcribeBatch).toBe(transcribeBatch);
@@ -168,8 +170,12 @@ describe("the agent declares its three workflows and nothing else", () => {
168
170
  });
169
171
 
170
172
  test("with no tools, because the interface is the page and the API", () => {
171
- // The point of the template: a workflow app needs no conversation. A tool
172
- // reappearing here would mean the voice path had crept back in.
173
+ // A PIN, deliberately, and the one assertion in this file you are expected
174
+ // to edit rather than satisfy. `workflowApp()` declares no session, so a
175
+ // tool has no conversation to be called from — a name appearing here means
176
+ // the voice path crept back in and the tool will never run. If you want a
177
+ // voice agent that STARTS a workflow, `research-workflow` is that template,
178
+ // and this line is what tells you you are on the wrong one.
173
179
  expect(Object.keys(agentDef.tools ?? {})).toEqual([]);
174
180
  });
175
181
 
@@ -890,7 +896,7 @@ describe("transcribeSegment", () => {
890
896
  // What makes the run's answer streamable rather than only its narration: the
891
897
  // page stitches whatever has arrived, so the transcript renders growing
892
898
  // instead of appearing when the last segment does. The reporter is the SDK's
893
- // published slot, which is the same seam `report()` goes through.
899
+ // published slot, which is the same seam `stepReport()` goes through.
894
900
  const reported = installStubReporter();
895
901
  stubProvider();
896
902
 
@@ -956,7 +962,7 @@ describe("transcribeSegment", () => {
956
962
  // `transcribeStreamFlow`'s own `transcribeSegment` call carries the same
957
963
  // budget and is asserted with that flow, not here — this drives only
958
964
  // `transcribeFlow`.
959
- const ctx = createWorkflowCtx({
965
+ const ctx = createWorkflowContext({
960
966
  runSteps: false,
961
967
  results: {
962
968
  normalizeRecording: { recording: UPLOAD_ID, converted: false },
@@ -1108,7 +1114,7 @@ describe("the streaming flow", () => {
1108
1114
 
1109
1115
  test("segmentStored refuses a window that STRADDLES a hole", () => {
1110
1116
  // A run is contiguous, so containment in one is the whole test — and it has to
1111
- // be, because `readUpload` clamps to the run a read starts in. A segment
1117
+ // be, because `stepReadUpload` clamps to the run a read starts in. A segment
1112
1118
  // spanning two runs would come back short and be transcribed as a fragment,
1113
1119
  // which is a wrong transcript rather than a failed one.
1114
1120
  const at = poll(
@@ -1183,12 +1189,12 @@ describe("the streaming flow", () => {
1183
1189
  });
1184
1190
 
1185
1191
  test("a segment reads SHORT rather than failing when its bytes have not landed", async () => {
1186
- // The property the whole flow rests on, and it predates streaming: `readUpload`
1192
+ // The property the whole flow rests on, and it predates streaming: `stepReadUpload`
1187
1193
  // clamps its window to what is stored. So a body that asks slightly early gets
1188
1194
  // what exists — which is why the body checks `end <= size` and can trust the
1189
1195
  // clamp for the final segment of a file that came up short.
1190
1196
  publishPartial(1000, 320_000);
1191
- const slice = await readUpload(UPLOAD_ID, { start: 44, end: 44 + 320_000 });
1197
+ const slice = await stepReadUpload(UPLOAD_ID, { start: 44, end: 44 + 320_000 });
1192
1198
  expect(slice.bytes.length).toBe(1000);
1193
1199
  expect(slice.end).toBe(44 + 1000);
1194
1200
  });
@@ -1348,6 +1354,16 @@ describe("normalizing the recording", () => {
1348
1354
  * later in `splitRecording` with a message about a header; a `timeout`
1349
1355
  * classified as fatal is a run that gives up on work that would have finished.
1350
1356
  */
1357
+ // Point the toolchain at nothing, so this block really is what its doc says:
1358
+ // a unit test that spawns no binary. Without it a developer with ffmpeg
1359
+ // installed runs a different test from CI, which has none — and the one
1360
+ // assertion below that reaches `normalizeRecording` is about a reporter line
1361
+ // emitted BEFORE the spawn. `unstubEnvs` is repo-wide, so there is no teardown.
1362
+ beforeEach(() => {
1363
+ vi.stubEnv("AAI_FFMPEG_PATH", "/nonexistent/ffmpeg");
1364
+ vi.stubEnv("AAI_FFPROBE_PATH", "/nonexistent/ffprobe");
1365
+ });
1366
+
1351
1367
  test("a canonical WAV is cuttable, so the desk converts nothing", () => {
1352
1368
  expect(cuttable(wavFile(MONO_16K, 32_000), 44 + 32_000)).toBe(true);
1353
1369
  });
@@ -40,7 +40,7 @@
40
40
  * `POST /workflows/uploads` (the browser does this for you: `uploads` below is
41
41
  * what makes `<WorkflowFields>` render a file picker, and `useWorkflowSubmit`
42
42
  * stores the file before starting the run), the input carries the returned id,
43
- * and each step reads exactly the window it needs with `readUpload` — which is
43
+ * and each step reads exactly the window it needs with `stepReadUpload` — which is
44
44
  * what keeps sixty steps from moving the same recording sixty times.
45
45
  *
46
46
  * None of that is this template's code. Uploads are the SDK's, for the reason
@@ -118,7 +118,7 @@ export const transcribe = workflow({
118
118
  }),
119
119
  // The one line that makes the form take a file: `<WorkflowFields>` renders a
120
120
  // picker for this property, `useWorkflowSubmit` stores the chosen file, and
121
- // the steps read it back with `readUpload`.
121
+ // the steps read it back with `stepReadUpload`.
122
122
  uploads: ["recording"],
123
123
  run: transcribeFlow,
124
124
  });
@@ -56,7 +56,7 @@
56
56
  * rather than a fourth option. A single request moves a file at one connection's
57
57
  * throughput, which over any distance is a fraction of the link — so the SDK cuts
58
58
  * the file into megabyte-aligned parts and sends four at once. Nothing about the
59
- * workflow changes: the agent reassembles them, `readUpload` reads the same
59
+ * workflow changes: the agent reassembles them, `stepReadUpload` reads the same
60
60
  * windows, and the streaming flow still watches the file grow (what it polls is the
61
61
  * CONTIGUOUS prefix, which is honest whether one connection or four are filling
62
62
  * it).
@@ -83,7 +83,7 @@
83
83
  * - **The page stitches with the RUN's own function.** `stitchChunks` is
84
84
  * `workflows/stitch.ts`, imported by both, so the live text and the stored one
85
85
  * cannot drift into two different transcripts of one recording.
86
- * - **It is a SEPARATE stream from the progress log.** `report()`'s lines go to
86
+ * - **It is a SEPARATE stream from the progress log.** `stepReport()`'s lines go to
87
87
  * the default one, which `<WorkflowProgress>` renders verbatim; objects in
88
88
  * there would come out as `[object Object]` between the sentences.
89
89
  * - **The finished run wins.** Once `output` exists the panel renders that
@@ -153,15 +153,17 @@
153
153
  * ## A reload keeps two of the three runs, and the third CANNOT be kept
154
154
  *
155
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:
156
+ * carries on. A correlation KEY is the handle that survives that, and
157
+ * `useWorkflowSubmit` keeps one and asks for its newest run on mount which
158
+ * here lands differently PER MODE:
158
159
  *
159
160
  * - **"After it uploads"** and **"Let the provider do it"** recover. Their input
160
161
  * names a recording that is already stored, so a later load adopting the run
161
162
  * is adopting something complete: the transcript arrives, the progress log
162
163
  * replays, and nobody is asked to send a 600 MB file a second time.
163
164
  * - **"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
+ * ignoring it** `useWorkflowStream` omits `recover` from its options type
166
+ * and mints no key. That run's input names an upload id this page load minted and
165
167
  * is still filling, so a later load could only adopt a run waiting for bytes
166
168
  * nobody is sending — and it is worse than useless: `workflows/stream.ts`
167
169
  * fails a run whose upload stops growing (`MAX_IDLE_POLLS`), so the reload
@@ -171,10 +173,11 @@
171
173
  * The MODE is remembered too, and that is not decoration: without it a reload
172
174
  * opens on the default flow while the recovered run sits behind a radio nobody
173
175
  * 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
176
+ * thing the key exists to prevent. The KEY is the hook's own — `use-run-key.ts`
177
+ * owns the minting, the storage and the argument for it being opaque rather
178
+ * than a `?key=` parameter, and the two submit hooks here share one because the
179
+ * slot is keyed by the PAGE; `recover.ts` owns the mode, which is this page's
180
+ * own concept, and the validation on the way back out of storage that turning a
178
181
  * stored string into a workflow name obliges.
179
182
  *
180
183
  * Two smaller consequences worth knowing. Both recovering hooks look up on
@@ -190,10 +193,9 @@ import "@alexkroman1/aai-ui/styles.css";
190
193
  import {
191
194
  Form,
192
195
  isTerminal,
193
- page,
196
+ mountPage,
194
197
  SubmitButton,
195
198
  UploadProgressBar,
196
- useRunKey,
197
199
  useWorkflowRuns,
198
200
  useWorkflowStream,
199
201
  useWorkflowSubmit,
@@ -268,38 +270,31 @@ function TranscriptionDesk() {
268
270
  // piece of state for all three hooks, because it describes the UPLOAD and every
269
271
  // mode has one — see the module doc.
270
272
  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);
278
273
  // ALL THREE hooks are called every render, because a hook may not be conditional —
279
274
  // and that costs nothing here: none of them does anything until its `submit` is
280
275
  // called, and `useWorkflowRun` underneath them holds no id until then either.
281
276
  //
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.
277
+ // The two submit hooks recover unconditionally rather than on `mode === …`:
278
+ // the lookup is a MOUNT-time act, so arming it when a mode is picked would
279
+ // re-adopt a run the reader had just cleared. They share ONE key without
280
+ // saying sothe hook's slot is keyed by the page — and `find` is scoped by
281
+ // workflow as well as by key, so the two modes recover separate runs. The
282
+ // streaming hook has neither half: it refuses `recover` by type, and mints no
283
+ // key it would never read back.
287
284
  const streamed = useWorkflowStream<typeof transcribe>(WORKFLOWS.streaming, { 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
- });
285
+ const stored = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.classic, { parallel });
286
+ const batched = useWorkflowSubmit<typeof transcribe>(WORKFLOWS.batch, { parallel });
298
287
  // The batch flow uploads the same way the classic one does — the id comes from the
299
288
  // store — so it is the SAME hook against a different workflow. Only the streaming
300
289
  // mode needs the other one, because only it needs the id before the bytes.
301
290
  const active = mode === "streaming" ? streamed : mode === "batch" ? batched : stored;
302
- const { submitForm, run, upload, pending, error, reset, pauseUpload, resumeUpload } = active;
291
+ // `startedHere` comes off the ACTIVE hook rather than being one flag for the
292
+ // page, and that is a fix rather than a tidy-up: a single piece of page state
293
+ // survived a mode switch, so pressing Transcribe in one mode and then
294
+ // switching made the other mode's panel claim the reader had started its run.
295
+ // Per-hook, switching modes shows the truth about the run being shown.
296
+ const { submitForm, run, upload, pending, error, reset, pauseUpload, resumeUpload, startedHere } =
297
+ active;
303
298
  // History is per WORKFLOW, so the list follows the mode: two flows that produce
304
299
  // the same output are still two different things to have run, and merging them
305
300
  // would put a run under a heading that cannot explain it.
@@ -356,7 +351,6 @@ function TranscriptionDesk() {
356
351
  <Form
357
352
  onSubmit={(values) => {
358
353
  total.start();
359
- setStartedHere(true);
360
354
  // Written at SUBMIT rather than on the radio, so the remembered mode
361
355
  // is always the mode a run exists under — which is the only thing the
362
356
  // next load can use it for.
@@ -376,8 +370,8 @@ function TranscriptionDesk() {
376
370
  </Form>
377
371
 
378
372
  <TotalLatency
379
- elapsedMs={total.elapsedMs}
380
- running={total.running}
373
+ startedAt={total.startedAt}
374
+ frozenMs={total.frozenMs}
381
375
  runMs={run?.status === "completed" ? run.output.elapsedMs : undefined}
382
376
  />
383
377
 
@@ -401,8 +395,8 @@ function TranscriptionDesk() {
401
395
  onClear={() => {
402
396
  // A recovered run is dismissed as deliberately as one this load
403
397
  // 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);
398
+ // undone by a second one and Clear really does clear. `reset()`
399
+ // also clears `startedHere`, which this page used to mirror here.
406
400
  reset();
407
401
  total.clear();
408
402
  }}
@@ -507,4 +501,4 @@ function UploadPicker({
507
501
  );
508
502
  }
509
503
 
510
- page({ name: "Transcription Desk", component: TranscriptionDesk });
504
+ mountPage({ name: "Transcription Desk", component: TranscriptionDesk });
@@ -14,7 +14,7 @@
14
14
  * `client.tsx` down to the page's shape:
15
15
  *
16
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
17
+ * can be tested from a `client.tsx`. That file ends in a `mountPage()` call and
18
18
  * imports a stylesheet, and this package's suites have no DOM at all: the
19
19
  * vitest `include` matches `.test.ts` and not `.test.tsx`, and the scaffold
20
20
  * declares no React testing library — so a `client.test.tsx` would be
@@ -21,6 +21,7 @@
21
21
  import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
22
22
  import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
23
23
  import {
24
+ Facts,
24
25
  isTerminal,
25
26
  useWorkflowProgress,
26
27
  WORKFLOW_STATUS_LABELS,
@@ -136,7 +137,7 @@ export function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onCle
136
137
  above, and the reason both exist: the status is `running` for the whole
137
138
  fan-out, so a sixty-segment recording and a one-segment recording look
138
139
  identical while they run. These lines come from the run itself
139
- (`report()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
140
+ (`stepReport()` in `workflows/transcribe.ts`), and they REPLAY, so looking a
140
141
  finished run up in the panel below shows how it got there. */}
141
142
  <WorkflowProgress runId={run.runId} />
142
143
 
@@ -150,15 +151,25 @@ export function RunPanel({ run, onClear }: { run: WorkflowRun<Transcript>; onCle
150
151
  object with optional fields. */}
151
152
  {run.status === "completed" && (
152
153
  <>
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>
154
+ <Facts
155
+ size="xs"
156
+ items={[
157
+ `${run.output.segments} ${plural(run.output.segments, "segment")}`,
158
+ `${formatDuration(run.output.durationMs)} of audio`,
159
+ `took ${formatDuration(run.output.elapsedMs)}`,
160
+ `${run.output.words} words`,
161
+ ]}
162
+ />
158
163
  <pre className="whitespace-pre-wrap text-sm leading-relaxed">{run.output.transcript}</pre>
159
164
  </>
160
165
  )}
161
- {run.status === "failed" && <p className="text-red-600">{run.error}</p>}
166
+ {/* `role="alert"`, the same contract `<Form>` gives the submit error in
167
+ `client.tsx`: this is the outcome the reader waited minutes for. */}
168
+ {run.status === "failed" && (
169
+ <p role="alert" className="text-red-600">
170
+ {run.error}
171
+ </p>
172
+ )}
162
173
  </section>
163
174
  );
164
175
  }
@@ -186,17 +197,26 @@ function LiveTranscript({ runId }: { runId: string }) {
186
197
  // Memoized on the ARRAY, which the hook appends to per read: stitching is a
187
198
  // seam search per segment, and a fan-out re-renders this panel on every
188
199
  // progress poll whether or not anything arrived.
189
- const transcript = useMemo(() => stitchChunks(progress), [progress]);
200
+ //
201
+ // The word count and the coverage are derived in the SAME memo, not below
202
+ // it: both are functions of `progress` alone, and `countWords` rescans a
203
+ // transcript that reaches ~14k words on a feature-length recording.
204
+ //
205
+ // `covered` is the furthest point reached, not the count: segments land out
206
+ // of order, so "6 segments" says nothing about how much is covered.
207
+ const { transcript, words, covered } = useMemo(() => {
208
+ const stitched = stitchChunks(progress);
209
+ return {
210
+ transcript: stitched,
211
+ words: countWords(stitched),
212
+ covered: progress.reduce((furthest, chunk) => Math.max(furthest, chunk.endMs), 0),
213
+ };
214
+ }, [progress]);
190
215
  if (progress.length === 0) return null;
191
216
 
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
217
  return (
196
218
  <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>
219
+ <Facts size="xs" items={[`${words} words so far`, `through ${formatDuration(covered)}`]} />
200
220
  <pre className="whitespace-pre-wrap text-sm leading-relaxed opacity-80">{transcript}</pre>
201
221
  </div>
202
222
  );
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { formatDuration } from "@alexkroman1/aai/utils";
18
+ import { Facts } from "@alexkroman1/aai-ui";
18
19
  import { useCallback, useEffect, useRef, useState } from "react";
19
20
 
20
21
  /**
@@ -29,12 +30,17 @@ const STOPWATCH_TICK_MS = 250;
29
30
  /** What {@link useTotalLatency} reports. */
30
31
  export type TotalLatency = {
31
32
  /**
32
- * Milliseconds since the submit ticking while the submission is in flight,
33
- * frozen at the finish, and undefined before the first one.
33
+ * The mark the clock started at, or undefined before the first submit.
34
+ *
35
+ * The raw mark rather than an elapsed duration, and that is the load-bearing
36
+ * part: an elapsed number is stale the instant it is computed, so a page
37
+ * holding one has to re-render to keep it true — which is what used to drive
38
+ * the whole tree at 4 Hz. {@link TotalLatency} turns these two marks into a
39
+ * duration, and only that section re-renders.
34
40
  */
35
- elapsedMs: number | undefined;
36
- /** Whether the clock is still running, which is what makes the label honest. */
37
- running: boolean;
41
+ startedAt: number | undefined;
42
+ /** The elapsed time at the moment the run settled, once it has. */
43
+ frozenMs: number | undefined;
38
44
  /** Start (or restart) the clock. Called from the form's own submit handler. */
39
45
  start: () => void;
40
46
  /** Drop it, for a panel that no longer describes the submission it timed. */
@@ -53,7 +59,10 @@ export type TotalLatency = {
53
59
  *
54
60
  * - **The interval re-renders; it does not accumulate.** The elapsed time is read
55
61
  * from the clock at render, so a tick the tab throttled or dropped cannot make
56
- * the number lag behind real time.
62
+ * the number lag behind real time. The interval lives in {@link TotalLatency}
63
+ * rather than here, because a hook called from the page makes its ticks the
64
+ * PAGE's re-renders — the form, the upload bar, the run panel and the whole
65
+ * history list, four times a second, to move one duration string.
57
66
  * - **`performance.now()`, not `Date.now()`.** It is monotonic, so a clock
58
67
  * correction (NTP, a laptop waking up) cannot make a transcription look
59
68
  * instant — or negative.
@@ -61,8 +70,6 @@ export type TotalLatency = {
61
70
  export function useTotalLatency(inFlight: boolean): TotalLatency {
62
71
  const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
63
72
  const [frozenMs, setFrozenMs] = useState<number | undefined>(undefined);
64
- // Re-render trigger only — see the doc above.
65
- const [, tick] = useState(0);
66
73
  // Whether `inFlight` has been seen true since the last `start()`. Without it,
67
74
  // a start that lands one render before the submission reports itself in flight
68
75
  // would freeze the clock at zero instead of running it.
@@ -72,8 +79,7 @@ export function useTotalLatency(inFlight: boolean): TotalLatency {
72
79
  if (startedAt === undefined || frozenMs !== undefined) return;
73
80
  if (inFlight) {
74
81
  began.current = true;
75
- const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
76
- return () => clearInterval(id);
82
+ return;
77
83
  }
78
84
  // Measured here rather than at render, so the frozen number is the one at the
79
85
  // moment the run settled rather than whenever this page next drew.
@@ -93,8 +99,8 @@ export function useTotalLatency(inFlight: boolean): TotalLatency {
93
99
  }, []);
94
100
 
95
101
  return {
96
- elapsedMs: frozenMs ?? (startedAt === undefined ? undefined : performance.now() - startedAt),
97
- running: startedAt !== undefined && frozenMs === undefined,
102
+ startedAt,
103
+ frozenMs,
98
104
  start,
99
105
  clear,
100
106
  };
@@ -114,17 +120,31 @@ export function useTotalLatency(inFlight: boolean): TotalLatency {
114
120
  * notices it finished. Clamped at zero, because the two numbers come from two
115
121
  * different clocks on two different machines and a few milliseconds the wrong way
116
122
  * would otherwise print a negative.
123
+ *
124
+ * The stopwatch interval lives HERE rather than in {@link useTotalLatency}, so a
125
+ * running clock re-renders this section and nothing else.
117
126
  */
118
127
  export function TotalLatency({
119
- elapsedMs,
120
- running,
128
+ startedAt,
129
+ frozenMs,
121
130
  runMs,
122
131
  }: {
123
- elapsedMs: number | undefined;
124
- running: boolean;
132
+ startedAt: number | undefined;
133
+ frozenMs: number | undefined;
125
134
  runMs: number | undefined;
126
135
  }) {
127
- if (elapsedMs === undefined) return null;
136
+ // Re-render trigger only: the elapsed time is read from the clock below, so a
137
+ // tick the tab throttled or dropped cannot make the number lag real time.
138
+ const [, tick] = useState(0);
139
+ const running = startedAt !== undefined && frozenMs === undefined;
140
+ useEffect(() => {
141
+ if (!running) return;
142
+ const id = setInterval(() => tick((n) => n + 1), STOPWATCH_TICK_MS);
143
+ return () => clearInterval(id);
144
+ }, [running]);
145
+
146
+ if (startedAt === undefined) return null;
147
+ const elapsedMs = frozenMs ?? performance.now() - startedAt;
128
148
  const outside = runMs === undefined ? undefined : Math.max(0, elapsedMs - runMs);
129
149
  return (
130
150
  <section className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-md border px-5 py-3">
@@ -134,9 +154,15 @@ export function TotalLatency({
134
154
  <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
135
155
  <span className="text-sm tabular-nums">{formatDuration(elapsedMs)}</span>
136
156
  {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>
157
+ <Facts
158
+ size="xs"
159
+ as="span"
160
+ className="tabular-nums"
161
+ items={[
162
+ `${formatDuration(outside)} before the run`,
163
+ `${formatDuration(runMs)} inside it`,
164
+ ]}
165
+ />
140
166
  )}
141
167
  </span>
142
168
  </section>
@@ -53,12 +53,12 @@
53
53
  * request rather than two and the value journaled by the last poll IS the result.
54
54
  */
55
55
 
56
- import type { WorkflowCtx } from "@alexkroman1/aai";
57
- import { report, TRANSCRIBE_API, uploadInfo } from "@alexkroman1/aai/step";
56
+ import type { WorkflowContext } from "@alexkroman1/aai";
57
+ import { stepReport, stepUploadInfo, TRANSCRIBE_API } from "@alexkroman1/aai/step";
58
58
  import {
59
- stepTranscribePollClassified,
60
- stepTranscribeSubmitClassified,
61
- stepTranscribeUploadClassified,
59
+ stepTranscribePollOrFail,
60
+ stepTranscribeSubmitOrFail,
61
+ stepTranscribeUploadOrFail,
62
62
  } from "@alexkroman1/aai/step-errors";
63
63
  import { countWords, formatBytes } from "@alexkroman1/aai/utils";
64
64
  import type { Transcript } from "./transcribe.ts";
@@ -78,7 +78,7 @@ const MAX_POLLS = 360;
78
78
  /** Transcribe a recording through the async API. */
79
79
  export async function transcribeBatchFlow(
80
80
  input: { recording: string },
81
- ctx: WorkflowCtx,
81
+ ctx: WorkflowContext,
82
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
@@ -122,24 +122,24 @@ export async function transcribeBatchFlow(
122
122
  * far smaller: if it expires before the next step runs, the run fails and a fresh one
123
123
  * re-uploads — which is what would have happened anyway, once, instead of five times.
124
124
  *
125
- * The `Classified` callers on `@alexkroman1/aai/step-errors` are the SDK's own
125
+ * The `OrFail` callers on `@alexkroman1/aai/step-errors` are the SDK's own
126
126
  * `stepTranscribe*` plus `throwStepError` and nothing else, which is what turns the
127
127
  * SDK's `TranscribeError` into the DevKit's verdict: a missing key and a 400 stop, a
128
128
  * 429 waits as long as the service asked. Every step here ends the same way for the
129
129
  * same reason.
130
130
  */
131
131
  export async function uploadToProvider(uploadId: string): Promise<{ audioUrl: string }> {
132
- const stored = await uploadInfo(uploadId);
133
- await report(
132
+ const stored = await stepUploadInfo(uploadId);
133
+ await stepReport(
134
134
  `Uploading ${stored.name || uploadId} (${formatBytes(stored.size)}) to the async API.`,
135
135
  );
136
- return await stepTranscribeUploadClassified(uploadId);
136
+ return await stepTranscribeUploadOrFail(uploadId);
137
137
  }
138
138
 
139
139
  /** Create the transcription job, and answer with the id that outlives this run. */
140
140
  export async function createJob(audioUrl: string): Promise<{ id: string }> {
141
- const job = await stepTranscribeSubmitClassified(audioUrl);
142
- await report(`Submitted — job ${job.id}.`);
141
+ const job = await stepTranscribeSubmitOrFail(audioUrl);
142
+ await stepReport(`Submitted — job ${job.id}.`);
143
143
  return job;
144
144
  }
145
145
 
@@ -156,13 +156,13 @@ export async function pollTranscript(
156
156
  id: string,
157
157
  startedAt: number,
158
158
  ): Promise<{ done: false } | { done: true; transcript: Transcript }> {
159
- const progress = await stepTranscribePollClassified(id);
159
+ const progress = await stepTranscribePollOrFail(id);
160
160
  if (!progress.done) {
161
- await report(`Transcript ${id} is ${progress.status}.`);
161
+ await stepReport(`Transcript ${id} is ${progress.status}.`);
162
162
  return { done: false };
163
163
  }
164
164
 
165
- const stored = await uploadInfo(uploadId);
165
+ const stored = await stepUploadInfo(uploadId);
166
166
  const transcript = progress.transcript.text;
167
167
  return {
168
168
  done: true,
@@ -152,7 +152,7 @@ export function downsampleSegment(
152
152
  const bind = sampleReaderFor(from);
153
153
  const frame = blockAlign(from);
154
154
  const inFrames = Math.floor(bytes.length / frame);
155
- // A CALLER ERROR, and stated as one. `readUpload` clamps a window to what is
155
+ // A CALLER ERROR, and stated as one. `stepReadUpload` clamps a window to what is
156
156
  // stored, so the streaming flow can hand a short read down here; with no whole
157
157
  // frame the averaging window below is zero wide, `total / 0` is `NaN`, and
158
158
  // `setInt16` writes that as a 0 — a two-byte WAV of silence, transcribed and