Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions apps/drive-integration/src/hooks/useWorkflowAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
RunStatus,
WorkflowFailureReason,
WorkflowRunError,
WorkflowDiagnosticInfo,
} from '@types';
import {
AgentGeneratePayload,
Expand Down Expand Up @@ -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);
Expand All @@ -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: {
Expand All @@ -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 {
Expand Down Expand Up @@ -204,6 +228,7 @@ const pollAgentRun = async (
): Promise<WorkflowRunResult> => {
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++) {
Expand All @@ -215,6 +240,7 @@ const pollAgentRun = async (
continue;
}

lastRunData = runData;
const status = getRunStatus(runData);
console.log(` #${attempt + 1} — status: ${status} (${elapsedSec(startMs)})`);

Expand All @@ -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;
Expand All @@ -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 = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +53,7 @@ interface PreviewErrorState {
reason: WorkflowFailureReason;
title: string;
message: string;
diagnosticInfo?: WorkflowDiagnosticInfo;
}

export const ModalOrchestrator = forwardRef<ModalOrchestratorHandle, ModalOrchestratorProps>(
Expand Down Expand Up @@ -159,6 +161,7 @@ export const ModalOrchestrator = forwardRef<ModalOrchestratorHandle, ModalOrches
reason: WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED,
title: 'Reconnect Drive to continue',
message: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR,
diagnosticInfo: error.diagnosticInfo,
});
return;
}
Expand All @@ -167,6 +170,7 @@ export const ModalOrchestrator = forwardRef<ModalOrchestratorHandle, ModalOrches
reason: WorkflowFailureReason.GENERIC,
title: 'Unable to generate preview',
message: ERROR_MESSAGES.GENERIC_ERROR,
diagnosticInfo: error instanceof WorkflowRunError ? error.diagnosticInfo : undefined,
});
};

Expand Down Expand Up @@ -332,6 +336,7 @@ export const ModalOrchestrator = forwardRef<ModalOrchestratorHandle, ModalOrches
secondaryActionLabel: 'Close',
onSecondaryAction: closePreviewErrorAndReset,
isPrimaryActionLoading: isReconnectPending && isOAuthBusy,
diagnosticInfo: previewErrorState.diagnosticInfo,
};
}

Expand All @@ -341,6 +346,7 @@ export const ModalOrchestrator = forwardRef<ModalOrchestratorHandle, ModalOrches
primaryActionLabel: 'Close',
onPrimaryAction: closePreviewErrorAndReset,
isPrimaryActionLoading: false,
diagnosticInfo: previewErrorState?.diagnosticInfo,
};
}, [
closePreviewErrorAndReset,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Button, Modal, Paragraph } from '@contentful/f36-components';
import { useState } from 'react';
import { Button, Modal, Paragraph, TextLink } from '@contentful/f36-components';
import type { WorkflowDiagnosticInfo } from '@types';

export interface ErrorModalConfig {
title: string;
Expand All @@ -8,6 +10,7 @@ export interface ErrorModalConfig {
secondaryActionLabel?: string;
onSecondaryAction?: () => void;
isPrimaryActionLoading?: boolean;
diagnosticInfo?: WorkflowDiagnosticInfo;
}

interface ErrorModalProps {
Expand All @@ -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<ErrorModalProps> = ({ isOpen, onClose, config }) => {
const {
title,
Expand All @@ -25,9 +37,19 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ 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 (
<Modal
Expand All @@ -41,6 +63,52 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({ isOpen, onClose, config
<Modal.Header title={title} />
<Modal.Content>
<Paragraph>{message}</Paragraph>
{diagnosticInfo && (
<div
style={{
marginTop: '16px',
padding: '12px',
background: '#f7f9fa',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '12px',
lineHeight: '1.6',
}}>
<Paragraph
marginBottom="spacingXs"
style={{ fontWeight: 600, fontSize: '12px', color: '#536171' }}>
Error details{' '}
<TextLink as="button" onClick={handleCopy}>
{copied ? 'Copied!' : 'Copy'}
</TextLink>
</Paragraph>
{diagnosticInfo.runId && (
<div>
<span style={{ color: '#536171' }}>Run ID:</span> {diagnosticInfo.runId}
</div>
)}
{diagnosticInfo.workflowRunId && (
<div>
<span style={{ color: '#536171' }}>Workflow Run ID:</span>{' '}
{diagnosticInfo.workflowRunId}
</div>
)}
{diagnosticInfo.spaceId && (
<div>
<span style={{ color: '#536171' }}>Space ID:</span> {diagnosticInfo.spaceId}
</div>
)}
{diagnosticInfo.environmentId && (
<div>
<span style={{ color: '#536171' }}>Environment ID:</span>{' '}
{diagnosticInfo.environmentId}
</div>
)}
<div>
<span style={{ color: '#536171' }}>Timestamp:</span> {diagnosticInfo.timestamp}
</div>
</div>
)}
</Modal.Content>
<Modal.Controls>
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +52,8 @@ export const ReviewPage = ({
const [createdEntries, setCreatedEntries] = useState<EntryProps[] | null>(null);
const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [createErrorDiagnostics, setCreateErrorDiagnostics] =
useState<WorkflowDiagnosticInfo | null>(null);
const [entryBlockGraph, setEntryBlockGraph] = useState<EntryBlockGraph>(() =>
structuredClone(payload.entryBlockGraph)
);
Expand Down Expand Up @@ -136,6 +138,7 @@ export const ReviewPage = ({
setCreateError(
errors[0]?.error ?? 'An unexpected error occurred while creating entries.'
);
setCreateErrorDiagnostics(null);
return;
}

Expand All @@ -147,13 +150,17 @@ 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(
error instanceof Error
? error.message
: 'An unexpected error occurred while creating entries.'
);
setCreateErrorDiagnostics(
error instanceof WorkflowRunError ? error.diagnosticInfo ?? null : null
);
} finally {
setIsCreatePending(false);
}
Expand Down Expand Up @@ -291,10 +298,14 @@ export const ReviewPage = ({
/>
<ErrorModal
isOpen={createError !== null}
onClose={() => setCreateError(null)}
onClose={() => {
setCreateError(null);
setCreateErrorDiagnostics(null);
}}
config={{
title: 'Failed to create entries',
message: createError ?? '',
diagnosticInfo: createErrorDiagnostics ?? undefined,
}}
/>
</>
Expand Down
16 changes: 15 additions & 1 deletion apps/drive-integration/src/types/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Loading