@@ -4,6 +4,7 @@ import path from 'node:path';
44
55import {
66 runVelaCommand ,
7+ velaCommandStdout ,
78 velaWorkspaceCommandOptions ,
89} from '../integrations/vela-command.js' ;
910
@@ -378,10 +379,17 @@ export async function renderVelaImage(
378379 outputPath ,
379380 '--json' ,
380381 ] ;
381- const stdout = await runCommand ( args , {
382- ...velaWorkspaceCommandOptions ( input . workspaceId ) ,
383- timeoutMs : VELA_IMAGE_TIMEOUT_MS ,
384- } ) ;
382+ let stdout : string ;
383+ try {
384+ stdout = await runCommand ( args , {
385+ ...velaWorkspaceCommandOptions ( input . workspaceId ) ,
386+ timeoutMs : VELA_IMAGE_TIMEOUT_MS ,
387+ } ) ;
388+ } catch ( error ) {
389+ // A refused request is a verdict the user can act on, so it must reach
390+ // them as one. Everything else keeps its original error untouched.
391+ throw velaMediaErrorFromFailure ( error , `image ${ command } ` ) ?? error ;
392+ }
385393 const asset = parseJsonObject ( stdout , `image ${ command } ` ) ;
386394 const assetId = nonEmptyString ( asset . asset_id ) ;
387395 const status = nonEmptyString ( asset . status ) ;
@@ -412,6 +420,97 @@ export async function renderVelaImage(
412420 }
413421}
414422
423+ /**
424+ * The stable, provider-neutral code Vela publishes when a content-safety
425+ * policy refused an image request. It is the same string at every layer from
426+ * the provider adapter through the API and the CLI, which is what makes it
427+ * safe to key product behaviour on.
428+ */
429+ export const VELA_SAFETY_REJECTION_CODE = 'safety_rejection' ;
430+
431+ /**
432+ * Optional, non-authoritative hint about what a safety policy objected to.
433+ * Absent whenever the upstream supplier could not prove it — callers must then
434+ * fall back to naming both possibilities rather than picking one.
435+ */
436+ export type VelaSafetySubject = 'prompt' | 'input_image' | 'output_image' ;
437+
438+ const VELA_SAFETY_SUBJECTS : readonly string [ ] = [
439+ 'prompt' ,
440+ 'input_image' ,
441+ 'output_image' ,
442+ ] ;
443+
444+ /**
445+ * A Vela media failure that arrived with a machine-readable verdict rather
446+ * than only a human sentence.
447+ *
448+ * `code` is carried on the error itself because the media task route copies
449+ * `err.code` straight into the persisted task snapshot; that is what lets the
450+ * web client render a definite explanation instead of depending on the agent
451+ * to repeat one correctly.
452+ */
453+ export class VelaMediaError extends Error {
454+ readonly code : string ;
455+ readonly subject : VelaSafetySubject | undefined ;
456+ readonly retryable : boolean | undefined ;
457+
458+ constructor (
459+ message : string ,
460+ detail : {
461+ code : string ;
462+ subject ?: VelaSafetySubject | undefined ;
463+ retryable ?: boolean | undefined ;
464+ } ,
465+ ) {
466+ super ( message ) ;
467+ this . name = 'VelaMediaError' ;
468+ this . code = detail . code ;
469+ this . subject = detail . subject ;
470+ this . retryable = detail . retryable ;
471+ }
472+ }
473+
474+ function safetySubject ( value : unknown ) : VelaSafetySubject | undefined {
475+ return typeof value === 'string' && VELA_SAFETY_SUBJECTS . includes ( value )
476+ ? ( value as VelaSafetySubject )
477+ : undefined ;
478+ }
479+
480+ /**
481+ * Rebuild a structured failure from a rejected `vela image --json` run.
482+ *
483+ * Returns undefined for every failure that carried no task JSON — a CLI
484+ * validation error, a crash, a timeout — so those keep their existing generic
485+ * handling. An unrecognised or absent `code` is deliberately NOT promoted to a
486+ * safety rejection: mislabelling an outage as a policy refusal would send a
487+ * user off to rewrite a prompt that was never the problem.
488+ */
489+ export function velaMediaErrorFromFailure (
490+ error : unknown ,
491+ label : string ,
492+ ) : VelaMediaError | undefined {
493+ const stdout = velaCommandStdout ( error ) . trim ( ) ;
494+ if ( ! stdout ) return undefined ;
495+ let parsed : unknown ;
496+ try {
497+ parsed = JSON . parse ( stdout ) ;
498+ } catch {
499+ return undefined ;
500+ }
501+ if ( ! isRecord ( parsed ) || ! isRecord ( parsed . error ) ) return undefined ;
502+ const code = nonEmptyString ( parsed . error . code ) ;
503+ if ( ! code ) return undefined ;
504+ const message = nonEmptyString ( parsed . error . message ) ;
505+ const retryable =
506+ typeof parsed . error . retryable === 'boolean' ? parsed . error . retryable : undefined ;
507+ return new VelaMediaError ( message ?? `Vela ${ label } failed with ${ code } ` , {
508+ code,
509+ subject : safetySubject ( parsed . error . subject ) ,
510+ retryable,
511+ } ) ;
512+ }
513+
415514export async function renderVelaVideo (
416515 input : VelaVideoRenderInput ,
417516 runCommand : VelaCommandRunner = runVelaCommand ,
0 commit comments