@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
@@ -4,9 +4,8 @@
4
4
  * steps.
5
5
  *
6
6
  * **The body itself is not driven here, and that is a property of what this
7
- * template demonstrates rather than a gap in the spec.** Imported through vitest
8
- * with no bundler in the path, a `"use step"` function is an ordinary async
9
- * function — so its retries, its `FatalError` guards, its HTTP handling and its
7
+ * template demonstrates rather than a gap in the spec.** A step is an ordinary
8
+ * exported async function so its retries, its `FatalError` guards, its HTTP handling and its
10
9
  * merge are all testable, while durability, suspension and replay are not. A
11
10
  * body test that looked like a durability test would be the worse failure; the
12
11
  * real thing is exercised end to end by `aai-cli`'s
@@ -19,7 +18,9 @@
19
18
 
20
19
  import { readdir } from "node:fs/promises";
21
20
  import { tmpdir } from "node:os";
22
- import { readUpload } from "@alexkroman1/aai/step";
21
+ import { readUpload, type UploadRange } from "@alexkroman1/aai/step";
22
+ import { FatalError, RetryableError } from "@alexkroman1/aai/step-errors";
23
+ import { createWorkflowCtx } from "@alexkroman1/aai/testing";
23
24
  import {
24
25
  installStubReporter,
25
26
  installStubStepFetch,
@@ -27,21 +28,22 @@ import {
27
28
  installStubUploads,
28
29
  } from "@alexkroman1/aai/testing/vitest";
29
30
  import { beforeEach, describe, expect, test, vi } from "vitest";
30
- import { FatalError, RetryableError } from "workflow";
31
31
  import { z } from "zod";
32
32
  import agentDef, { transcribe, transcribeBatch, transcribeStream } from "./agent.ts";
33
33
  import { createJob, pollTranscript, uploadToProvider } from "./workflows/batch.ts";
34
34
  import {
35
- cuttable,
36
- heavierThanNormalized,
35
+ downsampleSegment,
37
36
  NORMALIZED_CHANNELS,
38
37
  NORMALIZED_SAMPLE_RATE,
39
- normalizeRecording,
40
- } from "./workflows/normalize.ts";
38
+ requestFormat,
39
+ } from "./workflows/downsample.ts";
40
+ import { cuttable, heavierThanNormalized, normalizeRecording } from "./workflows/normalize.ts";
41
41
  import {
42
42
  expectedSegments,
43
+ nextPollDelay,
43
44
  planStreamed,
44
45
  probeUpload,
46
+ type StreamPlan,
45
47
  segmentStored,
46
48
  storedBytes,
47
49
  type UploadProgressView,
@@ -52,6 +54,8 @@ import {
52
54
  stitchChunks,
53
55
  stitchTranscript,
54
56
  TRANSCRIPT_STREAM,
57
+ type Transcript,
58
+ transcribeFlow,
55
59
  transcribeSegment,
56
60
  } from "./workflows/transcribe.ts";
57
61
  import {
@@ -497,6 +501,230 @@ describe("splitRecording", () => {
497
501
  });
498
502
  });
499
503
 
504
+ /**
505
+ * A sine, as interleaved little-endian 16-bit PCM with every channel identical.
506
+ *
507
+ * A TONE rather than noise or a ramp, because what a resampler can get wrong is
508
+ * frequency: decimating without a low-pass folds anything above the new Nyquist
509
+ * back into the speech band at full strength, and that is invisible to any
510
+ * assertion about lengths or formats.
511
+ */
512
+ function sineFrames(opts: {
513
+ hz: number;
514
+ sampleRate: number;
515
+ channels: number;
516
+ frames: number;
517
+ amplitude?: number;
518
+ }): Uint8Array {
519
+ const amplitude = opts.amplitude ?? 10_000;
520
+ const out = new Uint8Array(opts.frames * opts.channels * 2);
521
+ const view = new DataView(out.buffer);
522
+ for (let f = 0; f < opts.frames; f++) {
523
+ const value = Math.round(amplitude * Math.sin((2 * Math.PI * opts.hz * f) / opts.sampleRate));
524
+ for (let c = 0; c < opts.channels; c++) view.setInt16((f * opts.channels + c) * 2, value, true);
525
+ }
526
+ return out;
527
+ }
528
+
529
+ /** How loud 16-bit mono PCM is, in one number. */
530
+ function rms(bytes: Uint8Array): number {
531
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
532
+ let total = 0;
533
+ for (let at = 0; at < bytes.length; at += 2) total += view.getInt16(at, true) ** 2;
534
+ return Math.sqrt(total / (bytes.length / 2));
535
+ }
536
+
537
+ /** How FAST a tone is, without an FFT: a sine crosses zero twice per cycle. */
538
+ function zeroCrossings(bytes: Uint8Array): number {
539
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
540
+ let crossings = 0;
541
+ let previous = view.getInt16(0, true);
542
+ for (let at = 2; at < bytes.length; at += 2) {
543
+ const value = view.getInt16(at, true);
544
+ if (previous < 0 !== value < 0) crossings += 1;
545
+ previous = value;
546
+ }
547
+ return crossings;
548
+ }
549
+
550
+ describe("downsampleSegment — what actually goes on the wire", () => {
551
+ const STEREO_48K = { sampleRate: 48_000, channels: 2, bitsPerSample: 16 } as const;
552
+
553
+ test("passes already-normalized audio through UNTOUCHED, same array", () => {
554
+ // Identity rather than a copy, which is what the classic flow relies on:
555
+ // `normalizeRecording` has already converted the whole file, so this must cost
556
+ // nothing at all there — not a re-encode, not even an allocation.
557
+ const bytes = sineFrames({ hz: 440, sampleRate: 16_000, channels: 1, frames: 4000 });
558
+ const light = downsampleSegment(bytes, MONO_16K);
559
+
560
+ expect(light.bytes).toBe(bytes);
561
+ expect(light.format).toBe(MONO_16K);
562
+ });
563
+
564
+ test("cuts 48 kHz stereo to a SIXTH of the bytes, at 16 kHz mono", () => {
565
+ // The whole reason this exists: a 92-second segment is 17.66 MB at the source
566
+ // format and 2.94 MB here, against an endpoint that deadlines a request at 30
567
+ // seconds of wall clock INCLUDING the upload.
568
+ const bytes = sineFrames({ hz: 440, sampleRate: 48_000, channels: 2, frames: 48_000 });
569
+ const light = downsampleSegment(bytes, STEREO_48K);
570
+
571
+ expect(light.format).toEqual({ sampleRate: 16_000, channels: 1, bitsPerSample: 16 });
572
+ expect(light.bytes.length).toBe(32_000);
573
+ expect(light.bytes.length * 6).toBe(bytes.length);
574
+ });
575
+
576
+ test("the TONE survives — same pitch, same loudness", () => {
577
+ // The assertion the byte counts cannot make, and it is stated as an
578
+ // EQUIVALENCE rather than a constant: resampling a 440 Hz tone recorded at
579
+ // 48 kHz should give back what recording the same tone at 16 kHz would have
580
+ // given. 440 Hz is far below the new 8 kHz Nyquist, so the box filter costs
581
+ // it about 0.1%.
582
+ const bytes = sineFrames({ hz: 440, sampleRate: 48_000, channels: 2, frames: 12_000 });
583
+ const light = downsampleSegment(bytes, STEREO_48K);
584
+ const reference = sineFrames({ hz: 440, sampleRate: 16_000, channels: 1, frames: 4000 });
585
+
586
+ expect(zeroCrossings(light.bytes)).toBe(zeroCrossings(reference));
587
+ expect(rms(light.bytes)).toBeCloseTo(rms(reference), -2);
588
+ });
589
+
590
+ test("ATTENUATES a tone above the new Nyquist instead of folding it back", () => {
591
+ // The test that tells a box filter from plain decimation, and the reason the
592
+ // averaging is not a wasted pass. Taking every third sample of a 20 kHz tone
593
+ // at 48 kHz reproduces it at 4 kHz — in the middle of speech — at FULL
594
+ // amplitude. Averaging leaves about a quarter of it.
595
+ const bytes = sineFrames({ hz: 20_000, sampleRate: 48_000, channels: 2, frames: 12_000 });
596
+ const light = downsampleSegment(bytes, STEREO_48K);
597
+
598
+ const before = rms(bytes);
599
+ // Bounded on BOTH sides: an upper bound alone is satisfied by a resampler
600
+ // that returns silence, which would pass while destroying every transcript.
601
+ expect(rms(light.bytes)).toBeLessThan(before * 0.4);
602
+ expect(rms(light.bytes)).toBeGreaterThan(before * 0.1);
603
+ });
604
+
605
+ test("downmixes by AVERAGING the channels, not by dropping one", () => {
606
+ // A stereo call with one party per channel is exactly the file where dropping
607
+ // a channel loses a speaker outright. Left is +8000, right is -8000, so an
608
+ // average is silence and a dropped channel is a loud tone.
609
+ const bytes = new Uint8Array(4 * 2 * 2);
610
+ const view = new DataView(bytes.buffer);
611
+ for (let f = 0; f < 4; f++) {
612
+ view.setInt16(f * 4, 8000, true);
613
+ view.setInt16(f * 4 + 2, -8000, true);
614
+ }
615
+ const light = downsampleSegment(bytes, { sampleRate: 16_000, channels: 2, bitsPerSample: 16 });
616
+
617
+ expect(light.format).toEqual({ sampleRate: 16_000, channels: 1, bitsPerSample: 16 });
618
+ expect([...light.bytes]).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
619
+ });
620
+
621
+ test("never UPSAMPLES — 8 kHz stereo comes back at 8 kHz, mono", () => {
622
+ // The rate is a floor to come DOWN to, never a target to reach: resampling a
623
+ // narrowband recording up would invent bytes to pay the deadline with.
624
+ const bytes = sineFrames({ hz: 300, sampleRate: 8000, channels: 2, frames: 800 });
625
+ const light = downsampleSegment(bytes, { sampleRate: 8000, channels: 2, bitsPerSample: 16 });
626
+
627
+ expect(light.format).toEqual({ sampleRate: 8000, channels: 1, bitsPerSample: 16 });
628
+ expect(light.bytes.length).toBe(1600);
629
+ });
630
+
631
+ test("reads 8-bit samples as UNSIGNED, which is RIFF's one asymmetry", () => {
632
+ // Read as signed, a silent 8-bit recording (every byte 128) decodes as a
633
+ // constant -128 — full-scale DC, which transcribes as nothing but is the
634
+ // shape of the bug. 128 is silence; 255 is the positive peak.
635
+ const silent = downsampleSegment(new Uint8Array([128, 128, 128, 128]), {
636
+ sampleRate: 48_000,
637
+ channels: 2,
638
+ bitsPerSample: 8,
639
+ });
640
+ expect([...silent.bytes]).toEqual([0, 0]);
641
+
642
+ const loud = downsampleSegment(new Uint8Array([255, 255]), {
643
+ sampleRate: 16_000,
644
+ channels: 2,
645
+ bitsPerSample: 8,
646
+ });
647
+ expect(new DataView(loud.bytes.buffer).getInt16(0, true)).toBe(32_512);
648
+ });
649
+
650
+ test("sign-extends 24-bit samples", () => {
651
+ // The depth with no `DataView` accessor, so the sign extension is hand-written
652
+ // and is the one thing to get wrong: -1 is `ff ff ff`, which read unsigned is
653
+ // +8388607 rather than a whisker below zero.
654
+ const bytes = new Uint8Array([0xff, 0xff, 0xff, 0x00, 0x00, 0x40]);
655
+ const light = downsampleSegment(bytes, {
656
+ sampleRate: 16_000,
657
+ channels: 2,
658
+ bitsPerSample: 24,
659
+ });
660
+
661
+ // -1 and +4194304 on the 24-bit scale are -1/256 and +16384 on the 16-bit
662
+ // one; their average rounds to 8192.
663
+ expect(new DataView(light.bytes.buffer).getInt16(0, true)).toBe(8192);
664
+ });
665
+
666
+ test("REFUSES a bit depth it cannot read, terminally", () => {
667
+ // `parseWav` admits any multiple of 8, so this is reachable from a real file.
668
+ // Terminal because it answers the same way on every attempt — a retry would
669
+ // spend the step's whole budget arriving back here.
670
+ expect(() =>
671
+ downsampleSegment(new Uint8Array(16), {
672
+ sampleRate: 48_000,
673
+ channels: 2,
674
+ bitsPerSample: 64,
675
+ }),
676
+ ).toThrow(UnsupportedRecordingError);
677
+ });
678
+
679
+ test("REFUSES a depth it cannot read on the LIGHT path too, where nothing resamples", () => {
680
+ // The classification used to hang off the resampler, so it only ever ran when
681
+ // there was resampling to do — and a recording already at 16 kHz mono takes
682
+ // the identity path, where `requestFormat` hands its own argument back. Both
683
+ // depths below reach that path from a real file, `parseWav` admitting any
684
+ // multiple of 8, and each fails a different way once past here: 12 bits is a
685
+ // `RangeError` out of `encodeWav`, which is UNCLASSIFIED and therefore
686
+ // retried six times against a file that can never work, and 64 bits passes
687
+ // the header check and goes on the wire MISLABELLED — the worse of the two,
688
+ // because it comes back as a transcript rather than as an error.
689
+ for (const bitsPerSample of [12, 64]) {
690
+ expect(() => downsampleSegment(new Uint8Array(16), { ...MONO_16K, bitsPerSample })).toThrow(
691
+ UnsupportedRecordingError,
692
+ );
693
+ }
694
+ });
695
+
696
+ test("REFUSES a window with no whole frame rather than sending silence", () => {
697
+ // Reachable from the streaming flow, whose reads are CLAMPED to what has
698
+ // arrived: a segment whose window comes back short of one frame used to
699
+ // divide by a zero-wide averaging window, and `setInt16` turns the resulting
700
+ // `NaN` into a 0 — so the desk sent a two-byte WAV of silence and reported a
701
+ // transcript of it. A retry re-reads the same clamped window, so this is
702
+ // terminal like every other answer this module gives twice.
703
+ expect(() => downsampleSegment(new Uint8Array(2), STEREO_48K)).toThrow(
704
+ UnsupportedRecordingError,
705
+ );
706
+ // And on the light path, where there is no averaging to divide by zero and an
707
+ // empty request is just as useless.
708
+ expect(() => downsampleSegment(new Uint8Array(0), MONO_16K)).toThrow(UnsupportedRecordingError);
709
+ });
710
+
711
+ test("requestFormat is what the fan-out's WIDTH is priced from", () => {
712
+ // The two must not drift: `segmentConcurrency` divides a byte budget by a
713
+ // segment's cost, and that budget is bytes UPLOADING. Pricing the source
714
+ // format would make the width six times too cautious on exactly the
715
+ // recordings this change is for.
716
+ const sent = downsampleSegment(
717
+ sineFrames({ hz: 440, sampleRate: 48_000, channels: 2, frames: 48_000 }),
718
+ STEREO_48K,
719
+ );
720
+ expect(requestFormat(STEREO_48K)).toEqual(sent.format);
721
+ expect(requestFormat(MONO_16K)).toBe(MONO_16K);
722
+ expect(bytesPerSecond(requestFormat(STEREO_48K))).toBe(
723
+ NORMALIZED_SAMPLE_RATE * NORMALIZED_CHANNELS * 2,
724
+ );
725
+ });
726
+ });
727
+
500
728
  describe("transcribeSegment", () => {
501
729
  const FORMAT: WavFormat = { ...MONO_16K, dataStart: 44, dataEnd: 44 + 320_000 };
502
730
  const SEGMENT = { index: 0, start: 44, end: 44 + 32_000, startMs: 0, endMs: 1000 };
@@ -550,6 +778,101 @@ describe("transcribeSegment", () => {
550
778
  expect(decoded).toContain("RIFF");
551
779
  });
552
780
 
781
+ test("sends the DOWNSAMPLED window when the recording is heavier than 16 kHz mono", async () => {
782
+ // The end-to-end half of `downsampleSegment`'s own specs: that the lighter
783
+ // bytes reach the WIRE, which is the only place the endpoint's 30-second
784
+ // budget is spent. One second of 48 kHz stereo is 192,000 bytes and leaves
785
+ // here as 32,000.
786
+ const stereo: WavFormat = {
787
+ sampleRate: 48_000,
788
+ channels: 2,
789
+ bitsPerSample: 16,
790
+ dataStart: 44,
791
+ dataEnd: 44 + 192_000,
792
+ };
793
+ publishRecording(new Uint8Array(stereo.dataEnd));
794
+ const calls = installStubTranscribe({ text: "hello there" }).calls;
795
+
796
+ await transcribeSegment(UPLOAD_ID, stereo, {
797
+ index: 0,
798
+ start: 44,
799
+ end: 44 + 192_000,
800
+ startMs: 0,
801
+ endMs: 1000,
802
+ });
803
+
804
+ const sent = calls.find((call) => call.leg === "sync")?.body;
805
+ if (!(sent instanceof Uint8Array)) return expect.fail("the sync leg carries bytes");
806
+ expect(sent.length).toBeLessThan(40_000);
807
+
808
+ // And the HEADER agrees with the samples under it. A body that shrank while
809
+ // still declaring 48 kHz stereo is the one failure a byte count cannot see,
810
+ // and it plays back at a third speed rather than failing.
811
+ const at = new TextDecoder("latin1").decode(sent).indexOf("RIFF");
812
+ const header = new DataView(sent.buffer, sent.byteOffset + at, 44);
813
+ expect(header.getUint16(22, true)).toBe(1);
814
+ expect(header.getUint32(24, true)).toBe(16_000);
815
+ expect(header.getUint32(40, true)).toBe(32_000);
816
+ });
817
+
818
+ test("a depth it can CUT but not RESAMPLE fails the step FATALLY, not six times", async () => {
819
+ // `downsampleSegment`'s own spec asserts it throws; this asserts the
820
+ // CLASSIFICATION, which is the half that decides what the run does with it.
821
+ // `parseWav` admits any bit depth whose block align is positive, so a 48 kHz
822
+ // stereo 64-bit recording plans fine and only fails here — and it answers the
823
+ // same way on every attempt, so a plain throw would re-read this window out of
824
+ // the upload store six times to arrive back at the identical error. Only the
825
+ // STREAMING flow can get here: the classic one converts a heavy recording
826
+ // whole before any segment is cut.
827
+ const wide: WavFormat = {
828
+ sampleRate: 48_000,
829
+ channels: 2,
830
+ bitsPerSample: 64,
831
+ dataStart: 44,
832
+ dataEnd: 44 + 96_000,
833
+ };
834
+ publishRecording(new Uint8Array(wide.dataEnd));
835
+ installStubTranscribe({ text: "never reached" });
836
+
837
+ await expect(
838
+ transcribeSegment(UPLOAD_ID, wide, {
839
+ index: 0,
840
+ start: 44,
841
+ end: 44 + 96_000,
842
+ startMs: 0,
843
+ endMs: 1000,
844
+ }),
845
+ ).rejects.toBeInstanceOf(FatalError);
846
+ });
847
+
848
+ test("and fails fatally on the LIGHT path, which resamples nothing", async () => {
849
+ // The same classification one arm over, and the arm the case above cannot
850
+ // reach: a recording already at 16 kHz mono is passed through untouched, so
851
+ // nothing here ever asked what its samples were until it was too late to say
852
+ // so. 12 bits reached `encodeWav`, which refuses a depth that is not a
853
+ // multiple of 8 with a plain `RangeError` — retryable, so six attempts
854
+ // against a file no attempt can fix.
855
+ const narrow: WavFormat = {
856
+ sampleRate: 16_000,
857
+ channels: 1,
858
+ bitsPerSample: 12,
859
+ dataStart: 44,
860
+ dataEnd: 44 + 24_000,
861
+ };
862
+ publishRecording(new Uint8Array(narrow.dataEnd));
863
+ installStubTranscribe({ text: "never reached" });
864
+
865
+ await expect(
866
+ transcribeSegment(UPLOAD_ID, narrow, {
867
+ index: 0,
868
+ start: 44,
869
+ end: 44 + 24_000,
870
+ startMs: 0,
871
+ endMs: 1000,
872
+ }),
873
+ ).rejects.toBeInstanceOf(FatalError);
874
+ });
875
+
553
876
  test("EMITS the segment's words as it lands, into the transcript stream", async () => {
554
877
  // What makes the run's answer streamable rather than only its narration: the
555
878
  // page stitches whatever has arrived, so the transcript renders growing
@@ -610,8 +933,54 @@ describe("transcribeSegment", () => {
610
933
  );
611
934
  });
612
935
 
613
- test("retries beyond the default, because a rate limit is expected", () => {
614
- expect(transcribeSegment.maxRetries).toBeGreaterThan(3);
936
+ test("is raised on both of the classic flow's I/O steps, and on nothing else", async () => {
937
+ // The policy is an argument to `ctx.step` now, so it is observable only at
938
+ // the CALL — which is exactly the kind of thing a property on the function
939
+ // could not have said differently, `transcribeSegment` being called from two
940
+ // flows. `runSteps: false` with a skeleton of results: no provider, no
941
+ // ffmpeg, no bytes.
942
+ //
943
+ // `transcribeStreamFlow`'s own `transcribeSegment` call carries the same
944
+ // budget and is asserted with that flow, not here — this drives only
945
+ // `transcribeFlow`.
946
+ const ctx = createWorkflowCtx({
947
+ runSteps: false,
948
+ results: {
949
+ startClock: 0,
950
+ normalizeRecording: { recording: UPLOAD_ID, converted: false },
951
+ splitRecording: { format: FORMAT, segments: [SEGMENT], durationMs: 1000 },
952
+ transcribeSegment: { index: 0, text: "hello" },
953
+ // The real `Transcript` shape rather than `{ text }` — the body returns
954
+ // this straight out as the run's output, so a fixture the system cannot
955
+ // produce is the shape a reader copies.
956
+ mergeTranscript: {
957
+ source: "call.wav",
958
+ segments: 1,
959
+ durationMs: 1000,
960
+ elapsedMs: 10,
961
+ words: 1,
962
+ transcript: "hello",
963
+ } satisfies Transcript,
964
+ },
965
+ });
966
+ await transcribeFlow({ recording: UPLOAD_ID }, ctx);
967
+
968
+ const segments = ctx.steps.filter((step) => step.name === "transcribeSegment");
969
+ expect(segments.length).toBeGreaterThan(0);
970
+ // The EXACT budget, not `toBeGreaterThan(3)`: the value is a literal at the
971
+ // call site, so a typo'd `maxAttempts: 4` is what this should catch.
972
+ for (const step of segments) expect(step.maxAttempts).toBe(6);
973
+ // The OTHER raised step, whose assertion the migration dropped along with
974
+ // the `normalizeRecording.maxRetries` property it used to read.
975
+ const budgets = new Map(ctx.steps.map((step) => [step.name, step.maxAttempts]));
976
+ expect(budgets.get("normalizeRecording")).toBe(6);
977
+ // And the two that take the default, which is the other half of the claim.
978
+ // Asserted as PRESENT-with-no-budget rather than as `get(…) === undefined`,
979
+ // which a step the body never reached at all would also satisfy.
980
+ for (const name of ["startClock", "splitRecording"]) {
981
+ expect(budgets.has(name)).toBe(true);
982
+ expect(budgets.get(name)).toBeUndefined();
983
+ }
615
984
  });
616
985
  });
617
986
 
@@ -651,8 +1020,8 @@ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
651
1020
  * The STREAMING flow's own steps.
652
1021
  *
653
1022
  * Same honest line as the classic half above: the steps are driven directly and the
654
- * body is not, because imported through vitest a `"use step"` function is an ordinary
655
- * async function. Almost nothing here is new — the transcribing and the merging are
1023
+ * body is not, because a step is an ordinary exported async function. Almost
1024
+ * nothing here is new — the transcribing and the merging are
656
1025
  * `transcribe.ts`'s own steps, called unchanged — so what is worth asserting is the
657
1026
  * two things this flow adds: reading how far the upload has got, and planning from a
658
1027
  * header while most of the file is still missing.
@@ -679,6 +1048,10 @@ describe("the streaming flow", () => {
679
1048
  size: 44 + 1000,
680
1049
  complete: false,
681
1050
  stored: 44 + 1000,
1051
+ // A real clock, which is the point of it: `nextPollDelay` derives a rate from
1052
+ // two of these, so the step has to stamp when it looked rather than the body
1053
+ // guessing. Matched loosely because the VALUE is the wall clock.
1054
+ observedAt: expect.any(Number),
682
1055
  });
683
1056
  });
684
1057
 
@@ -698,6 +1071,10 @@ describe("the streaming flow", () => {
698
1071
  complete: prefix >= declared,
699
1072
  stored: storedBytes(prefix, ranges),
700
1073
  ranges,
1074
+ // Fixed rather than `Date.now()`: these fixtures feed pure functions, and a
1075
+ // real clock would make `nextPollDelay`'s arithmetic depend on how long the
1076
+ // suite took to get here.
1077
+ observedAt: 0,
701
1078
  };
702
1079
  }
703
1080
 
@@ -1132,7 +1509,6 @@ describe("the conversion, up to the spawn", () => {
1132
1509
  // It got as far as deciding the file needs converting — the failure is the
1133
1510
  // binary, not the input.
1134
1511
  expect(reporter.lines.join(" ")).toContain("standup.m4a");
1135
- expect(normalizeRecording.maxRetries).toBe(5);
1136
1512
  });
1137
1513
 
1138
1514
  test("leaves no temp directory behind when the conversion fails", async () => {
@@ -1147,3 +1523,239 @@ describe("the conversion, up to the spawn", () => {
1147
1523
  expect(leaked(await readdir(tmpdir()))).toEqual(before);
1148
1524
  });
1149
1525
  });
1526
+
1527
+ /**
1528
+ * The adaptive poll delay.
1529
+ *
1530
+ * `POLL_INTERVAL_MS` used to be the only interval, and on a slow uplink that meant
1531
+ * discovering every segment on average half an interval late — once per segment,
1532
+ * for the whole upload. `nextPollDelay` sleeps until the next segment should have
1533
+ * landed instead. It is pure over two journaled polls, which is what makes it both
1534
+ * replay-safe and testable without a clock.
1535
+ */
1536
+ describe("nextPollDelay", () => {
1537
+ /**
1538
+ * A poll: `stored` bytes at `observedAt` ms, prefix and total agreeing.
1539
+ *
1540
+ * Which is the WHOLE-FILE upload — the two numbers only agree there. The parts
1541
+ * fan-out is `detached` below, and it is a separate helper rather than an
1542
+ * optional argument so a case that means them to diverge has to say so.
1543
+ */
1544
+ const view = (stored: number, observedAt: number): UploadProgressView => ({
1545
+ size: stored,
1546
+ complete: false,
1547
+ stored,
1548
+ observedAt,
1549
+ });
1550
+ /** A poll under the parts fan-out: `stored` bytes landed, `size` readable. */
1551
+ const detached = (stored: number, size: number, observedAt: number): UploadProgressView => ({
1552
+ size,
1553
+ complete: false,
1554
+ stored,
1555
+ observedAt,
1556
+ });
1557
+ /**
1558
+ * A plan whose segments end at the given byte offsets.
1559
+ *
1560
+ * Both halves are REAL values rather than casts: `nextPollDelay` only reads
1561
+ * `segments`, but a `{} as never` format would stop reporting the moment the
1562
+ * function grew a second reader — which is the whole argument against the
1563
+ * cast. 16 kHz mono is what `normalizeRecording` produces.
1564
+ */
1565
+ const planAt = (...ends: number[]): StreamPlan => ({
1566
+ format: {
1567
+ sampleRate: 16_000,
1568
+ channels: 1,
1569
+ bitsPerSample: 16,
1570
+ dataStart: 44,
1571
+ dataEnd: 44 + Math.max(0, ...ends),
1572
+ },
1573
+ segments: ends.map((end, index) => ({ index, start: 0, end, startMs: 0, endMs: 0 })),
1574
+ });
1575
+
1576
+ test("falls back to the ceiling with no previous sample to take a rate from", () => {
1577
+ // The first sleep of every run: one point is not a rate.
1578
+ expect(nextPollDelay(view(1000, 5000), undefined, planAt(50_000), new Set())).toBe(5000);
1579
+ });
1580
+
1581
+ test("falls back to the ceiling for a STALLED upload rather than guessing", () => {
1582
+ // Nothing arrived between the two samples, so there is no arrival to
1583
+ // extrapolate. MAX_IDLE_POLLS is what ends such a run; this only declines to
1584
+ // predict it, and must not divide by zero doing so.
1585
+ expect(nextPollDelay(view(1000, 2000), view(1000, 1000), planAt(50_000), new Set())).toBe(5000);
1586
+ });
1587
+
1588
+ test("waits the time the next segment still needs, at the observed rate", () => {
1589
+ // 10_000 bytes over 1000ms is 10 bytes/ms; the next segment needs 10_000 more.
1590
+ const delay = nextPollDelay(view(20_000, 2000), view(10_000, 1000), planAt(30_000), new Set());
1591
+ expect(delay).toBe(1000);
1592
+ });
1593
+
1594
+ test("targets the EARLIEST unfinished segment, not the furthest", () => {
1595
+ // Segments land as they arrive, so what the loop can act on next is the first
1596
+ // one it has not done — waiting for the last would sleep through the rest.
1597
+ const done = new Set([0]);
1598
+ const delay = nextPollDelay(
1599
+ view(10_000, 2000),
1600
+ view(0, 1000),
1601
+ planAt(5000, 20_000, 90_000),
1602
+ done,
1603
+ );
1604
+ // 10 bytes/ms, 10_000 more needed to reach segment 1's end at 20_000.
1605
+ expect(delay).toBe(1000);
1606
+ });
1607
+
1608
+ test("clamps to the ceiling so a collapsing rate degrades to the old behaviour", () => {
1609
+ // 1 byte/ms against 10 MB outstanding is hours; the ceiling is what stops this
1610
+ // becoming a stall no MAX_IDLE_POLLS can see.
1611
+ const delay = nextPollDelay(view(1000, 2000), view(0, 1000), planAt(10_000_000), new Set());
1612
+ expect(delay).toBe(5000);
1613
+ });
1614
+
1615
+ test("clamps to the floor rather than spinning when the bytes are already there", () => {
1616
+ // The segment is stored but the loop reached the sleep anyway (it is waiting on
1617
+ // `complete`). A poll is cheap and not free — this must not become a spin.
1618
+ const delay = nextPollDelay(view(60_000, 2000), view(10_000, 1000), planAt(30_000), new Set());
1619
+ expect(delay).toBe(250);
1620
+ });
1621
+
1622
+ test("waits for the HEADER window before there is a plan to aim at", () => {
1623
+ // No plan yet, so what is being waited for is the probe window itself.
1624
+ const delay = nextPollDelay(view(0, 2000), view(0, 1000), undefined, new Set());
1625
+ // Rate is zero here, so this takes the stalled arm — the point is that a missing
1626
+ // plan is not a crash.
1627
+ expect(delay).toBe(5000);
1628
+ });
1629
+
1630
+ test("returns the ceiling once every segment is done", () => {
1631
+ // Nothing left to extrapolate toward: the loop is waiting on the `complete`
1632
+ // flag, which is set by the uploader rather than reached by bytes.
1633
+ const delay = nextPollDelay(
1634
+ view(50_000, 2000),
1635
+ view(40_000, 1000),
1636
+ planAt(30_000),
1637
+ new Set([0]),
1638
+ );
1639
+ expect(delay).toBe(5000);
1640
+ });
1641
+
1642
+ test("measures the wait against the PREFIX, not against every window that landed", () => {
1643
+ // The parts fan-out is the DEFAULT, and there `stored` and `size` diverge
1644
+ // completely (the module doc measured `size` at 0 for 45 seconds while the
1645
+ // whole file arrived). Both targets here are byte OFFSETS, so subtracting a
1646
+ // total that counts detached windows reads segment 1 as already reached and
1647
+ // collapses the sleep to its 250ms floor — 20x the polling on the path this
1648
+ // flow is normally on. 60_000 bytes have landed somewhere; only 4_000 of them
1649
+ // are readable from the start, so the segment ending at 30_000 needs 26_000
1650
+ // more at the observed 10 bytes/ms.
1651
+ const delay = nextPollDelay(
1652
+ detached(60_000, 4000, 2000),
1653
+ detached(50_000, 0, 1000),
1654
+ planAt(30_000),
1655
+ new Set(),
1656
+ );
1657
+ expect(delay).toBe(2600);
1658
+ });
1659
+
1660
+ test("waits for the HEADER window against the prefix too", () => {
1661
+ // Same trap one step earlier, and this is the arm that really runs first: the
1662
+ // body plans when `at.size >= HEADER_PROBE_BYTES`, so a delay derived from
1663
+ // `stored` is answering a different question than the one the loop asks. A
1664
+ // megabyte has landed in later windows and the header has not arrived.
1665
+ // 100 bytes/ms against the whole 64 KiB probe window is 656ms; measured
1666
+ // against `stored` the remainder is negative and the answer is the floor.
1667
+ const delay = nextPollDelay(
1668
+ detached(100_000, 0, 2000),
1669
+ detached(0, 0, 1000),
1670
+ undefined,
1671
+ new Set(),
1672
+ );
1673
+ expect(delay).toBe(656);
1674
+ });
1675
+
1676
+ /**
1677
+ * One 8 MiB upload window, the unit the store publishes a write in.
1678
+ *
1679
+ * The fan-out's real shape, and the one `size` cannot see: the prefix does not
1680
+ * move until the FIRST window lands, so a poll under it reads `size: 0` with
1681
+ * megabytes stored.
1682
+ */
1683
+ const PART_BYTES = 8 * 1024 * 1024;
1684
+
1685
+ /**
1686
+ * A poll of a parts upload whose PREFIX has not moved at all.
1687
+ *
1688
+ * The measured shape from the module doc — a 27 MB recording at 0.9 MB/s
1689
+ * reported `size: 0` for 45 of its 45 seconds while the windows landed. `size`
1690
+ * is fixed at 0 here for exactly that reason: a helper that let it drift would
1691
+ * let a case pass on the prefix arm, which is the hole these two cases exist
1692
+ * to close.
1693
+ */
1694
+ const landed = (ranges: readonly UploadRange[], observedAt: number): UploadProgressView => ({
1695
+ size: 0,
1696
+ complete: false,
1697
+ stored: storedBytes(0, ranges),
1698
+ ranges,
1699
+ observedAt,
1700
+ });
1701
+
1702
+ /** A plan whose segments are the given `[start, end)` windows. */
1703
+ const planOver = (...windows: readonly (readonly [number, number])[]): StreamPlan => ({
1704
+ format: {
1705
+ sampleRate: 48_000,
1706
+ channels: 2,
1707
+ bitsPerSample: 16,
1708
+ dataStart: 44,
1709
+ dataEnd: 44 + Math.max(0, ...windows.map(([, end]) => end)),
1710
+ },
1711
+ segments: windows.map(([start, end], index) => ({ index, start, end, startMs: 0, endMs: 0 })),
1712
+ });
1713
+
1714
+ test("measures a segment against the RANGES its readiness is decided on", () => {
1715
+ // The other half of the prefix rule above, and the one that decides this
1716
+ // flow's whole cadence: `segmentStored` reads `ranges`, so the work still
1717
+ // outstanding is what that test needs and not what the prefix is short by.
1718
+ // Under the fan-out the prefix stays at 0 for the length of the upload, so a
1719
+ // remainder measured against it is the WHOLE segment at every poll — which
1720
+ // saturates the ceiling and gives back the flat 5000ms interval this
1721
+ // function replaced, once per segment, for the entire recording.
1722
+ //
1723
+ // Two 8 MiB windows have landed contiguously from byte zero, at ~932
1724
+ // bytes/ms; the segment ends 866,384 bytes past them.
1725
+ const delay = nextPollDelay(
1726
+ landed([{ start: 0, end: 2 * PART_BYTES }], 10_000),
1727
+ landed([{ start: 0, end: PART_BYTES }], 1000),
1728
+ planOver([0, 17_643_600]),
1729
+ new Set(),
1730
+ );
1731
+ expect(delay).toBe(930);
1732
+ });
1733
+
1734
+ test("wakes for the segment CLOSEST to ready, which need not be the earliest", () => {
1735
+ // Windows land out of order — that is the premise the `ranges` arm rests on
1736
+ // — so the next segment the loop can act on is not always the first one it
1737
+ // has not done. Segment 0 has nothing covering its start and needs the whole
1738
+ // 20 MB prefix; segment 1 sits inside a window that is 500,000 bytes short.
1739
+ const delay = nextPollDelay(
1740
+ landed([{ start: 16_000_000, end: 35_500_000 }], 10_000),
1741
+ landed([{ start: 16_000_000, end: 27_111_608 }], 1000),
1742
+ planOver([0, 20_000_000], [16_000_000, 36_000_000]),
1743
+ new Set(),
1744
+ );
1745
+ // 932 bytes/ms against the 500,000 still missing from segment 1's window.
1746
+ expect(delay).toBe(537);
1747
+ });
1748
+
1749
+ test("falls back to the prefix for a window that does not cover the segment's START", () => {
1750
+ // A run has to cover a segment WHOLE to make it readable, so a window landing
1751
+ // in the middle of one moves nothing: what remains is the prefix's own
1752
+ // distance, which here is a ceiling's worth of waiting.
1753
+ const delay = nextPollDelay(
1754
+ landed([{ start: 8_000_000, end: 16_000_000 }], 10_000),
1755
+ landed([{ start: 8_000_000, end: 12_000_000 }], 1000),
1756
+ planOver([0, 17_643_600]),
1757
+ new Set(),
1758
+ );
1759
+ expect(delay).toBe(5000);
1760
+ });
1761
+ });