From 2786f9aa2d12f7d4799e33fa0187ba3c911c6956 Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Wed, 9 Sep 2026 02:15:44 +0530 Subject: [PATCH] feat: surface startup and preflight failures (#454) * fix: surface pre-workflow worker failures instead of dying silently * feat: clearer, aggregated config rule validation errors * fix: preserve blank lines when printing startup errors * feat: hold start until preflight passes and surface its failure * fix: cleaner formatting for scan-start failure messages --- apps/cli/src/commands/start.ts | 116 +++++++++++++- apps/cli/src/paths.ts | 7 + apps/worker/src/config-parser.ts | 169 ++++++++++---------- apps/worker/src/paths.ts | 7 + apps/worker/src/temporal/worker.ts | 145 ++++++++++++----- apps/worker/src/temporal/workflow-errors.ts | 5 +- 6 files changed, 311 insertions(+), 138 deletions(-) diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index 4816c880..1d18ddeb 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -25,12 +25,13 @@ import { resolveModelsConfig, resolveRepo, resolveRunFile, + STARTUP_ERROR_FILENAME, } from '../paths.js'; import { clearPendingWorkflowIdentity, writePendingWorkflowIdentity } from '../pending-workflow.js'; -import { indentFailureSegments } from '../scan/failure.js'; +import { indentFailureSegments, parseFailureSegments } from '../scan/failure.js'; import { resolveWorkflowId } from '../session.js'; import { displayPlainBanner, displaySplash } from '../splash.js'; -import { getTerminalOutcome } from '../temporal-client.js'; +import { describeWorkflowLifecycle, getTerminalOutcome, queryProgress } from '../temporal-client.js'; import { stdoutIsTerminal } from '../tty.js'; import { tailUntilComplete } from './logs.js'; @@ -314,6 +315,10 @@ export async function start(args: StartArgs): Promise { process.exit(1); } + // Clear a stale startup-error from a previous launch so the poll reacts only to this worker's. + const startupErrorPath = path.join(internalPath, STARTUP_ERROR_FILENAME); + fs.rmSync(startupErrorPath, { force: true }); + // 9. Spawn the worker container. const proc = spawnWorker({ version: args.version, @@ -383,6 +388,16 @@ export async function start(args: StartArgs): Promise { // Poll for the workflow to register in session.json; the spinner resolves once it does. spinner.message('Waiting for the scan to start'); for (let attempts = 0; attempts < 60; attempts++) { + // A pre-workflow failure leaves its reason here (nothing reached Temporal); surface it + // rather than polling out to a generic timeout. + const startupError = readStartupError(startupErrorPath); + if (startupError) { + cleaned = true; // The worker already exited; nothing to stop. + spinner.error('The scan could not start'); + printStartupError(startupError); + process.exit(1); + } + try { const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8')); const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? []; @@ -399,6 +414,17 @@ export async function start(args: StartArgs): Promise { } catch { warn(`Scan ${workspace} started, but its launch record could not be removed.`); } + + // Hold until preflight clears, so an unreachable target or bad credential is reported here + // rather than after "Scan started". + spinner.message('Running preflight checks'); + const outcome = await awaitPreflightOutcome(workflowId); + if (outcome.kind === 'failed') { + spinner.error('The scan could not start'); + printScanStartFailure(outcome.message); + process.exit(1); + } + spinner.stop(`Scan started — ${workspace}`); printInfo(args, workspace, repo.hostPath, workspacesDir); if (args.follow) { @@ -442,6 +468,92 @@ export function classifyStartupTimeout(sessionJsonPath: string): 'unregistered' return 'scan-running'; } +/** A pre-workflow failure the worker persisted; mirrors StartupErrorRecord in the worker. */ +interface StartupError { + phase?: string; + code?: string; + message?: string; +} + +/** + * Read the worker's pre-workflow failure record, if it wrote one. Undefined until the file exists + * and parses, so a partial write is simply re-read on the next poll rather than treated as failure. + */ +function readStartupError(startupErrorPath: string): StartupError | undefined { + let raw: string; + try { + raw = fs.readFileSync(startupErrorPath, 'utf-8'); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +/** Outcome of waiting for the in-workflow preflight to clear. */ +type PreflightOutcome = { kind: 'passed' } | { kind: 'failed'; message: string } | { kind: 'unconfirmed' }; + +/** + * Wait for the registered workflow's preflight to pass or fail: passed once `currentPhase` moves + * beyond 'preflight' (or the scan already closed ok), failed when the workflow terminates with an + * error. Bounded, so a Temporal query outage falls through as 'unconfirmed' rather than hanging. + */ +async function awaitPreflightOutcome(workflowId: string): Promise { + for (let attempts = 0; attempts < 80; attempts++) { + try { + const lifecycle = await describeWorkflowLifecycle(workflowId); + if (lifecycle.kind === 'terminal') { + const outcome = await getTerminalOutcome(workflowId); + return outcome.kind === 'failed' ? { kind: 'failed', message: outcome.message } : { kind: 'passed' }; + } + + const progress = await queryProgress(workflowId); + if (progress && progress.currentPhase !== null && progress.currentPhase !== 'preflight') { + return { kind: 'passed' }; + } + } catch { + // Transient query failure; keep waiting within the bound. + } + await sleep(1500); + } + return { kind: 'unconfirmed' }; +} + +/** Print a preflight failure: context line, then the indented reason and hint, then the reference code. */ +function printScanStartFailure(message: string): void { + const segments = parseFailureSegments(message); + const phaseContext = segments.shift() ?? 'The scan failed'; + const last = segments[segments.length - 1]; + const reference = last?.startsWith('Reference code:') ? segments.pop() : undefined; + + const lines = [` ${phaseContext}`, '', ...segments.map((segment) => ` ${segment}`)]; + if (reference) { + lines.push('', ` ${reference}`); + } + console.error(`\n${lines.join('\n')}\n`); +} + +/** Print the worker's persisted startup-failure reason, with its reference code when present. */ +function printStartupError(startupError: StartupError): void { + const message = + typeof startupError.message === 'string' && startupError.message.trim() + ? startupError.message.trim() + : 'The worker rejected the scan before it could start. Check the configuration file passed with -c.'; + console.error(''); + for (const line of message.split('\n')) { + console.error(line.length > 0 ? ` ${line}` : ''); + } + if (typeof startupError.code === 'string' && startupError.code.trim()) { + console.error(''); + console.error(` Reference code: ${startupError.code.trim()}`); + } + console.error(''); +} + /** Point the operator at a scan that is running but whose startup this CLI could not confirm. */ function printUnconfirmedScanHint(workspace: string, taskQueue: string, containerName: string): void { console.log(''); diff --git a/apps/cli/src/paths.ts b/apps/cli/src/paths.ts index 3b962c80..05544d6f 100644 --- a/apps/cli/src/paths.ts +++ b/apps/cli/src/paths.ts @@ -48,6 +48,13 @@ export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf'; */ export const FINAL_REPORT_MD_FILENAME = 'Security-Assessment-Report.md'; +/** + * Reason for a pre-workflow failure, written by the worker under INTERNAL_DIR. The CLI reads it + * during the startup poll to report the real cause instead of a generic timeout. Must match + * STARTUP_ERROR_FILENAME in the worker package. + */ +export const STARTUP_ERROR_FILENAME = 'startup-error.json'; + /** * Resolve a run-directory file (e.g. session.json, workflow.log), preferring the * current INTERNAL_DIR location and falling back to the legacy run-root location diff --git a/apps/worker/src/config-parser.ts b/apps/worker/src/config-parser.ts index 0a543178..c1bac228 100644 --- a/apps/worker/src/config-parser.ts +++ b/apps/worker/src/config-parser.ts @@ -462,8 +462,19 @@ const performSecurityValidation = (config: Config): void => { } if (config.rules) { - validateRulesSecurity(config.rules.avoid, 'avoid'); - validateRulesSecurity(config.rules.focus, 'focus'); + // Report every bad rule at once, so a config is fixed in one pass rather than one per re-run. + const ruleErrors: string[] = []; + collectRuleErrors(config.rules.avoid, 'avoid', ruleErrors); + collectRuleErrors(config.rules.focus, 'focus', ruleErrors); + if (ruleErrors.length > 0) { + throw new PentestError( + `Configuration validation failed:\n\n${ruleErrors.join('\n\n')}`, + 'config', + false, + { validationErrors: ruleErrors }, + ErrorCode.CONFIG_VALIDATION_FAILED, + ); + } checkForDuplicates(config.rules.avoid || [], 'avoid'); checkForDuplicates(config.rules.focus || [], 'focus'); @@ -513,126 +524,108 @@ const performSecurityValidation = (config: Config): void => { } }; -const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): void => { - if (!rules) return; +/** Human-readable rule label, e.g. "Focus rule 1" — 1-based to match how an operator counts them. */ +function ruleLabel(ruleType: string, index: number): string { + const capitalized = `${ruleType.charAt(0).toUpperCase()}${ruleType.slice(1)}`; + return `${capitalized} rule ${index + 1}`; +} - rules.forEach((rule, index) => { - for (const pattern of DANGEROUS_PATTERNS) { - if (pattern.test(rule.value)) { - throw new PentestError( - `rules.${ruleType}[${index}].value contains potentially dangerous pattern: ${pattern.source}`, - 'config', - false, - { field: `rules.${ruleType}[${index}].value`, pattern: pattern.source }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); - } - if (rule.description !== undefined && pattern.test(rule.description)) { - throw new PentestError( - `rules.${ruleType}[${index}].description contains potentially dangerous pattern: ${pattern.source}`, - 'config', - false, - { field: `rules.${ruleType}[${index}].description`, pattern: pattern.source }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); - } - } - - validateRuleTypeSpecific(rule, ruleType, index); - }); -}; - -const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number): void => { - const field = `rules.${ruleType}[${index}].value`; +/** A rule error as an aligned label/Value/Problem block, so the offending value is easy to spot. */ +function ruleValueMessage(label: string, value: string, problem: string): string { + return [`${label}:`, ` Value: ${value}`, ` Problem: ${problem}`].join('\n'); +} +/** + * The type-specific constraint a rule value breaks, or undefined when valid. Returns rather than + * throws so every bad rule can be collected and reported together. + */ +function ruleTypeProblem(rule: Rule): string | undefined { switch (rule.type) { case 'url_path': if (!rule.value.startsWith('/')) { - throw new PentestError( - `${field} for type 'url_path' must start with '/'`, - 'config', - false, - { field, ruleType: rule.type }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); + return "a 'url_path' rule matches the request path only, so it must begin with '/' (e.g. '/api/users')"; } - break; + return undefined; case 'code_path': if (rule.value.includes('://')) { - throw new PentestError( - `${field} for type 'code_path' must not contain a URL protocol (got '${rule.value}')`, - 'config', - false, - { field, ruleType: rule.type }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); + return "a 'code_path' rule points at source files, so it must not contain a URL protocol like 'http://' (e.g. 'src/api/users.ts' or 'src/**/*.ts')"; } - break; + return undefined; case 'subdomain': case 'domain': // Basic domain validation - no slashes allowed if (rule.value.includes('/')) { - throw new PentestError( - `${field} for type '${rule.type}' cannot contain '/' characters`, - 'config', - false, - { field, ruleType: rule.type }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); + return `a '${rule.type}' rule is a host name, so it cannot contain '/' (e.g. 'api.example.com')`; } // Must contain at least one dot for domains if (rule.type === 'domain' && !rule.value.includes('.')) { - throw new PentestError( - `${field} for type 'domain' must be a valid domain name`, - 'config', - false, - { field, ruleType: rule.type }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); + return "a 'domain' rule must be a full domain name, including the top-level domain (e.g. 'example.com')"; } - break; + return undefined; case 'method': { const allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; if (!allowedMethods.includes(rule.value.toUpperCase())) { - throw new PentestError( - `${field} for type 'method' must be one of: ${allowedMethods.join(', ')}`, - 'config', - false, - { field, ruleType: rule.type, allowedMethods }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); + return `'${rule.value}' is not a recognized HTTP method — use one of: ${allowedMethods.join(', ')}`; } - break; + return undefined; } case 'header': if (!rule.value.match(/^[a-zA-Z0-9\-_]+$/)) { - throw new PentestError( - `${field} for type 'header' must be a valid header name (alphanumeric, hyphens, underscores only)`, - 'config', - false, - { field, ruleType: rule.type }, - ErrorCode.CONFIG_VALIDATION_FAILED, - ); + return "a header name may contain only letters, digits, hyphens, and underscores (e.g. 'Authorization' or 'X-Api-Key')"; } - break; + return undefined; case 'parameter': if (!rule.value.match(/^[a-zA-Z0-9\-_]+$/)) { - throw new PentestError( - `${field} for type 'parameter' must be a valid parameter name (alphanumeric, hyphens, underscores only)`, - 'config', - false, - { field, ruleType: rule.type }, - ErrorCode.CONFIG_VALIDATION_FAILED, + return "a parameter name may contain only letters, digits, hyphens, and underscores (e.g. 'user_id' or 'redirect-url')"; + } + return undefined; + + default: + return undefined; + } +} + +/** + * Append a block to `blocks` for every invalid rule — a dangerous pattern in the value or + * description, or a broken type-specific constraint — so all bad rules can be reported together. + */ +function collectRuleErrors(rules: Rule[] | undefined, ruleType: string, blocks: string[]): void { + if (!rules) return; + + rules.forEach((rule, index) => { + const label = ruleLabel(ruleType, index); + const dangerousInValue = DANGEROUS_PATTERNS.find((pattern) => pattern.test(rule.value)); + if (dangerousInValue) { + blocks.push( + ruleValueMessage(label, rule.value, `contains a potentially dangerous pattern (${dangerousInValue.source})`), + ); + } else { + const problem = ruleTypeProblem(rule); + if (problem) { + blocks.push(ruleValueMessage(label, rule.value, problem)); + } + } + + const description = rule.description; + if (description !== undefined) { + const dangerousInDescription = DANGEROUS_PATTERNS.find((pattern) => pattern.test(description)); + if (dangerousInDescription) { + blocks.push( + ruleValueMessage( + `${label} (description)`, + description, + `contains a potentially dangerous pattern (${dangerousInDescription.source})`, + ), ); } - break; - } -}; + } + }); +} const checkForDuplicates = (rules: Rule[], ruleType: string): void => { const seen = new Set(); diff --git a/apps/worker/src/paths.ts b/apps/worker/src/paths.ts index 0540ce59..713408af 100644 --- a/apps/worker/src/paths.ts +++ b/apps/worker/src/paths.ts @@ -55,6 +55,13 @@ export const SARIF_FILENAME = 'report.sarif'; /** Deterministic receipt for the canonical report finalization commit. */ export const REPORT_FINALIZATION_MANIFEST_FILENAME = 'report_finalization_manifest.json'; +/** + * Reason for a pre-workflow failure (bad config, resume mismatch, worker setup), written under + * INTERNAL_DIR for the CLI to surface — at that point Temporal has no record of the run. Must + * match STARTUP_ERROR_FILENAME in the CLI package. + */ +export const STARTUP_ERROR_FILENAME = 'startup-error.json'; + /** * Resolve the session.json path for a run directory, preferring the current * `.shannon/` location and falling back to the legacy run-root location so diff --git a/apps/worker/src/temporal/worker.ts b/apps/worker/src/temporal/worker.ts index bf2ec553..49a47be0 100644 --- a/apps/worker/src/temporal/worker.ts +++ b/apps/worker/src/temporal/worker.ts @@ -28,6 +28,7 @@ * TEMPORAL_ADDRESS - Temporal server address (default: localhost:7233) */ +import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Client, Connection, type WorkflowHandle, WorkflowNotFoundError } from '@temporalio/client'; @@ -40,7 +41,8 @@ import { CAPELLA_FORMAT_VERSION, CAPELLA_PROMPT_SET_VERSION } from '../ai/sast/c import { summarizeOperationalMetrics } from '../audit/operational-summary.js'; import { sanitizeHostname } from '../audit/utils.js'; import { distributeConfig, parseConfig } from '../config-parser.js'; -import { deliverablesDir, resolveSessionJsonPath } from '../paths.js'; +import { deliverablesDir, INTERNAL_DIR, resolveSessionJsonPath, STARTUP_ERROR_FILENAME } from '../paths.js'; +import { PentestError } from '../services/error-handling.js'; import { isProviderFailureCategory } from '../types/errors.js'; import { ACCEPTED_CAPELLA_FAILURE_STAGES, @@ -511,31 +513,61 @@ interface OrchestrationConfig { exploit?: boolean; } +/** + * Parse the scan config into orchestration values, or throw on a broken config. Failing (rather + * than falling back to defaults that quietly change scope) lets the caller persist parseConfig's + * error for the CLI instead of running a misconfigured scan. + */ async function loadOrchestrationConfig(configPath: string | undefined): Promise { if (!configPath) return {}; - try { - const config = await parseConfig(configPath); - const distributed = distributeConfig(config); - const codePathAvoids = distributed.avoid.filter((rule) => rule.type === 'code_path').map((rule) => rule.value); - const codePathFocus = distributed.focus.filter((rule) => rule.type === 'code_path').map((rule) => rule.value); + const config = await parseConfig(configPath); + const distributed = distributeConfig(config); + const codePathAvoids = distributed.avoid.filter((rule) => rule.type === 'code_path').map((rule) => rule.value); + const codePathFocus = distributed.focus.filter((rule) => rule.type === 'code_path').map((rule) => rule.value); - return { - ...(distributed.agenticSast && { - agenticSast: { - codePathAvoids, - codePathFocus, - modelSpec: process.env.SHANNON_AI_MODEL?.trim() || DEFAULT_MODEL_SPEC, - capellaFormatVersion: CAPELLA_FORMAT_VERSION, - promptSetVersion: CAPELLA_PROMPT_SET_VERSION, - }, - }), - exploit: distributed.exploit, - }; - } catch (error) { - // A broken config must fail the run, not silently fall back to empty - // defaults that quietly change scope (vuln classes, exploit, retries). - console.error('Worker configuration could not be loaded. Reference code: CONFIG_VALIDATION_FAILED'); - process.exit(1); + return { + ...(distributed.agenticSast && { + agenticSast: { + codePathAvoids, + codePathFocus, + modelSpec: process.env.SHANNON_AI_MODEL?.trim() || DEFAULT_MODEL_SPEC, + capellaFormatVersion: CAPELLA_FORMAT_VERSION, + promptSetVersion: CAPELLA_PROMPT_SET_VERSION, + }, + }), + exploit: distributed.exploit, + }; +} + +// === Startup Failure Persistence === + +/** Reason for a failure that happens before the workflow is created. */ +interface StartupErrorRecord { + phase: string; + code?: string; + message: string; +} + +/** + * Persist a pre-workflow failure to the bind-mounted workspace so the CLI can surface it. The + * worker exits before the workflow exists, so Temporal has no record and `--rm` removes the + * container; the file under INTERNAL_DIR outlives it on the host mount. `workspace` is the CLI's + * `--workspace` name (the run directory); absent only when the worker is run off the CLI path. + * Best-effort — a persist failure must not mask the original error. + */ +function persistStartupError(workspace: string | undefined, error: unknown, phase: string): void { + if (!workspace) return; + const record: StartupErrorRecord = { + phase, + ...(error instanceof PentestError && error.code !== undefined && { code: error.code }), + message: error instanceof Error ? error.message : String(error), + }; + try { + const dir = path.join('./workspaces', workspace, INTERNAL_DIR); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, STARTUP_ERROR_FILENAME), JSON.stringify(record, null, 2), 'utf8'); + } catch { + // A broken bind mount must not compound the failure; the caller's console.error still fires. } } @@ -655,24 +687,26 @@ async function waitForWorkflowResult( // === Main Entry Point === -async function run(): Promise { - // 1. Parse CLI args - const args = parseCliArgs(process.argv.slice(2)); - - // 2. Connect to Temporal server - const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; - console.log(`Connecting to Temporal at ${address}...`); - - const connection = await NativeConnection.connect({ address }); - const clientConnection = await Connection.connect({ address }); - const client = new Client({ connection: clientConnection }); +/** A scan whose workflow is durably submitted, with the handles run() needs to await it. */ +interface StartedScan { + handle: WorkflowHandle<(input: PipelineInput) => Promise>; + workspace: WorkspaceResolution; + worker: Worker; + workerDone: Promise; +} +/** + * Run every step that precedes the durable creation of the workflow: config parsing, workspace + * resolution, worker setup, and workflow submission. A failure anywhere here is a startup failure + * — Temporal holds no record yet — so the reason is persisted for the CLI before it propagates. + */ +async function startScan(client: Client, connection: NativeConnection, args: CliArgs): Promise { try { - // 3. Validate orchestration and resume state before terminating any workflow. + // 1. Validate orchestration and resume state before terminating any workflow. const orchestration = await loadOrchestrationConfig(args.configPath); const workspace = await resolveWorkspace(client, args, orchestration.exploit ?? true); - // 4. Bundle workflows and create the worker with the collision-checked activity registry. + // 2. Bundle workflows and create the worker with the collision-checked activity registry. console.log('Preparing scan...'); const workflowBundle = await bundleWorkflowCode({ workflowsPath: path.join(__dirname, 'workflows.js'), @@ -695,13 +729,11 @@ async function run(): Promise { maxConcurrentActivityTaskExecutions: 25, }); - // 5. Build the fixed-scope pipeline input. + // 3. Build the fixed-scope pipeline input and start worker polling in the background. const input = buildPipelineInput(args, workspace, orchestration); - - // 6. Start worker polling in the background. const workerDone = worker.run(); - // 7. Submit workflow to the same task queue. + // 4. Submit workflow to the same task queue. Past this point the run exists in Temporal. const handle = await client.workflow.start<(input: PipelineInput) => Promise>( 'pentestPipelineWorkflow', { @@ -711,10 +743,33 @@ async function run(): Promise { }, ); - // 8. Wait for workflow result. + return { handle, workspace, worker, workerDone }; + } catch (startupError) { + persistStartupError(args.resumeFromWorkspace, startupError, 'startup'); + throw startupError; + } +} + +async function run(): Promise { + // 1. Parse CLI args + const args = parseCliArgs(process.argv.slice(2)); + + // 2. Connect to Temporal server + const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; + console.log(`Connecting to Temporal at ${address}...`); + + const connection = await NativeConnection.connect({ address }); + const clientConnection = await Connection.connect({ address }); + const client = new Client({ connection: clientConnection }); + + try { + // 3. Start the scan: parse config, resolve the workspace, and submit the workflow. + const { handle, workspace, worker, workerDone } = await startScan(client, connection, args); + + // 4. Wait for workflow result. await waitForWorkflowResult(handle, workspace); - // 9. Shut down worker gracefully. Final customer copies are workflow-owned. + // 5. Shut down worker gracefully. Final customer copies are workflow-owned. worker.shutdown(); await workerDone; } finally { @@ -725,8 +780,10 @@ async function run(): Promise { const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : undefined; if (invokedPath === fileURLToPath(import.meta.url)) { - run().catch(() => { - console.error('Worker failed. Reference code: WORKER_FAILED'); + run().catch((error) => { + // startScan persists pre-workflow failures for the CLI; this also logs them in the container. + const message = error instanceof Error ? error.message : String(error); + console.error(`Worker failed: ${message}`); process.exit(1); }); } diff --git a/apps/worker/src/temporal/workflow-errors.ts b/apps/worker/src/temporal/workflow-errors.ts index 1d42b22d..c826148c 100644 --- a/apps/worker/src/temporal/workflow-errors.ts +++ b/apps/worker/src/temporal/workflow-errors.ts @@ -131,10 +131,6 @@ export function formatWorkflowError(error: unknown, currentPhase: string | null, const segments: string[] = [phaseContext]; - if (unwrapped.type) { - segments.push(unwrapped.type); - } - segments.push( unwrapped.type === null ? 'The scan could not be completed.' @@ -146,6 +142,7 @@ export function formatWorkflowError(error: unknown, currentPhase: string | null, if (hint) { segments.push(`Hint: ${hint}`); } + segments.push(`Reference code: ${unwrapped.type}`); } return segments.join('|');