From f80aea2418bb5df3980157c7065e23ae0d52f837 Mon Sep 17 00:00:00 2001 From: Boykosoft Date: Thu, 26 Feb 2026 00:08:19 +0000 Subject: [PATCH 1/2] Standardize pipeline error shape across stages and web workflow --- .gitignore | 1 + .../src/app/api/transcription/final/route.ts | 26 +++--- .../app/api/transcription/segment/route.ts | 28 +++--- apps/web/src/app/page.tsx | 90 ++++++++++++++++--- apps/web/src/app/workflow-error-display.tsx | 22 +++++ apps/web/tsconfig.json | 2 + config/next.config.mjs | 1 + config/tsconfig.test.json | 2 + .../src/__tests__/session-store.test.ts | 22 +++++ .../pipeline/assemble/src/session-store.ts | 15 +++- .../src/__tests__/audio-processing.test.ts | 8 ++ .../src/capture/use-audio-recorder.ts | 28 +++--- packages/pipeline/audio-ingest/src/errors.ts | 9 ++ packages/pipeline/audio-ingest/src/index.ts | 1 + .../src/__tests__/note-generator.test.ts | 20 ++--- .../pipeline/note-core/src/note-generator.ts | 23 ++--- packages/pipeline/shared/src/error.ts | 89 ++++++++++++++++++ packages/pipeline/shared/src/index.ts | 1 + .../src/__tests__/transcribe.test.ts | 10 ++- packages/pipeline/transcribe/src/core/wav.ts | 10 ++- .../src/hooks/segment-upload-controller.ts | 37 +++++--- .../src/providers/medasr-transcriber.ts | 70 +++++++++------ .../providers/whisper-local-transcriber.ts | 43 +++++++-- .../src/providers/whisper-transcriber.ts | 21 +++-- tsconfig.json | 6 ++ 25 files changed, 453 insertions(+), 132 deletions(-) create mode 100644 apps/web/src/app/workflow-error-display.tsx create mode 100644 packages/pipeline/assemble/src/__tests__/session-store.test.ts create mode 100644 packages/pipeline/audio-ingest/src/errors.ts create mode 100644 packages/pipeline/shared/src/error.ts create mode 100644 packages/pipeline/shared/src/index.ts diff --git a/.gitignore b/.gitignore index 8c67056..e1a000b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ pnpm-debug.log* yarn-debug.log* yarn-error.log* *.tsbuildinfo +.idea diff --git a/apps/web/src/app/api/transcription/final/route.ts b/apps/web/src/app/api/transcription/final/route.ts index e157c97..fc0bec6 100644 --- a/apps/web/src/app/api/transcription/final/route.ts +++ b/apps/web/src/app/api/transcription/final/route.ts @@ -1,12 +1,13 @@ import type { NextRequest } from "next/server" +import { toPipelineError } from "@pipeline-errors" import { parseWavHeader, resolveTranscriptionProvider, transcribeWithResolvedProvider } from "@transcription" import { transcriptionSessionStore } from "@transcript-assembly" import { writeAuditEntry } from "@storage/audit-log" export const runtime = "nodejs" -function jsonError(status: number, code: string, message: string) { - return new Response(JSON.stringify({ error: { code, message } }), { +function jsonError(status: number, code: string, message: string, recoverable: boolean) { + return new Response(JSON.stringify({ error: { code, message, recoverable } }), { status, headers: { "Content-Type": "application/json" }, }) @@ -19,7 +20,7 @@ export async function POST(req: NextRequest) { const file = formData.get("file") if (typeof sessionId !== "string" || !(file instanceof Blob)) { - return jsonError(400, "validation_error", "Missing session_id or file") + return jsonError(400, "validation_error", "Missing session_id or file", false) } transcriptionSessionStore.setStatus(sessionId, "finalizing") @@ -29,11 +30,11 @@ export async function POST(req: NextRequest) { try { wavInfo = parseWavHeader(arrayBuffer) } catch (error) { - return jsonError(400, "validation_error", error instanceof Error ? error.message : "Invalid WAV file") + return jsonError(400, "validation_error", error instanceof Error ? error.message : "Invalid WAV file", true) } if (wavInfo.sampleRate !== 16000 || wavInfo.numChannels !== 1 || wavInfo.bitDepth !== 16) { - return jsonError(400, "validation_error", "Final recording must be 16kHz mono 16-bit PCM WAV") + return jsonError(400, "validation_error", "Final recording must be 16kHz mono 16-bit PCM WAV", true) } try { @@ -67,11 +68,12 @@ export async function POST(req: NextRequest) { } catch (error) { console.error("Final transcription failed", error) const resolvedProvider = resolveTranscriptionProvider() - transcriptionSessionStore.emitError( - sessionId, - "api_error", - error instanceof Error ? error.message : "Transcription API failure", - ) + const pipelineError = toPipelineError(error, { + code: "api_error", + message: "Transcription API failure", + recoverable: true, + }) + transcriptionSessionStore.emitError(sessionId, pipelineError) // Audit log: final transcription failed await writeAuditEntry({ @@ -85,10 +87,10 @@ export async function POST(req: NextRequest) { }, }) - return jsonError(502, "api_error", "Transcription API failed") + return jsonError(502, pipelineError.code, pipelineError.message, pipelineError.recoverable) } } catch (error) { console.error("Final recording ingestion failed", error) - return jsonError(500, "storage_error", "Failed to process final recording") + return jsonError(500, "storage_error", "Failed to process final recording", false) } } diff --git a/apps/web/src/app/api/transcription/segment/route.ts b/apps/web/src/app/api/transcription/segment/route.ts index 483713b..a3d1956 100644 --- a/apps/web/src/app/api/transcription/segment/route.ts +++ b/apps/web/src/app/api/transcription/segment/route.ts @@ -1,12 +1,13 @@ import type { NextRequest } from "next/server" +import { toPipelineError } from "@pipeline-errors" import { parseWavHeader, resolveTranscriptionProvider, transcribeWithResolvedProvider } from "@transcription" import { transcriptionSessionStore } from "@transcript-assembly" import { writeAuditEntry } from "@storage/audit-log" export const runtime = "nodejs" -function jsonError(status: number, code: string, message: string) { - return new Response(JSON.stringify({ error: { code, message } }), { +function jsonError(status: number, code: string, message: string, recoverable: boolean) { + return new Response(JSON.stringify({ error: { code, message, recoverable } }), { status, headers: { "Content-Type": "application/json" }, }) @@ -32,7 +33,7 @@ export async function POST(req: NextRequest) { Number.isNaN(overlapMs) || !(file instanceof Blob) ) { - return jsonError(400, "validation_error", "Missing required metadata or file") + return jsonError(400, "validation_error", "Missing required metadata or file", false) } const arrayBuffer = await file.arrayBuffer() @@ -40,15 +41,15 @@ export async function POST(req: NextRequest) { try { wavInfo = parseWavHeader(arrayBuffer) } catch (error) { - return jsonError(400, "validation_error", error instanceof Error ? error.message : "Invalid WAV file") + return jsonError(400, "validation_error", error instanceof Error ? error.message : "Invalid WAV file", true) } if (wavInfo.sampleRate !== 16000 || wavInfo.numChannels !== 1 || wavInfo.bitDepth !== 16) { - return jsonError(400, "validation_error", "Segments must be 16kHz mono 16-bit PCM WAV") + return jsonError(400, "validation_error", "Segments must be 16kHz mono 16-bit PCM WAV", true) } if (wavInfo.durationMs < 8000 || wavInfo.durationMs > 12000) { - return jsonError(400, "validation_error", "Segment duration must be between 8s and 12s") + return jsonError(400, "validation_error", "Segment duration must be between 8s and 12s", true) } try { @@ -85,11 +86,12 @@ export async function POST(req: NextRequest) { } catch (error) { console.error("Segment transcription failed", error) const resolvedProvider = resolveTranscriptionProvider() - transcriptionSessionStore.emitError( - sessionId, - "api_error", - error instanceof Error ? error.message : "Transcription API failure", - ) + const pipelineError = toPipelineError(error, { + code: "api_error", + message: "Transcription API failure", + recoverable: true, + }) + transcriptionSessionStore.emitError(sessionId, pipelineError) // Audit log: segment transcription failed await writeAuditEntry({ @@ -104,10 +106,10 @@ export async function POST(req: NextRequest) { }, }) - return jsonError(502, "api_error", "Transcription API failed") + return jsonError(502, pipelineError.code, pipelineError.message, pipelineError.recoverable) } } catch (error) { console.error("Segment ingestion failed", error) - return jsonError(500, "storage_error", "Failed to process audio segment") + return jsonError(500, "storage_error", "Failed to process audio segment", false) } } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 72d0e25..2f0e55f 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,11 +1,13 @@ "use client" import { useState, useCallback, useRef, useEffect } from "react" +import { createPipelineError, toPipelineError, type PipelineError } from "@pipeline-errors" import type { Encounter } from "@storage/types" import { useEncounters, EncounterList, IdleView, NewEncounterForm, RecordingView, ProcessingView, ErrorBoundary, PermissionsDialog, SettingsDialog, SettingsBar, useHttpsWarning } from "@ui" import { NoteEditor } from "@note-rendering" import { useAudioRecorder, type RecordedSegment, warmupMicrophonePermission, warmupSystemAudioPermission } from "@audio" import { useSegmentUpload, type UploadError } from "@transcription"; +import { WorkflowErrorDisplay } from "./workflow-error-display" import { generateClinicalNote } from "@/app/actions" import { getPreferences, @@ -91,8 +93,9 @@ function HomePageContent() { const [view, setView] = useState({ type: "idle" }) const [transcriptionStatus, setTranscriptionStatus] = useState("pending") const [noteGenerationStatus, setNoteGenerationStatus] = useState("pending") - const [processingMetrics, setProcessingMetrics] = useState({}) + const [, setProcessingMetrics] = useState({}) const [sessionId, setSessionId] = useState(null) + const [workflowError, setWorkflowError] = useState(null) const currentEncounterIdRef = useRef(null) const sessionIdRef = useRef(null) @@ -211,6 +214,13 @@ function HomePageContent() { const useLocalBackend = processingMode === "local" && localBackendAvailable const handleUploadError = useCallback((error: UploadError) => { + setWorkflowError(error) + setTranscriptionStatus("failed") + setProcessingMetrics((prev) => ({ + ...prev, + transcriptionEndedAt: prev.transcriptionEndedAt ?? Date.now(), + processingEndedAt: Date.now(), + })) debugError("Segment upload failed:", error.code, "-", error.message); }, []); @@ -313,7 +323,8 @@ function HomePageContent() { useEffect(() => { if (recordingError) { - debugError("Recording error:", recordingError) + setWorkflowError(recordingError) + debugError("Recording error:", recordingError.message) setTranscriptionStatus("failed") } }, [recordingError]) @@ -392,6 +403,7 @@ function HomePageContent() { }) await refreshRef.current() setNoteGenerationStatus("done") + setWorkflowError(null) setProcessingMetrics((prev) => ({ ...prev, noteGenerationEndedAt: Date.now(), @@ -404,6 +416,13 @@ function HomePageContent() { setView({ type: "viewing", encounterId }) } catch (err) { debugError("❌ Note generation failed:", err) + setWorkflowError( + toPipelineError(err, { + code: "note_generation_error", + message: "Failed to generate clinical note", + recoverable: true, + }), + ) setNoteGenerationStatus("failed") setProcessingMetrics((prev) => ({ ...prev, @@ -448,6 +467,11 @@ function HomePageContent() { const handleStreamError = useCallback((event: MessageEvent | Event) => { const readyState = eventSourceRef.current?.readyState debugError("Transcription stream error", { event, readyState, apiBaseUrl: apiBaseUrlRef.current }) + setWorkflowError( + createPipelineError("network_error", "Lost connection to transcription stream", true, { + readyState, + }), + ) setTranscriptionStatus("failed") setProcessingMetrics((prev) => ({ ...prev, @@ -562,6 +586,7 @@ function HomePageContent() { // Optimistically flip to recording immediately for responsive UI. setView({ type: "recording", encounterId: encounter.id }) setTranscriptionStatus("in-progress") + setWorkflowError(null) if (!useLocalBackend && localBackendRef.current) { const whisperReady = await localBackendRef.current.invoke("ensure-whisper-service") if (!(whisperReady as { success?: boolean }).success) { @@ -581,6 +606,13 @@ function HomePageContent() { } } catch (err) { debugError("Failed to start recording:", err) + setWorkflowError( + toPipelineError(err, { + code: "capture_error", + message: "Failed to start recording", + recoverable: true, + }), + ) setTranscriptionStatus("failed") setView({ type: "idle" }) } @@ -607,10 +639,13 @@ function HomePageContent() { } let message = `Final upload failed (${response.status})` try { - const body = (await response.json()) as { error?: { message?: string } } + const body = (await response.json()) as { error?: PipelineError } if (body?.error?.message) { message = body.error.message } + if (body?.error) { + setWorkflowError(body.error) + } } catch { // ignore } @@ -622,6 +657,13 @@ function HomePageContent() { return uploadFinalRecording(activeSessionId, blob, attempt + 1) } debugError("Failed to upload final recording:", error) + setWorkflowError( + toPipelineError(error, { + code: "api_error", + message: "Failed to upload final recording", + recoverable: true, + }), + ) setTranscriptionStatus("failed") throw error } @@ -655,6 +697,9 @@ function HomePageContent() { const audioBlob = await stopRecording() if (!audioBlob) { + setWorkflowError( + createPipelineError("processing_error", "Failed to finalize recording", true, { stage: "audio-ingest" }), + ) setTranscriptionStatus("failed") return } @@ -666,6 +711,7 @@ function HomePageContent() { void uploadFinalRecording(activeSessionId, audioBlob) } else { debugError("Missing session identifier for final upload") + setWorkflowError(createPipelineError("capture_error", "Missing session identifier for final upload", true)) setTranscriptionStatus("failed") } } @@ -694,9 +740,11 @@ function HomePageContent() { const meeting = lastMeetingDataRef.current const summaryFile = meeting?.session_info?.summary_file as string | undefined if (!summaryFile) { + setWorkflowError(createPipelineError("storage_error", "Unable to find meeting summary for retry", false)) setTranscriptionStatus("failed") return } + setWorkflowError(null) setTranscriptionStatus("in-progress") setNoteGenerationStatus("pending") setProcessingMetrics({ @@ -738,6 +786,13 @@ function HomePageContent() { })) } } catch (error) { + setWorkflowError( + toPipelineError(error, { + code: "transcription_error", + message: "Failed to retry transcription", + recoverable: true, + }), + ) setTranscriptionStatus("failed") setNoteGenerationStatus("failed") setProcessingMetrics((prev) => ({ @@ -753,6 +808,7 @@ function HomePageContent() { const activeSessionId = sessionIdRef.current if (!blob || !activeSessionId) return setTranscriptionStatus("in-progress") + setWorkflowError(null) try { await uploadFinalRecording(activeSessionId, blob) } catch { @@ -767,6 +823,7 @@ function HomePageContent() { const transcript = finalTranscriptRef.current const encounterId = currentEncounter?.id if (!encounterId || !transcript) return + setWorkflowError(null) setProcessingMetrics((prev) => ({ ...prev, noteGenerationStartedAt: Date.now(), @@ -839,6 +896,11 @@ function HomePageContent() { if (!encounterId) return if (!data.success) { + setWorkflowError( + createPipelineError("transcription_error", data.error || "Transcription failed", true, { + stage: "transcription", + }), + ) setTranscriptionStatus("failed") setNoteGenerationStatus("failed") setProcessingMetrics((prev) => ({ @@ -868,6 +930,7 @@ function HomePageContent() { setTranscriptionStatus("done") setNoteGenerationStatus("done") + setWorkflowError(null) setProcessingMetrics((prev) => ({ ...prev, transcriptionEndedAt: prev.transcriptionEndedAt ?? Date.now(), @@ -953,18 +1016,23 @@ function HomePageContent() { /> ) - case "processing": + case "processing": { + const retryAction = noteGenerationStatus === "failed" ? handleRetryNoteGeneration : handleRetryTranscription return (
- +
+ {workflowError && } + +
) + } case "viewing": return selectedEncounter ? ( diff --git a/apps/web/src/app/workflow-error-display.tsx b/apps/web/src/app/workflow-error-display.tsx new file mode 100644 index 0000000..f11f60d --- /dev/null +++ b/apps/web/src/app/workflow-error-display.tsx @@ -0,0 +1,22 @@ +"use client" + +import type { PipelineError } from "@pipeline-errors" +import { Button } from "@ui/lib/ui/button" + +interface WorkflowErrorDisplayProps { + error: PipelineError + onRetry?: () => void +} + +export function WorkflowErrorDisplay({ error, onRetry }: WorkflowErrorDisplayProps) { + return ( +
+

{error.message}

+ {error.recoverable && onRetry && ( + + )} +
+ ) +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 58f0fa4..661d0d0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -20,6 +20,8 @@ "@llm-medgemma/*": ["packages/llm-medgemma/src/*"], "@medgemma-scribe": ["packages/pipeline/medgemma-scribe/src/index.ts"], "@medgemma-scribe/*": ["packages/pipeline/medgemma-scribe/src/*"], + "@pipeline-errors": ["packages/pipeline/shared/src/index.ts"], + "@pipeline-errors/*": ["packages/pipeline/shared/src/*"], "@storage": ["packages/storage/src/index.ts"], "@storage/*": ["packages/storage/src/*"], "@ui": ["packages/ui/src/index.ts"], diff --git a/config/next.config.mjs b/config/next.config.mjs index 413ee02..3834151 100644 --- a/config/next.config.mjs +++ b/config/next.config.mjs @@ -86,6 +86,7 @@ const nextConfig = { '@llm': path.resolve(__dirname, '../packages/llm/src'), '@llm-medgemma': path.resolve(__dirname, '../packages/llm-medgemma/src'), '@medgemma-scribe': path.resolve(__dirname, '../packages/pipeline/medgemma-scribe/src'), + '@pipeline-errors': path.resolve(__dirname, '../packages/pipeline/shared/src'), '@storage': path.resolve(__dirname, '../packages/storage/src'), '@ui': path.resolve(__dirname, '../packages/ui/src'), '@ui/lib': path.resolve(__dirname, '../packages/ui/src/lib'), diff --git a/config/tsconfig.test.json b/config/tsconfig.test.json index b6e60ec..e8ed26e 100644 --- a/config/tsconfig.test.json +++ b/config/tsconfig.test.json @@ -15,7 +15,9 @@ "../packages/pipeline/eval/src/**/*.ts", "../packages/pipeline/audio-ingest/src/**/*.ts", "../packages/pipeline/transcribe/src/**/*.ts", + "../packages/pipeline/assemble/src/**/*.ts", "../packages/pipeline/note-core/src/**/*.ts", + "../packages/pipeline/shared/src/**/*.ts", "../packages/pipeline/medgemma-scribe/src/**/*.ts", "../packages/llm/src/**/*.ts", "../packages/llm-medgemma/src/**/*.ts", diff --git a/packages/pipeline/assemble/src/__tests__/session-store.test.ts b/packages/pipeline/assemble/src/__tests__/session-store.test.ts new file mode 100644 index 0000000..3eb12eb --- /dev/null +++ b/packages/pipeline/assemble/src/__tests__/session-store.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { transcriptionSessionStore } from "../session-store.js" + +test("transcriptionSessionStore emits standardized pipeline error shape", async () => { + const sessionId = `test-session-${Date.now()}` + const received: Record[] = [] + + const unsubscribe = transcriptionSessionStore.subscribe(sessionId, (event) => { + if (event.event === "error") { + received.push(event.data) + } + }) + + transcriptionSessionStore.emitError(sessionId, new Error("Assembler failure")) + unsubscribe() + + assert.equal(received.length, 1) + assert.equal(received[0].code, "assembly_error") + assert.equal(received[0].message, "Assembler failure") + assert.equal(received[0].recoverable, true) +}) diff --git a/packages/pipeline/assemble/src/session-store.ts b/packages/pipeline/assemble/src/session-store.ts index 586173f..5b48ff8 100644 --- a/packages/pipeline/assemble/src/session-store.ts +++ b/packages/pipeline/assemble/src/session-store.ts @@ -1,3 +1,5 @@ +import { toPipelineError, type PipelineError } from "../../shared/src/error" + type TranscriptionStatus = "recording" | "finalizing" | "completed" | "error" export interface SegmentMetadata { @@ -200,15 +202,22 @@ class TranscriptionSessionStore { }) } - emitError(sessionId: string, code: string, message: string) { + emitError(sessionId: string, error: PipelineError | Error | unknown) { const session = this.getSession(sessionId) session.status = "error" + const normalizedError = toPipelineError(error, { + code: "assembly_error", + message: "Failed to assemble transcript", + recoverable: true, + }) this.emit(session, { event: "error", data: { session_id: sessionId, - code, - message, + code: normalizedError.code, + message: normalizedError.message, + recoverable: normalizedError.recoverable, + details: normalizedError.details ?? null, }, }) } diff --git a/packages/pipeline/audio-ingest/src/__tests__/audio-processing.test.ts b/packages/pipeline/audio-ingest/src/__tests__/audio-processing.test.ts index ea7c9e4..3a8f817 100644 --- a/packages/pipeline/audio-ingest/src/__tests__/audio-processing.test.ts +++ b/packages/pipeline/audio-ingest/src/__tests__/audio-processing.test.ts @@ -8,6 +8,7 @@ import { createWavBlob, drainSegments, } from "../capture/audio-processing.js" +import { toAudioIngestError } from "../errors.js" test("StreamingResampler accumulates until enough samples are available", () => { const resampler = new StreamingResampler(48000, TARGET_SAMPLE_RATE) @@ -92,3 +93,10 @@ test("createWavBlob produces a valid PCM header and payload", async () => { assert.equal(firstSample, 0) assert.equal(secondSample, 16383) }) + +test("audio-ingest normalizes recorder failures to shared pipeline error shape", () => { + const error = toAudioIngestError(new Error("Microphone denied"), "capture_error") + assert.equal(error.code, "capture_error") + assert.equal(error.message, "Microphone denied") + assert.equal(error.recoverable, true) +}) diff --git a/packages/pipeline/audio-ingest/src/capture/use-audio-recorder.ts b/packages/pipeline/audio-ingest/src/capture/use-audio-recorder.ts index 8355c43..ae83fe2 100644 --- a/packages/pipeline/audio-ingest/src/capture/use-audio-recorder.ts +++ b/packages/pipeline/audio-ingest/src/capture/use-audio-recorder.ts @@ -1,7 +1,9 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" +import { PipelineStageError, type PipelineError } from "../../../shared/src/error" import { requestSystemAudioStream } from "../devices/system-audio" +import { toAudioIngestError } from "../errors" import { DEFAULT_OVERLAP_MS, DEFAULT_SEGMENT_MS, @@ -37,8 +39,8 @@ interface UseAudioRecorderReturn { stopRecording: () => Promise pauseRecording: () => void resumeRecording: () => void - error: string | null - errorCode: "capture_error" | "processing_error" | null + error: PipelineError | null + errorCode: string | null } export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudioRecorderReturn { @@ -46,8 +48,8 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi const [isRecording, setIsRecording] = useState(false) const [isPaused, setIsPaused] = useState(false) const [duration, setDuration] = useState(0) - const [error, setError] = useState(null) - const [errorCode, setErrorCode] = useState<"capture_error" | "processing_error" | null>(null) + const [error, setError] = useState(null) + const [errorCode, setErrorCode] = useState(null) const micStreamRef = useRef(null) const systemStreamRef = useRef(null) @@ -219,11 +221,16 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi setIsPaused(false) startTimer() } catch (err) { - const message = err instanceof Error ? err.message : "Failed to start capture" - setError(message) - setErrorCode("capture_error") + const pipelineError = toAudioIngestError(err, "capture_error") + setError(pipelineError) + setErrorCode(pipelineError.code) await cleanupAudio() - throw err + throw new PipelineStageError( + pipelineError.code, + pipelineError.message, + pipelineError.recoverable, + pipelineError.details, + ) } }, [cleanupAudio, setupProcessor, startTimer]) @@ -250,8 +257,9 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}): UseAudi return createWavBlob(merged, TARGET_SAMPLE_RATE) } catch (err) { console.error("Failed to finalize recording", err) - setError("Failed to finalize recording") - setErrorCode("processing_error") + const pipelineError = toAudioIngestError(err, "processing_error") + setError(pipelineError) + setErrorCode(pipelineError.code) return null } finally { await cleanupAudio() diff --git a/packages/pipeline/audio-ingest/src/errors.ts b/packages/pipeline/audio-ingest/src/errors.ts new file mode 100644 index 0000000..b4e80c9 --- /dev/null +++ b/packages/pipeline/audio-ingest/src/errors.ts @@ -0,0 +1,9 @@ +import { toPipelineError, type PipelineError } from "../../shared/src/error" + +export function toAudioIngestError(error: unknown, code: "capture_error" | "processing_error"): PipelineError { + return toPipelineError(error, { + code, + message: code === "capture_error" ? "Failed to start audio capture" : "Failed to finalize recording", + recoverable: true, + }) +} diff --git a/packages/pipeline/audio-ingest/src/index.ts b/packages/pipeline/audio-ingest/src/index.ts index 685798b..e3ce68c 100644 --- a/packages/pipeline/audio-ingest/src/index.ts +++ b/packages/pipeline/audio-ingest/src/index.ts @@ -1,5 +1,6 @@ export { useAudioRecorder } from "./capture/use-audio-recorder" export type { RecordedSegment } from "./capture/use-audio-recorder" +export { toAudioIngestError } from "./errors" export { requestSystemAudioStream, warmupMicrophonePermission, diff --git a/packages/pipeline/note-core/src/__tests__/note-generator.test.ts b/packages/pipeline/note-core/src/__tests__/note-generator.test.ts index b1da44d..3b02c8b 100644 --- a/packages/pipeline/note-core/src/__tests__/note-generator.test.ts +++ b/packages/pipeline/note-core/src/__tests__/note-generator.test.ts @@ -162,26 +162,20 @@ test("createClinicalNoteText handles short note length", async () => { }) test("createClinicalNoteText handles API errors gracefully", async () => { - // Skip if no API key - if (!process.env.ANTHROPIC_API_KEY) { - console.log("⚠️ Skipping live API test - ANTHROPIC_API_KEY not set") - return - } - - // Temporarily break the API key to trigger an error const originalKey = process.env.ANTHROPIC_API_KEY - process.env.ANTHROPIC_API_KEY = "invalid-key-12345" + process.env.ANTHROPIC_API_KEY = "" try { - const result = await createClinicalNoteText({ + await createClinicalNoteText({ transcript: "Test transcript", patient_name: "Test", visit_reason: "test", }) - - // Should return empty note on error - const emptyNote = createEmptyMarkdownNote() - assert.equal(result.trim(), emptyNote.trim(), "Should return empty note on API error") + assert.fail("Expected note generation to throw") + } catch (error) { + assert.equal((error as { code?: string }).code, "note_generation_error") + assert.equal(typeof (error as { message?: string }).message, "string") + assert.equal((error as { recoverable?: boolean }).recoverable, true) } finally { process.env.ANTHROPIC_API_KEY = originalKey } diff --git a/packages/pipeline/note-core/src/note-generator.ts b/packages/pipeline/note-core/src/note-generator.ts index 5acd2b7..d002e92 100644 --- a/packages/pipeline/note-core/src/note-generator.ts +++ b/packages/pipeline/note-core/src/note-generator.ts @@ -1,10 +1,11 @@ -import { runLLMRequest, prompts } from "@llm" +import { runLLMRequest, prompts } from "../../../llm/src/index" +import { toPipelineStageError } from "../../shared/src/error" import { extractMarkdownFromResponse, normalizeMarkdownSections, createEmptyMarkdownNote } from "./clinical-models/markdown-note" -import { debugLog, debugLogPHI, debugError, debugWarn } from "@storage" +import { debugLog, debugLogPHI, debugError, debugWarn } from "../../../storage/src/index" export type NoteLength = "short" | "long" @@ -84,14 +85,14 @@ export async function createClinicalNoteText(params: ClinicalNoteRequest): Promi return normalizedMarkdown } catch (error) { debugError("❌ Failed to generate clinical note:", error) - debugWarn("⚠️ Returning empty note due to error") - const emptyNote = createEmptyMarkdownNote() - debugLog("=".repeat(80)) - debugLog("FINAL CLINICAL NOTE (ERROR FALLBACK):") - debugLog("-".repeat(80)) - debugLogPHI(emptyNote) - debugLog("-".repeat(80)) - debugLog("=".repeat(80)) - return emptyNote + debugWarn("⚠️ Propagating note generation error") + throw toPipelineStageError(error, { + code: "note_generation_error", + message: "Failed to generate clinical note", + recoverable: true, + details: { + template: template || "default", + }, + }) } } diff --git a/packages/pipeline/shared/src/error.ts b/packages/pipeline/shared/src/error.ts new file mode 100644 index 0000000..8d8b990 --- /dev/null +++ b/packages/pipeline/shared/src/error.ts @@ -0,0 +1,89 @@ +export interface PipelineError { + code: string + message: string + recoverable: boolean + details?: Record +} + +export class PipelineStageError extends Error implements PipelineError { + code: string + recoverable: boolean + details?: Record + + constructor(code: string, message: string, recoverable: boolean, details?: Record) { + super(message) + this.name = "PipelineStageError" + this.code = code + this.recoverable = recoverable + this.details = details + } +} + +export function createPipelineError( + code: string, + message: string, + recoverable: boolean, + details?: Record, +): PipelineError { + return { code, message, recoverable, details } +} + +export function isPipelineError(error: unknown): error is PipelineError { + if (!error || typeof error !== "object") return false + const candidate = error as Partial + return ( + typeof candidate.code === "string" && + typeof candidate.message === "string" && + typeof candidate.recoverable === "boolean" + ) +} + +export function toPipelineError( + error: unknown, + fallback: { + code: string + message: string + recoverable: boolean + details?: Record + }, +): PipelineError { + if (error instanceof PipelineStageError) { + return createPipelineError(error.code, error.message, error.recoverable, error.details) + } + + if (isPipelineError(error)) { + return createPipelineError(error.code, error.message, error.recoverable, error.details) + } + + if (error instanceof Error) { + return createPipelineError( + fallback.code, + error.message || fallback.message, + fallback.recoverable, + fallback.details, + ) + } + + return createPipelineError( + fallback.code, + typeof error === "string" ? error : fallback.message, + fallback.recoverable, + fallback.details, + ) +} + +export function toPipelineStageError( + error: unknown, + fallback: { + code: string + message: string + recoverable: boolean + details?: Record + }, +): PipelineStageError { + if (error instanceof PipelineStageError) { + return error + } + const normalized = toPipelineError(error, fallback) + return new PipelineStageError(normalized.code, normalized.message, normalized.recoverable, normalized.details) +} diff --git a/packages/pipeline/shared/src/index.ts b/packages/pipeline/shared/src/index.ts new file mode 100644 index 0000000..d86d433 --- /dev/null +++ b/packages/pipeline/shared/src/index.ts @@ -0,0 +1 @@ +export * from "./error" diff --git a/packages/pipeline/transcribe/src/__tests__/transcribe.test.ts b/packages/pipeline/transcribe/src/__tests__/transcribe.test.ts index 379223e..e5f6870 100644 --- a/packages/pipeline/transcribe/src/__tests__/transcribe.test.ts +++ b/packages/pipeline/transcribe/src/__tests__/transcribe.test.ts @@ -70,7 +70,15 @@ test("parseWavHeader returns accurate metadata", () => { test("parseWavHeader rejects short buffers", () => { const tiny = new ArrayBuffer(10) - assert.throws(() => parseWavHeader(tiny), /WAV buffer too small/) + try { + parseWavHeader(tiny) + assert.fail("Expected parseWavHeader to throw") + } catch (error) { + assert.equal(typeof error, "object") + assert.equal((error as { code?: string }).code, "validation_error") + assert.equal((error as { message?: string }).message, "WAV buffer too small") + assert.equal((error as { recoverable?: boolean }).recoverable, true) + } }) test("SegmentUploadController enforces concurrency limits", async () => { diff --git a/packages/pipeline/transcribe/src/core/wav.ts b/packages/pipeline/transcribe/src/core/wav.ts index 2bbfb43..670da33 100644 --- a/packages/pipeline/transcribe/src/core/wav.ts +++ b/packages/pipeline/transcribe/src/core/wav.ts @@ -1,3 +1,5 @@ +import { PipelineStageError } from "../../../shared/src/error" + export interface WavInfo { sampleRate: number numChannels: number @@ -16,14 +18,14 @@ function readString(view: DataView, offset: number, length: number): string { export function parseWavHeader(buffer: ArrayBuffer): WavInfo { if (buffer.byteLength < 44) { - throw new Error("WAV buffer too small") + throw new PipelineStageError("validation_error", "WAV buffer too small", true) } const view = new DataView(buffer) const riff = readString(view, 0, 4) const wave = readString(view, 8, 4) if (riff !== "RIFF" || wave !== "WAVE") { - throw new Error("Invalid WAV header") + throw new PipelineStageError("validation_error", "Invalid WAV header", true) } let offset = 12 @@ -40,7 +42,7 @@ export function parseWavHeader(buffer: ArrayBuffer): WavInfo { if (chunkId === "fmt ") { const audioFormat = view.getUint16(chunkStart, true) if (audioFormat !== 1) { - throw new Error("Only PCM WAV files are supported") + throw new PipelineStageError("validation_error", "Only PCM WAV files are supported", true) } numChannels = view.getUint16(chunkStart + 2, true) sampleRate = view.getUint32(chunkStart + 4, true) @@ -54,7 +56,7 @@ export function parseWavHeader(buffer: ArrayBuffer): WavInfo { } if (!sampleRate || !numChannels || !bitDepth || !dataBytes) { - throw new Error("Incomplete WAV data") + throw new PipelineStageError("validation_error", "Incomplete WAV data", true) } const bytesPerSample = bitDepth / 8 diff --git a/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts b/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts index 7c1038d..53a0517 100644 --- a/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts +++ b/packages/pipeline/transcribe/src/hooks/segment-upload-controller.ts @@ -1,3 +1,5 @@ +import { PipelineStageError, createPipelineError, toPipelineError, type PipelineError } from "../../../shared/src/error" + export interface PendingSegment { seqNo: number startMs: number @@ -7,10 +9,7 @@ export interface PendingSegment { blob: Blob } -export interface UploadError { - code: "capture_error" | "validation_error" | "api_error" | "storage_error" | "network_error" - message: string -} +export type UploadError = PipelineError export interface SegmentUploadControllerOptions { onError?: (error: UploadError) => void @@ -27,11 +26,15 @@ const MAX_RETRIES = 3 class UploadException extends Error implements UploadError { code: UploadError["code"] + recoverable: boolean + details?: Record - constructor(code: UploadError["code"], message: string) { + constructor(code: UploadError["code"], message: string, recoverable: boolean, details?: Record) { super(message) this.name = "UploadException" this.code = code + this.recoverable = recoverable + this.details = details } } @@ -59,7 +62,7 @@ export class SegmentUploadController { this.apiBaseUrl = options?.apiBaseUrl this.fetchFn = deps?.fetchFn ?? globalThis.fetch.bind(globalThis) if (!this.fetchFn) { - throw new Error("fetch API is required for SegmentUploadController") + throw new PipelineStageError("configuration_error", "fetch API is required for SegmentUploadController", false) } this.waitFn = deps?.waitFn ?? defaultWait } @@ -72,7 +75,7 @@ export class SegmentUploadController { enqueueSegment(segment: PendingSegment) { if (!this.sessionId) { console.warn('[SegmentUpload] Segment', segment.seqNo, 'dropped - session not initialized yet') - this.notifyError({ code: "capture_error", message: "Session not initialized" }) + this.notifyError(createPipelineError("capture_error", "Session not initialized", true)) return } this.queue.push(segment) @@ -115,11 +118,12 @@ export class SegmentUploadController { if (error) { const uploadError: UploadError = error instanceof UploadException - ? { code: error.code, message: error.message } - : { - code: "network_error", - message: error instanceof Error ? error.message : String(error) || "Upload failed" - } + ? createPipelineError(error.code, error.message, error.recoverable, error.details) + : toPipelineError(error, { + code: "network_error", + message: "Upload failed", + recoverable: true, + }) console.error('[SegmentUpload] Final upload failure for segment', segment.seqNo, ':', uploadError.code, uploadError.message) this.notifyError(uploadError) } @@ -160,6 +164,10 @@ export class SegmentUploadController { const errorCode = errorBody?.error?.code || (response.status >= 500 ? "api_error" : "validation_error") const message = errorBody?.error?.message || `Upload failed with status ${response.status}` + const recoverable = + typeof errorBody?.error?.recoverable === "boolean" + ? errorBody.error.recoverable + : response.status === 429 || response.status >= 500 const shouldRetry = (response.status === 429 || response.status >= 500) && attempt < MAX_RETRIES if (shouldRetry) { @@ -167,7 +175,10 @@ export class SegmentUploadController { return this.uploadSegment(session, segment, attempt + 1) } - throw new UploadException(errorCode as UploadError["code"], message) + throw new UploadException(errorCode as UploadError["code"], message, recoverable, { + status: response.status, + endpoint: "segment_upload", + }) } } catch (error) { if (error instanceof DOMException && error.name === "AbortError") { diff --git a/packages/pipeline/transcribe/src/providers/medasr-transcriber.ts b/packages/pipeline/transcribe/src/providers/medasr-transcriber.ts index 73b6e92..2dd266a 100644 --- a/packages/pipeline/transcribe/src/providers/medasr-transcriber.ts +++ b/packages/pipeline/transcribe/src/providers/medasr-transcriber.ts @@ -1,9 +1,11 @@ +import { PipelineStageError, toPipelineStageError } from "../../../shared/src/error" + /** * MedASR Local Transcriber - * + * * Transcribes audio using a local MedASR server instead of OpenAI Whisper. * Compatible with the existing transcription pipeline. - * + * * Requires: MedASR server running locally (scripts/medasr_server.py) */ @@ -17,35 +19,32 @@ function validateLocalOrHttpsUrl(url: string, serviceName: string): void { try { const parsed = new URL(url) const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" - + if (!isLocalhost && parsed.protocol !== "https:") { - throw new Error( - `SECURITY ERROR: ${serviceName} endpoint must use HTTPS or localhost. ` + - `Received: ${parsed.protocol}//${parsed.host}` + throw new PipelineStageError( + "configuration_error", + `SECURITY ERROR: ${serviceName} endpoint must use HTTPS or localhost. ` + `Received: ${parsed.protocol}//${parsed.host}`, + false, ) } } catch (error) { if (error instanceof TypeError) { - throw new Error(`Invalid ${serviceName} URL: ${url}`) + throw new PipelineStageError("configuration_error", `Invalid ${serviceName} URL: ${url}`, false) } throw error } } -export async function transcribeWavBuffer( - buffer: Buffer, - filename: string, - baseUrl?: string -): Promise { +export async function transcribeWavBuffer(buffer: Buffer, filename: string, baseUrl?: string): Promise { const url = baseUrl || process.env.MEDASR_URL || DEFAULT_MEDASR_URL - + // Validate URL (localhost is OK, remote must be HTTPS) validateLocalOrHttpsUrl(url, "MedASR API") const formData = new FormData() const blob = new Blob([new Uint8Array(buffer)], { type: "audio/wav" }) formData.append("file", blob, filename) - formData.append("model", "medasr") // Informational only + formData.append("model", "medasr") formData.append("response_format", "json") try { @@ -56,32 +55,47 @@ export async function transcribeWavBuffer( if (!response.ok) { const errorText = await response.text() - - // Provide helpful error messages + if (response.status === 503) { - throw new Error( + throw new PipelineStageError( + "service_unavailable", "MedASR server is not ready. Please ensure the server is running:\n" + - " pnpm medasr:server\n" + - "Or start both servers together:\n" + - " pnpm dev:local:medasr" + " pnpm medasr:server\n" + + "Or start both servers together:\n" + + " pnpm dev:local:medasr", + true, + { status: response.status, provider: "medasr" }, ) } - - throw new Error(`MedASR transcription failed (${response.status}): ${errorText}`) + + throw new PipelineStageError( + "api_error", + `MedASR transcription failed (${response.status}): ${errorText}`, + response.status >= 500, + { status: response.status, provider: "medasr" }, + ) } const result = (await response.json()) as { text?: string } return result.text?.trim() ?? "" } catch (error) { if (error instanceof TypeError && error.message.includes("fetch")) { - throw new Error( + throw new PipelineStageError( + "network_error", `Cannot connect to MedASR server at ${url}.\n` + - "Please start the MedASR server:\n" + - " pnpm medasr:server\n" + - "Or start both servers together:\n" + - " pnpm dev:local:medasr" + "Please start the MedASR server:\n" + + " pnpm medasr:server\n" + + "Or start both servers together:\n" + + " pnpm dev:local:medasr", + true, + { provider: "medasr", url }, ) } - throw error + throw toPipelineStageError(error, { + code: "transcription_error", + message: "MedASR transcription failed", + recoverable: true, + details: { provider: "medasr" }, + }) } } diff --git a/packages/pipeline/transcribe/src/providers/whisper-local-transcriber.ts b/packages/pipeline/transcribe/src/providers/whisper-local-transcriber.ts index cb8f968..b258265 100644 --- a/packages/pipeline/transcribe/src/providers/whisper-local-transcriber.ts +++ b/packages/pipeline/transcribe/src/providers/whisper-local-transcriber.ts @@ -1,3 +1,5 @@ +import { PipelineStageError, toPipelineStageError } from "../../../shared/src/error" + const DEFAULT_WHISPER_LOCAL_URL = "http://127.0.0.1:8002/v1/audio/transcriptions" const DEFAULT_WHISPER_LOCAL_MODEL = "tiny.en" const DEFAULT_TIMEOUT_MS = 15_000 @@ -26,14 +28,16 @@ function validateLocalOrHttpsUrl(url: string, serviceName: string): void { const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" if (!isLocalhost && parsed.protocol !== "https:") { - throw new Error( + throw new PipelineStageError( + "configuration_error", `SECURITY ERROR: ${serviceName} endpoint must use HTTPS or localhost. ` + `Received: ${parsed.protocol}//${parsed.host}`, + false, ) } } catch (error) { if (error instanceof TypeError) { - throw new Error(`Invalid ${serviceName} URL: ${url}`) + throw new PipelineStageError("configuration_error", `Invalid ${serviceName} URL: ${url}`, false) } throw error } @@ -96,15 +100,23 @@ export async function transcribeWavBuffer( } if (response.status === 503) { - throw new Error( + throw new PipelineStageError( + "service_unavailable", "Whisper local server is not ready. Please ensure the server is running:\n" + " pnpm whisper:server\n" + "Or start both servers together:\n" + " pnpm dev:local", + true, + { status: response.status, provider: "whisper_local" }, ) } - throw new Error(`Whisper local transcription failed (${response.status}): ${errorText}`) + throw new PipelineStageError( + "api_error", + `Whisper local transcription failed (${response.status}): ${errorText}`, + shouldRetryStatus(response.status), + { status: response.status, provider: "whisper_local" }, + ) } const result = (await response.json()) as { text?: string } @@ -119,22 +131,37 @@ export async function transcribeWavBuffer( } if (isAbort) { - throw new Error(`Whisper local transcription timed out after ${timeoutMs}ms (attempt ${attempt}/${totalAttempts}).`) + throw new PipelineStageError( + "timeout_error", + `Whisper local transcription timed out after ${timeoutMs}ms (attempt ${attempt}/${totalAttempts}).`, + true, + { timeoutMs, attempt, totalAttempts, provider: "whisper_local" }, + ) } if (isNetworkFetch) { - throw new Error( + throw new PipelineStageError( + "network_error", `Cannot connect to Whisper local server at ${url}.\n` + "Please start the Whisper local server:\n" + " pnpm whisper:server\n" + "Or start both servers together:\n" + " pnpm dev:local", + true, + { provider: "whisper_local", url }, ) } - throw error + throw toPipelineStageError(error, { + code: "transcription_error", + message: "Whisper local transcription failed", + recoverable: true, + details: { provider: "whisper_local" }, + }) } } - throw new Error("Whisper local transcription failed after retries") + throw new PipelineStageError("api_error", "Whisper local transcription failed after retries", true, { + provider: "whisper_local", + }) } diff --git a/packages/pipeline/transcribe/src/providers/whisper-transcriber.ts b/packages/pipeline/transcribe/src/providers/whisper-transcriber.ts index c8f96e6..51faaad 100644 --- a/packages/pipeline/transcribe/src/providers/whisper-transcriber.ts +++ b/packages/pipeline/transcribe/src/providers/whisper-transcriber.ts @@ -1,3 +1,5 @@ +import { PipelineStageError } from "../../../shared/src/error" + const DEFAULT_WHISPER_URL = "https://api.openai.com/v1/audio/transcriptions" const DEFAULT_WHISPER_MODEL = "whisper-1" @@ -9,14 +11,16 @@ function validateHttpsUrl(url: string, serviceName: string): void { try { const parsed = new URL(url) if (parsed.protocol !== "https:") { - throw new Error( + throw new PipelineStageError( + "configuration_error", `SECURITY ERROR: ${serviceName} endpoint must use HTTPS for HIPAA compliance. ` + - `Received: ${parsed.protocol}//${parsed.host}` + `Received: ${parsed.protocol}//${parsed.host}`, + false, ) } } catch (error) { if (error instanceof TypeError) { - throw new Error(`Invalid ${serviceName} URL: ${url}`) + throw new PipelineStageError("configuration_error", `Invalid ${serviceName} URL: ${url}`, false) } throw error } @@ -31,7 +35,11 @@ export async function transcribeWavBuffer(buffer: Buffer, filename: string, apiK const key = apiKey || process.env.OPENAI_API_KEY if (!key) { - throw new Error("Missing OPENAI_API_KEY. Please configure your API key in Settings.") + throw new PipelineStageError( + "configuration_error", + "Missing OPENAI_API_KEY. Please configure your API key in Settings.", + false, + ) } const formData = new FormData() const blob = new Blob([new Uint8Array(buffer)], { type: "audio/wav" }) @@ -48,7 +56,10 @@ export async function transcribeWavBuffer(buffer: Buffer, filename: string, apiK if (!response.ok) { const errorText = await response.text() - throw new Error(`Transcription failed: ${response.status} ${errorText}`) + throw new PipelineStageError("api_error", `Transcription failed: ${response.status} ${errorText}`, true, { + status: response.status, + provider: "whisper_openai", + }) } const result = (await response.json()) as { text?: string } diff --git a/tsconfig.json b/tsconfig.json index 07da55d..3673997 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -75,6 +75,12 @@ "@medgemma-scribe/*": [ "packages/pipeline/medgemma-scribe/src/*" ], + "@pipeline-errors": [ + "packages/pipeline/shared/src/index.ts" + ], + "@pipeline-errors/*": [ + "packages/pipeline/shared/src/*" + ], "@storage": [ "packages/storage/src/index.ts" ], From 8b13b067405ef1ece3a9743cfba90b8286d8a385 Mon Sep 17 00:00:00 2001 From: Boykosoft Date: Sun, 1 Mar 2026 18:03:50 +0000 Subject: [PATCH 2/2] codestyle + unref + additional tests --- apps/web/src/app/page.tsx | 41 ++++++----- .../pipeline/assemble/src/session-store.ts | 3 +- .../src/__tests__/error-propagation.test.ts | 71 +++++++++++++++++++ .../pipeline/shared/src/final-upload-error.ts | 48 +++++++++++++ packages/pipeline/shared/src/index.ts | 1 + 5 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 packages/pipeline/shared/src/__tests__/error-propagation.test.ts create mode 100644 packages/pipeline/shared/src/final-upload-error.ts diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 2f0e55f..62c8012 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,7 +1,14 @@ "use client" import { useState, useCallback, useRef, useEffect } from "react" -import { createPipelineError, toPipelineError, type PipelineError } from "@pipeline-errors" +import { + createFinalUploadFailure, + createPipelineError, + isPipelineError, + toFinalUploadWorkflowError, + toPipelineError, + type PipelineError, +} from "@pipeline-errors" import type { Encounter } from "@storage/types" import { useEncounters, EncounterList, IdleView, NewEncounterForm, RecordingView, ProcessingView, ErrorBoundary, PermissionsDialog, SettingsDialog, SettingsBar, useHttpsWarning } from "@ui" import { NoteEditor } from "@note-rendering" @@ -637,19 +644,20 @@ function HomePageContent() { await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) return uploadFinalRecording(activeSessionId, blob, attempt + 1) } - let message = `Final upload failed (${response.status})` + let serverError: unknown = null try { - const body = (await response.json()) as { error?: PipelineError } - if (body?.error?.message) { - message = body.error.message - } - if (body?.error) { - setWorkflowError(body.error) - } + const body = (await response.json()) as { error?: unknown } + serverError = body?.error } catch { - // ignore + // ignore JSON parse failures + } + const failure = createFinalUploadFailure(response.status, serverError) + const parsedError = failure.parsedError + if (parsedError) { + setWorkflowError(parsedError) + throw failure.error } - throw new Error(message) + throw failure.error } } catch (error) { if (attempt < 3) { @@ -657,13 +665,10 @@ function HomePageContent() { return uploadFinalRecording(activeSessionId, blob, attempt + 1) } debugError("Failed to upload final recording:", error) - setWorkflowError( - toPipelineError(error, { - code: "api_error", - message: "Failed to upload final recording", - recoverable: true, - }), - ) + const finalUploadWorkflowError = toFinalUploadWorkflowError(error) + if (finalUploadWorkflowError) { + setWorkflowError(finalUploadWorkflowError) + } setTranscriptionStatus("failed") throw error } diff --git a/packages/pipeline/assemble/src/session-store.ts b/packages/pipeline/assemble/src/session-store.ts index 5b48ff8..5f50f3c 100644 --- a/packages/pipeline/assemble/src/session-store.ts +++ b/packages/pipeline/assemble/src/session-store.ts @@ -63,6 +63,7 @@ class TranscriptionSessionStore { constructor() { // Run cleanup every 5 minutes this.cleanupInterval = setInterval(() => this.cleanupOldSessions(), 5 * 60 * 1000) + if (this.cleanupInterval?.unref) this.cleanupInterval.unref() } private cleanupOldSessions() { @@ -217,7 +218,7 @@ class TranscriptionSessionStore { code: normalizedError.code, message: normalizedError.message, recoverable: normalizedError.recoverable, - details: normalizedError.details ?? null, + details: normalizedError.details, }, }) } diff --git a/packages/pipeline/shared/src/__tests__/error-propagation.test.ts b/packages/pipeline/shared/src/__tests__/error-propagation.test.ts new file mode 100644 index 0000000..c3a25b1 --- /dev/null +++ b/packages/pipeline/shared/src/__tests__/error-propagation.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + createFinalUploadFailure, + toFinalUploadWorkflowError, + createPipelineError, + PipelineStageError, + isPipelineError, + toPipelineError, + type PipelineError, +} from "../index.js" + +test("server-provided PipelineError preserves code, recoverable, and details through catch", () => { + const serverError = new PipelineStageError( + "transcription_limit_exceeded", + "Monthly transcription limit reached", + false, + { limit: 1000, used: 1000 }, + ) + + let caughtError: unknown + try { + throw serverError + } catch (error) { + caughtError = error + } + + assert.ok(isPipelineError(caughtError), "caught error should pass isPipelineError check") + + const pe = caughtError as PipelineStageError + assert.equal(pe.code, "transcription_limit_exceeded") + assert.equal(pe.message, "Monthly transcription limit reached") + assert.equal(pe.recoverable, false, "unrecoverable error must stay unrecoverable") + assert.deepEqual(pe.details, { limit: 1000, used: 1000 }) +}) + +test("toPipelineError does not overwrite when input is already a PipelineError", () => { + const original = new PipelineStageError("auth_error", "Token expired", false, { userId: "abc" }) + + const result = toPipelineError(original, { + code: "api_error", + message: "Generic fallback", + recoverable: true, + }) + + assert.equal(result.code, "auth_error") + assert.equal(result.message, "Token expired") + assert.equal(result.recoverable, false, "must not reclassify to recoverable") + assert.deepEqual(result.details, { userId: "abc" }) +}) + +test("final-upload failure propagation keeps server error unchanged in workflow state", () => { + const serverError = createPipelineError( + "transcription_limit_exceeded", + "Monthly transcription limit reached", + false, + { limit: 1000, used: 1000 }, + ) + const failure = createFinalUploadFailure(400, serverError) + let workflowError: PipelineError | null = null + if (failure.parsedError) { + workflowError = failure.parsedError + } + const normalizedFromCatch = toFinalUploadWorkflowError(failure.error) + assert.ok(isPipelineError(workflowError), "workflow error should remain a PipelineError") + assert.equal(normalizedFromCatch, null, "catch should not re-normalize existing PipelineError") + assert.equal(workflowError?.code, "transcription_limit_exceeded") + assert.equal(workflowError?.message, "Monthly transcription limit reached") + assert.equal(workflowError?.recoverable, false) + assert.deepEqual(workflowError?.details, { limit: 1000, used: 1000 }) +}) diff --git a/packages/pipeline/shared/src/final-upload-error.ts b/packages/pipeline/shared/src/final-upload-error.ts new file mode 100644 index 0000000..b606957 --- /dev/null +++ b/packages/pipeline/shared/src/final-upload-error.ts @@ -0,0 +1,48 @@ +import { + isPipelineError, + PipelineStageError, + toPipelineError, + type PipelineError, +} from "./error" + +export function createFinalUploadFailure( + responseStatus: number, + serverError: unknown, +): { + parsedError: PipelineError | null + error: PipelineStageError +} { + if (isPipelineError(serverError)) { + return { + parsedError: serverError, + error: new PipelineStageError( + serverError.code, + serverError.message, + serverError.recoverable, + serverError.details, + ), + } + } + + const retryable = responseStatus === 429 || responseStatus >= 500 + return { + parsedError: null, + error: new PipelineStageError( + "api_error", + `Final upload failed (${responseStatus})`, + retryable, + ), + } +} + +export function toFinalUploadWorkflowError(error: unknown): PipelineError | null { + if (isPipelineError(error)) { + return null + } + + return toPipelineError(error, { + code: "api_error", + message: "Failed to upload final recording", + recoverable: true, + }) +} diff --git a/packages/pipeline/shared/src/index.ts b/packages/pipeline/shared/src/index.ts index d86d433..c68876d 100644 --- a/packages/pipeline/shared/src/index.ts +++ b/packages/pipeline/shared/src/index.ts @@ -1 +1,2 @@ export * from "./error" +export * from "./final-upload-error"