diff --git a/examples/audiobook-curator/src/evidence.ts b/examples/audiobook-curator/src/evidence.ts index ecdf909da..53a9b446f 100644 --- a/examples/audiobook-curator/src/evidence.ts +++ b/examples/audiobook-curator/src/evidence.ts @@ -1,5 +1,4 @@ -import { lstat, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import type { JsonObject, JsonValue } from '@agent-bundle/runtime'; @@ -156,6 +155,19 @@ const pythonMatcher = (python: string, process: MediaProcess): AcousticMatcher = } }; +// Evidence staging holds decoded audio, and os.tmpdir() is commonly a +// RAM-backed tmpfs on Linux; media work must stay on regular disk. Stage +// beside the requested receipt when one exists, otherwise beside the source +// media, mirroring the sibling modules' same-directory staging convention. +const evidenceWorkDir = async ( + input: Readonly<{ file: string; receipt?: string }>, + prefix: string, +): Promise => { + const root = dirname(resolve(input.receipt ?? input.file)); + await mkdir(root, { recursive: true }); + return mkdtemp(join(root, prefix)); +}; + const productUrl = (region: AudibleRegion, asin: string): string => { const groups = new URLSearchParams({ response_groups: 'contributors,media,product_desc,product_extended_attrs,sample' }); return `https://${audibleHosts[region]}/1.0/catalog/products/${encodeURIComponent(asin)}?${groups.toString()}`; @@ -184,7 +196,7 @@ const sampleMatch = async ( if (sampleUrl === undefined || sampleUrl === '') throw new CuratorError('Audible candidate has no sample URL'); const bytes = await requestWithAttempts(http, sampleUrl, attempts, { binary: true, signal: dependencies.signal }); if (!Buffer.isBuffer(bytes)) throw new CuratorError('Audible sample response is not binary.'); - const work = await mkdtemp(join(tmpdir(), 'audiobook-curator-acoustic-')); + const work = await evidenceWorkDir(input, '.audiobook-curator-acoustic-'); const sample = join(work, 'sample.mp3'); try { await writeFile(sample, bytes, { mode: 0o600 }); @@ -327,7 +339,7 @@ export const verifyWithWhisper = async ( const windowSeconds = Math.max(1, input.windowSeconds ?? 35); const minimumChars = Math.max(1, input.minimumChars ?? 80); const process = dependencies.process ?? runMediaProcess; - const work = await mkdtemp(join(tmpdir(), 'audiobook-curator-whisper-')); + const work = await evidenceWorkDir(input, '.audiobook-curator-whisper-'); const windows: WhisperWindow[] = []; try { for (const [offset, fraction] of whisperSamplingFractions(maximumWindows).entries()) { diff --git a/examples/audiobook-curator/tests/evidence-parity.test.ts b/examples/audiobook-curator/tests/evidence-parity.test.ts index 87d67b237..5af48c8ca 100644 --- a/examples/audiobook-curator/tests/evidence-parity.test.ts +++ b/examples/audiobook-curator/tests/evidence-parity.test.ts @@ -1,6 +1,6 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; @@ -85,6 +85,39 @@ describe('optional identity evidence parity', () => { expect(receipt).toMatchObject({ exitCode: 0, identified: { asin: 'MATCH' }, verifiedRecording: true }); }); + it('stages evidence work directories on regular disk, never under os.tmpdir()', async () => { + const root = await mkdtemp(join(tmpdir(), 'curator-evidence-disk-')); + roots.push(root); + const file = join(root, 'library', 'book.m4b'); + const receiptPath = join(root, 'receipts', 'acoustic.json'); + await mkdir(dirname(file), { recursive: true }); + await mkdir(dirname(receiptPath), { recursive: true }); + await writeFile(file, 'book'); + const sampleDirs: string[] = []; + const matcher: AcousticMatcher = async (_source, sample) => { + sampleDirs.push(dirname(sample)); + return { found: true }; + }; + const http: CuratorHttpClient = async () => Buffer.from('sample'); + + await verifyAudibleSample( + { asin: 'ASIN', file, receipt: receiptPath, sampleUrl: 'https://sample.example/book.mp3' }, + { http, matcher }, + ); + await verifyAudibleSample( + { asin: 'ASIN', file, sampleUrl: 'https://sample.example/book.mp3' }, + { http, matcher }, + ); + + const [besideReceipt, besideFile] = sampleDirs; + expect(besideReceipt!.startsWith(join(dirname(receiptPath), '.audiobook-curator-acoustic-'))).toBe(true); + expect(besideFile!.startsWith(join(dirname(file), '.audiobook-curator-acoustic-'))).toBe(true); + for (const workDir of sampleDirs) expect(workDir.startsWith(join(tmpdir(), 'audiobook-curator-'))).toBe(false); + // Staging is cleaned up even though it lives beside durable outputs. + expect((await readdir(dirname(receiptPath))).filter((entry) => entry.startsWith('.audiobook-curator-'))).toEqual([]); + expect((await readdir(dirname(file))).filter((entry) => entry.startsWith('.audiobook-curator-'))).toEqual([]); + }); + it('extracts distributed PCM windows and returns review evidence without internal deadlines', async () => { const root = await mkdtemp(join(tmpdir(), 'curator-whisper-')); roots.push(root);