@alexkroman1/aai-cli 9.1.0 → 10.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 (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 +720 -16
  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 +311 -38
  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
@@ -35,6 +35,32 @@
35
35
  * transcript of most of a recording and report success. The stall is what
36
36
  * {@link MAX_IDLE_POLLS} is for, and it FAILS the run rather than finishing it.
37
37
  *
38
+ * ## A poll reads THREE numbers, and each answers a different question
39
+ *
40
+ * `size` is the CONTIGUOUS PREFIX, `stored` is every byte that has landed, and
41
+ * `ranges` is where those bytes are. They are one number only for a whole-file
42
+ * upload; under the browser's default fan-out they diverge completely, and reading
43
+ * the wrong one is two separate bugs:
44
+ *
45
+ * - **Readiness on the prefix alone made this flow a no-op.** The client sends
46
+ * `UPLOAD_PART_CONCURRENCY` windows of `UPLOAD_PART_BYTES` at once, so every part
47
+ * of any recording that fits in one round shares the uplink and they all finish
48
+ * together. The prefix cannot move until the FIRST part completes, which is
49
+ * within a second of the last. Measured on a deployed agent, a 27 MB recording at
50
+ * 0.9 MB/s: `size` was 0 at every poll for 45 seconds and then the whole file, so
51
+ * the run planned nothing, transcribed nothing, and did its entire fan-out after
52
+ * the upload — the classic flow, with extra steps. `segmentStored` reads `ranges`
53
+ * instead, and `readUpload` clamps to the run a read starts in rather than to the
54
+ * prefix, so a window that has landed is a window this flow can work on.
55
+ * - **The stall test on the prefix would then FAIL a healthy upload.** A parts
56
+ * upload moving at full speed reports the same prefix at every poll, which is
57
+ * indistinguishable from a dead client — so past {@link MAX_IDLE_POLLS} the run
58
+ * abandons an upload that is still arriving. It reads `stored`, which grows with
59
+ * every window whatever order they land in.
60
+ *
61
+ * `size` keeps the two jobs only it can do: the header probe (which reads from byte
62
+ * zero) and the finished recording's duration.
63
+ *
38
64
  * ## It really does overlap, and the granularity is a SEGMENT
39
65
  *
40
66
  * Watched directly — the same 10-minute recording at 2 MB/s, polling the upload's
@@ -60,9 +86,12 @@
60
86
  *
61
87
  * - a segment is `SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS` of audio — ~17.6 MB at
62
88
  * 48 kHz stereo, which is ~9s of a 2 MB/s uplink;
63
- * - the store publishes `size` a `UPLOAD_CHUNK_BYTES` chunk at a time (1 MiB), so the
64
- * view a poll reads is at most a megabyte stale;
65
- * - the body sleeps {@link POLL_INTERVAL} between polls when nothing is ready, cut
89
+ * - the store publishes bytes an `UPLOAD_PART_BYTES` window at a time (8 MiB), so the
90
+ * view a poll reads is up to a window stale. This paragraph said 1 MiB, naming
91
+ * `UPLOAD_CHUNK_BYTES`, which is the chunk a range READ is served in and not the
92
+ * unit a write publishes: `putWindows` cuts a body into `UPLOAD_PART_BYTES`
93
+ * windows so one byte layout serves every route an upload can arrive by;
94
+ * - the body sleeps {@link POLL_INTERVAL_MS} between polls when nothing is ready, cut
66
95
  * short by the client's wake.
67
96
  *
68
97
  * 9s + one poll is the 14s above. Nothing here can go below a segment without a
@@ -138,10 +167,16 @@
138
167
  * what keeps that order a pure function of journaled values.
139
168
  */
140
169
 
141
- import { mapConcurrent, readUpload, report, uploadInfo } from "@alexkroman1/aai/step";
170
+ import type { WorkflowCtx } from "@alexkroman1/aai";
171
+ import {
172
+ mapConcurrent,
173
+ readUpload,
174
+ report,
175
+ type UploadRange,
176
+ uploadInfo,
177
+ } from "@alexkroman1/aai/step";
142
178
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
143
- import { formatDuration, plural } from "@alexkroman1/aai/utils";
144
- import { sleep } from "workflow";
179
+ import { formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
145
180
  import {
146
181
  fatalOnUnsupported,
147
182
  mergeTranscript,
@@ -160,14 +195,37 @@ import {
160
195
  type WavFormat,
161
196
  } from "./wav.ts";
162
197
 
163
- /** How long the body waits between polls when nothing new has arrived. */
164
- const POLL_INTERVAL = "5s";
198
+ /**
199
+ * The LONGEST the body waits between polls, and its fallback when it cannot do
200
+ * better.
201
+ *
202
+ * It used to be the only interval, and on a slow uplink that is most of what this
203
+ * flow was still leaving on the table. A poll answers "has the next segment
204
+ * landed"; a flat interval answers it on average half an interval late, once per
205
+ * segment, for the whole upload — 20 segments of a 30-minute recording is ~50s of
206
+ * pure waiting added to a run whose entire point is to finish as the bytes arrive.
207
+ *
208
+ * So it is a CEILING now: {@link nextPollDelay} sleeps until the next segment
209
+ * should have landed, and falls back here when there is nothing to predict from.
210
+ */
211
+ const POLL_INTERVAL_MS = 5000;
212
+
213
+ /**
214
+ * The shortest the body will sleep.
215
+ *
216
+ * A poll is one cheap step (the body's own note above the `continue` says so), but
217
+ * it is not free — it is a journal write and, on the platform, a step execution —
218
+ * so a rate estimate that comes out near zero must not turn the loop into a spin.
219
+ * 250ms is under the latency of any single segment's transcription, so nothing is
220
+ * waiting on this.
221
+ */
222
+ const MIN_POLL_INTERVAL_MS = 250;
165
223
 
166
224
  /**
167
225
  * Consecutive polls with NO new bytes before the run gives up.
168
226
  *
169
227
  * An upload that died stays incomplete forever, so without a bound the run polls for
170
- * as long as the world will replay it. At {@link POLL_INTERVAL} this is five minutes
228
+ * as long as the world will replay it. At {@link POLL_INTERVAL_MS} this is five minutes
171
229
  * of silence — far longer than any stall a live uplink produces, and short enough
172
230
  * that the failure reaches whoever is watching.
173
231
  *
@@ -179,10 +237,41 @@ const MAX_IDLE_POLLS = 60;
179
237
 
180
238
  /** What one poll of the upload found. */
181
239
  export type UploadProgressView = {
182
- /** Bytes stored so far. */
240
+ /**
241
+ * The CONTIGUOUS PREFIX — how far the file can be read from byte zero.
242
+ *
243
+ * Not how much has arrived: see {@link UploadProgressView.stored}. It is what
244
+ * the header probe and the final duration are measured against, because both
245
+ * want a length rather than a coverage map.
246
+ */
183
247
  size: number;
184
248
  /** Whether that is all of them. The ONLY field an exit may be decided on. */
185
249
  complete: boolean;
250
+ /**
251
+ * Total bytes landed, prefix and windows ahead of it alike.
252
+ *
253
+ * The one number a STALL may be judged on. `size` cannot be: a fan-out lands
254
+ * its windows out of order, so the prefix stays at zero through an upload that
255
+ * is moving at full speed and {@link MAX_IDLE_POLLS} would call it dead.
256
+ */
257
+ stored: number;
258
+ /**
259
+ * The windows that have landed, when the upload arrived as parts.
260
+ *
261
+ * Absent for a whole-file write, whose bytes are the prefix and nothing else.
262
+ * This is what makes a segment readable before the windows in front of it
263
+ * arrive — see the readiness test in the body.
264
+ */
265
+ ranges?: readonly UploadRange[];
266
+ /**
267
+ * When this view was taken, as the step that took it saw the clock.
268
+ *
269
+ * Journaled, which is the only reason the body may read a clock at all: the
270
+ * sleep below is derived from the RATE between two of these, and a value the
271
+ * body sampled itself would make that derivation diverge on a replay. Same rule
272
+ * as every other field here — see the body's own note on why its state is legal.
273
+ */
274
+ observedAt: number;
186
275
  };
187
276
 
188
277
  /** The cut, derived once from the header. */
@@ -198,26 +287,39 @@ export type StreamPlan = {
198
287
  * an upload id exactly as in the classic flow; what differs is that the client chose
199
288
  * it and the bytes are still on their way.
200
289
  */
201
- export async function transcribeStreamFlow(input: { recording: string }) {
202
- "use workflow";
203
-
204
- const startedAt = await startClock();
290
+ export async function transcribeStreamFlow(input: { recording: string }, ctx: WorkflowCtx) {
291
+ const startedAt = await ctx.step("startClock", () => startClock());
205
292
  let plan: StreamPlan | undefined;
206
293
  // Body state, and legal because every value in it came out of a journaled step
207
294
  // result — a replay rebuilds the identical sets in the identical order.
208
295
  const done = new Set<number>();
209
296
  const parts: SegmentTranscript[] = [];
210
297
  let idlePolls = 0;
211
- let lastSize = -1;
298
+ // The prefix at the last poll, which is what the final duration is measured
299
+ // from — and deliberately NOT what the stall test reads; see `lastStored`.
300
+ let lastSize = 0;
301
+ // Total bytes landed at the last poll. A fan-out lands its windows out of
302
+ // order, so this is the only number that distinguishes an upload that has
303
+ // stopped from one whose prefix has not caught up yet.
304
+ let lastStored = -1;
305
+ /**
306
+ * The previous poll, so {@link nextPollDelay} has two journaled samples to take
307
+ * a rate from. Body state for the same reason the rest is: it came out of a step.
308
+ */
309
+ let previous: UploadProgressView | undefined;
212
310
 
213
311
  for (;;) {
214
- const at = await probeUpload(input.recording);
312
+ const at = await ctx.step("probeUpload", () => probeUpload(input.recording));
313
+ // Every poll, because this is only ever read at the END — the run breaks out
314
+ // on a `complete` view, whose prefix is the whole file. Updating it inside a
315
+ // branch is how it used to end up describing whichever poll last had work.
316
+ lastSize = at.size;
215
317
 
216
318
  // The header has to be present before anything can be planned, and it is the
217
319
  // first thing to arrive. `complete` also qualifies, for a recording shorter
218
320
  // than the probe window.
219
321
  if (!plan && (at.size >= HEADER_PROBE_BYTES || at.complete)) {
220
- plan = await planStreamed(input.recording);
322
+ plan = await ctx.step("planStreamed", () => planStreamed(input.recording));
221
323
  }
222
324
 
223
325
  if (plan) {
@@ -229,11 +331,11 @@ export async function transcribeStreamFlow(input: { recording: string }) {
229
331
  const ready = plan.segments.filter(
230
332
  (segment) =>
231
333
  !done.has(segment.index) &&
232
- (segment.end <= at.size || (at.complete && segment.start < at.size)),
334
+ (segmentStored(segment, at) || (at.complete && segment.start < at.size)),
233
335
  );
234
336
  if (ready.length > 0) {
235
337
  idlePolls = 0;
236
- lastSize = at.size;
338
+ lastStored = at.stored;
237
339
  for (const segment of ready) done.add(segment.index);
238
340
  // One step per segment, bounded, in an order a replay reproduces exactly —
239
341
  // `ready` is derived from a journaled poll, and `mapConcurrent` issues its
@@ -243,7 +345,13 @@ export async function transcribeStreamFlow(input: { recording: string }) {
243
345
  ...(await mapConcurrent(
244
346
  ready,
245
347
  segmentConcurrency((plan as StreamPlan).format),
246
- (segment) => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
348
+ (segment) =>
349
+ // `maxAttempts: 6` was `transcribeSegment.maxRetries = 5`.
350
+ ctx.step(
351
+ "transcribeSegment",
352
+ () => transcribeSegment(input.recording, (plan as StreamPlan).format, segment),
353
+ { maxAttempts: 6 },
354
+ ),
247
355
  )),
248
356
  );
249
357
  // Straight back to the top WITHOUT sleeping, and this line was measured
@@ -259,23 +367,32 @@ export async function transcribeStreamFlow(input: { recording: string }) {
259
367
 
260
368
  // Nothing to work on, so this view is current and the exit can be trusted.
261
369
  if (at.complete && plan && done.size >= expectedSegments(plan, at.size)) break;
262
- // A stall, not an ending — see MAX_IDLE_POLLS.
263
- if (at.size === lastSize) idlePolls += 1;
370
+ // A stall, not an ending — see MAX_IDLE_POLLS. Judged on `stored` rather than
371
+ // on the prefix: under the browser's default fan-out the prefix does not move
372
+ // at all until the first window lands, so a run reading it would call a
373
+ // healthy upload abandoned five minutes in and fail.
374
+ if (at.stored === lastStored) idlePolls += 1;
264
375
  else {
265
376
  idlePolls = 0;
266
- lastSize = at.size;
377
+ lastStored = at.stored;
267
378
  }
268
379
  if (idlePolls > MAX_IDLE_POLLS) abandon(input.recording, at);
269
- await sleep(POLL_INTERVAL);
380
+ // Sleep until the next segment should HAVE landed, rather than for a fixed
381
+ // interval — see `nextPollDelay`. Both arguments are journaled step results,
382
+ // so a replay computes the same delay from the same two samples.
383
+ await ctx.sleep(nextPollDelay(at, previous, plan, done));
384
+ previous = at;
270
385
  }
271
386
 
272
387
  const finished = plan;
273
- if (!finished) abandon(input.recording, { size: 0, complete: false });
274
- return await mergeTranscript(
275
- input.recording,
276
- offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
277
- parts,
278
- startedAt,
388
+ if (!finished) abandon(input.recording, { size: 0, stored: 0 });
389
+ return await ctx.step("mergeTranscript", () =>
390
+ mergeTranscript(
391
+ input.recording,
392
+ offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
393
+ parts,
394
+ startedAt,
395
+ ),
279
396
  );
280
397
  }
281
398
 
@@ -289,10 +406,167 @@ export async function transcribeStreamFlow(input: { recording: string }) {
289
406
  * from.
290
407
  */
291
408
  export async function probeUpload(id: string): Promise<UploadProgressView> {
292
- "use step";
293
-
294
409
  const info = await uploadInfo(id);
295
- return { size: info.size, complete: info.complete };
410
+ return {
411
+ size: info.size,
412
+ complete: info.complete,
413
+ stored: storedBytes(info.size, info.ranges),
414
+ // Legal HERE and nowhere else in this flow: a step's internals are not
415
+ // replayed, only its result — which is what makes a step the place a clock
416
+ // belongs. See `sync-api.ts`'s `timed` for the same rule.
417
+ observedAt: Date.now(),
418
+ // `omitUndefined` rather than a spread, because a journaled step result is
419
+ // compared on replay and `{ ranges: undefined }` is not `{}` once it has been
420
+ // through JSON.
421
+ ...omitUndefined({ ranges: info.ranges }),
422
+ };
423
+ }
424
+
425
+ /**
426
+ * How long to wait before asking again — the time the NEXT segment still needs.
427
+ *
428
+ * The flat {@link POLL_INTERVAL_MS} this replaced is wrong in both directions on a
429
+ * slow uplink: too long when a segment is seconds away, and equally too long when
430
+ * it is a minute away, so the run discovers work late and then asks again pointlessly.
431
+ * Two consecutive polls give a byte RATE, the plan gives the byte offset the next
432
+ * un-transcribed segment needs, and the difference is a wait with a reason.
433
+ *
434
+ * Every input is a journaled step result — both views, and a plan derived from one —
435
+ * so a replay computes the identical delay. That is the whole reason
436
+ * {@link UploadProgressView.observedAt} exists rather than the body reading a clock.
437
+ *
438
+ * It is deliberately an ESTIMATE with a floor and a ceiling rather than a promise.
439
+ * Undershooting costs one extra cheap poll; overshooting is bounded by
440
+ * {@link POLL_INTERVAL_MS}, so a rate that collapses mid-upload degrades to exactly
441
+ * the old behaviour instead of stalling. Note the estimate is only ever used to
442
+ * SLEEP: readiness is still decided by {@link segmentStored} against a real view, so
443
+ * a wrong guess here can waste a poll and can never transcribe a partial segment.
444
+ */
445
+ export function nextPollDelay(
446
+ at: UploadProgressView,
447
+ previous: UploadProgressView | undefined,
448
+ plan: StreamPlan | undefined,
449
+ done: ReadonlySet<number>,
450
+ ): number {
451
+ // No previous sample, or a clock that did not advance: nothing to derive a rate
452
+ // from. The first sleep of every run takes this arm.
453
+ const elapsedMs = previous ? at.observedAt - previous.observedAt : 0;
454
+ if (!previous || elapsedMs <= 0) return POLL_INTERVAL_MS;
455
+ const bytesPerMs = (at.stored - previous.stored) / elapsedMs;
456
+ // A stalled or shrinking upload has no arrival to predict. `MAX_IDLE_POLLS` is
457
+ // what ends that run; this only declines to guess about it.
458
+ if (bytesPerMs <= 0) return POLL_INTERVAL_MS;
459
+ // Before the header is read there is no plan, so what is being waited for is the
460
+ // probe window itself — small, and usually one part away. It is measured against
461
+ // the PREFIX because that is what the probe reads: from byte zero. Never against
462
+ // `stored`, which counts every window that has landed wherever it landed — the
463
+ // module doc's third section is about exactly that divergence, and under the
464
+ // browser's default fan-out `HEADER_PROBE_BYTES - stored` goes NEGATIVE before
465
+ // the header this arm is waiting for has arrived at all, collapsing the sleep to
466
+ // its floor. The RATE above is still `stored`'s, which is right: that one is a
467
+ // throughput, and throughput is what every window contributes to.
468
+ //
469
+ // The plan arm asks {@link segmentStored}'s own question instead of subtracting
470
+ // an offset, because that test does not read the prefix either. Measuring a
471
+ // segment against `size` saturates for the whole upload — 45 seconds of `size: 0`
472
+ // on the measured 27 MB recording — so every segment's sleep came back as the
473
+ // flat POLL_INTERVAL_MS this function exists to replace.
474
+ const remaining = plan ? bytesUntilNextSegment(plan, done, at) : HEADER_PROBE_BYTES - at.size;
475
+ // Every segment is already stored: the loop is waiting on `complete`, which is a
476
+ // flag the uploader sets rather than bytes to extrapolate.
477
+ if (remaining === undefined) return POLL_INTERVAL_MS;
478
+ if (remaining <= 0) return MIN_POLL_INTERVAL_MS;
479
+ return Math.min(
480
+ POLL_INTERVAL_MS,
481
+ Math.max(MIN_POLL_INTERVAL_MS, Math.ceil(remaining / bytesPerMs)),
482
+ );
483
+ }
484
+
485
+ /**
486
+ * How many bytes away the NEAREST un-transcribed segment is from being readable.
487
+ *
488
+ * The nearest rather than the earliest, and that is the `ranges` arm's doing: a
489
+ * fan-out lands its windows out of order, so the next segment the loop can act on
490
+ * is whichever one is closest to covered — not the first one in the file. They are
491
+ * the same segment for a whole-file upload, where coverage is a prefix and the
492
+ * least distance belongs to the lowest `end`.
493
+ *
494
+ * `undefined` when there is nothing left to wait for.
495
+ */
496
+ function bytesUntilNextSegment(
497
+ plan: StreamPlan,
498
+ done: ReadonlySet<number>,
499
+ at: UploadProgressView,
500
+ ): number | undefined {
501
+ let nearest: number | undefined;
502
+ for (const segment of plan.segments) {
503
+ if (done.has(segment.index)) continue;
504
+ const missing = bytesUntilStored(segment, at);
505
+ if (nearest === undefined || missing < nearest) nearest = missing;
506
+ }
507
+ return nearest;
508
+ }
509
+
510
+ /**
511
+ * The bytes {@link segmentStored} still wants before it answers `true`.
512
+ *
513
+ * Derived from the same two readings that test uses, which is the whole point: a
514
+ * remainder taken from anything else predicts an arrival the readiness test will
515
+ * not agree with. Two ways for the window to be covered, so two candidates and the
516
+ * smaller wins — the PREFIX growing to `segment.end`, or the run that already
517
+ * holds `segment.start` growing to the same place. A run starting AFTER the
518
+ * segment does can never cover it alone (`rangesOf` merges the adjacent ones), so
519
+ * it is not a candidate at all and such a segment is left waiting on the prefix.
520
+ */
521
+ function bytesUntilStored(segment: Segment, at: UploadProgressView): number {
522
+ if (segmentStored(segment, at)) return 0;
523
+ let missing = segment.end - at.size;
524
+ for (const range of at.ranges ?? []) {
525
+ if (range.start > segment.start) continue;
526
+ missing = Math.min(missing, segment.end - range.end);
527
+ }
528
+ return missing;
529
+ }
530
+
531
+ /**
532
+ * How many bytes have landed in total, prefix and detached windows alike.
533
+ *
534
+ * `ranges` COVERS the prefix when it is present (it is every window the record
535
+ * holds, merged), so this is the larger of the two rather than their sum — adding
536
+ * them would double-count the prefix and make a stalled upload look like it was
537
+ * still growing, which is the one thing {@link MAX_IDLE_POLLS} must not be lied
538
+ * to about.
539
+ */
540
+ export function storedBytes(size: number, ranges: readonly UploadRange[] | undefined): number {
541
+ if (!ranges) return size;
542
+ return Math.max(
543
+ size,
544
+ ranges.reduce((total, range) => total + (range.end - range.start), 0),
545
+ );
546
+ }
547
+
548
+ /**
549
+ * Whether every byte of `segment` is stored, wherever in the file it landed.
550
+ *
551
+ * The prefix answers most of this — a whole-file upload has no windows and a
552
+ * finished one is covered end to end — and the `ranges` arm is what makes the
553
+ * streaming flow work against the browser's DEFAULT upload. That fan-out puts
554
+ * `UPLOAD_PART_CONCURRENCY` windows on the link at once, so they finish together
555
+ * and the prefix is zero until the last moment; measured on a deployed agent, a
556
+ * 27 MB recording at 0.9 MB/s reported `size: 0` for 45 of its 45 seconds. Read
557
+ * only the prefix and the run has nothing to do until the upload is over, which
558
+ * is the entire wait this flow exists to remove.
559
+ *
560
+ * A window has to be covered WHOLE by one run: `readUpload` clamps to the run a
561
+ * read starts in, so a segment straddling a hole would come back short and be
562
+ * transcribed as a fragment. `rangesOf` merges adjacent windows, so a run really
563
+ * is a contiguous stretch and one containment test is the whole check.
564
+ */
565
+ export function segmentStored(segment: Segment, at: UploadProgressView): boolean {
566
+ if (segment.end <= at.size) return true;
567
+ return (at.ranges ?? []).some(
568
+ (range) => range.start <= segment.start && segment.end <= range.end,
569
+ );
296
570
  }
297
571
 
298
572
  /**
@@ -310,8 +584,6 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
310
584
  * which is what the classic flow is for.
311
585
  */
312
586
  export async function planStreamed(id: string): Promise<StreamPlan> {
313
- "use step";
314
-
315
587
  const head = await readUpload(id, { end: HEADER_PROBE_BYTES });
316
588
  const format = fatalOnUnsupported(() => parseWav(head.bytes, Number.POSITIVE_INFINITY));
317
589
  if (!Number.isFinite(format.dataEnd)) {
@@ -354,9 +626,10 @@ export function expectedSegments(plan: StreamPlan, size: number): number {
354
626
  * should happen here, and dressing it up as a step error would suggest a retry
355
627
  * policy with nothing to apply to.
356
628
  */
357
- function abandon(id: string, at: UploadProgressView): never {
629
+ function abandon(id: string, at: Pick<UploadProgressView, "size" | "stored">): never {
358
630
  throw new Error(
359
- `Gave up waiting for ${id}: ${at.size} byte(s) stored and still incomplete. ` +
360
- `Nothing new arrived for ${MAX_IDLE_POLLS} polls — the uploader stopped.`,
631
+ `Gave up waiting for ${id}: ${at.stored} byte(s) stored, ${at.size} readable from the ` +
632
+ `start, and still incomplete. Nothing new arrived for ${MAX_IDLE_POLLS} polls — the ` +
633
+ "uploader stopped.",
361
634
  );
362
635
  }
@@ -63,16 +63,19 @@
63
63
  * `output` exists only when the last segment does.
64
64
  */
65
65
 
66
+ import type { WorkflowCtx } from "@alexkroman1/aai";
66
67
  import {
67
68
  emit,
68
69
  encodeWav,
69
70
  mapConcurrent,
70
71
  readUpload,
71
72
  report,
73
+ requireCompleteUpload,
72
74
  uploadInfo,
73
75
  } from "@alexkroman1/aai/step";
74
76
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
75
77
  import { countWords, formatDuration, plural } from "@alexkroman1/aai/utils";
78
+ import { downsampleSegment, requestFormat } from "./downsample.ts";
76
79
  import { normalizeRecording } from "./normalize.ts";
77
80
  import { stitchTranscript, TRANSCRIPT_STREAM, type TranscriptChunk } from "./stitch.ts";
78
81
  import { elapsed, timed, transcribeWav } from "./sync-api.ts";
@@ -168,15 +171,18 @@ export const BYTES_IN_FLIGHT = 640 * 1024 * 1024;
168
171
  * The table above was measured under the old per-round barrier. Re-measuring it is
169
172
  * worth doing before this number moves again: the window makes a wide fan-out
170
173
  * cheaper at the tail, which if anything argues for a HIGHER knee. *
171
- * **What EXECUTES at this width is the world's call, not this number's.**
174
+ * **What EXECUTES at this width is the engine's call, not this number's.**
172
175
  * `mapConcurrent` bounds how many step calls the body has in flight; how many
173
- * run at once is the workflow world's worker concurrency, which on the
174
- * `DATABASE_URL` path defaults to three so on a default deployment a width
175
- * above three is inert while still costing a queued job per item. That makes
176
- * this the FAR SIDE's knee and the width to use once an operator has raised
177
- * the ceiling, not a promise about a stock deployment. See "The WINDOW is not
178
- * the concurrency" in `@alexkroman1/aai/step`'s `mapConcurrent`; the numbers
179
- * above were measured against the endpoint and say nothing about that layer.
176
+ * run at once is `DEFAULT_STEP_CONCURRENCY` (`aai-runtime`), which is **16** —
177
+ * measured against a real microVM at Modal's guaranteed reservation, where a
178
+ * concurrent segment of 48 kHz stereo costs 26.1 MB. So a width above 16 is
179
+ * inert on a stock deployment while still costing a queued job per item, and
180
+ * this number is the FAR SIDE's knee: the one to use once an operator has
181
+ * raised `AAI_WORKFLOW_STEP_CONCURRENCY` for a larger guest. It was three,
182
+ * inherited from graphile-worker and never measured, which made every number
183
+ * in the table above unreachable. See "The WINDOW is not the concurrency" in
184
+ * `@alexkroman1/aai/step`'s `mapConcurrent`; the numbers above were measured
185
+ * against the endpoint and say nothing about that layer.
180
186
  */
181
187
  export const MAX_SEGMENT_CONCURRENCY = 32;
182
188
 
@@ -199,7 +205,13 @@ export const MAX_SEGMENT_CONCURRENCY = 32;
199
205
  * `stepFetch` pins.
200
206
  */
201
207
  export function segmentConcurrency(format: WavFormat): number {
202
- const perSegment = bytesPerSecond(format) * (SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS);
208
+ // The format that will be SENT, not the one that was cut. The budget is bytes
209
+ // UPLOADING and `transcribeSegment` normalizes each window first, so asking the
210
+ // source format would price a 48 kHz stereo segment at 17.66 MB when 2.94 MB
211
+ // goes on the wire — six times too cautious, and drifting the moment either
212
+ // side of that pair changes. Both derive it from `requestFormat`.
213
+ const perSegment =
214
+ bytesPerSecond(requestFormat(format)) * (SEGMENT_SECONDS + SEGMENT_OVERLAP_SECONDS);
203
215
  if (perSegment <= 0) return MAX_SEGMENT_CONCURRENCY;
204
216
  return Math.max(1, Math.min(MAX_SEGMENT_CONCURRENCY, Math.floor(BYTES_IN_FLIGHT / perSegment)));
205
217
  }
@@ -236,9 +248,7 @@ export type SegmentTranscript = {
236
248
  * The input is what `POST /workflows/runs` carries — see `agent.ts` for the
237
249
  * schema it is validated against before a run exists.
238
250
  */
239
- export async function transcribeFlow(input: { recording: string }) {
240
- "use workflow";
241
-
251
+ export async function transcribeFlow(input: { recording: string }, ctx: WorkflowCtx) {
242
252
  // Both at once: neither needs the other, and issued together they are one
243
253
  // round trip instead of two before any audio is read. The ORDER is still a
244
254
  // pure function of this line — the two calls go out synchronously, left to
@@ -247,12 +257,20 @@ export async function transcribeFlow(input: { recording: string }) {
247
257
  // The clock starts before the conversion rather than after it, because a
248
258
  // reader comparing the three flows over one file is comparing what the desk
249
259
  // COST them, and re-encoding an m4a is part of that.
250
- const [startedAt, ready] = await Promise.all([startClock(), normalizeRecording(input.recording)]);
260
+ // `maxAttempts: 6` was `normalizeRecording.maxRetries = 5`. More than the
261
+ // default 3, and not because a conversion is flaky — a corrupt file fails
262
+ // identically forever, and `throwFfmpegStepError` is what stops the engine
263
+ // retrying that. It is the two I/O halves that are worth another attempt: this
264
+ // step reads a whole recording out of the store and writes a whole one back.
265
+ const [startedAt, ready] = await Promise.all([
266
+ ctx.step("startClock", () => startClock()),
267
+ ctx.step("normalizeRecording", () => normalizeRecording(input.recording), { maxAttempts: 6 }),
268
+ ]);
251
269
 
252
270
  // `ready.recording` from here on, not `input.recording`: a converted file is a
253
271
  // DIFFERENT upload, and cutting the original by offsets planned against the
254
272
  // converted one is a fan-out of garbage that still reports success.
255
- const plan = await splitRecording(ready.recording);
273
+ const plan = await ctx.step("splitRecording", () => splitRecording(ready.recording));
256
274
 
257
275
  // One step per segment, bounded, in an order a replay reproduces exactly.
258
276
  // A failed segment fails the RUN, deliberately: every sibling that finished is
@@ -260,13 +278,20 @@ export async function transcribeFlow(input: { recording: string }) {
260
278
  // what is missing, where catching here to salvage a partial transcript would
261
279
  // return a recording with a silent hole in it and report success.
262
280
  const parts = await mapConcurrent(plan.segments, segmentConcurrency(plan.format), (segment) =>
263
- transcribeSegment(ready.recording, plan.format, segment),
281
+ // `maxAttempts: 6` was `transcribeSegment.maxRetries = 5` — more than the
282
+ // default 3 because a rate limit is the expected failure here, and a segment
283
+ // that 429s is not a segment that is wrong.
284
+ ctx.step("transcribeSegment", () => transcribeSegment(ready.recording, plan.format, segment), {
285
+ maxAttempts: 6,
286
+ }),
264
287
  );
265
288
 
266
289
  // The ORIGINAL id, and only here: `mergeTranscript` uses it for the filename a
267
290
  // reader sees, and `standup.m4a` is the recording they uploaded — where the
268
291
  // converted copy is an artifact of how the desk works.
269
- return await mergeTranscript(input.recording, plan.durationMs, parts, startedAt);
292
+ return await ctx.step("mergeTranscript", () =>
293
+ mergeTranscript(input.recording, plan.durationMs, parts, startedAt),
294
+ );
270
295
  }
271
296
 
272
297
  /**
@@ -283,10 +308,14 @@ export async function splitRecording(uploadId: string): Promise<{
283
308
  segments: Segment[];
284
309
  durationMs: number;
285
310
  }> {
286
- "use step";
287
-
311
+ // The whole file, refused if it is still arriving. `info.size` is the readable
312
+ // PREFIX, and it is what the segment plan's width is derived from — so against a
313
+ // half-arrived recording this planned a fan-out over the first half and the run
314
+ // returned a transcript of it, reporting success. `stream.ts` is the flow for a
315
+ // recording that is still landing; this one wants all of it.
316
+ const stored = await requireCompleteUpload(uploadId);
288
317
  const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
289
- const format = fatalOnUnsupported(() => parseWav(head.bytes, head.info.size));
318
+ const format = fatalOnUnsupported(() => parseWav(head.bytes, stored.size));
290
319
  const segments = fatalOnUnsupported(() => planSegments(format));
291
320
  const durationMs = segments.at(-1)?.endMs ?? 0;
292
321
 
@@ -308,8 +337,6 @@ export async function transcribeSegment(
308
337
  format: WavFormat,
309
338
  segment: Segment,
310
339
  ): Promise<SegmentTranscript> {
311
- "use step";
312
-
313
340
  // One line per segment, which is what makes the fan-out legible to a page: the
314
341
  // status is `running` for the whole thing, so without this a sixty-segment
315
342
  // recording and a one-segment recording look identical while they run.
@@ -338,9 +365,29 @@ export async function transcribeSegment(
338
365
  // template's: a `WavFormat` is structurally a `PcmFormat`, and 22 lines of
339
366
  // `DataView` writes with a comment about which of the two declared lengths a
340
367
  // decoder trusts is not a thing worth a second copy of.
368
+ // Down to 16 kHz mono BEFORE the header goes on, because the endpoint's budget
369
+ // is 30 seconds of wall clock and that covers the upload. At 48 kHz stereo this
370
+ // window is 17.66 MB and the same audio is 2.94 MB normalized — six times the
371
+ // bytes against a fixed deadline, which is what turns a slow segment into a
372
+ // `504 request exceeded 30.0s` and then into a failed run. Inert on the classic
373
+ // flow, where `normalizeRecording` already converted the whole file; see
374
+ // `downsample.ts` for why the streaming flow cannot do the same.
375
+ //
376
+ // Through `fatalOnUnsupported` for the same reason `planStreamed` reads its
377
+ // header through it: `parseWav` admits any bit depth whose block align is
378
+ // positive, and `downsampleSegment` can serve only four of them — so a
379
+ // recording the desk could cut but cannot send raises
380
+ // `UnsupportedRecordingError` here, and a plain throw would spend all six
381
+ // attempts re-reading this window out of the upload store to arrive at the
382
+ // identical answer. BOTH flows can reach it, which is newer than it looks:
383
+ // the check used to hang off the resampler, so a 12-bit recording already at
384
+ // 16 kHz mono — light for both flows, and therefore converted by neither —
385
+ // sailed past it into an unclassified `RangeError` from `encodeWav`.
386
+ const light = fatalOnUnsupported(() => downsampleSegment(audio.bytes, format));
387
+
341
388
  const { value: text, ms } = await timed(() =>
342
389
  transcribeWav(
343
- encodeWav(audio.bytes, format),
390
+ encodeWav(light.bytes, light.format),
344
391
  `segment-${segment.index}.wav`,
345
392
  `Segment ${segment.index} (${formatDuration(segment.startMs)})`,
346
393
  ),
@@ -364,12 +411,6 @@ export async function transcribeSegment(
364
411
  return { index: segment.index, text };
365
412
  }
366
413
 
367
- /**
368
- * Retries beyond the default 3, because a rate limit is the expected failure and
369
- * a segment that 429s is not a segment that is wrong.
370
- */
371
- transcribeSegment.maxRetries = 5;
372
-
373
414
  /**
374
415
  * Stitch the segments into one transcript.
375
416
  *
@@ -385,8 +426,6 @@ export async function mergeTranscript(
385
426
  parts: readonly SegmentTranscript[],
386
427
  startedAt: number,
387
428
  ): Promise<Transcript> {
388
- "use step";
389
-
390
429
  await report(`Stitching ${parts.length} ${plural(parts.length, "segment")} together.`);
391
430
 
392
431
  // `mapConcurrent` resolves in ITEM order however the calls settled, so this is
@@ -427,8 +466,6 @@ export async function mergeTranscript(
427
466
  * which needs one number measured one way.
428
467
  */
429
468
  export async function startClock(): Promise<number> {
430
- "use step";
431
-
432
469
  return Date.now();
433
470
  }
434
471