@alexkroman1/aai-cli 9.1.0 → 9.2.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,9 +14,9 @@
14
14
  "publish:agent": "aai publish"
15
15
  },
16
16
  "dependencies": {
17
- "@alexkroman1/aai": "^9.1.0",
18
- "@alexkroman1/aai-runtime": "^9.1.0",
19
- "@alexkroman1/aai-ui": "^9.1.0",
17
+ "@alexkroman1/aai": "^9.2.0",
18
+ "@alexkroman1/aai-runtime": "^9.2.0",
19
+ "@alexkroman1/aai-ui": "^9.2.0",
20
20
  "@workflow/world-postgres": "4.3.3",
21
21
  "react": "^19.2.8",
22
22
  "react-dom": "^19.2.8",
@@ -26,7 +26,7 @@
26
26
  "zod": "^4.4.3"
27
27
  },
28
28
  "devDependencies": {
29
- "@alexkroman1/aai-cli": "^9.1.0",
29
+ "@alexkroman1/aai-cli": "^9.2.0",
30
30
  "@tailwindcss/vite": "^4.3.3",
31
31
  "@types/node": "^26.2.0",
32
32
  "@types/react": "^19.2.18",
@@ -38,7 +38,14 @@ import {
38
38
  NORMALIZED_SAMPLE_RATE,
39
39
  normalizeRecording,
40
40
  } from "./workflows/normalize.ts";
41
- import { expectedSegments, planStreamed, probeUpload } from "./workflows/stream.ts";
41
+ import {
42
+ expectedSegments,
43
+ planStreamed,
44
+ probeUpload,
45
+ segmentStored,
46
+ storedBytes,
47
+ type UploadProgressView,
48
+ } from "./workflows/stream.ts";
42
49
  import {
43
50
  mergeTranscript,
44
51
  splitRecording,
@@ -666,7 +673,92 @@ describe("the streaming flow", () => {
666
673
  publishPartial(1000, 320_000);
667
674
  // The poll the body runs. `complete` is separate from `size` because a size that
668
675
  // stopped growing is not a claim that the file is finished.
669
- await expect(probeUpload(UPLOAD_ID)).resolves.toEqual({ size: 44 + 1000, complete: false });
676
+ // `stored` equals `size` here and only here: a whole-file upload's bytes ARE
677
+ // its prefix, and the store publishes no windows for one.
678
+ await expect(probeUpload(UPLOAD_ID)).resolves.toEqual({
679
+ size: 44 + 1000,
680
+ complete: false,
681
+ stored: 44 + 1000,
682
+ });
683
+ });
684
+
685
+ /**
686
+ * A poll of a parts upload: `landed` windows of a `declared`-byte file.
687
+ *
688
+ * Built by hand rather than through `stubUploads`, which models an upload as one
689
+ * contiguous buffer and so cannot express a HOLE — which is the entire state under
690
+ * test. These two functions are pure over a poll's result, so a literal is the
691
+ * whole fixture.
692
+ */
693
+ function poll(landed: readonly [number, number][], declared: number): UploadProgressView {
694
+ const ranges = landed.map(([start, end]) => ({ start, end }));
695
+ const prefix = ranges.find((range) => range.start === 0)?.end ?? 0;
696
+ return {
697
+ size: prefix,
698
+ complete: prefix >= declared,
699
+ stored: storedBytes(prefix, ranges),
700
+ ranges,
701
+ };
702
+ }
703
+
704
+ test("segmentStored reads a landed window the PREFIX cannot see", () => {
705
+ // The state the browser's default fan-out produces and the reason this flow
706
+ // was a no-op against it: eight windows go up at once, share the uplink, and
707
+ // finish together — so nothing starts at byte zero until the very end. Measured
708
+ // on a deployed agent, a 27 MB recording at 0.9 MB/s reported `size: 0` at every
709
+ // poll for 45 seconds and then the whole file.
710
+ const at = poll([[8000, 24_000]], 32_000);
711
+ expect(at.size).toBe(0);
712
+ expect(segmentStored({ index: 1, start: 8000, end: 16_000, startMs: 0, endMs: 0 }, at)).toBe(
713
+ true,
714
+ );
715
+ // And the prefix arm still answers on its own, which is what keeps a whole-file
716
+ // upload (no windows at all) behaving exactly as it did.
717
+ expect(segmentStored({ index: 0, start: 0, end: 4000, startMs: 0, endMs: 0 }, at)).toBe(false);
718
+ });
719
+
720
+ test("segmentStored refuses a window that STRADDLES a hole", () => {
721
+ // A run is contiguous, so containment in one is the whole test — and it has to
722
+ // be, because `readUpload` clamps to the run a read starts in. A segment
723
+ // spanning two runs would come back short and be transcribed as a fragment,
724
+ // which is a wrong transcript rather than a failed one.
725
+ const at = poll(
726
+ [
727
+ [0, 8000],
728
+ [16_000, 24_000],
729
+ ],
730
+ 32_000,
731
+ );
732
+ expect(segmentStored({ index: 1, start: 4000, end: 20_000, startMs: 0, endMs: 0 }, at)).toBe(
733
+ false,
734
+ );
735
+ expect(segmentStored({ index: 2, start: 16_000, end: 24_000, startMs: 0, endMs: 0 }, at)).toBe(
736
+ true,
737
+ );
738
+ });
739
+
740
+ test("storedBytes counts the WINDOWS, so a moving upload never reads as stalled", () => {
741
+ // The other half of the fix. Judge a stall on the prefix and a parts upload
742
+ // running at full speed reports the same number at every poll — so the run
743
+ // abandons it after MAX_IDLE_POLLS with the bytes still arriving.
744
+ const first = poll([[8000, 16_000]], 32_000);
745
+ const later = poll(
746
+ [
747
+ [8000, 16_000],
748
+ [24_000, 32_000],
749
+ ],
750
+ 32_000,
751
+ );
752
+ expect(first.size).toBe(later.size);
753
+ expect(later.stored).toBeGreaterThan(first.stored);
754
+ });
755
+
756
+ test("storedBytes does not double-count the prefix", () => {
757
+ // `ranges` COVERS the prefix rather than sitting beside it, so summing the two
758
+ // would report a growing total for an upload that had stopped.
759
+ expect(storedBytes(8000, [{ start: 0, end: 8000 }])).toBe(8000);
760
+ // And an upload with no windows at all is its prefix.
761
+ expect(storedBytes(8000, undefined)).toBe(8000);
670
762
  });
671
763
 
672
764
  test("probeUpload reports complete once it is", async () => {
@@ -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,8 +86,11 @@
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;
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;
65
94
  * - the body sleeps {@link POLL_INTERVAL} between polls when nothing is ready, cut
66
95
  * short by the client's wake.
67
96
  *
@@ -138,9 +167,15 @@
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 {
171
+ mapConcurrent,
172
+ readUpload,
173
+ report,
174
+ type UploadRange,
175
+ uploadInfo,
176
+ } from "@alexkroman1/aai/step";
142
177
  import { throwFatalStepError } from "@alexkroman1/aai/step-errors";
143
- import { formatDuration, plural } from "@alexkroman1/aai/utils";
178
+ import { formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
144
179
  import { sleep } from "workflow";
145
180
  import {
146
181
  fatalOnUnsupported,
@@ -179,10 +214,32 @@ const MAX_IDLE_POLLS = 60;
179
214
 
180
215
  /** What one poll of the upload found. */
181
216
  export type UploadProgressView = {
182
- /** Bytes stored so far. */
217
+ /**
218
+ * The CONTIGUOUS PREFIX — how far the file can be read from byte zero.
219
+ *
220
+ * Not how much has arrived: see {@link UploadProgressView.stored}. It is what
221
+ * the header probe and the final duration are measured against, because both
222
+ * want a length rather than a coverage map.
223
+ */
183
224
  size: number;
184
225
  /** Whether that is all of them. The ONLY field an exit may be decided on. */
185
226
  complete: boolean;
227
+ /**
228
+ * Total bytes landed, prefix and windows ahead of it alike.
229
+ *
230
+ * The one number a STALL may be judged on. `size` cannot be: a fan-out lands
231
+ * its windows out of order, so the prefix stays at zero through an upload that
232
+ * is moving at full speed and {@link MAX_IDLE_POLLS} would call it dead.
233
+ */
234
+ stored: number;
235
+ /**
236
+ * The windows that have landed, when the upload arrived as parts.
237
+ *
238
+ * Absent for a whole-file write, whose bytes are the prefix and nothing else.
239
+ * This is what makes a segment readable before the windows in front of it
240
+ * arrive — see the readiness test in the body.
241
+ */
242
+ ranges?: readonly UploadRange[];
186
243
  };
187
244
 
188
245
  /** The cut, derived once from the header. */
@@ -208,10 +265,20 @@ export async function transcribeStreamFlow(input: { recording: string }) {
208
265
  const done = new Set<number>();
209
266
  const parts: SegmentTranscript[] = [];
210
267
  let idlePolls = 0;
211
- let lastSize = -1;
268
+ // The prefix at the last poll, which is what the final duration is measured
269
+ // from — and deliberately NOT what the stall test reads; see `lastStored`.
270
+ let lastSize = 0;
271
+ // Total bytes landed at the last poll. A fan-out lands its windows out of
272
+ // order, so this is the only number that distinguishes an upload that has
273
+ // stopped from one whose prefix has not caught up yet.
274
+ let lastStored = -1;
212
275
 
213
276
  for (;;) {
214
277
  const at = await probeUpload(input.recording);
278
+ // Every poll, because this is only ever read at the END — the run breaks out
279
+ // on a `complete` view, whose prefix is the whole file. Updating it inside a
280
+ // branch is how it used to end up describing whichever poll last had work.
281
+ lastSize = at.size;
215
282
 
216
283
  // The header has to be present before anything can be planned, and it is the
217
284
  // first thing to arrive. `complete` also qualifies, for a recording shorter
@@ -229,11 +296,11 @@ export async function transcribeStreamFlow(input: { recording: string }) {
229
296
  const ready = plan.segments.filter(
230
297
  (segment) =>
231
298
  !done.has(segment.index) &&
232
- (segment.end <= at.size || (at.complete && segment.start < at.size)),
299
+ (segmentStored(segment, at) || (at.complete && segment.start < at.size)),
233
300
  );
234
301
  if (ready.length > 0) {
235
302
  idlePolls = 0;
236
- lastSize = at.size;
303
+ lastStored = at.stored;
237
304
  for (const segment of ready) done.add(segment.index);
238
305
  // One step per segment, bounded, in an order a replay reproduces exactly —
239
306
  // `ready` is derived from a journaled poll, and `mapConcurrent` issues its
@@ -259,18 +326,21 @@ export async function transcribeStreamFlow(input: { recording: string }) {
259
326
 
260
327
  // Nothing to work on, so this view is current and the exit can be trusted.
261
328
  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;
329
+ // A stall, not an ending — see MAX_IDLE_POLLS. Judged on `stored` rather than
330
+ // on the prefix: under the browser's default fan-out the prefix does not move
331
+ // at all until the first window lands, so a run reading it would call a
332
+ // healthy upload abandoned five minutes in and fail.
333
+ if (at.stored === lastStored) idlePolls += 1;
264
334
  else {
265
335
  idlePolls = 0;
266
- lastSize = at.size;
336
+ lastStored = at.stored;
267
337
  }
268
338
  if (idlePolls > MAX_IDLE_POLLS) abandon(input.recording, at);
269
339
  await sleep(POLL_INTERVAL);
270
340
  }
271
341
 
272
342
  const finished = plan;
273
- if (!finished) abandon(input.recording, { size: 0, complete: false });
343
+ if (!finished) abandon(input.recording, { size: 0, complete: false, stored: 0 });
274
344
  return await mergeTranscript(
275
345
  input.recording,
276
346
  offsetToMs(finished.format, Math.min(finished.format.dataEnd, lastSize)),
@@ -292,7 +362,56 @@ export async function probeUpload(id: string): Promise<UploadProgressView> {
292
362
  "use step";
293
363
 
294
364
  const info = await uploadInfo(id);
295
- return { size: info.size, complete: info.complete };
365
+ return {
366
+ size: info.size,
367
+ complete: info.complete,
368
+ stored: storedBytes(info.size, info.ranges),
369
+ // `omitUndefined` rather than a spread, because a journaled step result is
370
+ // compared on replay and `{ ranges: undefined }` is not `{}` once it has been
371
+ // through JSON.
372
+ ...omitUndefined({ ranges: info.ranges }),
373
+ };
374
+ }
375
+
376
+ /**
377
+ * How many bytes have landed in total, prefix and detached windows alike.
378
+ *
379
+ * `ranges` COVERS the prefix when it is present (it is every window the record
380
+ * holds, merged), so this is the larger of the two rather than their sum — adding
381
+ * them would double-count the prefix and make a stalled upload look like it was
382
+ * still growing, which is the one thing {@link MAX_IDLE_POLLS} must not be lied
383
+ * to about.
384
+ */
385
+ export function storedBytes(size: number, ranges: readonly UploadRange[] | undefined): number {
386
+ if (!ranges) return size;
387
+ return Math.max(
388
+ size,
389
+ ranges.reduce((total, range) => total + (range.end - range.start), 0),
390
+ );
391
+ }
392
+
393
+ /**
394
+ * Whether every byte of `segment` is stored, wherever in the file it landed.
395
+ *
396
+ * The prefix answers most of this — a whole-file upload has no windows and a
397
+ * finished one is covered end to end — and the `ranges` arm is what makes the
398
+ * streaming flow work against the browser's DEFAULT upload. That fan-out puts
399
+ * `UPLOAD_PART_CONCURRENCY` windows on the link at once, so they finish together
400
+ * and the prefix is zero until the last moment; measured on a deployed agent, a
401
+ * 27 MB recording at 0.9 MB/s reported `size: 0` for 45 of its 45 seconds. Read
402
+ * only the prefix and the run has nothing to do until the upload is over, which
403
+ * is the entire wait this flow exists to remove.
404
+ *
405
+ * A window has to be covered WHOLE by one run: `readUpload` clamps to the run a
406
+ * read starts in, so a segment straddling a hole would come back short and be
407
+ * transcribed as a fragment. `rangesOf` merges adjacent windows, so a run really
408
+ * is a contiguous stretch and one containment test is the whole check.
409
+ */
410
+ export function segmentStored(segment: Segment, at: UploadProgressView): boolean {
411
+ if (segment.end <= at.size) return true;
412
+ return (at.ranges ?? []).some(
413
+ (range) => range.start <= segment.start && segment.end <= range.end,
414
+ );
296
415
  }
297
416
 
298
417
  /**
@@ -356,7 +475,8 @@ export function expectedSegments(plan: StreamPlan, size: number): number {
356
475
  */
357
476
  function abandon(id: string, at: UploadProgressView): never {
358
477
  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.`,
478
+ `Gave up waiting for ${id}: ${at.stored} byte(s) stored, ${at.size} readable from the ` +
479
+ `start, and still incomplete. Nothing new arrived for ${MAX_IDLE_POLLS} polls — the ` +
480
+ "uploader stopped.",
361
481
  );
362
482
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-cli",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "aai": "bin.mjs"
@@ -44,9 +44,9 @@
44
44
  "p-timeout": "^7.0.1",
45
45
  "vite": "^8.2.1",
46
46
  "zod": "^4.4.3",
47
- "@alexkroman1/aai": "9.1.0",
48
- "@alexkroman1/aai-runtime": "9.1.0",
49
- "@alexkroman1/aai-ui": "9.1.0"
47
+ "@alexkroman1/aai": "9.2.0",
48
+ "@alexkroman1/aai-runtime": "9.2.0",
49
+ "@alexkroman1/aai-ui": "9.2.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "playwright": "^1.62.1",