@alexkroman1/aai-cli 9.0.2 → 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.0
|
|
18
|
-
"@alexkroman1/aai-runtime": "^9.0
|
|
19
|
-
"@alexkroman1/aai-ui": "^9.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.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",
|
|
@@ -31,8 +31,21 @@ 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
|
-
import {
|
|
35
|
-
|
|
34
|
+
import {
|
|
35
|
+
cuttable,
|
|
36
|
+
heavierThanNormalized,
|
|
37
|
+
NORMALIZED_CHANNELS,
|
|
38
|
+
NORMALIZED_SAMPLE_RATE,
|
|
39
|
+
normalizeRecording,
|
|
40
|
+
} from "./workflows/normalize.ts";
|
|
41
|
+
import {
|
|
42
|
+
expectedSegments,
|
|
43
|
+
planStreamed,
|
|
44
|
+
probeUpload,
|
|
45
|
+
segmentStored,
|
|
46
|
+
storedBytes,
|
|
47
|
+
type UploadProgressView,
|
|
48
|
+
} from "./workflows/stream.ts";
|
|
36
49
|
import {
|
|
37
50
|
mergeTranscript,
|
|
38
51
|
splitRecording,
|
|
@@ -660,7 +673,92 @@ describe("the streaming flow", () => {
|
|
|
660
673
|
publishPartial(1000, 320_000);
|
|
661
674
|
// The poll the body runs. `complete` is separate from `size` because a size that
|
|
662
675
|
// stopped growing is not a claim that the file is finished.
|
|
663
|
-
|
|
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);
|
|
664
762
|
});
|
|
665
763
|
|
|
666
764
|
test("probeUpload reports complete once it is", async () => {
|
|
@@ -897,6 +995,59 @@ describe("normalizing the recording", () => {
|
|
|
897
995
|
expect(cuttable(dense, 44 + 32_000)).toBe(false);
|
|
898
996
|
});
|
|
899
997
|
|
|
998
|
+
test("48 kHz stereo is cuttable and still too heavy to cut as it is", () => {
|
|
999
|
+
// The file that broke a real run. It parses, it cuts, and every 92-second
|
|
1000
|
+
// segment of it is 17.7 MB against 2.94 MB normalized — six times the upload
|
|
1001
|
+
// per request, against a sync endpoint that deadlines at 30s. `cuttable`
|
|
1002
|
+
// cannot see that, which is the whole reason there are two predicates.
|
|
1003
|
+
const heavy = wavFile({ sampleRate: 48_000, channels: 2, bitsPerSample: 16 }, 32_000);
|
|
1004
|
+
expect(cuttable(heavy, 44 + 32_000)).toBe(true);
|
|
1005
|
+
expect(heavierThanNormalized(heavy, 44 + 32_000)).toBe(true);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test.each([
|
|
1009
|
+
["a higher rate alone", { sampleRate: 44_100, channels: 1, bitsPerSample: 16 }],
|
|
1010
|
+
["more channels alone", { sampleRate: 16_000, channels: 2, bitsPerSample: 16 }],
|
|
1011
|
+
])("%s is enough to convert", (_label, fmt) => {
|
|
1012
|
+
// Either axis on its own, because the segment cost is their PRODUCT — a file
|
|
1013
|
+
// that is only wide or only fast still costs a multiple of the target.
|
|
1014
|
+
expect(heavierThanNormalized(wavFile(fmt, 32_000), 44 + 32_000)).toBe(true);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test("the normalize target itself is not heavier than itself", () => {
|
|
1018
|
+
// The predicate has to be false at the fixed point or the fast path is dead
|
|
1019
|
+
// and every recording pays an ffmpeg pass that produces its own input.
|
|
1020
|
+
const target = {
|
|
1021
|
+
sampleRate: NORMALIZED_SAMPLE_RATE,
|
|
1022
|
+
channels: NORMALIZED_CHANNELS,
|
|
1023
|
+
bitsPerSample: 16,
|
|
1024
|
+
};
|
|
1025
|
+
expect(heavierThanNormalized(wavFile(target, 32_000), 44 + 32_000)).toBe(false);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
test("a rate BELOW the target is left alone rather than upsampled", () => {
|
|
1029
|
+
// 8 kHz telephony audio. Converting it would invent no information and cost a
|
|
1030
|
+
// full pass over the recording, so the comparison is `>` and not `!==`.
|
|
1031
|
+
const narrow = wavFile({ sampleRate: 8000, channels: 1, bitsPerSample: 16 }, 32_000);
|
|
1032
|
+
expect(heavierThanNormalized(narrow, 44 + 32_000)).toBe(false);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
test("a heavy WAV is CONVERTED, and the line says why rather than lying", async () => {
|
|
1036
|
+
// The report used to read "not a WAV we can cut" on every conversion, which
|
|
1037
|
+
// for this file contradicts the thing the caller uploaded. The conversion
|
|
1038
|
+
// itself is out of tier (it spawns ffmpeg), so what is asserted is that the
|
|
1039
|
+
// fast path was declined and the reason given is the weight.
|
|
1040
|
+
publishRecording(
|
|
1041
|
+
wavFile({ sampleRate: 48_000, channels: 2, bitsPerSample: 16 }, 32_000),
|
|
1042
|
+
"workshop.wav",
|
|
1043
|
+
);
|
|
1044
|
+
const reporter = installStubReporter();
|
|
1045
|
+
await normalizeRecording(UPLOAD_ID).catch(() => undefined);
|
|
1046
|
+
const said = reporter.lines.join(" ");
|
|
1047
|
+
expect(said).not.toContain("already linear-PCM WAV");
|
|
1048
|
+
expect(said).toContain("heavier per second than 16 kHz mono");
|
|
1049
|
+
});
|
|
1050
|
+
|
|
900
1051
|
test("an already-cuttable recording keeps the id it came in under", async () => {
|
|
901
1052
|
// The property that matters: no second upload, so the fan-out reads the file
|
|
902
1053
|
// the caller stored. A step that copied it would double the storage every run
|
|
@@ -33,9 +33,18 @@
|
|
|
33
33
|
* including a 192 kHz 32-bit stereo WAV that trips
|
|
34
34
|
* {@link MAX_BYTES_PER_SECOND}, which downsampling genuinely repairs.
|
|
35
35
|
*
|
|
36
|
-
* The fast path costs one 64 KB read and no subprocess at all: a WAV that
|
|
37
|
-
* already cuttable is returned by the id it came in
|
|
38
|
-
* and nothing is re-encoded.
|
|
36
|
+
* The fast path costs one 64 KB read and no subprocess at all: a WAV that is
|
|
37
|
+
* already cuttable AND already light enough is returned by the id it came in
|
|
38
|
+
* under, so nothing is copied and nothing is re-encoded.
|
|
39
|
+
*
|
|
40
|
+
* ## Cuttable is not the same as worth cutting
|
|
41
|
+
*
|
|
42
|
+
* {@link parseWav} succeeding is necessary and not sufficient. A 48 kHz stereo
|
|
43
|
+
* WAV parses and cuts perfectly and is six times the bytes per request that the
|
|
44
|
+
* same audio is at {@link NORMALIZED_SAMPLE_RATE} mono — which the sync
|
|
45
|
+
* endpoint's 30-second deadline turns from a cost into a failure. So the fast
|
|
46
|
+
* path is gated on {@link heavierThanNormalized} as well, and that predicate's
|
|
47
|
+
* own doc carries the measurement.
|
|
39
48
|
*
|
|
40
49
|
* ## File → file, not bytes → bytes
|
|
41
50
|
*
|
|
@@ -130,7 +139,7 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
|
|
|
130
139
|
const stored = await uploadInfo(uploadId);
|
|
131
140
|
const head = await readUpload(uploadId, { end: HEADER_PROBE_BYTES });
|
|
132
141
|
|
|
133
|
-
if (cuttable(head.bytes, stored.size)) {
|
|
142
|
+
if (cuttable(head.bytes, stored.size) && !heavierThanNormalized(head.bytes, stored.size)) {
|
|
134
143
|
// No subprocess, no copy, no second upload. The overwhelmingly common case
|
|
135
144
|
// for a desk whose form says WAV, and the reason the check is a 64 KB read.
|
|
136
145
|
await report(`${stored.name || uploadId} is already linear-PCM WAV — cutting it as it is.`);
|
|
@@ -141,8 +150,16 @@ export async function normalizeRecording(uploadId: string): Promise<NormalizedRe
|
|
|
141
150
|
// long recording and a run that says nothing until the conversion finishes looks
|
|
142
151
|
// stuck. It is also the line that distinguishes "this file needs converting" from
|
|
143
152
|
// the fast path above.
|
|
153
|
+
// WHY it is being converted, because there are now two reasons and they look
|
|
154
|
+
// nothing alike to a reader watching the log: a file the parser refused, and a
|
|
155
|
+
// WAV that is fine but too heavy to cut at this rate. Reporting "not a WAV we
|
|
156
|
+
// can cut" for the second one is a line that contradicts the file they
|
|
157
|
+
// uploaded.
|
|
144
158
|
await report(
|
|
145
|
-
`Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) —
|
|
159
|
+
`Converting ${stored.name || uploadId} (${formatBytes(stored.size)}) — ` +
|
|
160
|
+
(cuttable(head.bytes, stored.size)
|
|
161
|
+
? `heavier per second than ${NORMALIZED_SAMPLE_RATE / 1000} kHz mono.`
|
|
162
|
+
: "not a WAV we can cut."),
|
|
146
163
|
);
|
|
147
164
|
|
|
148
165
|
// The temp directory's lifetime is this lexical scope, and the `finally` inside
|
|
@@ -238,6 +255,44 @@ export function cuttable(head: Uint8Array, totalBytes: number): boolean {
|
|
|
238
255
|
}
|
|
239
256
|
}
|
|
240
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Whether cutting this file AS IS would make every request too heavy.
|
|
260
|
+
*
|
|
261
|
+
* {@link cuttable} asks whether `splitRecording` CAN read the header; this asks
|
|
262
|
+
* whether it SHOULD. They are different questions and the answers point opposite
|
|
263
|
+
* ways for one common file: a 48 kHz stereo recording parses perfectly and cuts
|
|
264
|
+
* perfectly, and each 92-second segment of it is 17.7 MB against the 2.94 MB the
|
|
265
|
+
* same segment is once normalized. The sync endpoint deadlines a request at 30s,
|
|
266
|
+
* so six times the upload per request is the difference between segments landing
|
|
267
|
+
* in single digits and segments landing at 22-28s — which is not a slow run, it
|
|
268
|
+
* is a run where the first straggler past 30s takes the whole thing down (a
|
|
269
|
+
* segment burns `maxRetries`, the body throws, and every sibling still in flight
|
|
270
|
+
* is discarded and re-billed on the resume).
|
|
271
|
+
*
|
|
272
|
+
* This is NOT the "second opinion" the module doc warns about. That warning is
|
|
273
|
+
* about the pass-through decision disagreeing with the CUT decision — passing
|
|
274
|
+
* through something `splitRecording` then cannot read. This predicate can only
|
|
275
|
+
* ever send MORE files to ffmpeg, never fewer, and what comes back is 16 kHz mono
|
|
276
|
+
* by construction, so the two decisions still cannot disagree.
|
|
277
|
+
*
|
|
278
|
+
* Compared against the normalize targets rather than against a byte budget of its
|
|
279
|
+
* own: the question is literally "would converting make this smaller", and
|
|
280
|
+
* anything at or below {@link NORMALIZED_SAMPLE_RATE} / {@link NORMALIZED_CHANNELS}
|
|
281
|
+
* would only be re-encoded into itself. Note this deliberately does NOT look at
|
|
282
|
+
* `bitsPerSample` — `wavEncodeArgs` emits `pcm_s16le`, so a 24- or 32-bit file at
|
|
283
|
+
* 16 kHz mono really would shrink, but that is a 1.5-2x saving on a file already
|
|
284
|
+
* inside the budget, and converting it costs an ffmpeg pass over the whole
|
|
285
|
+
* recording. Revisit if a 32-bit mono source ever shows up in practice.
|
|
286
|
+
*
|
|
287
|
+
* Safe to call only where {@link cuttable} has already answered `true` — it
|
|
288
|
+
* re-parses the same window and a rejected header would throw here rather than
|
|
289
|
+
* answering.
|
|
290
|
+
*/
|
|
291
|
+
export function heavierThanNormalized(head: Uint8Array, totalBytes: number): boolean {
|
|
292
|
+
const format = parseWav(head, totalBytes);
|
|
293
|
+
return format.sampleRate > NORMALIZED_SAMPLE_RATE || format.channels > NORMALIZED_CHANNELS;
|
|
294
|
+
}
|
|
295
|
+
|
|
241
296
|
/** `41:20 of aac`, or as much of that as ffprobe would say. */
|
|
242
297
|
function describeSource(codec: string | undefined, durationSec: number | undefined): string {
|
|
243
298
|
const length =
|
|
@@ -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
|
|
64
|
-
* view a poll reads is
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
299
|
+
(segmentStored(segment, at) || (at.complete && segment.start < at.size)),
|
|
233
300
|
);
|
|
234
301
|
if (ready.length > 0) {
|
|
235
302
|
idlePolls = 0;
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
360
|
-
`Nothing new arrived for ${MAX_IDLE_POLLS} polls — the
|
|
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.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.0
|
|
48
|
-
"@alexkroman1/aai-runtime": "9.0
|
|
49
|
-
"@alexkroman1/aai-ui": "9.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",
|