From ba8ffffa0b5695d207cc89949e90e5902839fd67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 29 Oct 2026 07:36:32 +0000 Subject: [PATCH] fix(cli): a failed render names its failure code for the Desktop app (#6725) --- packages/cli/src/ui/renderDiagnostic.test.ts | 55 ++++++++++++++- packages/cli/src/ui/renderDiagnostic.ts | 73 +++++++++++++++++--- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/renderDiagnostic.test.ts b/packages/cli/src/ui/renderDiagnostic.test.ts index f2cc3adc3e..a18ed2670d 100644 --- a/packages/cli/src/ui/renderDiagnostic.test.ts +++ b/packages/cli/src/ui/renderDiagnostic.test.ts @@ -2,10 +2,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { processCompositionAudio, type AudioFailureDiagnostic } from "@hyperframes/engine"; +import { + CaptureFailure, + processCompositionAudio, + type AudioFailureDiagnostic, +} from "@hyperframes/engine"; import { applyDistributedAudioWarningPolicy } from "../../../producer/src/services/distributed/plan.js"; import { buildSyntheticRenderJob } from "../../../producer/src/services/distributed/shared.js"; -import { createRenderJob, executeRenderJob, type RenderJob } from "@hyperframes/producer"; +import { + createRenderJob, + executeRenderJob, + RenderQualityError, + type RenderJob, +} from "@hyperframes/producer"; import { runAudioStage } from "../../../producer/src/services/render/stages/audioStage.js"; import { renderFailureDiagnostic, renderMachineDiagnostic } from "./renderDiagnostic.js"; @@ -91,6 +100,48 @@ describe("render diagnostic wire", () => { expect(line).not.toContain(PRIVATE); }); + it.each([ + [ + new RenderQualityError([ + { code: "audio_processing_failed", message: PRIVATE, stage: "capture-readiness" }, + ]), + "AUDIO_PROCESSING_FAILED", + ], + [ + new Error(`Video extraction failed [VIDEO_SOURCE_UNRENDERABLE; source_missing=1] ${PRIVATE}`), + "VIDEO_SOURCE_UNRENDERABLE", + ], + [ + Object.assign(new Error(`ENOENT: no such file, open '${PRIVATE}'`), { code: "ENOENT" }), + "ENOENT", + ], + [new Error(`EPERM: operation not permitted, rename '${PRIVATE}'`), "EPERM"], + [ + new CaptureFailure({ + kind: "transient_browser", + message: `[Parallel] Capture failed: Worker 0: [FrameCapture] Composition has zero duration. ${PRIVATE}`, + }), + "COMPOSITION_ZERO_DURATION", + ], + [ + new CaptureFailure({ kind: "authoring", message: PRIVATE, pageErrorLines: [PRIVATE] }), + "PAGE_ERROR", + ], + [ + new CaptureFailure({ kind: "protocol_timeout", message: PRIVATE }), + "CAPTURE_PROTOCOL_TIMEOUT", + ], + [new Error(PRIVATE), "RENDER_FAILED"], + ])("names a failed render's code without its words (%#)", (error, code) => { + const line = capture(() => renderFailureDiagnostic(undefined, error, false)); + expect(JSON.parse(line.slice("@hf-diagnostic ".length))).toEqual({ + version: 1, + operation: "render", + code, + }); + expect(line).not.toContain(PRIVATE); + }); + it("stays silent for a terminal or a container attached to a terminal", () => { expect(capture([failure], true)).toBe(""); vi.unstubAllEnvs(); diff --git a/packages/cli/src/ui/renderDiagnostic.ts b/packages/cli/src/ui/renderDiagnostic.ts index cfc2b17b7c..f9dc899fce 100644 --- a/packages/cli/src/ui/renderDiagnostic.ts +++ b/packages/cli/src/ui/renderDiagnostic.ts @@ -1,14 +1,62 @@ -import type { RenderJob } from "@hyperframes/producer"; -import { audioFailureDiagnostics } from "@hyperframes/engine"; +import { RenderQualityError, type RenderJob } from "@hyperframes/producer"; +import { CaptureFailure, audioFailureDiagnostics } from "@hyperframes/engine"; + +const FILE_ERRORS = [ + "ENOENT", + "EPERM", + "EACCES", + "EBUSY", + "ENOSPC", + "EMFILE", + "EISDIR", + "ENOTDIR", + "EEXIST", + "EXDEV", +]; +const FILE_ERROR = new RegExp(`\\b(${FILE_ERRORS.join("|")})\\b`); +const CLI_CODE = /\[(?!(?:DEBUG|INFO|WARN|ERROR)\b)([A-Z][A-Z0-9_]{2,63})[\];]/; + +function fileErrorOf(error: unknown, message: string): string | undefined { + let seen: unknown = error; + for (let depth = 0; depth < 5 && seen instanceof Error; depth++) { + const code = (seen as NodeJS.ErrnoException).code; + if (code && FILE_ERRORS.includes(code)) return code; + seen = seen.cause; + } + return FILE_ERROR.exec(message)?.[1]; +} + +function captureCodeOf(error: unknown, message: string): string | undefined { + if (/Composition has zero duration/.test(message)) return "COMPOSITION_ZERO_DURATION"; + if (!(error instanceof CaptureFailure)) return undefined; + return error.pageErrorLines.length > 0 ? "PAGE_ERROR" : `CAPTURE_${error.kind.toUpperCase()}`; +} + +/** A failed render's code: never its words, which can name the person's files and folders. */ +function renderFailureCode(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof RenderQualityError && error.warnings[0]) + return error.warnings[0].code.toUpperCase(); + return ( + CLI_CODE.exec(message)?.[1] ?? + fileErrorOf(error, message) ?? + captureCodeOf(error, message) ?? + "RENDER_FAILED" + ); +} /** Versioned, content-free failure line for Desktop's piped CLI, separate from human error output. */ -export function renderMachineDiagnostic(values: readonly unknown[]): void { +export function renderMachineDiagnostic(values: readonly unknown[], code?: string): void { if (process.stdout.isTTY || process.env.HYPERFRAMES_STDOUT_IS_TTY === "1") return; const failures = audioFailureDiagnostics(values); - if (failures.length === 0) return; - process.stdout.write( - `@hf-diagnostic ${JSON.stringify({ version: 1, operation: "render", audioFailures: failures })}\n`, - ); + if (failures.length === 0 && !code) return; + const line = { + version: 1, + operation: "render", + ...(code ? { code } : {}), + ...(failures.length > 0 ? { audioFailures: failures } : {}), + }; + process.stdout.write(`@hf-diagnostic ${JSON.stringify(line)}\n`); } export function renderFailureDiagnostic( @@ -24,8 +72,11 @@ export function renderFailureDiagnostic( Array.isArray(error.audioFailures) ? error.audioFailures : []; - renderMachineDiagnostic([ - ...interruptedFailures, - ...(job?.warnings.flatMap((warning) => warning.details?.audioFailures ?? []) ?? []), - ]); + renderMachineDiagnostic( + [ + ...interruptedFailures, + ...(job?.warnings.flatMap((warning) => warning.details?.audioFailures ?? []) ?? []), + ], + renderFailureCode(error), + ); }