diff --git a/apps/drive-integration/src/hooks/useWorkflowAgent.ts b/apps/drive-integration/src/hooks/useWorkflowAgent.ts index 8d35961c28..f66bb71d4b 100644 --- a/apps/drive-integration/src/hooks/useWorkflowAgent.ts +++ b/apps/drive-integration/src/hooks/useWorkflowAgent.ts @@ -15,6 +15,7 @@ import { RunStatus, WorkflowFailureReason, WorkflowRunError, + WorkflowDiagnosticInfo, } from '@types'; import { AgentGeneratePayload, @@ -147,9 +148,24 @@ const getSuspendPayload = ( return runData.metadata?.suspendPayload; }; +const buildDiagnosticInfo = ( + runData: AgentRunData, + runId: string, + spaceId: string, + environmentId: string +): WorkflowDiagnosticInfo => ({ + runId, + workflowRunId: runData.metadata?.workflowRunId, + spaceId, + environmentId, + timestamp: new Date().toISOString(), +}); + const getWorkflowRunResult = ( runData: AgentRunData, threadId: string, + spaceId: string, + environmentId: string, pendingReviewMissingPayloadCount: number ): WorkflowRunResult | null => { const status = getRunStatus(runData); @@ -158,7 +174,11 @@ const getWorkflowRunResult = ( case RunStatus.FAILED: { const failureReason = getBackendWorkflowFailureReason(runData) ?? WorkflowFailureReason.GENERIC; - throw new WorkflowRunError(getWorkflowFailureMessage(runData, failureReason), failureReason); + throw new WorkflowRunError( + getWorkflowFailureMessage(runData, failureReason), + failureReason, + buildDiagnosticInfo(runData, threadId, spaceId, environmentId) + ); } case RunStatus.PENDING_REVIEW: { @@ -167,7 +187,11 @@ const getWorkflowRunResult = ( if (pendingReviewMissingPayloadCount < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { return null; // suspendPayload not flushed yet; poller will retry } - throw new Error('Workflow paused for review, but suspend payload was missing.'); + throw new WorkflowRunError( + 'Workflow paused for review, but suspend payload was missing.', + WorkflowFailureReason.GENERIC, + buildDiagnosticInfo(runData, threadId, spaceId, environmentId) + ); } return { @@ -204,6 +228,7 @@ const pollAgentRun = async ( ): Promise => { const startMs = Date.now(); let pendingReviewMissingPayloadCount = 0; + let lastRunData: AgentRunData | null = null; console.log(`⏳ Polling run [${runId}]`); for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { @@ -215,6 +240,7 @@ const pollAgentRun = async ( continue; } + lastRunData = runData; const status = getRunStatus(runData); console.log(` #${attempt + 1} — status: ${status} (${elapsedSec(startMs)})`); @@ -224,7 +250,13 @@ const pollAgentRun = async ( pendingReviewMissingPayloadCount = 0; } - const workflowRun = getWorkflowRunResult(runData, runId, pendingReviewMissingPayloadCount); + const workflowRun = getWorkflowRunResult( + runData, + runId, + spaceId, + environmentId, + pendingReviewMissingPayloadCount + ); if (workflowRun) { console.log(`✓ Run [${runId}] settled: ${status} in ${elapsedSec(startMs)}`); return workflowRun; @@ -234,7 +266,11 @@ const pollAgentRun = async ( } console.error(`✗ Run [${runId}] timed out after ${elapsedSec(startMs)}`); - throw new Error('Workflow polling timeout'); + throw new WorkflowRunError( + 'Workflow polling timeout', + WorkflowFailureReason.GENERIC, + buildDiagnosticInfo(lastRunData ?? {}, runId, spaceId, environmentId) + ); }; export const useWorkflowAgent = ({ diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx index 1311789f2e..9afcdb06f7 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx @@ -19,6 +19,7 @@ import { WorkflowRunResult, WorkflowFailureReason, WorkflowRunError, + WorkflowDiagnosticInfo, } from '@types'; import { ContentTypePickerModal } from '../modals/step_2/ContentTypePickerModal'; import { IncludeImagesModal } from '../modals/step_4/IncludeImagesModal'; @@ -52,6 +53,7 @@ interface PreviewErrorState { reason: WorkflowFailureReason; title: string; message: string; + diagnosticInfo?: WorkflowDiagnosticInfo; } export const ModalOrchestrator = forwardRef( @@ -159,6 +161,7 @@ export const ModalOrchestrator = forwardRef void; isPrimaryActionLoading?: boolean; + diagnosticInfo?: WorkflowDiagnosticInfo; } interface ErrorModalProps { @@ -16,6 +19,15 @@ interface ErrorModalProps { config: ErrorModalConfig; } +const formatDiagnosticBlob = (info: WorkflowDiagnosticInfo): string => { + const lines = [`Timestamp: ${info.timestamp}`]; + if (info.runId) lines.push(`Run ID: ${info.runId}`); + if (info.workflowRunId) lines.push(`Workflow Run ID: ${info.workflowRunId}`); + if (info.spaceId) lines.push(`Space ID: ${info.spaceId}`); + if (info.environmentId) lines.push(`Environment ID: ${info.environmentId}`); + return lines.join('\n'); +}; + export const ErrorModal: React.FC = ({ isOpen, onClose, config }) => { const { title, @@ -25,9 +37,19 @@ export const ErrorModal: React.FC = ({ isOpen, onClose, config secondaryActionLabel, onSecondaryAction, isPrimaryActionLoading = false, + diagnosticInfo, } = config; const handlePrimaryAction = onPrimaryAction ?? onClose; const handleSecondaryAction = onSecondaryAction ?? onClose; + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (!diagnosticInfo) return; + void navigator.clipboard.writeText(formatDiagnosticBlob(diagnosticInfo)).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; return ( = ({ isOpen, onClose, config {message} + {diagnosticInfo && ( +
+ + Error details{' '} + + {copied ? 'Copied!' : 'Copy'} + + + {diagnosticInfo.runId && ( +
+ Run ID: {diagnosticInfo.runId} +
+ )} + {diagnosticInfo.workflowRunId && ( +
+ Workflow Run ID:{' '} + {diagnosticInfo.workflowRunId} +
+ )} + {diagnosticInfo.spaceId && ( +
+ Space ID: {diagnosticInfo.spaceId} +
+ )} + {diagnosticInfo.environmentId && ( +
+ Environment ID:{' '} + {diagnosticInfo.environmentId} +
+ )} +
+ Timestamp: {diagnosticInfo.timestamp} +
+
+ )}
<> diff --git a/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx b/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx index 32424515a0..2fb3ab04d2 100644 --- a/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx @@ -5,8 +5,8 @@ import tokens from '@contentful/f36-tokens'; import { PageAppSDK } from '@contentful/app-sdk'; import { cx } from '@emotion/css'; import type { EntryProps } from 'contentful-management'; -import type { EntryBlockGraph, MappingReviewSuspendPayload } from '@types'; -import { RunStatus } from '@types'; +import type { EntryBlockGraph, MappingReviewSuspendPayload, WorkflowDiagnosticInfo } from '@types'; +import { RunStatus, WorkflowRunError } from '@types'; import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; import { createEntriesFromPreviewPayload } from '../../../../services/entryService'; import type { ContentTypeDisplayInfoMap } from '../../../../utils/overviewEntryList'; @@ -52,6 +52,8 @@ export const ReviewPage = ({ const [createdEntries, setCreatedEntries] = useState(null); const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false); const [createError, setCreateError] = useState(null); + const [createErrorDiagnostics, setCreateErrorDiagnostics] = + useState(null); const [entryBlockGraph, setEntryBlockGraph] = useState(() => structuredClone(payload.entryBlockGraph) ); @@ -136,6 +138,7 @@ export const ReviewPage = ({ setCreateError( errors[0]?.error ?? 'An unexpected error occurred while creating entries.' ); + setCreateErrorDiagnostics(null); return; } @@ -147,6 +150,7 @@ export const ReviewPage = ({ // WorkflowRunResult is COMPLETED | PENDING_REVIEW; only PENDING_REVIEW reaches here. console.warn('[ReviewPage] workflow re-suspended after resume; status:', result.status); setCreateError('The review workflow did not return a completed payload.'); + setCreateErrorDiagnostics(null); } catch (error) { console.error(error); setCreateError( @@ -154,6 +158,9 @@ export const ReviewPage = ({ ? error.message : 'An unexpected error occurred while creating entries.' ); + setCreateErrorDiagnostics( + error instanceof WorkflowRunError ? error.diagnosticInfo ?? null : null + ); } finally { setIsCreatePending(false); } @@ -291,10 +298,14 @@ export const ReviewPage = ({ /> setCreateError(null)} + onClose={() => { + setCreateError(null); + setCreateErrorDiagnostics(null); + }} config={{ title: 'Failed to create entries', message: createError ?? '', + diagnosticInfo: createErrorDiagnostics ?? undefined, }} /> diff --git a/apps/drive-integration/src/types/workflow.ts b/apps/drive-integration/src/types/workflow.ts index ea1ff0b7e1..be1298254c 100644 --- a/apps/drive-integration/src/types/workflow.ts +++ b/apps/drive-integration/src/types/workflow.ts @@ -21,13 +21,27 @@ export interface WorkflowFailure { httpStatus?: number; } +export interface WorkflowDiagnosticInfo { + runId?: string; + workflowRunId?: string; + spaceId?: string; + environmentId?: string; + timestamp: string; +} + export class WorkflowRunError extends Error { reason: WorkflowFailureReason; + diagnosticInfo?: WorkflowDiagnosticInfo; - constructor(message: string, reason: WorkflowFailureReason = WorkflowFailureReason.GENERIC) { + constructor( + message: string, + reason: WorkflowFailureReason = WorkflowFailureReason.GENERIC, + diagnosticInfo?: WorkflowDiagnosticInfo + ) { super(message); this.name = 'WorkflowRunError'; this.reason = reason; + this.diagnosticInfo = diagnosticInfo; } }