poddrill 0.0.0-stage → 0.1.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.
package/dist/jobs.js ADDED
@@ -0,0 +1,1053 @@
1
+ /**
2
+ * The episode store. One instance per workspace, held by the owner process.
3
+ * Invariants:
4
+ * - This module is the only writer under `episodes/`. Its write helpers are not exported.
5
+ * - The in-memory entries mirror the episode directories. The single writer keeps them in sync.
6
+ * - Each artifact kind runs one episode at a time. Parallelism is inside that stage.
7
+ * - An artifact's state is derived from which files exist. A ready output wins over `failed.json`.
8
+ */
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { createReadStream } from "node:fs";
11
+ import { copyFile, link, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
12
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { cutSlice, probeDurationMs } from "./audio.js";
14
+ import { asRecord, errorCode, pathExists, readJsonFile } from "./disk.js";
15
+ import { parseWhistle, planSlices, projectSlices, serializeWhistle, } from "./episode.js";
16
+ import { assignCues, cueRows, hostNames, knownGroupIds, mergeLabels, pageCues, parseLabelUpdates, readAssignments, readLabels, readStretchVectors, readVttCues, } from "./groups.js";
17
+ import { fleetSize, modelForLanguage } from "./models.js";
18
+ import { transcriptRefusal } from "./provider.js";
19
+ import { planStretches, readCueSpans } from "./stretch.js";
20
+ import { dumpSummary, emptySummary, inspectPostBody, inspectPutBody, parseSummaryFile, orderCueSet, freshSummaryId, parseSummaryId, requireSummaryText, } from "./summary.js";
21
+ import { artifactKinds, episodeIdFromDigest, episodeLayout, parseEpisodeId, sliceFiles, stretchWav, workspaceDirs, } from "./workspace.js";
22
+ /** An artifact runs only after the one it names here is ready. */
23
+ const DEPENDS_ON = {
24
+ transcript: undefined,
25
+ embedding: "transcript",
26
+ };
27
+ export class JobQueue {
28
+ root;
29
+ config;
30
+ hosts;
31
+ entries = new Map();
32
+ /** Starts past the capacity check whose episode dir is not committed yet. Counted against capacity. */
33
+ admitting = new Map();
34
+ /** Two calls for one episode must not read labels.json until the earlier rename has finished. */
35
+ episodeWrites = new Map();
36
+ summaryHeads = new Map();
37
+ stop = new AbortController();
38
+ /** The episode each stage holds. `running` for that kind is exactly this id. */
39
+ running = new Map();
40
+ afterLabelRead;
41
+ constructor(root, config, hosts, afterLabelRead) {
42
+ this.root = root;
43
+ this.config = config;
44
+ this.hosts = hosts;
45
+ this.afterLabelRead = afterLabelRead;
46
+ }
47
+ /** Sweep uncommitted writes, load every episode dir, and resume the oldest pending work of each kind. */
48
+ static async open(root, config, hosts, hooks) {
49
+ const queue = new JobQueue(root, config, hosts, hooks?.afterLabelRead);
50
+ for (const entry of await loadEpisodes(root)) {
51
+ const id = entry.manifest.episodeId;
52
+ queue.entries.set(id, entry);
53
+ queue.summaryHeads.set(id, await loadSummaryHeads(episodeLayout(root, id), id));
54
+ }
55
+ queue.wakeAll();
56
+ return queue;
57
+ }
58
+ /** Admit one audio file under `audio/`. Idempotent per audio bytes. An omitted language is `en`. */
59
+ async start(audioPath, language = "en") {
60
+ if (this.stop.signal.aborted) {
61
+ throw new Error("episode store is closed");
62
+ }
63
+ const located = await locateAudio(this.root, audioPath);
64
+ if (located.kind === "rejected") {
65
+ return located;
66
+ }
67
+ const digest = await sha256File(located.path);
68
+ const id = episodeIdFromDigest(digest);
69
+ const pending = this.admitting.get(id);
70
+ if (pending !== undefined) {
71
+ return pending.then((result) => result.kind === "started" ? { ...result, created: false } : result);
72
+ }
73
+ if (this.entries.get(id) === undefined && modelForLanguage(language) === undefined) {
74
+ return rejected("unknown-language", `unknown language ${language}`);
75
+ }
76
+ const admission = admit(this.entries, this.runOrder("transcript"), this.admitting.size, this.config.queueLength, id);
77
+ switch (admission.kind) {
78
+ case "existing":
79
+ return { kind: "started", created: false, episode: this.present(admission.entry) };
80
+ case "retry":
81
+ return this.retry(admission.entry, admission.kinds);
82
+ case "full":
83
+ return { kind: "full", queueLength: this.config.queueLength, unfinished: admission.unfinished };
84
+ case "admit": {
85
+ const admitting = this.create(id, digest, located.path, language).finally(() => {
86
+ this.admitting.delete(id);
87
+ });
88
+ this.admitting.set(id, admitting);
89
+ return admitting;
90
+ }
91
+ default: {
92
+ const leftover = admission;
93
+ throw new Error(`unexpected admission ${JSON.stringify(leftover)}`);
94
+ }
95
+ }
96
+ }
97
+ /** Pure read of the entries. Never touches disk. */
98
+ episode(episodeId) {
99
+ const id = parseEpisodeId(episodeId);
100
+ const entry = id === undefined ? undefined : this.entries.get(id);
101
+ if (entry === undefined) {
102
+ return { state: "unknown", episodeId };
103
+ }
104
+ return this.present(entry);
105
+ }
106
+ async cues(episodeId, query = {}) {
107
+ this.requireOpen();
108
+ const limit = resolveLimit(query.limit);
109
+ if (typeof limit !== "number") {
110
+ return limit;
111
+ }
112
+ const id = parseEpisodeId(episodeId);
113
+ if (id === undefined || !this.entries.has(id)) {
114
+ return { state: "unknown", episodeId };
115
+ }
116
+ return this.withEpisode(id, async () => {
117
+ const loaded = await this.loadCueRows(episodeId);
118
+ if (!("rows" in loaded)) {
119
+ return loaded;
120
+ }
121
+ const page = pageCues(loaded.rows, knownGroupIds(loaded.rows), limit, query.after, query.groupId);
122
+ switch (page.kind) {
123
+ case "unknown-group":
124
+ return { state: "unknown", episodeId: loaded.episodeId, groupId: page.groupId };
125
+ case "unknown-cue":
126
+ return { state: "unknown", episodeId: loaded.episodeId, cueId: page.cueId };
127
+ case "page":
128
+ if (query.hosts === true) {
129
+ return {
130
+ state: "present",
131
+ episodeId: loaded.episodeId,
132
+ cues: page.cues,
133
+ hostNames: hostNames(loaded.labels),
134
+ };
135
+ }
136
+ return { state: "present", episodeId: loaded.episodeId, cues: page.cues };
137
+ default: {
138
+ const leftover = page;
139
+ throw new Error(`unexpected page ${JSON.stringify(leftover)}`);
140
+ }
141
+ }
142
+ });
143
+ }
144
+ async labelGroups(episodeId, groups) {
145
+ this.requireOpen();
146
+ const updates = parseLabelUpdates(groups);
147
+ if (updates === undefined) {
148
+ return {
149
+ kind: "error",
150
+ message: "labelGroups expects a JSON array of { groupId, label }",
151
+ };
152
+ }
153
+ const id = parseEpisodeId(episodeId);
154
+ if (id === undefined || !this.entries.has(id)) {
155
+ return { state: "unknown", episodeId };
156
+ }
157
+ return this.withEpisode(id, async () => {
158
+ const loaded = await this.loadCueRows(episodeId);
159
+ if (!("rows" in loaded)) {
160
+ return loaded;
161
+ }
162
+ const known = knownGroupIds(loaded.rows);
163
+ for (const update of updates) {
164
+ if (!known.has(update.groupId)) {
165
+ return { state: "unknown", episodeId: loaded.episodeId, groupId: update.groupId };
166
+ }
167
+ }
168
+ const labels = mergeLabels(loaded.labels, updates);
169
+ if (this.afterLabelRead !== undefined) {
170
+ await this.afterLabelRead();
171
+ }
172
+ const layout = episodeLayout(this.root, loaded.episodeId);
173
+ const wrote = await commitFile(layout.labels, this.stop.signal, (temp) => writeFile(temp, `${JSON.stringify({ groups: labels }, null, 2)}\n`));
174
+ if (!wrote) {
175
+ throw new Error("episode store is closed");
176
+ }
177
+ return { state: "present", episodeId: loaded.episodeId, labels };
178
+ });
179
+ }
180
+ async startSummary(episodeId) {
181
+ this.requireOpen();
182
+ const id = this.knownEpisode(episodeId);
183
+ if (id === undefined) {
184
+ return { state: "unknown", episodeId };
185
+ }
186
+ return this.withEpisode(id, async () => {
187
+ for (let attempt = 0; attempt < 8; attempt += 1) {
188
+ const summaryId = freshSummaryId();
189
+ const existing = await this.loadSummaryDocument(id, summaryId);
190
+ if (existing.kind !== "missing") {
191
+ continue;
192
+ }
193
+ const created = emptySummary(id, summaryId);
194
+ await this.storeSummary(id, created);
195
+ return presentSummary(id, created);
196
+ }
197
+ return { kind: "error", message: "could not start a summary" };
198
+ });
199
+ }
200
+ async setSummary(episodeId, summaryId, summary) {
201
+ this.requireOpen();
202
+ const text = requireSummaryText(summary);
203
+ if (!text.ok) {
204
+ return { kind: "error", message: text.message };
205
+ }
206
+ const id = this.knownEpisode(episodeId);
207
+ if (id === undefined) {
208
+ return { state: "unknown", episodeId };
209
+ }
210
+ const parsedId = parseSummaryId(summaryId);
211
+ if (parsedId === undefined) {
212
+ return { state: "unknown", episodeId: id, summaryId };
213
+ }
214
+ return this.withEpisode(id, async () => {
215
+ const loaded = await this.loadSummaryDocument(id, parsedId);
216
+ if (loaded.kind === "missing") {
217
+ return { state: "unknown", episodeId: id, summaryId: parsedId };
218
+ }
219
+ if (loaded.kind === "error") {
220
+ return loaded;
221
+ }
222
+ if (loaded.document.paragraphs.length === 0) {
223
+ return { kind: "error", message: "write the paragraphs before the episode summary" };
224
+ }
225
+ const next = { ...loaded.document, summary: text.value };
226
+ await this.storeSummary(id, next);
227
+ return presentSummary(id, next);
228
+ });
229
+ }
230
+ async postParagraph(episodeId, summaryId, body) {
231
+ this.requireOpen();
232
+ const span = inspectPostBody(body);
233
+ if (!span.ok) {
234
+ return { kind: "error", message: span.message };
235
+ }
236
+ return this.changeParagraph(episodeId, summaryId, span.value, undefined);
237
+ }
238
+ async putParagraph(episodeId, summaryId, body) {
239
+ this.requireOpen();
240
+ const span = inspectPutBody(body);
241
+ if (!span.ok) {
242
+ return { kind: "error", message: span.message };
243
+ }
244
+ return this.changeParagraph(episodeId, summaryId, span.value, span.value.index);
245
+ }
246
+ async summary(episodeId, summaryId) {
247
+ this.requireOpen();
248
+ const id = this.knownEpisode(episodeId);
249
+ if (id === undefined) {
250
+ return { state: "unknown", episodeId };
251
+ }
252
+ const parsedId = parseSummaryId(summaryId);
253
+ if (parsedId === undefined) {
254
+ return { state: "unknown", episodeId: id, summaryId };
255
+ }
256
+ return this.withEpisode(id, async () => {
257
+ const loaded = await this.loadSummaryDocument(id, parsedId);
258
+ if (loaded.kind === "missing") {
259
+ return { state: "unknown", episodeId: id, summaryId: parsedId };
260
+ }
261
+ if (loaded.kind === "error") {
262
+ return loaded;
263
+ }
264
+ return presentSummary(id, loaded.document);
265
+ });
266
+ }
267
+ /** True when no artifact of `kind` is queued or running. The owner's idle timer for that host reads it. */
268
+ idle(kind) {
269
+ const admitting = kind === "transcript" ? this.admitting.size : 0;
270
+ return admitting === 0 && this.runOrder(kind).length === 0;
271
+ }
272
+ /**
273
+ * Stop every stage without waiting for the work in flight. Nothing from it is renamed into place,
274
+ * so close and a crash leave the same disk state. Pending artifacts resume at the next `open`.
275
+ * The caller owns the hosts and closes them separately.
276
+ */
277
+ async close() {
278
+ this.stop.abort();
279
+ }
280
+ requireOpen() {
281
+ if (this.stop.signal.aborted) {
282
+ throw new Error("episode store is closed");
283
+ }
284
+ }
285
+ knownEpisode(episodeId) {
286
+ const id = parseEpisodeId(episodeId);
287
+ if (id === undefined || !this.entries.has(id)) {
288
+ return undefined;
289
+ }
290
+ return id;
291
+ }
292
+ async changeParagraph(episodeId, summaryId, span, index) {
293
+ const id = this.knownEpisode(episodeId);
294
+ if (id === undefined) {
295
+ return { state: "unknown", episodeId };
296
+ }
297
+ const parsedId = parseSummaryId(summaryId);
298
+ if (parsedId === undefined) {
299
+ return { state: "unknown", episodeId: id, summaryId };
300
+ }
301
+ return this.withEpisode(id, async () => {
302
+ const entry = this.entries.get(id);
303
+ if (entry === undefined) {
304
+ return { state: "unknown", episodeId: id };
305
+ }
306
+ if (entry.artifacts.transcript.kind !== "ready") {
307
+ return { state: "not-ready", episodeId: id, on: "transcript" };
308
+ }
309
+ const loaded = await this.loadSummaryDocument(id, parsedId);
310
+ if (loaded.kind === "missing") {
311
+ return { state: "unknown", episodeId: id, summaryId: parsedId };
312
+ }
313
+ if (loaded.kind === "error") {
314
+ return loaded;
315
+ }
316
+ if (index !== undefined && !loaded.document.paragraphs.some((paragraph) => paragraph.index === index)) {
317
+ return { kind: "error", message: "putParagraph index does not exist" };
318
+ }
319
+ const cues = await this.readTranscriptCues(id);
320
+ if (cues === undefined) {
321
+ return { kind: "error", message: "the transcript could not be read" };
322
+ }
323
+ const times = orderCueSet(span.cues, span.startMs, span.endMs, cues);
324
+ if (!times.ok) {
325
+ return { kind: "error", message: times.message };
326
+ }
327
+ const paragraph = {
328
+ index: index ?? loaded.document.paragraphs.length + 1,
329
+ text: span.text,
330
+ cues: times.value.cues,
331
+ startMs: times.value.startMs,
332
+ endMs: times.value.endMs,
333
+ };
334
+ const paragraphs = index === undefined
335
+ ? [...loaded.document.paragraphs, paragraph]
336
+ : loaded.document.paragraphs.map((item) => (item.index === index ? paragraph : item));
337
+ const next = { ...loaded.document, paragraphs };
338
+ await this.storeSummary(id, next);
339
+ return presentSummary(id, next);
340
+ });
341
+ }
342
+ async loadSummaryDocument(episodeId, summaryId) {
343
+ const path = episodeLayout(this.root, episodeId).summaryFile(summaryId);
344
+ let text;
345
+ try {
346
+ text = await readFile(path, "utf8");
347
+ }
348
+ catch (error) {
349
+ if (errorCode(error) === "ENOENT") {
350
+ return { kind: "missing" };
351
+ }
352
+ return { kind: "error", message: "the summary could not be read" };
353
+ }
354
+ const document = parseSummaryFile(text, episodeId, summaryId);
355
+ if (document === undefined) {
356
+ return { kind: "error", message: "the summary could not be read" };
357
+ }
358
+ return { kind: "ok", document };
359
+ }
360
+ async readTranscriptCues(episodeId) {
361
+ try {
362
+ const vtt = await readFile(episodeLayout(this.root, episodeId).artifacts.transcript.output, "utf8");
363
+ return readVttCues(vtt);
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
368
+ }
369
+ async storeSummary(episodeId, document) {
370
+ const layout = episodeLayout(this.root, episodeId);
371
+ await mkdir(layout.summaries, { recursive: true });
372
+ const wrote = await commitFile(layout.summaryFile(document.summaryId), this.stop.signal, (temp) => writeFile(temp, dumpSummary(document)));
373
+ if (!wrote) {
374
+ throw new Error("episode store is closed");
375
+ }
376
+ this.rememberSummary(episodeId, document);
377
+ }
378
+ rememberSummary(episodeId, document) {
379
+ const head = {
380
+ summaryId: document.summaryId,
381
+ };
382
+ const current = this.summaryHeads.get(episodeId) ?? [];
383
+ if (current.some((item) => item.summaryId === head.summaryId)) {
384
+ return;
385
+ }
386
+ const summaries = [...current, head].sort((left, right) => compareText(left.summaryId, right.summaryId));
387
+ this.summaryHeads.set(episodeId, summaries);
388
+ }
389
+ withEpisode(episodeId, work) {
390
+ const previous = this.episodeWrites.get(episodeId) ?? Promise.resolve();
391
+ const settled = previous.then(() => undefined, () => undefined);
392
+ const run = settled.then(work);
393
+ this.episodeWrites.set(episodeId, run.then(() => undefined, () => undefined));
394
+ return run;
395
+ }
396
+ async loadCueRows(episodeId) {
397
+ const id = parseEpisodeId(episodeId);
398
+ const entry = id === undefined ? undefined : this.entries.get(id);
399
+ if (id === undefined || entry === undefined) {
400
+ return { state: "unknown", episodeId };
401
+ }
402
+ if (entry.artifacts.embedding.kind !== "ready") {
403
+ return { state: "not-ready", episodeId: id, on: "embedding" };
404
+ }
405
+ const layout = episodeLayout(this.root, id);
406
+ const rows = await readOrBuildRows(layout, this.stop.signal);
407
+ if (rows === undefined) {
408
+ if (this.stop.signal.aborted) {
409
+ throw new Error("episode store is closed");
410
+ }
411
+ return { kind: "error", message: "the transcript or embedding could not be read" };
412
+ }
413
+ const labels = await readStoredLabels(layout.labels);
414
+ if (labels === undefined) {
415
+ return { kind: "error", message: "labels.json could not be read" };
416
+ }
417
+ return { episodeId: id, rows, labels };
418
+ }
419
+ present(entry) {
420
+ const id = entry.manifest.episodeId;
421
+ const layout = episodeLayout(this.root, id);
422
+ return {
423
+ state: "present",
424
+ episodeId: id,
425
+ sourcePath: join(layout.dir, entry.manifest.sourceFile),
426
+ durationMs: entry.manifest.durationMs,
427
+ language: entry.manifest.language,
428
+ artifacts: artifactKinds.map((kind) => ({
429
+ artifact: kind,
430
+ ...artifactState(entry, kind, layout.artifacts[kind], this.runOrder(kind), this.running.get(kind)),
431
+ })),
432
+ summaries: this.summaryHeads.get(id) ?? [],
433
+ };
434
+ }
435
+ setArtifact(id, kind, progress) {
436
+ const entry = this.entries.get(id);
437
+ if (entry !== undefined) {
438
+ this.entries.set(id, { ...entry, artifacts: { ...entry.artifacts, [kind]: progress } });
439
+ }
440
+ }
441
+ /** Pending artifacts of `kind` whose dependency is ready, by enqueuedAt, then id. */
442
+ eligible(kind) {
443
+ const dependency = DEPENDS_ON[kind];
444
+ return [...this.entries.values()]
445
+ .filter((entry) => entry.artifacts[kind].kind === "pending" &&
446
+ (dependency === undefined || entry.artifacts[dependency].kind === "ready"))
447
+ .sort((a, b) => {
448
+ const byTime = compareText(a.manifest.enqueuedAt, b.manifest.enqueuedAt);
449
+ return byTime !== 0 ? byTime : compareText(a.manifest.episodeId, b.manifest.episodeId);
450
+ });
451
+ }
452
+ /** The running episode first, because a retried older episode can sort ahead of it. Then queue order. */
453
+ runOrder(kind) {
454
+ const ids = this.eligible(kind).map((entry) => entry.manifest.episodeId);
455
+ const running = this.running.get(kind);
456
+ if (running === undefined || !ids.includes(running)) {
457
+ return ids;
458
+ }
459
+ return [running, ...ids.filter((id) => id !== running)];
460
+ }
461
+ wakeAll() {
462
+ for (const kind of artifactKinds) {
463
+ this.wake(kind);
464
+ }
465
+ }
466
+ wake(kind) {
467
+ if (this.running.has(kind) || this.stop.signal.aborted) {
468
+ return;
469
+ }
470
+ const next = this.eligible(kind)[0];
471
+ if (next === undefined) {
472
+ return;
473
+ }
474
+ this.running.set(kind, next.manifest.episodeId);
475
+ void this.runArtifact(kind, next).then(() => {
476
+ this.running.delete(kind);
477
+ this.wakeAll();
478
+ });
479
+ }
480
+ async retry(entry, kinds) {
481
+ const id = entry.manifest.episodeId;
482
+ const layout = episodeLayout(this.root, id);
483
+ for (const kind of kinds) {
484
+ const progress = entry.artifacts[kind];
485
+ if (progress.kind === "failed") {
486
+ this.setArtifact(id, kind, { kind: "pending", done: progress.done, total: progress.total });
487
+ }
488
+ await rm(layout.artifacts[kind].failed, { force: true });
489
+ }
490
+ this.wakeAll();
491
+ const current = this.entries.get(id) ?? entry;
492
+ return { kind: "started", created: false, episode: this.present(current) };
493
+ }
494
+ async create(id, digest, source, language) {
495
+ const durationMs = await probeDurationMs(source);
496
+ if (durationMs === undefined || durationMs === 0) {
497
+ return rejected("not-audio", `${source} has no decodable audio`);
498
+ }
499
+ const layout = episodeLayout(this.root, id);
500
+ const sourceFile = basename(layout.source(extname(source).toLowerCase()));
501
+ const manifest = {
502
+ episodeId: id,
503
+ sha256: digest,
504
+ sourceName: basename(source),
505
+ sourceFile,
506
+ durationMs,
507
+ enqueuedAt: new Date().toISOString(),
508
+ language,
509
+ };
510
+ const staging = join(workspaceDirs(this.root).episodesDir, `.staging-${randomBytes(6).toString("hex")}`);
511
+ await mkdir(staging);
512
+ try {
513
+ await linkOrCopy(source, join(staging, sourceFile));
514
+ await writeFile(join(staging, basename(layout.manifest)), `${JSON.stringify(manifest, null, 2)}\n`);
515
+ await rename(staging, layout.dir);
516
+ }
517
+ catch (error) {
518
+ await rm(staging, { recursive: true, force: true });
519
+ throw error;
520
+ }
521
+ const plan = planSlices(durationMs);
522
+ const entry = {
523
+ manifest,
524
+ plan,
525
+ artifacts: {
526
+ transcript: { kind: "pending", done: 0, total: plan.length },
527
+ embedding: { kind: "pending", done: 0, total: 0 },
528
+ },
529
+ };
530
+ this.entries.set(id, entry);
531
+ this.wakeAll();
532
+ return { kind: "started", created: true, episode: this.present(entry) };
533
+ }
534
+ /** Never rejects. Every outcome lands in the entry, and on disk unless the queue closed. */
535
+ async runArtifact(kind, entry) {
536
+ const signal = this.stop.signal;
537
+ const id = entry.manifest.episodeId;
538
+ const progress = entry.artifacts[kind];
539
+ let counts = progress.kind === "ready" ? { done: 0, total: 0 } : { done: progress.done, total: progress.total };
540
+ const run = {
541
+ entry,
542
+ layout: episodeLayout(this.root, id),
543
+ signal,
544
+ report: (done, total) => {
545
+ counts = { done, total };
546
+ this.setArtifact(id, kind, { kind: "pending", done, total });
547
+ },
548
+ };
549
+ try {
550
+ if (await this.runStage(kind, run)) {
551
+ this.setArtifact(id, kind, { kind: "ready" });
552
+ }
553
+ }
554
+ catch (error) {
555
+ if (signal.aborted) {
556
+ return;
557
+ }
558
+ await this.fail(id, kind, error, counts.done, counts.total);
559
+ }
560
+ }
561
+ /** True once the output is renamed into place. False when the queue closed first. Throws on failure. */
562
+ runStage(kind, run) {
563
+ switch (kind) {
564
+ case "transcript":
565
+ return this.transcribe(run);
566
+ case "embedding":
567
+ return this.embed(run);
568
+ default: {
569
+ const leftover = kind;
570
+ throw new Error(`unexpected artifact ${String(leftover)}`);
571
+ }
572
+ }
573
+ }
574
+ async transcribe(run) {
575
+ const { entry, layout, signal } = run;
576
+ const artifact = layout.artifacts.transcript;
577
+ const source = join(layout.dir, entry.manifest.sourceFile);
578
+ const total = entry.plan.length;
579
+ const model = modelForLanguage(entry.manifest.language);
580
+ if (model === undefined) {
581
+ throw new Error(`unknown language ${entry.manifest.language}`);
582
+ }
583
+ const refusal = transcriptRefusal(model, this.config.modelWeights);
584
+ if (refusal !== undefined) {
585
+ throw new Error(refusal);
586
+ }
587
+ await mkdir(artifact.workDir, { recursive: true });
588
+ const todo = await missingSlices(layout, entry.plan);
589
+ let done = total - todo.length;
590
+ run.report(done, total);
591
+ const outcome = await runLanes(todo, fleetSize(model.id, this.config), signal, async (slice) => {
592
+ const files = sliceFiles(layout, slice.index);
593
+ if (!(await pathExists(files.wavPath))) {
594
+ const cut = await commitFile(files.wavPath, signal, (temp) => cutSlice(source, slice, temp));
595
+ if (!cut) {
596
+ return false;
597
+ }
598
+ }
599
+ const result = await this.hosts.whistle.transcribeSlice({
600
+ sliceIndex: slice.index,
601
+ startMs: slice.startMs,
602
+ wavPath: files.wavPath,
603
+ language: entry.manifest.language,
604
+ });
605
+ const stored = await commitFile(files.whistlePath, signal, (temp) => writeFile(temp, serializeWhistle(result)));
606
+ if (!stored) {
607
+ return false;
608
+ }
609
+ done += 1;
610
+ run.report(done, total);
611
+ return true;
612
+ });
613
+ if (!finished(outcome)) {
614
+ return false;
615
+ }
616
+ const records = await Promise.all(entry.plan.map(async (slice) => {
617
+ const files = sliceFiles(layout, slice.index);
618
+ const whistle = parseWhistle(JSON.parse(await readFile(files.whistlePath, "utf8")));
619
+ return { plan: slice, slicePath: files.wavPath, whistle };
620
+ }));
621
+ const { vtt } = projectSlices(records);
622
+ return commitFile(artifact.output, signal, (temp) => writeFile(temp, vtt));
623
+ }
624
+ async embed(run) {
625
+ const { entry, layout, signal } = run;
626
+ const artifact = layout.artifacts.embedding;
627
+ const source = join(layout.dir, entry.manifest.sourceFile);
628
+ await this.hosts.embed.prepare();
629
+ const stretches = planStretches(readCueSpans(await readFile(layout.artifacts.transcript.output, "utf8")));
630
+ await mkdir(artifact.workDir, { recursive: true });
631
+ const vectors = new Map();
632
+ run.report(0, stretches.length);
633
+ const outcome = await runLanes(stretches, this.config.embeddingLimit, signal, async (stretch) => {
634
+ const wavPath = stretchWav(layout, stretch.index);
635
+ if (!(await pathExists(wavPath))) {
636
+ const cut = await commitFile(wavPath, signal, (temp) => cutSlice(source, stretch, temp));
637
+ if (!cut) {
638
+ return false;
639
+ }
640
+ }
641
+ vectors.set(stretch.index, await this.hosts.embed.embedStretch(wavPath));
642
+ run.report(vectors.size, stretches.length);
643
+ return true;
644
+ });
645
+ if (!finished(outcome)) {
646
+ return false;
647
+ }
648
+ const body = {
649
+ stretches: stretches.map((stretch) => {
650
+ const vector = vectors.get(stretch.index);
651
+ if (vector === undefined) {
652
+ throw new Error(`stretch ${stretch.index} has no vector`);
653
+ }
654
+ return { startMs: stretch.startMs, endMs: stretch.endMs, vector };
655
+ }),
656
+ };
657
+ return commitFile(artifact.output, signal, (temp) => writeFile(temp, `${JSON.stringify(body)}\n`));
658
+ }
659
+ async fail(id, kind, error, done, total) {
660
+ const message = error instanceof Error ? error.message : String(error);
661
+ const record = `${JSON.stringify({ message, at: new Date().toISOString() }, null, 2)}\n`;
662
+ const artifact = episodeLayout(this.root, id).artifacts[kind];
663
+ const failedPath = artifact.failed;
664
+ try {
665
+ await mkdir(artifact.dir, { recursive: true });
666
+ await commitFile(failedPath, this.stop.signal, (temp) => writeFile(temp, record));
667
+ }
668
+ catch (writeError) {
669
+ const reason = writeError instanceof Error ? writeError.message : String(writeError);
670
+ console.error(`poddrill: could not write ${failedPath}: ${reason}`);
671
+ }
672
+ this.setArtifact(id, kind, { kind: "failed", message, done, total });
673
+ }
674
+ }
675
+ /**
676
+ * Run `work` over `items` with at most `lanes` in flight. Items are taken in order.
677
+ * After the first rejection, a `false`, or an abort, no lane takes a new item, and lanes in flight finish.
678
+ * Resolves only after every lane stops, so once it resolves nothing this stage started can still write.
679
+ */
680
+ async function runLanes(items, lanes, signal, work) {
681
+ let cursor = 0;
682
+ let stopped = false;
683
+ let failure;
684
+ const lane = async () => {
685
+ while (!signal.aborted && !stopped && failure === undefined) {
686
+ const item = items[cursor];
687
+ if (item === undefined) {
688
+ return;
689
+ }
690
+ cursor += 1;
691
+ try {
692
+ if (!(await work(item))) {
693
+ stopped = true;
694
+ }
695
+ }
696
+ catch (error) {
697
+ failure ??= { error };
698
+ }
699
+ }
700
+ };
701
+ await Promise.all(Array.from({ length: Math.min(lanes, items.length) }, lane));
702
+ if (signal.aborted || stopped) {
703
+ return { kind: "stopped" };
704
+ }
705
+ return failure === undefined ? { kind: "done" } : { kind: "failed", error: failure.error };
706
+ }
707
+ /** True when every lane finished its item. Rethrows the first lane failure. */
708
+ function finished(outcome) {
709
+ switch (outcome.kind) {
710
+ case "done":
711
+ return true;
712
+ case "stopped":
713
+ return false;
714
+ case "failed":
715
+ throw outcome.error;
716
+ default: {
717
+ const leftover = outcome;
718
+ throw new Error(`unexpected lane outcome ${JSON.stringify(leftover)}`);
719
+ }
720
+ }
721
+ }
722
+ /** Slices with no Whistle JSON yet, in index order. */
723
+ async function missingSlices(layout, plan) {
724
+ const missing = [];
725
+ for (const slice of plan) {
726
+ if (!(await pathExists(sliceFiles(layout, slice.index).whistlePath))) {
727
+ missing.push(slice);
728
+ }
729
+ }
730
+ return missing;
731
+ }
732
+ /**
733
+ * Dedupe first, then capacity. Capacity counts pending transcripts plus `admitting`.
734
+ * A failed transcript must pass capacity to retry. A failed embedding retries without it.
735
+ */
736
+ function admit(entries, unfinished, admittingCount, queueLength, id) {
737
+ const full = unfinished.length + admittingCount >= queueLength;
738
+ const existing = entries.get(id);
739
+ if (existing === undefined) {
740
+ return full ? { kind: "full", unfinished } : { kind: "admit" };
741
+ }
742
+ const failed = artifactKinds.filter((kind) => existing.artifacts[kind].kind === "failed");
743
+ if (failed.length === 0) {
744
+ return { kind: "existing", entry: existing };
745
+ }
746
+ if (failed.includes("transcript") && full) {
747
+ return { kind: "full", unfinished };
748
+ }
749
+ return { kind: "retry", entry: existing, kinds: failed };
750
+ }
751
+ function compareText(a, b) {
752
+ if (a === b) {
753
+ return 0;
754
+ }
755
+ return a < b ? -1 : 1;
756
+ }
757
+ function shownProgress(done, total) {
758
+ if (total === 0) {
759
+ return 0;
760
+ }
761
+ return Math.min(99, Math.floor((done / total) * 100));
762
+ }
763
+ /** `position` counts only queued episodes ahead for this kind, so the next to run is 1. */
764
+ function artifactState(entry, kind, layout, order, running) {
765
+ const id = entry.manifest.episodeId;
766
+ const progress = entry.artifacts[kind];
767
+ switch (progress.kind) {
768
+ case "ready":
769
+ return { state: "ready", progress: 100, path: layout.output };
770
+ case "failed":
771
+ return {
772
+ state: "failed",
773
+ progress: shownProgress(progress.done, progress.total),
774
+ message: progress.message,
775
+ };
776
+ case "pending": {
777
+ const dependency = DEPENDS_ON[kind];
778
+ if (dependency !== undefined && entry.artifacts[dependency].kind !== "ready") {
779
+ return { state: "waiting", on: dependency };
780
+ }
781
+ const shown = shownProgress(progress.done, progress.total);
782
+ if (id === running) {
783
+ return { state: "running", progress: shown };
784
+ }
785
+ const queued = order.filter((other) => other !== running);
786
+ return { state: "queued", position: queued.indexOf(id) + 1, progress: shown };
787
+ }
788
+ default: {
789
+ const leftover = progress;
790
+ throw new Error(`unexpected artifact progress ${JSON.stringify(leftover)}`);
791
+ }
792
+ }
793
+ }
794
+ async function loadEpisodes(root) {
795
+ const { episodesDir } = workspaceDirs(root);
796
+ await mkdir(episodesDir, { recursive: true });
797
+ const entries = [];
798
+ for (const dirent of await readdir(episodesDir, { withFileTypes: true })) {
799
+ if (dirent.name.startsWith(".staging-")) {
800
+ await rm(join(episodesDir, dirent.name), { recursive: true, force: true });
801
+ continue;
802
+ }
803
+ const id = parseEpisodeId(dirent.name);
804
+ if (id === undefined || !dirent.isDirectory()) {
805
+ continue;
806
+ }
807
+ const entry = await loadEpisode(root, id);
808
+ if (entry === undefined) {
809
+ console.error(`poddrill: ignoring ${join(episodesDir, dirent.name)}: episode.json is malformed`);
810
+ continue;
811
+ }
812
+ entries.push(entry);
813
+ }
814
+ return entries;
815
+ }
816
+ function presentSummary(episodeId, document) {
817
+ return {
818
+ state: "present",
819
+ episodeId,
820
+ version: 1,
821
+ summaryId: document.summaryId,
822
+ summary: document.summary,
823
+ paragraphs: document.paragraphs,
824
+ };
825
+ }
826
+ async function loadSummaryHeads(layout, episodeId) {
827
+ await removeTempFiles(layout.summaries);
828
+ let names;
829
+ try {
830
+ names = await readdir(layout.summaries);
831
+ }
832
+ catch (error) {
833
+ if (errorCode(error) === "ENOENT") {
834
+ return [];
835
+ }
836
+ throw error;
837
+ }
838
+ const heads = [];
839
+ for (const name of names) {
840
+ if (!name.endsWith(".yaml")) {
841
+ continue;
842
+ }
843
+ const summaryId = parseSummaryId(name.slice(0, -".yaml".length));
844
+ if (summaryId === undefined) {
845
+ continue;
846
+ }
847
+ let text;
848
+ try {
849
+ text = await readFile(layout.summaryFile(summaryId), "utf8");
850
+ }
851
+ catch {
852
+ continue;
853
+ }
854
+ const document = parseSummaryFile(text, episodeId, summaryId);
855
+ if (document === undefined) {
856
+ continue;
857
+ }
858
+ heads.push({ summaryId: document.summaryId });
859
+ }
860
+ heads.sort((left, right) => compareText(left.summaryId, right.summaryId));
861
+ return heads;
862
+ }
863
+ async function loadEpisode(root, id) {
864
+ const layout = episodeLayout(root, id);
865
+ await removeTempFiles(layout.dir);
866
+ for (const kind of artifactKinds) {
867
+ await removeTempFiles(layout.artifacts[kind].dir);
868
+ await removeTempFiles(layout.artifacts[kind].workDir);
869
+ }
870
+ const manifest = parseManifest(await readJsonFile(layout.manifest), id);
871
+ if (manifest === undefined) {
872
+ return undefined;
873
+ }
874
+ const plan = planSlices(manifest.durationMs);
875
+ let slicesDone = 0;
876
+ for (const slice of plan) {
877
+ if (await pathExists(sliceFiles(layout, slice.index).whistlePath)) {
878
+ slicesDone += 1;
879
+ }
880
+ }
881
+ return {
882
+ manifest,
883
+ plan,
884
+ artifacts: {
885
+ transcript: await readArtifact(layout.artifacts.transcript, slicesDone, plan.length),
886
+ // Vectors live only in the output, so a restarted embedding counts from 0.
887
+ embedding: await readArtifact(layout.artifacts.embedding, 0, 0),
888
+ },
889
+ };
890
+ }
891
+ async function readArtifact(layout, done, total) {
892
+ if (await pathExists(layout.output)) {
893
+ return { kind: "ready" };
894
+ }
895
+ if (await pathExists(layout.failed)) {
896
+ const message = asRecord(await readJsonFile(layout.failed))?.["message"];
897
+ return {
898
+ kind: "failed",
899
+ message: typeof message === "string" ? message : "failed.json is unreadable",
900
+ done,
901
+ total,
902
+ };
903
+ }
904
+ return { kind: "pending", done, total };
905
+ }
906
+ /** A malformed manifest makes the dir invisible. */
907
+ function parseManifest(value, id) {
908
+ const record = asRecord(value);
909
+ if (record === undefined) {
910
+ return undefined;
911
+ }
912
+ const { episodeId, sha256, sourceName, sourceFile, durationMs, enqueuedAt } = record;
913
+ if (episodeId !== id ||
914
+ typeof sha256 !== "string" ||
915
+ typeof sourceName !== "string" ||
916
+ typeof sourceFile !== "string" ||
917
+ basename(sourceFile) !== sourceFile ||
918
+ typeof durationMs !== "number" ||
919
+ !Number.isInteger(durationMs) ||
920
+ durationMs < 1 ||
921
+ typeof enqueuedAt !== "string") {
922
+ return undefined;
923
+ }
924
+ const language = record["language"] === undefined ? "en" : record["language"];
925
+ if (typeof language !== "string" || language === "") {
926
+ return undefined;
927
+ }
928
+ return { episodeId: id, sha256, sourceName, sourceFile, durationMs, enqueuedAt, language };
929
+ }
930
+ async function removeTempFiles(dir) {
931
+ let names;
932
+ try {
933
+ names = await readdir(dir);
934
+ }
935
+ catch (error) {
936
+ if (errorCode(error) === "ENOENT") {
937
+ return;
938
+ }
939
+ throw error;
940
+ }
941
+ for (const name of names) {
942
+ if (name.endsWith(".tmp")) {
943
+ await rm(join(dir, name), { force: true });
944
+ }
945
+ }
946
+ }
947
+ /** Relative paths resolve against the workspace root, because the owner's cwd is not the caller's. */
948
+ async function locateAudio(root, audioPath) {
949
+ const { audioDir } = workspaceDirs(root);
950
+ const requested = resolve(root, audioPath);
951
+ let real;
952
+ try {
953
+ real = await realpath(requested);
954
+ }
955
+ catch (error) {
956
+ if (errorCode(error) === "ENOENT") {
957
+ return rejected("not-found", `${requested} does not exist`);
958
+ }
959
+ throw error;
960
+ }
961
+ const inside = relative(await realpath(audioDir), real);
962
+ if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
963
+ return rejected("outside-audio-dir", `${requested} is not under ${audioDir}. Save the episode audio there first.`);
964
+ }
965
+ if (!(await stat(real)).isFile()) {
966
+ return rejected("not-found", `${requested} is not a file`);
967
+ }
968
+ return { kind: "located", path: real };
969
+ }
970
+ function rejected(reason, message) {
971
+ return { kind: "rejected", reason, message };
972
+ }
973
+ async function sha256File(path) {
974
+ const hash = createHash("sha256");
975
+ for await (const chunk of createReadStream(path)) {
976
+ hash.update(chunk);
977
+ }
978
+ return hash.digest("hex");
979
+ }
980
+ async function linkOrCopy(from, to) {
981
+ try {
982
+ await link(from, to);
983
+ }
984
+ catch (error) {
985
+ if (errorCode(error) !== "EXDEV") {
986
+ throw error;
987
+ }
988
+ await copyFile(from, to);
989
+ }
990
+ }
991
+ function resolveLimit(limit) {
992
+ if (limit === undefined) {
993
+ return 8;
994
+ }
995
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
996
+ return {
997
+ kind: "rejected",
998
+ reason: "limit",
999
+ message: "limit must be an integer from 1 through 20",
1000
+ };
1001
+ }
1002
+ return limit;
1003
+ }
1004
+ async function readOrBuildRows(layout, signal) {
1005
+ let vtt;
1006
+ try {
1007
+ vtt = await readFile(layout.artifacts.transcript.output, "utf8");
1008
+ }
1009
+ catch (error) {
1010
+ if (errorCode(error) === "ENOENT") {
1011
+ return undefined;
1012
+ }
1013
+ throw error;
1014
+ }
1015
+ const cues = readVttCues(vtt);
1016
+ if (await pathExists(layout.groups)) {
1017
+ const stored = readAssignments(await readJsonFile(layout.groups));
1018
+ return stored === undefined ? undefined : cueRows(cues, stored);
1019
+ }
1020
+ const stretches = readStretchVectors(await readJsonFile(layout.artifacts.embedding.output));
1021
+ if (stretches === undefined) {
1022
+ return undefined;
1023
+ }
1024
+ const assignments = assignCues(cues, stretches);
1025
+ const wrote = await commitFile(layout.groups, signal, (temp) => writeFile(temp, `${JSON.stringify({ cues: assignments }, null, 2)}\n`));
1026
+ return wrote ? cueRows(cues, assignments) : undefined;
1027
+ }
1028
+ async function readStoredLabels(path) {
1029
+ if (!(await pathExists(path))) {
1030
+ return [];
1031
+ }
1032
+ return readLabels(await readJsonFile(path));
1033
+ }
1034
+ /**
1035
+ * Write through a temp file, then rename. Returns false without renaming once the queue closed,
1036
+ * so a closed queue leaves the same files a crash would.
1037
+ */
1038
+ async function commitFile(target, signal, write) {
1039
+ const temp = `${target}.${randomBytes(4).toString("hex")}.tmp`;
1040
+ try {
1041
+ await write(temp);
1042
+ }
1043
+ catch (error) {
1044
+ await rm(temp, { force: true });
1045
+ throw error;
1046
+ }
1047
+ if (signal.aborted) {
1048
+ await rm(temp, { force: true });
1049
+ return false;
1050
+ }
1051
+ await rename(temp, target);
1052
+ return true;
1053
+ }