@alexkroman1/aai-cli 12.0.0 → 13.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.
@@ -14,24 +14,24 @@
14
14
  "publish:agent": "aai publish"
15
15
  },
16
16
  "dependencies": {
17
- "@alexkroman1/aai": "^12.0.0",
18
- "@alexkroman1/aai-runtime": "^12.0.0",
19
- "@alexkroman1/aai-ui": "^12.0.0",
17
+ "@alexkroman1/aai": "^13.0.0",
18
+ "@alexkroman1/aai-runtime": "^13.0.0",
19
+ "@alexkroman1/aai-ui": "^13.0.0",
20
20
  "react": "^19.2.8",
21
21
  "react-dom": "^19.2.8",
22
22
  "tailwindcss": "^4.0.0",
23
- "xstate": "^5.32.5",
24
- "zod": "^4.4.3"
23
+ "xstate": "^5.32.6",
24
+ "zod": "^4.5.4"
25
25
  },
26
26
  "devDependencies": {
27
- "@alexkroman1/aai-cli": "^12.0.0",
27
+ "@alexkroman1/aai-cli": "^13.0.0",
28
28
  "@tailwindcss/vite": "^4.3.3",
29
- "@types/node": "^26.2.0",
29
+ "@types/node": "^26.4.1",
30
30
  "@types/react": "^19.2.18",
31
- "@types/react-dom": "^19.2.4",
32
- "@vitejs/plugin-react": "^6.0.5",
31
+ "@types/react-dom": "^19.2.5",
32
+ "@vitejs/plugin-react": "^6.1.1",
33
33
  "typescript": "^7.0.2",
34
- "vite": "^8.2.1",
35
- "vitest": "^4.1.10"
34
+ "vite": "^8.2.2",
35
+ "vitest": "^4.1.11"
36
36
  }
37
37
  }
@@ -321,8 +321,31 @@ describe("reading the loudness measurement", () => {
321
321
  expect(() => parseLoudness(missing)).toThrow(/input_tp/);
322
322
  });
323
323
 
324
+ test("names the KEY when the value is unreadable, and QUOTES what came", () => {
325
+ // The other half of the case above: the key is there and holds something no
326
+ // number can be read out of — `"n/a"`, which is ffmpeg's own spelling for a
327
+ // value it could not measure, or a type that was never a number. Terminal
328
+ // either way, and the message has to carry the value, which is why the
329
+ // object gate and the value schema are two parses rather than one.
330
+ expect(() => parseLoudness(LOUDNORM_STDERR.replace('"-7.42"', '"n/a"'))).toThrow(
331
+ /input_tp.*got "n\/a"/,
332
+ );
333
+ expect(() => parseLoudness(LOUDNORM_STDERR.replace('"-7.42"', "[1,2]"))).toThrow(/input_tp/);
334
+ });
335
+
336
+ test("coerces exactly as `Number(…)` does, corners included", () => {
337
+ // Every value is a STRING on the wire, so the read has always been a
338
+ // coercion — and `Number(null)` is 0, not `NaN`. Pinned rather than
339
+ // tightened: the schema replaced a hand-written reader, so its corners are
340
+ // the ones that reader had, and narrowing them is a change to make on
341
+ // purpose rather than a side effect of declaring a schema.
342
+ const nulled = LOUDNORM_STDERR.replace('"0.13"', "null");
343
+ expect(parseLoudness(nulled).targetOffset).toBe(0);
344
+ });
345
+
324
346
  test("refuses a block that is not JSON at all", () => {
325
347
  expect(() => parseLoudness("[loudnorm] {not json}")).toThrow(MediaAnalysisError);
348
+ expect(() => parseLoudness("[loudnorm] {not json}")).toThrow(/not JSON/);
326
349
  });
327
350
  });
328
351
 
@@ -59,7 +59,7 @@
59
59
  */
60
60
 
61
61
  import type { PcmFormat } from "@alexkroman1/aai/step";
62
- import { isRecord } from "@alexkroman1/aai/utils";
62
+ import { z } from "zod";
63
63
 
64
64
  /**
65
65
  * The format every recording is converted to before anything measures it.
@@ -255,9 +255,9 @@ export function measureLoudnessArgs(input: string): string[] {
255
255
  * `{…}` in the text — last because a re-run's block would follow an earlier one,
256
256
  * and because nothing else ffmpeg logs at info level is brace-delimited.
257
257
  *
258
- * Every value arrives as a STRING (`"input_i" : "-16.19"`), which is ffmpeg's
259
- * shape and not a quirk of one version; a numeric coercion that silently yields
260
- * `NaN` is what a missing key would otherwise become, so each one is checked.
258
+ * Both halves of the read are declared below {@link LoudnessBlock} for "is it
259
+ * an object", {@link LoudnessValues} for the five values, every one of which
260
+ * arrives as a STRING and has to coerce without silently yielding `NaN`.
261
261
  */
262
262
  export function parseLoudness(stderr: string): Loudness {
263
263
  const open = stderr.lastIndexOf("{");
@@ -268,23 +268,68 @@ export function parseLoudness(stderr: string): Loudness {
268
268
  "loses `-loglevel info`, since `print_format=json` writes through ffmpeg's log.",
269
269
  );
270
270
  }
271
- // `isRecord` from the SDK rather than a hand-written
272
- // `typeof x === "object" && x !== null` this repo's rule (`guard-invariants`
273
- // rule 17). It also NARROWS, so nothing below needs the
274
- // `as Record<string, unknown>` the open-coded version required.
275
- const parsed = safeJson(stderr.slice(open, close + 1));
276
- if (!isRecord(parsed)) {
271
+ // Two parses, one per sentence this function can say. The block being an
272
+ // OBJECT and the five values being readable are different failures with
273
+ // different remedies, and the second message quotes the value ffmpeg actually
274
+ // printed — which needs the block still in hand, so the gate cannot be folded
275
+ // into the schema below.
276
+ const block = LoudnessBlock.safeParse(safeJson(stderr.slice(open, close + 1)));
277
+ if (!block.success) {
277
278
  throw new MediaAnalysisError("The loudness pass printed a block that is not JSON.");
278
279
  }
280
+
281
+ const values = LoudnessValues.safeParse(block.data);
282
+ if (!values.success) {
283
+ // Zod reports issues in the schema's own field order, so the first one names
284
+ // the same key the first per-key read named — and there is always a key,
285
+ // the object gate above having already passed.
286
+ const key = String(values.error.issues[0]?.path[0]);
287
+ throw new MediaAnalysisError(
288
+ `The loudness pass reported no usable \`${key}\` (got ${JSON.stringify(block.data[key])}).`,
289
+ );
290
+ }
291
+
279
292
  return {
280
- inputLufs: numberAt(parsed, "input_i"),
281
- inputTruePeak: numberAt(parsed, "input_tp"),
282
- inputRange: numberAt(parsed, "input_lra"),
283
- inputThreshold: numberAt(parsed, "input_thresh"),
284
- targetOffset: numberAt(parsed, "target_offset"),
293
+ inputLufs: values.data.input_i,
294
+ inputTruePeak: values.data.input_tp,
295
+ inputRange: values.data.input_lra,
296
+ inputThreshold: values.data.input_thresh,
297
+ targetOffset: values.data.target_offset,
285
298
  };
286
299
  }
287
300
 
301
+ /**
302
+ * Is the found `{…}` an object at all?
303
+ *
304
+ * The reachable failure is {@link safeJson} answering `undefined` — a brace pair
305
+ * found in ffmpeg's chatter with something other than JSON between them — and
306
+ * that is a different sentence from a value being unreadable, which is why this
307
+ * gate exists at all rather than being folded into {@link LoudnessValues}.
308
+ *
309
+ * `looseObject`, not `object`: it asks the ONE question and says nothing about
310
+ * keys, which matters because ffmpeg prints ten and the schema below reads five.
311
+ */
312
+ const LoudnessBlock = z.looseObject({});
313
+
314
+ /**
315
+ * The five numbers, in the order {@link parseLoudness} reports them missing.
316
+ *
317
+ * `z.coerce.number()` is the load-bearing choice: every value arrives as a
318
+ * STRING (`"input_i" : "-16.19"`), which is ffmpeg's shape and not a quirk of
319
+ * one version, so the schema has to coerce exactly as `Number(…)` did. What it
320
+ * adds is the check — zod 4's `z.number()` refuses `NaN` and both infinities,
321
+ * so a key ffmpeg stopped printing fails HERE with its name attached instead of
322
+ * flowing into pass two's argv as the literal text `NaN` and coming back as an
323
+ * ffmpeg option-parsing error about a filter.
324
+ */
325
+ const LoudnessValues = z.object({
326
+ input_i: z.coerce.number(),
327
+ input_tp: z.coerce.number(),
328
+ input_lra: z.coerce.number(),
329
+ input_thresh: z.coerce.number(),
330
+ target_offset: z.coerce.number(),
331
+ });
332
+
288
333
  /**
289
334
  * Pass two: apply the measurement, find the pauses, and write the audio.
290
335
  *
@@ -616,22 +661,3 @@ function safeJson(text: string): unknown {
616
661
  return undefined;
617
662
  }
618
663
  }
619
-
620
- /**
621
- * One of `loudnorm`'s values, as a number.
622
- *
623
- * Checked rather than coerced: every value arrives as a string, so `Number(…)` on
624
- * a key ffmpeg stopped printing yields `NaN`, which then flows into the second
625
- * pass's argv as the literal text `NaN` and makes ffmpeg reject the filter with a
626
- * message about option parsing. Naming the key here is what turns that into a
627
- * sentence about the analysis.
628
- */
629
- function numberAt(raw: Record<string, unknown>, key: string): number {
630
- const parsed = Number(raw[key]);
631
- if (!Number.isFinite(parsed)) {
632
- throw new MediaAnalysisError(
633
- `The loudness pass reported no usable \`${key}\` (got ${JSON.stringify(raw[key])}).`,
634
- );
635
- }
636
- return parsed;
637
- }
@@ -512,6 +512,72 @@ describe("discoverEpisodes", () => {
512
512
  expect(episodes[0]?.podcastTitle).toBe("Example Show");
513
513
  });
514
514
 
515
+ /**
516
+ * The three degradation cases of the iTunes payload, which is third-party
517
+ * JSON reached without an API key — so every one of them is a shape a live
518
+ * run really meets, and NONE of them may fail the run. A schema parsed with a
519
+ * throwing `parse` fails all three.
520
+ */
521
+ test("keeps the usable hit when a neighbouring entry is not even an object", async () => {
522
+ stub({
523
+ "itunes.apple.com/lookup": {
524
+ body: {
525
+ results: [
526
+ "no results",
527
+ 7,
528
+ null,
529
+ { feedUrl: "https://show.test/feed.xml", collectionName: "Example Show" },
530
+ ],
531
+ },
532
+ },
533
+ "show.test/feed.xml": { body: THREE_EPISODES },
534
+ });
535
+
536
+ const episodes = await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1);
537
+
538
+ expect(episodes[0]?.podcastTitle).toBe("Example Show");
539
+ });
540
+
541
+ test("reads a malformed lookup payload as no hits, and takes the other route", async () => {
542
+ // An interstitial, a rate-limit body, a schema change: `results` is not an
543
+ // array, so there is nothing to search — which is what the page fallback
544
+ // below this exists for, and is a normal outcome rather than a failed run.
545
+ stub({
546
+ "itunes.apple.com/lookup": { body: { results: "temporarily unavailable" } },
547
+ "podcasts.apple.com": {
548
+ body:
549
+ '<script id="serialized-server-data">' +
550
+ '{"d":[{"model":{"adamId":"123","feedUrl":"https://show.test/feed.xml"}}]}' +
551
+ "</script>",
552
+ },
553
+ "show.test/feed.xml": { body: THREE_EPISODES },
554
+ });
555
+
556
+ expect(await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1)).toHaveLength(
557
+ 1,
558
+ );
559
+ });
560
+
561
+ test("falls back to the feed's host when the hit names the show unusably", async () => {
562
+ // `collectionName` of the wrong type and no `trackName` behind it is the
563
+ // same case as neither field being there: the hit still carries the one
564
+ // field the caller came for, so it is used, and the title falls back to the
565
+ // feed's host. The feed here has no `<title>` of its own, which is what
566
+ // makes that fallback observable at all.
567
+ stub({
568
+ "itunes.apple.com/lookup": {
569
+ body: { results: [{ feedUrl: "https://show.test/feed.xml", collectionName: 42 }] },
570
+ },
571
+ "show.test/feed.xml": {
572
+ body: `<?xml version="1.0"?><rss><channel>${itemXml("only", "Tue, 01 Jan 2030 00:00:00 GMT")}</channel></rss>`,
573
+ },
574
+ });
575
+
576
+ const episodes = await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1);
577
+
578
+ expect(episodes[0]?.podcastTitle).toBe("show.test");
579
+ });
580
+
515
581
  test("falls back to Apple's own page when the lookup omits the feed", async () => {
516
582
  stub({
517
583
  "itunes.apple.com/lookup": { body: { results: [{ collectionName: "Example Show" }] } },
@@ -68,6 +68,7 @@ import { type FeedItem, pageMetadata, parseFeed } from "@alexkroman1/aai/html";
68
68
  import { report } from "@alexkroman1/aai/step";
69
69
  import { FatalError, stepFetchOk } from "@alexkroman1/aai/step-errors";
70
70
  import { isRecord, omitUndefined, safeJsonParse } from "@alexkroman1/aai/utils";
71
+ import { z } from "zod";
71
72
 
72
73
  /** How long any one of these lookups may take before it is a failure. */
73
74
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -463,27 +464,64 @@ function normalizeTitle(value: string): string {
463
464
  .trim();
464
465
  }
465
466
 
467
+ /** One iTunes hit, reduced to the three fields this file reads. */
468
+ type AppleResult = { feedUrl?: string; title?: string; artist?: string };
469
+
466
470
  /**
467
- * The iTunes payload, reduced to the three fields this file reads.
471
+ * One entry of an iTunes `results` array, reduced on the way through.
472
+ *
473
+ * Every field is `.catch(undefined)`, which is this schema's whole subject: the
474
+ * caller is looking for a hit with a `feedUrl` on it, so a neighbouring entry
475
+ * that spells `collectionName` as a number is a hit that does not match, never
476
+ * a reason to abandon the search. The reduction rides in the `transform` for
477
+ * the same reason the fields are declared here rather than at the call site —
478
+ * `collectionName ?? trackName` is a fact about iTunes' payload, and this is
479
+ * the module that is allowed to know vendor facts.
468
480
  *
469
481
  * `omitUndefined` rather than a conditional spread per field: under
470
482
  * `exactOptionalPropertyTypes` an absent field and a field set to `undefined`
471
483
  * are different types, and this is the SDK's one spelling for the difference.
484
+ * It is still needed after a schema, because a field the `.catch` above turned
485
+ * into `undefined` is PRESENT in zod's output holding `undefined` (only an
486
+ * absent field stays absent).
472
487
  */
473
- function appleResults(body: unknown): Array<{ feedUrl?: string; title?: string; artist?: string }> {
474
- if (!(isRecord(body) && Array.isArray(body.results))) return [];
475
- return body.results.filter(isRecord).map((result) =>
476
- omitUndefined({
477
- feedUrl: asString(result.feedUrl),
478
- title: asString(result.collectionName) ?? asString(result.trackName),
479
- artist: asString(result.artistName),
480
- }),
481
- );
482
- }
488
+ const AppleResultEntry = z
489
+ .object({
490
+ feedUrl: z.string().optional().catch(undefined),
491
+ collectionName: z.string().optional().catch(undefined),
492
+ trackName: z.string().optional().catch(undefined),
493
+ artistName: z.string().optional().catch(undefined),
494
+ })
495
+ .transform(
496
+ (result): AppleResult =>
497
+ omitUndefined({
498
+ feedUrl: result.feedUrl,
499
+ title: result.collectionName ?? result.trackName,
500
+ artist: result.artistName,
501
+ }),
502
+ )
503
+ // An entry that is not an object at all — the one shape the fields above
504
+ // cannot absorb — becomes `null` and is dropped below, rather than taking the
505
+ // whole array down with it. That is the `.filter(isRecord)` this replaces.
506
+ .nullable()
507
+ .catch(null);
508
+
509
+ /** The iTunes lookup and search payloads, which share the one field read here. */
510
+ const AppleResponse = z.object({ results: z.array(AppleResultEntry) });
483
511
 
484
- /** A JSON field, when the far side really did send a string. */
485
- function asString(value: unknown): string | undefined {
486
- return typeof value === "string" ? value : undefined;
512
+ /**
513
+ * The hits in an iTunes response, and NOTHING is an ordinary answer.
514
+ *
515
+ * `safeParse`, never `parse`: both callers treat an empty list as "no match
516
+ * found" and already have a sentence for it, so a body that is not an object,
517
+ * or one whose `results` is not an array, must degrade rather than fail the
518
+ * run. These are live third-party endpoints reached without an API key — a
519
+ * throw here would turn a rate-limit interstitial into a failed durable run.
520
+ */
521
+ function appleResults(body: unknown): AppleResult[] {
522
+ const parsed = AppleResponse.safeParse(body);
523
+ if (!parsed.success) return [];
524
+ return parsed.data.results.filter((result) => result !== null);
487
525
  }
488
526
 
489
527
  // ---- HTTP -------------------------------------------------------------------
@@ -554,6 +554,33 @@ describe("checkTranscript", () => {
554
554
  stubProvider({ status: "transcribing" });
555
555
  await expect(checkTranscript("t_1")).rejects.toThrow(/unknown transcript status/);
556
556
  });
557
+
558
+ test("drops a field of the wrong type rather than failing the poll", async () => {
559
+ // THE degradation rule, and the test that fails against a schema parsed
560
+ // with a throwing `parse`: the job is running and `status` says so, so a
561
+ // `text` the provider sent as a number is one unusable field — not a reason
562
+ // to end a run that has already paid for the transcription. Each optional
563
+ // field carries its own `.catch(undefined)` for exactly this.
564
+ stubProvider({ status: "processing", text: 42, error: [], audio_duration: "254" });
565
+ expect(await checkTranscript("t_1")).toEqual({ status: "processing" });
566
+ });
567
+
568
+ test("drops a non-finite duration, which `Math.round(x / 60)` cannot use", async () => {
569
+ // JSON cannot spell `Infinity`, so this arrives as a string or a null and
570
+ // has to be refused the same way — `z.number()` refuses both, which is the
571
+ // `Number.isFinite` test the hand-written reader carried.
572
+ stubProvider({ status: "completed", text: "Hello.", audio_duration: null });
573
+ expect(await checkTranscript("t_1")).toEqual({ status: "completed", text: "Hello." });
574
+ });
575
+
576
+ test("names an unreadable body as `undefined` rather than throwing twice", async () => {
577
+ // Valid JSON that is not this endpoint's object — a proxy that answered
578
+ // with a list, say. There is no status to report, so the sentence says
579
+ // exactly that rather than the report itself failing on the body it was
580
+ // sent to describe.
581
+ stubProvider([{ status: "completed" }]);
582
+ await expect(checkTranscript("t_1")).rejects.toThrow(/unknown transcript status: undefined/);
583
+ });
557
584
  });
558
585
 
559
586
  describe("discardTranscript — the compensation", () => {
@@ -120,7 +120,7 @@ import {
120
120
  stepTranscribeSubmitClassified,
121
121
  toStepError,
122
122
  } from "@alexkroman1/aai/step-errors";
123
- import { errorMessage, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
123
+ import { errorMessage, omitUndefined } from "@alexkroman1/aai/utils";
124
124
  import { z } from "zod";
125
125
  import { retentionToken, transcriptToken } from "./tokens.ts";
126
126
 
@@ -278,6 +278,58 @@ export type TranscriptState = {
278
278
  audioDuration?: number;
279
279
  };
280
280
 
281
+ /**
282
+ * The four statuses this desk knows how to act on.
283
+ *
284
+ * Declared as a schema rather than checked with a `!==` chain because the union
285
+ * IS the provider's contract with this template — see {@link checkTranscript},
286
+ * which reads it as a value the saga branches on — and a schema is the one
287
+ * spelling that both narrows the type and names the four in the error.
288
+ */
289
+ const TranscriptStatus = z.enum(["queued", "processing", "completed", "error"]);
290
+
291
+ /**
292
+ * The poll response, as much of it as this desk reads.
293
+ *
294
+ * **The three optional fields DEGRADE and the status does not**, which is the
295
+ * whole reason each one is spelled differently. A provider that answers
296
+ * `"text": null` on a job still running, or drops `audio_duration` from a
297
+ * failed one, is describing a job this desk can still act on — so a field of
298
+ * the wrong type is `undefined` (`.catch`) and an absent one stays absent. A
299
+ * status outside the four is the opposite: nothing downstream knows what to do
300
+ * with it, and polling to the bound and failing with the wrong reason is worse
301
+ * than stopping here. So a bad status fails the parse and
302
+ * {@link checkTranscript} turns that into a sentence.
303
+ *
304
+ * `audio_duration` is `z.number()` rather than a coercion, and zod 4's
305
+ * `z.number()` refuses `NaN` and both infinities — the `Number.isFinite` test
306
+ * the hand-written reader carried, which is what stops a non-finite duration
307
+ * reaching `Math.round(… / 60)` in {@link summarize}.
308
+ */
309
+ const TranscriptBody = z.object({
310
+ status: TranscriptStatus,
311
+ text: z.string().optional().catch(undefined),
312
+ error: z.string().optional().catch(undefined),
313
+ audio_duration: z.number().optional().catch(undefined),
314
+ });
315
+
316
+ /**
317
+ * The status to NAME when {@link TranscriptBody} refused the body.
318
+ *
319
+ * Reporting the value is the job, so nothing here may throw a second time while
320
+ * reporting the first. `.catch({})` absorbs every shape that is not "an object
321
+ * with a string status" — a body that is not an object at all, a `status` that
322
+ * is a number — and each then arrives in the sentence as the word "undefined",
323
+ * which is a truthful "the provider sent something this desk cannot read" and
324
+ * the same thing the per-field read this replaced said. That `.catch` is also
325
+ * what makes `.parse` legal at the one call site: there is no input left for it
326
+ * to throw on.
327
+ */
328
+ const ReportedStatus = z
329
+ .object({ status: z.string().optional() })
330
+ .catch({})
331
+ .transform((body) => body.status);
332
+
281
333
  /**
282
334
  * One undo, and the name it goes by when it fails.
283
335
  *
@@ -636,26 +688,31 @@ export function callbackUrl(token: string): string | undefined {
636
688
  export async function checkTranscript(id: string): Promise<TranscriptState> {
637
689
  const response = await request(`${TRANSCRIPT_ENDPOINT}/${id}`);
638
690
  const body = await response.json();
639
- const status = readString(body, "status");
640
- if (
641
- status !== "queued" &&
642
- status !== "processing" &&
643
- status !== "completed" &&
644
- status !== "error"
645
- )
646
- throw new Error(`The provider reported an unknown transcript status: ${String(status)}`);
647
-
648
- await report(`Transcript ${status}.`);
691
+ // `safeParse`, not `parse`: the only shape this desk refuses is an
692
+ // unrecognised status, and it owes that a sentence naming what arrived rather
693
+ // than a zod issue about an enum.
694
+ const parsed = TranscriptBody.safeParse(body);
695
+ if (!parsed.success) {
696
+ throw new Error(
697
+ `The provider reported an unknown transcript status: ${String(ReportedStatus.parse(body))}`,
698
+ );
699
+ }
700
+
701
+ await report(`Transcript ${parsed.data.status}.`);
649
702
  return {
650
- status,
703
+ status: parsed.data.status,
651
704
  // `omitUndefined` rather than a spread-ternary per field: under
652
705
  // `exactOptionalPropertyTypes` an absent field and a field set to
653
706
  // `undefined` are different types, and this is the SDK's one spelling for
654
- // the difference.
707
+ // the difference. Still needed after the schema, and for a reason worth
708
+ // knowing: an ABSENT field is absent from zod's output too, but a field
709
+ // whose value the schema `.catch`-ed to `undefined` is PRESENT and holding
710
+ // `undefined` — and this result crosses a queue, where such a key does not
711
+ // survive the trip.
655
712
  ...omitUndefined({
656
- text: readString(body, "text"),
657
- error: readString(body, "error"),
658
- audioDuration: readNumber(body, "audio_duration"),
713
+ text: parsed.data.text,
714
+ error: parsed.data.error,
715
+ audioDuration: parsed.data.audio_duration,
659
716
  }),
660
717
  };
661
718
  }
@@ -744,7 +801,7 @@ export async function note(line: string): Promise<void> {
744
801
  await report(line);
745
802
  }
746
803
 
747
- // ---- HTTP and parsing -------------------------------------------------------
804
+ // ---- HTTP -------------------------------------------------------------------
748
805
 
749
806
  /**
750
807
  * One authenticated request to the pre-recorded API, with this desk's retry
@@ -776,15 +833,3 @@ async function request(
776
833
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
777
834
  });
778
835
  }
779
-
780
- /** A string field of a JSON body, when it really is one. */
781
- function readString(body: unknown, key: string): string | undefined {
782
- const value = isRecord(body) ? body[key] : undefined;
783
- return typeof value === "string" ? value : undefined;
784
- }
785
-
786
- /** A number field of a JSON body, when it really is one. */
787
- function readNumber(body: unknown, key: string): number | undefined {
788
- const value = isRecord(body) ? body[key] : undefined;
789
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
790
- }
@@ -1615,10 +1615,14 @@ describe("nextPollDelay", () => {
1615
1615
  });
1616
1616
 
1617
1617
  test("clamps to the floor rather than spinning when the bytes are already there", () => {
1618
- // The segment is stored but the loop reached the sleep anyway (it is waiting on
1619
- // `complete`). A poll is cheap and not free this must not become a spin.
1618
+ // A remainder of zero, which the `Math.max` below the estimate answers with the
1619
+ // floor there is no arm of its own for it, and the one that used to be there
1620
+ // was unreachable from the body anyway (a stored segment goes in `ready` and
1621
+ // the loop `continue`s past the sleep). What the floor is FOR is that the body
1622
+ // must not poll faster than the round trip its own sleep costs, which is why it
1623
+ // is 1000ms and not the 250ms that could not sleep at all.
1620
1624
  const delay = nextPollDelay(view(60_000, 2000), view(10_000, 1000), planAt(30_000), new Set());
1621
- expect(delay).toBe(250);
1625
+ expect(delay).toBe(1000);
1622
1626
  });
1623
1627
 
1624
1628
  test("waits for the HEADER window before there is a plan to aim at", () => {
@@ -1664,15 +1668,21 @@ describe("nextPollDelay", () => {
1664
1668
  // body plans when `at.size >= HEADER_PROBE_BYTES`, so a delay derived from
1665
1669
  // `stored` is answering a different question than the one the loop asks. A
1666
1670
  // megabyte has landed in later windows and the header has not arrived.
1667
- // 100 bytes/ms against the whole 64 KiB probe window is 656ms; measured
1671
+ // 50 bytes/ms against the whole 64 KiB probe window is 1311ms; measured
1668
1672
  // against `stored` the remainder is negative and the answer is the floor.
1673
+ //
1674
+ // The rate is half what this case used to carry, and deliberately: at 100
1675
+ // bytes/ms the answer is 656ms, which is UNDER `MIN_POLL_INTERVAL_MS` and
1676
+ // therefore the same 1000 a broken remainder would give — the case would
1677
+ // still pass while measuring nothing. Every case below keeps its answer
1678
+ // clear of both bounds for that reason.
1669
1679
  const delay = nextPollDelay(
1670
- detached(100_000, 0, 2000),
1680
+ detached(100_000, 0, 3000),
1671
1681
  detached(0, 0, 1000),
1672
1682
  undefined,
1673
1683
  new Set(),
1674
1684
  );
1675
- expect(delay).toBe(656);
1685
+ expect(delay).toBe(1311);
1676
1686
  });
1677
1687
 
1678
1688
  /**
@@ -1722,15 +1732,15 @@ describe("nextPollDelay", () => {
1722
1732
  // saturates the ceiling and gives back the flat 5000ms interval this
1723
1733
  // function replaced, once per segment, for the entire recording.
1724
1734
  //
1725
- // Two 8 MiB windows have landed contiguously from byte zero, at ~932
1735
+ // Two 8 MiB windows have landed contiguously from byte zero, at ~466
1726
1736
  // bytes/ms; the segment ends 866,384 bytes past them.
1727
1737
  const delay = nextPollDelay(
1728
- landed([{ start: 0, end: 2 * PART_BYTES }], 10_000),
1738
+ landed([{ start: 0, end: 2 * PART_BYTES }], 19_000),
1729
1739
  landed([{ start: 0, end: PART_BYTES }], 1000),
1730
1740
  planOver([0, 17_643_600]),
1731
1741
  new Set(),
1732
1742
  );
1733
- expect(delay).toBe(930);
1743
+ expect(delay).toBe(1860);
1734
1744
  });
1735
1745
 
1736
1746
  test("wakes for the segment CLOSEST to ready, which need not be the earliest", () => {
@@ -1739,13 +1749,13 @@ describe("nextPollDelay", () => {
1739
1749
  // has not done. Segment 0 has nothing covering its start and needs the whole
1740
1750
  // 20 MB prefix; segment 1 sits inside a window that is 500,000 bytes short.
1741
1751
  const delay = nextPollDelay(
1742
- landed([{ start: 16_000_000, end: 35_500_000 }], 10_000),
1752
+ landed([{ start: 16_000_000, end: 35_500_000 }], 19_000),
1743
1753
  landed([{ start: 16_000_000, end: 27_111_608 }], 1000),
1744
1754
  planOver([0, 20_000_000], [16_000_000, 36_000_000]),
1745
1755
  new Set(),
1746
1756
  );
1747
- // 932 bytes/ms against the 500,000 still missing from segment 1's window.
1748
- expect(delay).toBe(537);
1757
+ // 466 bytes/ms against the 500,000 still missing from segment 1's window.
1758
+ expect(delay).toBe(1073);
1749
1759
  });
1750
1760
 
1751
1761
  test("falls back to the prefix for a window that does not cover the segment's START", () => {
@@ -215,22 +215,43 @@ const POLL_INTERVAL_MS = 5000;
215
215
  * A poll is one cheap step (the body's own note above the `continue` says so), but
216
216
  * it is not free — it is a journal write and, on the platform, a step execution —
217
217
  * so a rate estimate that comes out near zero must not turn the loop into a spin.
218
- * 250ms is under the latency of any single segment's transcription, so nothing is
219
- * waiting on this.
218
+ *
219
+ * **The comparison that decides this number is the ROUND TRIP of the machinery
220
+ * that implements the sleep, not the latency of a segment's transcription.** It
221
+ * was 250ms on the second reading, which made it DEAD: `ctx.sleep`'s deadline is
222
+ * computed before its journal write is issued and tested after that write comes
223
+ * back, so on the platform — where one journal call was measured at ~558ms mean,
224
+ * p50 ranging 164-796ms — a 250ms sleep has already expired by the time its own
225
+ * write answers. It did not sleep at all, and the loop then polled as fast as the
226
+ * journal would let it. A floor under the round trip is not a floor.
227
+ *
228
+ * 1000ms is above every one of those readings and still an order of magnitude
229
+ * under a single segment's transcription, so nothing is waiting on it.
220
230
  */
221
- const MIN_POLL_INTERVAL_MS = 250;
231
+ const MIN_POLL_INTERVAL_MS = 1000;
222
232
 
223
233
  /**
224
234
  * Consecutive polls with NO new bytes before the run gives up.
225
235
  *
226
236
  * An upload that died stays incomplete forever, so without a bound the run polls for
227
- * as long as the world will replay it. At {@link POLL_INTERVAL_MS} this is five minutes
228
- * of silence — far longer than any stall a live uplink produces, and short enough
229
- * that the failure reaches whoever is watching.
237
+ * as long as the world will replay it.
238
+ *
239
+ * **What one poll COSTS is a delivery, not an interval, and this doc said
240
+ * otherwise.** It read "at {@link POLL_INTERVAL_MS} this is five minutes of
241
+ * silence", which counts the sleep and nothing else. A poll that sleeps also
242
+ * suspends the run, so the wall clock between two polls is the sleep plus a
243
+ * queue round trip plus the next delivery's opening reads — measured on a
244
+ * deployed run at 11-40 seconds. Sixty of those is **20 to 40 minutes**, not
245
+ * five.
246
+ *
247
+ * That is longer than intended and is left as it is: what the bound has to
248
+ * outlast is a stall a live uplink really produces, and erring long costs an
249
+ * abandoned upload some idle deliveries where erring short fails a healthy
250
+ * recording. The number to write down is the honest one.
230
251
  *
231
252
  * It resets on every byte, so a slow upload is bounded by its own quietest gap
232
253
  * rather than by its total length: a two-hour recording on a bad connection is fine
233
- * as long as something arrives every five minutes.
254
+ * as long as something arrives inside that window.
234
255
  */
235
256
  const MAX_IDLE_POLLS = 60;
236
257
 
@@ -266,8 +287,9 @@ export type UploadProgressView = {
266
287
  * When this view was taken, as the step that took it saw the clock.
267
288
  *
268
289
  * Journaled, which is the only reason the body may read a clock at all: the
269
- * sleep below is derived from the RATE between two of these, and a value the
270
- * body sampled itself would make that derivation diverge on a replay. Same rule
290
+ * sleep below is derived from the RATE between the run's FIRST view and this
291
+ * one, and a value the body sampled itself would make that derivation diverge
292
+ * on a replay. Same rule
271
293
  * as every other field here — see the body's own note on why its state is legal.
272
294
  */
273
295
  observedAt: number;
@@ -304,10 +326,20 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
304
326
  // stopped from one whose prefix has not caught up yet.
305
327
  let lastStored = -1;
306
328
  /**
307
- * The previous poll, so {@link nextPollDelay} has two journaled samples to take
308
- * a rate from. Body state for the same reason the rest is: it came out of a step.
329
+ * The FIRST poll, so {@link nextPollDelay} has a baseline to take an AVERAGE
330
+ * rate from. Body state for the same reason the rest is: it came out of a step.
331
+ *
332
+ * It used to be the PREVIOUS poll, and a two-sample difference is not a
333
+ * throughput here: the store publishes bytes an `UPLOAD_PART_BYTES` window at a
334
+ * time, so consecutive polls see either no change at all — which reads as a
335
+ * stall and gives back the flat ceiling — or one whole 8 MiB window, an
336
+ * instantaneous burst rate tens of times the real average that collapses the
337
+ * sleep to its floor. The distribution is bimodal and neither mode is the
338
+ * number the estimate wants. A baseline that never moves measures exactly what
339
+ * the arithmetic below claims to: bytes delivered per millisecond, over the
340
+ * upload so far.
309
341
  */
310
- let previous: UploadProgressView | undefined;
342
+ let baseline: UploadProgressView | undefined;
311
343
 
312
344
  for (;;) {
313
345
  const at = await ctx.step("probeUpload", () => probeUpload(input.recording));
@@ -315,6 +347,11 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
315
347
  // on a `complete` view, whose prefix is the whole file. Updating it inside a
316
348
  // branch is how it used to end up describing whichever poll last had work.
317
349
  lastSize = at.size;
350
+ // HERE, not after the sleep below, and that placement is the whole bug the
351
+ // baseline replaced: the `continue` on a batch of ready segments skipped the
352
+ // old `previous = at`, so the next rate was computed against a view taken
353
+ // before the batch. Set once, before any branch can be taken.
354
+ baseline ??= at;
318
355
 
319
356
  // The header has to be present before anything can be planned, and it is the
320
357
  // first thing to arrive. `complete` also qualifies, for a recording shorter
@@ -381,8 +418,7 @@ export async function transcribeStreamFlow(input: { recording: string }, ctx: Wo
381
418
  // Sleep until the next segment should HAVE landed, rather than for a fixed
382
419
  // interval — see `nextPollDelay`. Both arguments are journaled step results,
383
420
  // so a replay computes the same delay from the same two samples.
384
- await ctx.sleep("poll", nextPollDelay(at, previous, plan, done));
385
- previous = at;
421
+ await ctx.sleep("poll", nextPollDelay(at, baseline, plan, done));
386
422
  }
387
423
 
388
424
  const finished = plan;
@@ -429,8 +465,16 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
429
465
  * The flat {@link POLL_INTERVAL_MS} this replaced is wrong in both directions on a
430
466
  * slow uplink: too long when a segment is seconds away, and equally too long when
431
467
  * it is a minute away, so the run discovers work late and then asks again pointlessly.
432
- * Two consecutive polls give a byte RATE, the plan gives the byte offset the next
433
- * un-transcribed segment needs, and the difference is a wait with a reason.
468
+ * The run's FIRST poll and its latest one give a byte RATE, the plan gives the byte
469
+ * offset the next un-transcribed segment needs, and the difference is a wait with a
470
+ * reason.
471
+ *
472
+ * **`baseline` is the first poll of the run and never moves**, so what this reads is
473
+ * the AVERAGE throughput rather than a first difference between two adjacent polls.
474
+ * That is not a refinement: bytes are published an `UPLOAD_PART_BYTES` window at a
475
+ * time, so a two-sample difference is 0 or one whole 8 MiB window and never the
476
+ * rate — see the body's own note where the baseline is set. The arithmetic below is
477
+ * unchanged, which is why it is the CALLER that had the bug.
434
478
  *
435
479
  * Every input is a journaled step result — both views, and a plan derived from one —
436
480
  * so a replay computes the identical delay. That is the whole reason
@@ -445,15 +489,16 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
445
489
  */
446
490
  export function nextPollDelay(
447
491
  at: UploadProgressView,
448
- previous: UploadProgressView | undefined,
492
+ baseline: UploadProgressView | undefined,
449
493
  plan: StreamPlan | undefined,
450
494
  done: ReadonlySet<number>,
451
495
  ): number {
452
- // No previous sample, or a clock that did not advance: nothing to derive a rate
453
- // from. The first sleep of every run takes this arm.
454
- const elapsedMs = previous ? at.observedAt - previous.observedAt : 0;
455
- if (!previous || elapsedMs <= 0) return POLL_INTERVAL_MS;
456
- const bytesPerMs = (at.stored - previous.stored) / elapsedMs;
496
+ // No baseline, or a clock that has not advanced past it: nothing to derive a
497
+ // rate from. The first sleep of every run takes this arm, the baseline BEING
498
+ // that poll.
499
+ const elapsedMs = baseline ? at.observedAt - baseline.observedAt : 0;
500
+ if (!baseline || elapsedMs <= 0) return POLL_INTERVAL_MS;
501
+ const bytesPerMs = (at.stored - baseline.stored) / elapsedMs;
457
502
  // A stalled or shrinking upload has no arrival to predict. `MAX_IDLE_POLLS` is
458
503
  // what ends that run; this only declines to guess about it.
459
504
  if (bytesPerMs <= 0) return POLL_INTERVAL_MS;
@@ -476,7 +521,13 @@ export function nextPollDelay(
476
521
  // Every segment is already stored: the loop is waiting on `complete`, which is a
477
522
  // flag the uploader sets rather than bytes to extrapolate.
478
523
  if (remaining === undefined) return POLL_INTERVAL_MS;
479
- if (remaining <= 0) return MIN_POLL_INTERVAL_MS;
524
+ // There is no `remaining <= 0` arm, and there used to be one returning the
525
+ // floor. It was UNREACHABLE from the body — `bytesUntilStored` answers 0 only
526
+ // when `segmentStored` does, which would have put the segment in `ready` and
527
+ // taken the `continue` above, and the no-plan arm is only taken while
528
+ // `at.size < HEADER_PROBE_BYTES` — and REDUNDANT besides: `Math.ceil` of a
529
+ // non-positive quotient is non-positive, so the `Math.max` below already
530
+ // answers the floor for it. Deleting it changes no answer for any input.
480
531
  return Math.min(
481
532
  POLL_INTERVAL_MS,
482
533
  Math.max(MIN_POLL_INTERVAL_MS, Math.ceil(remaining / bytesPerMs)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "12.0.0",
3
+ "version": "13.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -50,18 +50,18 @@
50
50
  "ofetch": "^1.5.1",
51
51
  "p-debounce": "^5.1.0",
52
52
  "p-timeout": "^7.0.1",
53
- "vite": "^8.2.1",
54
- "zod": "^4.4.3",
55
- "@alexkroman1/aai-runtime": "12.0.0",
56
- "@alexkroman1/aai": "12.0.0",
57
- "@alexkroman1/aai-ui": "12.0.0"
53
+ "vite": "^8.2.2",
54
+ "zod": "^4.5.4",
55
+ "@alexkroman1/aai": "13.0.0",
56
+ "@alexkroman1/aai-runtime": "13.0.0",
57
+ "@alexkroman1/aai-ui": "13.0.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "playwright": "^1.62.1",
61
61
  "tsdown": "^0.22.14",
62
- "verdaccio": "^6.9.2",
63
- "vitest": "^4.1.10",
64
- "aai-templates": "0.3.9"
62
+ "verdaccio": "^6.10.1",
63
+ "vitest": "^4.1.11",
64
+ "aai-templates": "0.3.10"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "vitest": "^4.1.10"