diff --git a/apps/drive-integration/package-lock.json b/apps/drive-integration/package-lock.json index fa2b6429c9..7f7ed74183 100644 --- a/apps/drive-integration/package-lock.json +++ b/apps/drive-integration/package-lock.json @@ -21,6 +21,7 @@ "ai": "^5.0.81", "contentful-management": "^11.76.0", "googleapis": "^166.0.0", + "launchdarkly-react-client-sdk": "^3.0.8", "mammoth": "^1.11.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -8849,6 +8850,47 @@ "json-buffer": "3.0.1" } }, + "node_modules/launchdarkly-js-client-sdk": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/launchdarkly-js-client-sdk/-/launchdarkly-js-client-sdk-3.9.3.tgz", + "integrity": "sha512-1q+TqfBEBQcaz77EHwPf5F0L1hc22iwoa/S+69O5DSyp7+Txg35SDrvIQJWDcpigYxg0WN9VswAiq3uFci1ZhA==", + "license": "Apache-2.0", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "launchdarkly-js-sdk-common": "5.8.1" + } + }, + "node_modules/launchdarkly-js-sdk-common": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/launchdarkly-js-sdk-common/-/launchdarkly-js-sdk-common-5.8.1.tgz", + "integrity": "sha512-q6sYOatAhkKJYIT+8eeJyFBy4HZxOjHLxg8028Q6j7/ZdaySbo451ntPnlyKBrvXw9YfyB+62pL9ZHME3U6trg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/launchdarkly-js-sdk-common/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, + "node_modules/launchdarkly-react-client-sdk": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/launchdarkly-react-client-sdk/-/launchdarkly-react-client-sdk-3.9.2.tgz", + "integrity": "sha512-jSQw8ZqvmhIsLUxkNNiUYD+kFgVQiJj1C5dEN4pq9567GjDHkoHk6AmhXvRkjg8yjyWI4pJBKbIIgRdcmRsxfA==", + "license": "Apache-2.0", + "dependencies": { + "hoist-non-react-statics": "^3.3.2", + "launchdarkly-js-client-sdk": "^3.9.3", + "lodash.camelcase": "^4.3.0" + }, + "peerDependencies": { + "react": "^16.6.3 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.4 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8898,6 +8940,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx b/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx index b3b74938ff..1523678f25 100644 --- a/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx +++ b/apps/drive-integration/src/hooks/useGoogleDocPicker.tsx @@ -71,7 +71,7 @@ export function useGoogleDocsPicker( const docs: PickerCallbackData[] = pickedItems.map((doc: any) => ({ id: doc.id, - name: doc.name, + name: doc.name ?? doc.title ?? 'Untitled Document', mimeType: doc.mimeType, url: doc.url, })); diff --git a/apps/drive-integration/src/hooks/useGoogleDocsAgentFlags.ts b/apps/drive-integration/src/hooks/useGoogleDocsAgentFlags.ts index 3ccdc9c1f4..f15be16230 100644 --- a/apps/drive-integration/src/hooks/useGoogleDocsAgentFlags.ts +++ b/apps/drive-integration/src/hooks/useGoogleDocsAgentFlags.ts @@ -2,11 +2,13 @@ import { useFlags } from 'launchdarkly-react-client-sdk'; interface GoogleDocsAgentFlags { 'google-docs-agent-improvements': boolean; + 'google-docs-async-runs': boolean; } export const useGoogleDocsAgentFlags = (): GoogleDocsAgentFlags => { const flags = useFlags(); return { 'google-docs-agent-improvements': flags['google-docs-agent-improvements'] ?? false, + 'google-docs-async-runs': flags['google-docs-async-runs'] ?? false, }; }; diff --git a/apps/drive-integration/src/hooks/useRunStorage.ts b/apps/drive-integration/src/hooks/useRunStorage.ts new file mode 100644 index 0000000000..5428ac60f0 --- /dev/null +++ b/apps/drive-integration/src/hooks/useRunStorage.ts @@ -0,0 +1,104 @@ +import { useState, useCallback } from 'react'; +import type { RunRecord } from '../types/runs'; + +const MAX_RUNS = 50; + +function makeKey(spaceId: string, environmentId: string): string { + return `gdrive-import-runs::${spaceId}::${environmentId}`; +} + +function readFromStorage(key: string): RunRecord[] { + try { + const raw = localStorage.getItem(key); + if (!raw) return []; + return JSON.parse(raw) as RunRecord[]; + } catch { + return []; + } +} + +function writeToStorage(key: string, records: RunRecord[]): void { + localStorage.setItem(key, JSON.stringify(records)); +} + +export interface UseRunStorage { + runs: RunRecord[]; + addRun(record: RunRecord): void; + removeRun(runId: string): void; + retryRun(oldRunId: string, newRecord: RunRecord): void; + markCompleted(runId: string, entryIds: string[]): void; + storageError: string | null; +} + +export function useRunStorage(spaceId: string, environmentId: string): UseRunStorage { + const key = makeKey(spaceId, environmentId); + const [runs, setRuns] = useState(() => readFromStorage(key)); + const [storageError, setStorageError] = useState(null); + + const persist = useCallback( + (next: RunRecord[]) => { + try { + writeToStorage(key, next); + setStorageError(null); + } catch (err) { + const msg = + err instanceof Error + ? err.message + : 'Unable to save import history. Storage may be full.'; + setStorageError(msg); + } + setRuns(next); + }, + [key] + ); + + const addRun = useCallback( + (record: RunRecord) => { + setRuns((current) => { + if (current.some((r) => r.runId === record.runId)) return current; + const next = [record, ...current]; + if (next.length > MAX_RUNS) next.splice(MAX_RUNS); + persist(next); + return next; + }); + }, + [persist] + ); + + const removeRun = useCallback( + (runId: string) => { + setRuns((current) => { + const next = current.filter((r) => r.runId !== runId); + persist(next); + return next; + }); + }, + [persist] + ); + + const retryRun = useCallback( + (oldRunId: string, newRecord: RunRecord) => { + setRuns((current) => { + const next = current.map((r) => (r.runId === oldRunId ? newRecord : r)); + persist(next); + return next; + }); + }, + [persist] + ); + + const markCompleted = useCallback( + (runId: string, entryIds: string[]) => { + setRuns((current) => { + const next = current.map((r) => + r.runId === runId ? { ...r, createdEntryIds: entryIds } : r + ); + persist(next); + return next; + }); + }, + [persist] + ); + + return { runs, addRun, removeRun, retryRun, markCompleted, storageError }; +} diff --git a/apps/drive-integration/src/hooks/useRunsPolling.ts b/apps/drive-integration/src/hooks/useRunsPolling.ts new file mode 100644 index 0000000000..415b90048c --- /dev/null +++ b/apps/drive-integration/src/hooks/useRunsPolling.ts @@ -0,0 +1,149 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { RunStatus } from '@types'; +import { getWorkflowRun, AgentRunData } from '../services/agents-api'; +import { DisplayStatus } from '../types/runs'; +import type { RunRecord } from '../types/runs'; + +const POLL_INTERVAL_MS = 10_000; + +// A null response (404 or network error) is treated as transient until this +// many consecutive misses — matching MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES. +const MAX_CONSECUTIVE_NULLS = 5; + +export const TERMINAL_STATUSES = new Set([ + DisplayStatus.COMPLETED, + DisplayStatus.FAILED, + DisplayStatus.EXPIRED, +]); + +const RUN_STATUS_TO_DISPLAY: Partial> = { + [RunStatus.IN_PROGRESS]: DisplayStatus.RUNNING, + [RunStatus.DRAFT]: DisplayStatus.RUNNING, + [RunStatus.PENDING_REVIEW]: DisplayStatus.NEEDS_REVIEW, + [RunStatus.COMPLETED]: DisplayStatus.COMPLETED, + [RunStatus.FAILED]: DisplayStatus.FAILED, +}; + +function toDisplayStatus(runData: AgentRunData | null): DisplayStatus | null { + if (!runData) return null; + const status = runData.sys?.status ?? runData.metadata?.status; + return (status && RUN_STATUS_TO_DISPLAY[status]) ?? DisplayStatus.EXPIRED; +} + +function extractErrorMessage(runData: AgentRunData | null): string | undefined { + return runData?.metadata?.workflowFailure?.message; +} + +function isActive(status: DisplayStatus | undefined): boolean { + return !TERMINAL_STATUSES.has(status ?? DisplayStatus.LOADING); +} + +export interface UseRunsPollingResult { + statusMap: Map; + errorMap: Map; + titleMap: Map; +} + +export function useRunsPolling(runs: RunRecord[], sdk: PageAppSDK): UseRunsPollingResult { + const [statusMap, setStatusMap] = useState>(new Map()); + const [errorMap, setErrorMap] = useState>(new Map()); + const [titleMap, setTitleMap] = useState>(new Map()); + const intervalRef = useRef | null>(null); + // Mirrors the latest committed maps so fetchAllStatuses reads current state without stale closures. + const statusRef = useRef>(new Map()); + const errorRef = useRef>(new Map()); + const titleRef = useRef>(new Map()); + // Tracks consecutive null responses per runId to distinguish transient from permanent 404s. + const nullCountRef = useRef>(new Map()); + + const fetchAllStatuses = useCallback(async (): Promise<{ + nextStatus: Map; + hasActive: boolean; + } | null> => { + if (runs.length === 0) return null; + + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + const currentStatus = statusRef.current; + + // Only poll runs that haven't reached a terminal state yet. + const activeRuns = runs.filter((r) => isActive(currentStatus.get(r.runId))); + + if (activeRuns.length === 0) return null; + + const results = await Promise.all( + activeRuns.map((r) => getWorkflowRun(sdk, spaceId, environmentId, r.runId).catch(() => null)) + ); + + // Start from existing state so terminal runs keep their status/errors/titles. + const nextStatus = new Map(currentStatus); + const nextErrors = new Map(errorRef.current); + const nextTitles = new Map(titleRef.current); + + for (let i = 0; i < activeRuns.length; i++) { + const runId = activeRuns[i].runId; + const data = results[i]; + const resolved = toDisplayStatus(data); + + if (resolved === null) { + // Transient null (404 or network blip) — only expire after threshold. + const misses = (nullCountRef.current.get(runId) ?? 0) + 1; + nullCountRef.current.set(runId, misses); + if (misses >= MAX_CONSECUTIVE_NULLS) { + nextStatus.set(runId, DisplayStatus.EXPIRED); + nextErrors.delete(runId); + } + // else: leave existing status intact (stays LOADING/RUNNING) + } else { + nullCountRef.current.delete(runId); + nextStatus.set(runId, resolved); + const errMsg = extractErrorMessage(data); + if (errMsg) { + nextErrors.set(runId, errMsg); + } else { + nextErrors.delete(runId); + } + const title = data?.metadata?.suspendPayload?.documentTitle; + if (title) nextTitles.set(runId, title); + } + } + + statusRef.current = nextStatus; + errorRef.current = nextErrors; + titleRef.current = nextTitles; + setStatusMap(nextStatus); + setErrorMap(nextErrors); + setTitleMap(nextTitles); + + // A run is "active" if it has no terminal status in the map (undefined → LOADING is active). + const hasActive = runs.some((r) => isActive(nextStatus.get(r.runId))); + return { nextStatus, hasActive }; + }, [runs, sdk]); + + useEffect(() => { + void fetchAllStatuses().then((result) => { + if (!result?.hasActive) return; + + if (intervalRef.current) clearInterval(intervalRef.current); + + intervalRef.current = setInterval(() => { + void fetchAllStatuses().then((updated) => { + if (!updated?.hasActive && intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }); + }, POLL_INTERVAL_MS); + }); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [fetchAllStatuses]); + + return { statusMap, errorMap, titleMap }; +} diff --git a/apps/drive-integration/src/hooks/useWorkflowAgent.ts b/apps/drive-integration/src/hooks/useWorkflowAgent.ts index 48220306b7..bd1fec5c17 100644 --- a/apps/drive-integration/src/hooks/useWorkflowAgent.ts +++ b/apps/drive-integration/src/hooks/useWorkflowAgent.ts @@ -1,32 +1,7 @@ -import { useState, useCallback } from 'react'; +import { useCallback } from 'react'; import { PageAppSDK } from '@contentful/app-sdk'; -import { - POLL_INTERVAL_MS, - MAX_POLL_ATTEMPTS, - EXTENDED_MAX_POLL_ATTEMPTS, - WORKFLOW_AGENT_ID, - MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES, -} from '../utils/constants/agent'; -import { useGoogleDocsAgentFlags } from './useGoogleDocsAgentFlags'; -import { - MappingReviewSuspendPayload, - ResumePayload, - CompletedWorkflowPayload, - WorkflowRunResult, - RunStatus, - WorkflowFailureReason, - WorkflowRunError, -} from '@types'; -import { - AgentGeneratePayload, - AgentRunData, - DocumentSelection, - getWorkflowRun, - resumeWorkflowRun, - startAgentRun, -} from '../services/agents-api'; -import { validatePayloadShape } from '../utils/createEntries'; -import { ERROR_MESSAGES } from '@constants/messages'; +import { WORKFLOW_AGENT_ID } from '../utils/constants/agent'; +import { AgentGeneratePayload, DocumentSelection, startAgentRun } from '../services/agents-api'; interface UseWorkflowParams { sdk: PageAppSDK; @@ -35,211 +10,19 @@ interface UseWorkflowParams { } interface WorkflowHook { - isAnalyzing: boolean; startWorkflow: ( contentTypeIds: string[], documentSelection: DocumentSelection - ) => Promise; - resumeWorkflow: (runId: string, resumePayload: ResumePayload) => Promise; + ) => Promise; } -const wait = async (ms: number): Promise => { - await new Promise((resolve) => setTimeout(resolve, ms)); -}; - -const getRunStatus = (runData: AgentRunData): RunStatus | null => { - return runData.sys?.status ?? runData.metadata?.status ?? null; -}; - -const getAgentPayload = (runData: AgentRunData): string | null => { - if (runData.payload && typeof runData.payload === 'string') { - return runData.payload; - } - - if (!runData.messages || !Array.isArray(runData.messages)) { - return null; - } - - const assistantMessage = runData.messages.find((message) => message.role === 'assistant'); - if (!assistantMessage?.content?.parts) { - return null; - } - - const textPart = assistantMessage.content.parts.find((part) => part.type === 'text' && part.text); - return textPart?.text || null; -}; - -const previewPayloadFromCompletedRun = (runData: AgentRunData): CompletedWorkflowPayload => { - const googleDocPayload = runData.metadata?.googleDocPayload; - if (googleDocPayload == null) { - throw new Error('Workflow completed but result payload was missing.'); - } - - if ( - typeof googleDocPayload === 'object' && - googleDocPayload !== null && - 'cancelled' in googleDocPayload && - (googleDocPayload as { cancelled?: unknown }).cancelled === true - ) { - // Cancelled runs complete without full preview payload; return a no-op preview shape. - return { - entries: [], - assets: [], - referenceGraph: {}, - }; - } - - return validatePayloadShape(googleDocPayload); -}; - -const getRunErrorMessage = (runData: AgentRunData): string => { - const workflowFailureMessage = runData.metadata?.workflowFailure?.message; - if (typeof workflowFailureMessage === 'string' && workflowFailureMessage.trim().length > 0) { - return workflowFailureMessage; - } - - const payload = getAgentPayload(runData); - if (payload) { - return payload; - } - - return 'Workflow failed'; -}; - -const KNOWN_FAILURE_REASONS = new Set(Object.values(WorkflowFailureReason)); - -const getBackendWorkflowFailureReason = (runData: AgentRunData): WorkflowFailureReason | null => { - const workflowFailure = runData.metadata?.workflowFailure; - if (!workflowFailure) return null; - return KNOWN_FAILURE_REASONS.has(workflowFailure.code) - ? (workflowFailure.code as WorkflowFailureReason) - : null; -}; - -// document-too-complex and out-of-domain are not yet emitted by the backend; handlers are in place for when they ship. -const FAILURE_REASON_MESSAGES: Partial> = { - [WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED]: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, - [WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND]: ERROR_MESSAGES.GOOGLE_DOCS_NOT_FOUND, - [WorkflowFailureReason.AI_SERVICE_UNAVAILABLE]: ERROR_MESSAGES.AI_SERVICE_UNAVAILABLE, - [WorkflowFailureReason.APP_NOT_INSTALLED]: ERROR_MESSAGES.APP_NOT_INSTALLED, - [WorkflowFailureReason.DOCUMENT_TOO_COMPLEX]: ERROR_MESSAGES.DOCUMENT_TOO_COMPLEX, - [WorkflowFailureReason.PROCESSING_TIMEOUT]: ERROR_MESSAGES.PROCESSING_TIMEOUT, - [WorkflowFailureReason.OUT_OF_DOMAIN]: ERROR_MESSAGES.OUT_OF_DOMAIN, - [WorkflowFailureReason.MISSING_PARAMETER]: ERROR_MESSAGES.MISSING_PARAMETER, -}; - -const getWorkflowFailureMessage = ( - runData: AgentRunData, - failureReason: WorkflowFailureReason -): string => FAILURE_REASON_MESSAGES[failureReason] ?? getRunErrorMessage(runData); - -const getSuspendPayload = (runData: AgentRunData): MappingReviewSuspendPayload | undefined => { - return runData.metadata?.suspendPayload; -}; - -const getWorkflowRunResult = ( - runData: AgentRunData, - threadId: string, - pendingReviewMissingPayloadCount: number -): WorkflowRunResult | null => { - const status = getRunStatus(runData); - - switch (status) { - case RunStatus.FAILED: { - const failureReason = - getBackendWorkflowFailureReason(runData) ?? WorkflowFailureReason.GENERIC; - throw new WorkflowRunError(getWorkflowFailureMessage(runData, failureReason), failureReason); - } - - case RunStatus.PENDING_REVIEW: { - const suspendPayload = getSuspendPayload(runData); - if (!suspendPayload) { - 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.'); - } - - return { - status, - runId: threadId, - suspendPayload, - messages: runData.messages ?? [], - }; - } - - case RunStatus.COMPLETED: { - const messages = runData.messages ?? []; - - return { - status, - runId: threadId, - messages, - googleDocPayload: previewPayloadFromCompletedRun(runData), - }; - } - - default: - return null; - } -}; - -const elapsedSec = (startMs: number) => `${((Date.now() - startMs) / 1000).toFixed(1)}s`; - -const pollAgentRun = async ( - sdk: PageAppSDK, - spaceId: string, - environmentId: string, - runId: string, - maxAttempts: number -): Promise => { - const startMs = Date.now(); - let pendingReviewMissingPayloadCount = 0; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const runData = await getWorkflowRun(sdk, spaceId, environmentId, runId); - - if (!runData) { - await wait(POLL_INTERVAL_MS); - continue; - } - - const status = getRunStatus(runData); - - if (status === RunStatus.PENDING_REVIEW && !getSuspendPayload(runData)) { - pendingReviewMissingPayloadCount++; - } else { - pendingReviewMissingPayloadCount = 0; - } - - const workflowRun = getWorkflowRunResult(runData, runId, pendingReviewMissingPayloadCount); - if (workflowRun) { - return workflowRun; - } - - await wait(POLL_INTERVAL_MS); - } - - console.error(`✗ Run [${runId}] timed out after ${elapsedSec(startMs)}`); - throw new WorkflowRunError( - ERROR_MESSAGES.PROCESSING_TIMEOUT, - WorkflowFailureReason.PROCESSING_TIMEOUT - ); -}; - export const useWorkflowAgent = ({ sdk, documentId, oauthToken, }: UseWorkflowParams): WorkflowHook => { - const [isAnalyzing, setIsAnalyzing] = useState(false); - const { 'google-docs-agent-improvements': extendedTimeout } = useGoogleDocsAgentFlags(); - const maxPollAttempts = extendedTimeout ? EXTENDED_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS; - const startWorkflow = useCallback( - async (contentTypeIds: string[], documentSelection: DocumentSelection) => { - setIsAnalyzing(true); - + async (contentTypeIds: string[], documentSelection: DocumentSelection): Promise => { const spaceId = sdk.ids.space; const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; const threadId = [crypto.randomUUID(), WORKFLOW_AGENT_ID].join('-'); @@ -268,42 +51,13 @@ export const useWorkflowAgent = ({ }; try { - const runId = await startAgentRun(sdk, spaceId, environmentId, payload); - return await pollAgentRun(sdk, spaceId, environmentId, runId, maxPollAttempts); + return await startAgentRun(sdk, spaceId, environmentId, payload); } catch (err) { - const error = err instanceof Error ? err : new Error('Workflow failed'); - throw error; - } finally { - setIsAnalyzing(false); + throw err instanceof Error ? err : new Error('Workflow failed'); } }, [sdk, documentId, oauthToken] ); - const resumeWorkflow = useCallback( - async (runId: string, resumePayload: ResumePayload) => { - setIsAnalyzing(true); - - const spaceId = sdk.ids.space; - const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; - - try { - await resumeWorkflowRun(sdk, spaceId, environmentId, runId, resumePayload); - return await pollAgentRun(sdk, spaceId, environmentId, runId, maxPollAttempts); - } catch (err) { - console.error(`✗ resumeWorkflow [${runId}] failed`, err); - const error = err instanceof Error ? err : new Error('Workflow failed'); - throw error; - } finally { - setIsAnalyzing(false); - } - }, - [sdk] - ); - - return { - isAnalyzing, - startWorkflow, - resumeWorkflow, - }; + return { startWorkflow }; }; diff --git a/apps/drive-integration/src/hooks/useWorkflowAgentLegacy.ts b/apps/drive-integration/src/hooks/useWorkflowAgentLegacy.ts new file mode 100644 index 0000000000..96abcac302 --- /dev/null +++ b/apps/drive-integration/src/hooks/useWorkflowAgentLegacy.ts @@ -0,0 +1,248 @@ +import { useState, useCallback } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { + POLL_INTERVAL_MS, + MAX_POLL_ATTEMPTS, + WORKFLOW_AGENT_ID, + MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES, +} from '../utils/constants/agent'; +import { + MappingReviewSuspendPayload, + ResumePayload, + TabsImagesSuspendPayload, + CompletedWorkflowPayload, + WorkflowRunResult, + RunStatus, + WorkflowFailureReason, + WorkflowRunError, +} from '@types'; +import { + AgentGeneratePayload, + AgentRunData, + getWorkflowRun, + resumeWorkflowRun, + startAgentRun, +} from '../services/agents-api'; +import { validatePayloadShape } from '../utils/createEntries'; +import { ERROR_MESSAGES } from '@constants/messages'; +import { DocumentSelection } from '../services/agents-api'; + +interface UseWorkflowParams { + sdk: PageAppSDK; + documentId: string; + oauthToken: string; +} + +interface WorkflowHook { + isAnalyzing: boolean; + startWorkflow: ( + contentTypeIds: string[], + documentSelection: DocumentSelection + ) => Promise; + resumeWorkflow: (runId: string, resumePayload: ResumePayload) => Promise; +} + +const wait = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +const getRunStatus = (runData: AgentRunData): RunStatus | null => { + return runData.sys?.status ?? runData.metadata?.status ?? null; +}; + +const getAgentPayload = (runData: AgentRunData): string | null => { + if (runData.payload && typeof runData.payload === 'string') { + return runData.payload; + } + if (!runData.messages || !Array.isArray(runData.messages)) return null; + const assistantMessage = runData.messages.find((message) => message.role === 'assistant'); + if (!assistantMessage?.content?.parts) return null; + const textPart = assistantMessage.content.parts.find((part) => part.type === 'text' && part.text); + return textPart?.text || null; +}; + +const previewPayloadFromCompletedRun = (runData: AgentRunData): CompletedWorkflowPayload => { + const googleDocPayload = runData.metadata?.googleDocPayload; + if (googleDocPayload == null) throw new Error('Workflow completed but result payload was missing.'); + if ( + typeof googleDocPayload === 'object' && + googleDocPayload !== null && + 'cancelled' in googleDocPayload && + (googleDocPayload as { cancelled?: unknown }).cancelled === true + ) { + return { entries: [], assets: [], referenceGraph: {} }; + } + return validatePayloadShape(googleDocPayload); +}; + +const getRunErrorMessage = (runData: AgentRunData): string => { + const workflowFailureMessage = runData.metadata?.workflowFailure?.message; + if (typeof workflowFailureMessage === 'string' && workflowFailureMessage.trim().length > 0) { + return workflowFailureMessage; + } + const payload = getAgentPayload(runData); + if (payload) return payload; + return 'Workflow failed'; +}; + +const KNOWN_FAILURE_REASONS = new Set(Object.values(WorkflowFailureReason)); + +const getBackendWorkflowFailureReason = (runData: AgentRunData): WorkflowFailureReason | null => { + const workflowFailure = runData.metadata?.workflowFailure; + if (!workflowFailure) return null; + return KNOWN_FAILURE_REASONS.has(workflowFailure.code) + ? (workflowFailure.code as WorkflowFailureReason) + : null; +}; + +const FAILURE_REASON_MESSAGES: Partial> = { + [WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED]: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, + [WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND]: ERROR_MESSAGES.GOOGLE_DOCS_NOT_FOUND, + [WorkflowFailureReason.AI_SERVICE_UNAVAILABLE]: ERROR_MESSAGES.AI_SERVICE_UNAVAILABLE, + [WorkflowFailureReason.APP_NOT_INSTALLED]: ERROR_MESSAGES.APP_NOT_INSTALLED, + [WorkflowFailureReason.DOCUMENT_TOO_COMPLEX]: ERROR_MESSAGES.DOCUMENT_TOO_COMPLEX, + [WorkflowFailureReason.PROCESSING_TIMEOUT]: ERROR_MESSAGES.PROCESSING_TIMEOUT, + [WorkflowFailureReason.OUT_OF_DOMAIN]: ERROR_MESSAGES.OUT_OF_DOMAIN, +}; + +const getWorkflowFailureMessage = ( + runData: AgentRunData, + failureReason: WorkflowFailureReason +): string => FAILURE_REASON_MESSAGES[failureReason] ?? getRunErrorMessage(runData); + +const getSuspendPayload = ( + runData: AgentRunData +): TabsImagesSuspendPayload | MappingReviewSuspendPayload | undefined => + runData.metadata?.suspendPayload; + +const getWorkflowRunResult = ( + runData: AgentRunData, + threadId: string, + pendingReviewMissingPayloadCount: number +): WorkflowRunResult | null => { + const status = getRunStatus(runData); + switch (status) { + case RunStatus.FAILED: { + const failureReason = + getBackendWorkflowFailureReason(runData) ?? WorkflowFailureReason.GENERIC; + throw new WorkflowRunError(getWorkflowFailureMessage(runData, failureReason), failureReason); + } + case RunStatus.PENDING_REVIEW: { + const suspendPayload = getSuspendPayload(runData); + if (!suspendPayload) { + if (pendingReviewMissingPayloadCount < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) return null; + throw new Error('Workflow paused for review, but suspend payload was missing.'); + } + return { status, runId: threadId, suspendPayload: suspendPayload as MappingReviewSuspendPayload, messages: runData.messages ?? [] }; + } + case RunStatus.COMPLETED: { + return { + status, + runId: threadId, + messages: runData.messages ?? [], + googleDocPayload: previewPayloadFromCompletedRun(runData), + }; + } + default: + return null; + } +}; + +const elapsedSec = (startMs: number) => `${((Date.now() - startMs) / 1000).toFixed(1)}s`; + +const pollAgentRun = async ( + sdk: PageAppSDK, + spaceId: string, + environmentId: string, + runId: string +): Promise => { + const startMs = Date.now(); + let pendingReviewMissingPayloadCount = 0; + console.log(`⏳ Polling run [${runId}]`); + + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + const runData = await getWorkflowRun(sdk, spaceId, environmentId, runId); + if (!runData) { + console.log(` #${attempt + 1} — not found yet (${elapsedSec(startMs)})`); + await wait(POLL_INTERVAL_MS); + continue; + } + const status = getRunStatus(runData); + console.log(` #${attempt + 1} — status: ${status} (${elapsedSec(startMs)})`); + if (status === RunStatus.PENDING_REVIEW && !getSuspendPayload(runData)) { + pendingReviewMissingPayloadCount++; + } else { + pendingReviewMissingPayloadCount = 0; + } + const workflowRun = getWorkflowRunResult(runData, runId, pendingReviewMissingPayloadCount); + if (workflowRun) { + console.log(`✓ Run [${runId}] settled: ${status} in ${elapsedSec(startMs)}`); + return workflowRun; + } + await wait(POLL_INTERVAL_MS); + } + + console.error(`✗ Run [${runId}] timed out after ${elapsedSec(startMs)}`); + throw new WorkflowRunError(ERROR_MESSAGES.PROCESSING_TIMEOUT, WorkflowFailureReason.PROCESSING_TIMEOUT); +}; + +export const useWorkflowAgentLegacy = ({ + sdk, + documentId, + oauthToken, +}: UseWorkflowParams): WorkflowHook => { + const [isAnalyzing, setIsAnalyzing] = useState(false); + + const startWorkflow = useCallback( + async (contentTypeIds: string[], documentSelection: DocumentSelection) => { + setIsAnalyzing(true); + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + const threadId = [crypto.randomUUID(), WORKFLOW_AGENT_ID].join('-'); + const payload: AgentGeneratePayload = { + messages: [ + { + role: 'user' as const, + parts: [ + { + type: 'text' as const, + text: `Analyze the following google docs document ${documentId} and extract the Contentful entries and assets for the following content types: ${contentTypeIds.join(', ')}`, + }, + ], + }, + ], + metadata: { documentId, contentTypeIds, oauthToken, documentSelection }, + threadId, + }; + try { + const runId = await startAgentRun(sdk, spaceId, environmentId, payload); + return await pollAgentRun(sdk, spaceId, environmentId, runId); + } catch (err) { + throw err instanceof Error ? err : new Error('Workflow failed'); + } finally { + setIsAnalyzing(false); + } + }, + [sdk, documentId, oauthToken] + ); + + const resumeWorkflow = useCallback( + async (runId: string, resumePayload: ResumePayload) => { + setIsAnalyzing(true); + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + try { + await resumeWorkflowRun(sdk, spaceId, environmentId, runId, resumePayload); + return await pollAgentRun(sdk, spaceId, environmentId, runId); + } catch (err) { + console.error(`✗ resumeWorkflow [${runId}] failed`, err); + throw err instanceof Error ? err : new Error('Workflow failed'); + } finally { + setIsAnalyzing(false); + } + }, + [sdk] + ); + + return { isAnalyzing, startWorkflow, resumeWorkflow }; +}; diff --git a/apps/drive-integration/src/index.tsx b/apps/drive-integration/src/index.tsx index 71cfdd9355..86b129ce91 100644 --- a/apps/drive-integration/src/index.tsx +++ b/apps/drive-integration/src/index.tsx @@ -4,7 +4,6 @@ import { withLDProvider } from 'launchdarkly-react-client-sdk'; import { createRoot } from 'react-dom/client'; import App from './App'; import LocalhostWarning from './locations/LocalhostWarning'; - const AppWithLD = withLDProvider({ clientSideID: import.meta.env.VITE_LD_CLIENT_ID ?? '', options: { bootstrap: 'localStorage' }, diff --git a/apps/drive-integration/src/locations/Page/Page.tsx b/apps/drive-integration/src/locations/Page/Page.tsx index a0d70fd6ca..ce7ca107e8 100644 --- a/apps/drive-integration/src/locations/Page/Page.tsx +++ b/apps/drive-integration/src/locations/Page/Page.tsx @@ -1,153 +1,10 @@ -import { useRef, useState } from 'react'; -import { PageAppSDK } from '@contentful/app-sdk'; -import { useSDK } from '@contentful/react-apps-toolkit'; -import { Flex, Heading, Layout, Note } from '@contentful/f36-components'; -import { - ModalOrchestrator, - ModalOrchestratorHandle, -} from './components/mainpage/ModalOrchestrator'; -import { MainPageView } from './components/mainpage/MainPageView'; -import { ReviewPage } from './components/review/ReviewPage'; -import type { EntryBlockGraph, MappingReviewSuspendPayload } from '@types'; -import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; -import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; -import { isAiAccessDeniedError } from '../../utils/aiAccess'; +import { useGoogleDocsAgentFlags } from '../../hooks/useGoogleDocsAgentFlags'; +import { PageAsyncRuns } from './PageAsyncRuns'; +import { PageLegacy } from './PageLegacy'; const Page = () => { - const sdk = useSDK(); - const modalOrchestratorRef = useRef(null); - const [aiAccessDeniedMessage, setAiAccessDeniedMessage] = useState(null); - const [mappingReviewState, setMappingReviewState] = useState<{ - payload: MappingReviewSuspendPayload; - runId?: string; - } | null>(null); - const { oauthToken, isOAuthConnected, isOAuthLoading, isOAuthBusy, startOAuth, disconnectOAuth } = - useGoogleDriveOAuth(sdk); - const { resumeWorkflow } = useWorkflowAgent({ - sdk, - documentId: '', - oauthToken: '', - }); - - const handleSelectFile = () => { - modalOrchestratorRef.current?.startFlow(); - }; - - const handleAiAccessDenied = (message: string) => { - setAiAccessDeniedMessage(message); - setMappingReviewState(null); - }; - - const handleAiAccessRestored = () => { - if (aiAccessDeniedMessage !== null) { - setAiAccessDeniedMessage(null); - } - }; - - const handleMappingReviewReady = (payload: MappingReviewSuspendPayload, runId: string) => { - setMappingReviewState({ payload, runId }); - }; - - const handleReturnToMainPage = () => { - setMappingReviewState(null); - }; - - const resetFlowAndReturnToMainPage = () => { - modalOrchestratorRef.current?.resetFlow(); - handleReturnToMainPage(); - }; - - const handleCancelMappingReview = async (graph: EntryBlockGraph) => { - if (!mappingReviewState?.runId) { - resetFlowAndReturnToMainPage(); - return; - } - - try { - await resumeWorkflow(mappingReviewState.runId, { cancelled: true, entryBlockGraph: graph }); - } catch (error) { - console.error(error); - } finally { - resetFlowAndReturnToMainPage(); - } - }; - - const handleConnectGoogleDrive = async () => { - handleAiAccessRestored(); - try { - await startOAuth(); - } catch (error) { - if (isAiAccessDeniedError(error)) { - handleAiAccessDenied(error.message); - } - } - }; - - const handleDisconnectGoogleDrive = async () => { - try { - await disconnectOAuth(); - } catch (error) { - if (isAiAccessDeniedError(error)) { - handleAiAccessDenied(error.message); - } - } - }; - - if (aiAccessDeniedMessage !== null) { - return ( - - - - Drive Integration - {aiAccessDeniedMessage} - - - - ); - } - - return ( - <> - - {mappingReviewState ? ( - - ) : ( - <> - - - )} - - - - - ); + const flags = useGoogleDocsAgentFlags(); + return flags['google-docs-async-runs'] ? : ; }; export default Page; diff --git a/apps/drive-integration/src/locations/Page/PageAsyncRuns.tsx b/apps/drive-integration/src/locations/Page/PageAsyncRuns.tsx new file mode 100644 index 0000000000..5b31035956 --- /dev/null +++ b/apps/drive-integration/src/locations/Page/PageAsyncRuns.tsx @@ -0,0 +1,296 @@ +import { useEffect, useRef, useState } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { Button, Flex, Layout, Note, Spinner } from '@contentful/f36-components'; +import { + ModalOrchestrator, + ModalOrchestratorHandle, +} from './components/mainpage/ModalOrchestrator'; +import { ReviewPage } from './components/review/ReviewPage'; +import { RunsPage } from './components/runs/RunsPage'; +import type { AppView, MappingReviewSuspendPayload } from '@types'; +import { AppViewKind } from '../../types/runs'; +import type { EntryBlockGraph } from '../../types/entryBlockGraph'; +import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; +import { isAiAccessDeniedError } from '../../utils/aiAccess'; +import { resumeAndPollWorkflow } from '../../services/workflowService'; +import { useRunStorage } from '../../hooks/useRunStorage'; +import { getWorkflowRun, startAgentRun } from '../../services/agents-api'; +import { + WORKFLOW_AGENT_ID, + MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES, + POLL_INTERVAL_MS, +} from '../../utils/constants/agent'; + +export const PageAsyncRuns = () => { + const sdk = useSDK(); + const modalOrchestratorRef = useRef(null); + + const [aiAccessDeniedMessage, setAiAccessDeniedMessage] = useState(null); + const [appView, setAppView] = useState({ view: AppViewKind.RUNS }); + const [pendingReviewPayload, setPendingReviewPayload] = + useState(null); + const [isLoadingReviewPayload, setIsLoadingReviewPayload] = useState(false); + const [reviewPayloadFailed, setReviewPayloadFailed] = useState(false); + + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + const { runs, addRun, removeRun, retryRun, markCompleted, storageError } = useRunStorage( + spaceId, + environmentId + ); + + const { oauthToken, isOAuthConnected, isOAuthBusy, startOAuth, disconnectOAuth } = + useGoogleDriveOAuth(sdk); + + // When navigating to the review view, fetch the suspend payload from the backend. + // Retries up to MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES times because the backend + // writes PENDING_REVIEW status before the suspendPayload metadata flushes (~50s window). + useEffect(() => { + if (appView.view !== AppViewKind.REVIEW) { + setPendingReviewPayload(null); + setIsLoadingReviewPayload(false); + setReviewPayloadFailed(false); + return; + } + + let isCancelled = false; + let attempt = 0; + setIsLoadingReviewPayload(true); + setReviewPayloadFailed(false); + + const tryFetch = () => { + void getWorkflowRun(sdk, spaceId, environmentId, appView.runId) + .then((runData) => { + if (isCancelled) return; + const payload = runData?.metadata?.suspendPayload ?? null; + if (payload) { + setPendingReviewPayload(payload); + setIsLoadingReviewPayload(false); + return; + } + attempt++; + if (attempt < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { + setTimeout(tryFetch, POLL_INTERVAL_MS); + } else { + setIsLoadingReviewPayload(false); + setReviewPayloadFailed(true); + } + }) + .catch(() => { + if (isCancelled) return; + attempt++; + if (attempt < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { + setTimeout(tryFetch, POLL_INTERVAL_MS); + } else { + setIsLoadingReviewPayload(false); + setReviewPayloadFailed(true); + } + }); + }; + + tryFetch(); + + return () => { + isCancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-fetch when the runId changes + }, [appView.view === AppViewKind.REVIEW ? appView.runId : null]); + + const handleSelectFile = () => { + modalOrchestratorRef.current?.startFlow(); + }; + + const handleAiAccessDenied = (message: string) => { + setAiAccessDeniedMessage(message); + }; + + const handleAiAccessRestored = () => { + if (aiAccessDeniedMessage !== null) { + setAiAccessDeniedMessage(null); + } + }; + + const handleRunStarted = () => { + setAppView({ view: AppViewKind.RUNS }); + }; + + const handleReviewRun = (runId: string) => { + setAppView({ view: AppViewKind.REVIEW, runId }); + }; + + const handleExitReview = () => { + modalOrchestratorRef.current?.resetFlow(); + handleRunStarted(); + }; + + const handleRunCompleted = (runId: string, entryIds: string[]) => { + markCompleted(runId, entryIds); + }; + + const handleCancelReview = async (runId: string, entryBlockGraph: EntryBlockGraph) => { + try { + await resumeAndPollWorkflow(sdk, runId, { cancelled: true, entryBlockGraph }); + } catch (error) { + console.error(error); + } + modalOrchestratorRef.current?.resetFlow(); + handleRunStarted(); + }; + + const handleRetryRun = async (runId: string) => { + const record = runs.find((r) => r.runId === runId); + if (!record) return; + + const threadId = [crypto.randomUUID(), WORKFLOW_AGENT_ID].join('-'); + const newRunId = await startAgentRun(sdk, spaceId, environmentId, { + messages: [ + { + role: 'user', + parts: [ + { + type: 'text', + text: `Analyze the following google docs document ${ + record.documentId + } and extract the Contentful entries and assets for the following content types: ${record.contentTypeIds.join( + ', ' + )}`, + }, + ], + }, + ], + metadata: { + documentId: record.documentId, + contentTypeIds: record.contentTypeIds, + oauthToken, + documentSelection: record.documentSelection, + }, + threadId, + }); + + retryRun(runId, { + ...record, + runId: newRunId, + startedAt: new Date().toISOString(), + createdEntryIds: undefined, + }); + }; + + const handleConnectGoogleDrive = async () => { + handleAiAccessRestored(); + try { + await startOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) { + handleAiAccessDenied(error.message); + } + } + }; + + const handleDisconnectGoogleDrive = async () => { + try { + await disconnectOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) { + handleAiAccessDenied(error.message); + } + } + }; + + if (aiAccessDeniedMessage !== null) { + return ( + + + + {aiAccessDeniedMessage} + + + + ); + } + + const renderView = () => { + switch (appView.view) { + case AppViewKind.RUNS: + return ( + + ); + + case AppViewKind.REVIEW: { + if (reviewPayloadFailed) { + return ( + + + Could not load the review data. The import may still be processing. + + + + ); + } + + if (isLoadingReviewPayload || !pendingReviewPayload) { + return ( + + + + ); + } + + return ( + handleCancelReview(appView.runId, graph)} + onExitReview={handleExitReview} + onRunCompleted={(entryIds) => handleRunCompleted(appView.runId, entryIds)} + /> + ); + } + } + }; + + return ( + <> + + {renderView()} + + + + + ); +}; + diff --git a/apps/drive-integration/src/locations/Page/PageLegacy.tsx b/apps/drive-integration/src/locations/Page/PageLegacy.tsx new file mode 100644 index 0000000000..ab4c6b9dca --- /dev/null +++ b/apps/drive-integration/src/locations/Page/PageLegacy.tsx @@ -0,0 +1,145 @@ +import { useRef, useState } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { Flex, Heading, Layout, Note } from '@contentful/f36-components'; +import { + ModalOrchestratorLegacy, + ModalOrchestratorLegacyHandle, +} from './components/mainpage/ModalOrchestratorLegacy'; +import { MainPageView } from './components/mainpage/MainPageView'; +import { ReviewPage } from './components/review/ReviewPage'; +import type { MappingReviewSuspendPayload } from '@types'; +import { useWorkflowAgentLegacy } from '@hooks/useWorkflowAgentLegacy'; +import { useGoogleDriveOAuth } from '@hooks/useGoogleDriveOAuth'; +import { isAiAccessDeniedError } from '../../utils/aiAccess'; + +export const PageLegacy = () => { + const sdk = useSDK(); + const modalOrchestratorRef = useRef(null); + const [aiAccessDeniedMessage, setAiAccessDeniedMessage] = useState(null); + const [mappingReviewState, setMappingReviewState] = useState<{ + payload: MappingReviewSuspendPayload; + runId?: string; + } | null>(null); + const { oauthToken, isOAuthConnected, isOAuthLoading, isOAuthBusy, startOAuth, disconnectOAuth } = + useGoogleDriveOAuth(sdk); + const { resumeWorkflow } = useWorkflowAgentLegacy({ + sdk, + documentId: '', + oauthToken: '', + }); + + + const handleSelectFile = () => { + modalOrchestratorRef.current?.startFlow(); + }; + + const handleAiAccessDenied = (message: string) => { + setAiAccessDeniedMessage(message); + setMappingReviewState(null); + }; + + const handleAiAccessRestored = () => { + if (aiAccessDeniedMessage !== null) setAiAccessDeniedMessage(null); + }; + + const handleMappingReviewReady = (payload: MappingReviewSuspendPayload, runId: string) => { + setMappingReviewState({ payload, runId }); + }; + + const handleReturnToMainPage = () => { + setMappingReviewState(null); + }; + + const resetFlowAndReturnToMainPage = () => { + modalOrchestratorRef.current?.resetFlow(); + handleReturnToMainPage(); + }; + + const handleCancelMappingReview = async () => { + if (!mappingReviewState?.runId) { + resetFlowAndReturnToMainPage(); + return; + } + try { + await resumeWorkflow(mappingReviewState.runId, { cancelled: true }); + } catch (error) { + console.error(error); + } finally { + resetFlowAndReturnToMainPage(); + } + }; + + const handleConnectGoogleDrive = async () => { + handleAiAccessRestored(); + try { + await startOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) handleAiAccessDenied(error.message); + } + }; + + const handleDisconnectGoogleDrive = async () => { + try { + await disconnectOAuth(); + } catch (error) { + if (isAiAccessDeniedError(error)) handleAiAccessDenied(error.message); + } + }; + + if (aiAccessDeniedMessage !== null) { + return ( + + + + Drive Integration + {aiAccessDeniedMessage} + + + + ); + } + + return ( + <> + + {mappingReviewState ? ( + + ) : ( + <> + + + )} + + + + + ); +}; 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 e6e2ccd313..c9794beb0e 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestrator.tsx @@ -5,17 +5,9 @@ import { ContentTypeProps } from 'contentful-management'; import { ConfirmCancelModal } from '../modals/ConfirmCancelModal'; import { ErrorModal, type ErrorModalConfig } from '../modals/ErrorModal'; import SelectDocumentModal from '../modals/step_1/SelectDocumentModal'; -import { LoadingModal } from '../modals/LoadingModal'; import { ERROR_MESSAGES } from '@constants/messages'; import { SelectTabsModal } from '../modals/step_3/SelectTabsModal'; -import { - DocumentTabProps, - MappingReviewSuspendPayload, - RunStatus, - WorkflowRunResult, - WorkflowFailureReason, - WorkflowRunError, -} from '@types'; +import { DocumentTabProps, WorkflowFailureReason, WorkflowRunError } from '@types'; import { ContentTypePickerModal } from '../modals/step_2/ContentTypePickerModal'; import { IncludeImagesModal } from '../modals/step_4/IncludeImagesModal'; import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; @@ -25,6 +17,7 @@ import { DocumentSelectionConfig, } from '../../../../utils/fetchDocumentSelection'; import { isAiAccessDeniedError } from '../../../../utils/aiAccess'; +import type { RunRecord } from '../../../../types/runs'; export interface ModalOrchestratorHandle { startFlow: () => void; @@ -35,7 +28,6 @@ enum FlowStep { CONTENT_TYPE_PICKER = 'contentTypePicker', SELECT_TABS = 'selectTabs', INCLUDE_IMAGES = 'includeImages', - LOADING = 'loading', } interface ModalOrchestratorProps { @@ -45,8 +37,9 @@ interface ModalOrchestratorProps { isOAuthBusy?: boolean; onReconnectGoogleDrive?: () => Promise; onAiAccessDenied?: (message: string) => void; - onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; - onResetToMain: () => void; + onRunStarted: (runId: string) => void; + addRun: (record: RunRecord) => void; + storageError: string | null; } interface PreviewErrorState { @@ -63,9 +56,10 @@ export const ModalOrchestrator = forwardRef undefined, - onMappingReviewReady, - onResetToMain, + onRunStarted, onAiAccessDenied, + addRun, + storageError, }, ref ) => { @@ -75,12 +69,14 @@ export const ModalOrchestrator = forwardRef(null); const [documentId, setDocumentId] = useState(''); + const [documentTitle, setDocumentTitle] = useState('Untitled Document'); const [selectedContentTypes, setSelectedContentTypes] = useState([]); const [availableTabs, setAvailableTabs] = useState([]); const [selectedTabs, setSelectedTabs] = useState([]); const [useAllTabs, setUseAllTabs] = useState(null); const [includeImages, setIncludeImages] = useState(null); const [requiresImageSelection, setRequiresImageSelection] = useState(false); + const { startWorkflow } = useWorkflowAgent({ sdk, documentId, @@ -109,6 +105,7 @@ export const ModalOrchestrator = forwardRef { setDocumentId(''); + setDocumentTitle('Untitled Document'); setSelectedContentTypes([]); resetDocumentSelection(); setFlowStep(null); @@ -121,7 +118,6 @@ export const ModalOrchestrator = forwardRef { - if (flowStep === FlowStep.LOADING) return; showDiscardConfirmation(); }; @@ -129,13 +125,11 @@ export const ModalOrchestrator = forwardRef { setIsConfirmCancelModalOpen(false); resetProgress(); - onResetToMain(); }; const showWorkflowError = (error?: unknown) => { @@ -256,19 +250,19 @@ export const ModalOrchestrator = forwardRef { if (isAiAccessDeniedError(error)) { resetProgress(); - onResetToMain(); onAiAccessDenied?.(error.message); return; } showWorkflowError(error); }, - [onAiAccessDenied, onResetToMain, resetProgress] + [onAiAccessDenied, resetProgress] ); - const handleUploadModalCloseRequest = (docId?: string) => { + const handleUploadModalCloseRequest = (docId?: string, docTitle?: string) => { if (docId) { setDocumentId(docId); + setDocumentTitle(docTitle ?? 'Untitled Document'); setIsUploadModalOpen(false); setFlowStep(FlowStep.CONTENT_TYPE_PICKER); return; @@ -303,23 +297,37 @@ export const ModalOrchestrator = forwardRef { - if (workflowRun.status === RunStatus.PENDING_REVIEW) { - setFlowStep(null); - onMappingReviewReady(workflowRun.suspendPayload, workflowRun.runId); - return; - } - - setFlowStep(null); - }; - const startWorkflowWithScope = async ( contentTypeIds: string[], documentSelection: DocumentSelection ) => { - setFlowStep(FlowStep.LOADING); - const result = await startWorkflow(contentTypeIds, documentSelection); - handleWorkflowResult(result); + if (storageError) { + showWorkflowError( + new WorkflowRunError( + 'Unable to track this import: browser storage is unavailable or full.', + WorkflowFailureReason.GENERIC + ) + ); + return; + } + + try { + const runId = await startWorkflow(contentTypeIds, documentSelection); + + addRun({ + runId, + documentTitle, + documentId, + contentTypeIds, + documentSelection, + startedAt: new Date().toISOString(), + }); + + resetProgress(); + onRunStarted(runId); + } catch (err) { + handleWorkflowError(err); + } }; const handleContentTypeContinue = async (contentTypeIds: string[]) => { @@ -450,14 +458,6 @@ export const ModalOrchestrator = forwardRef ); - case FlowStep.LOADING: - return ( - - ); default: return null; } @@ -476,7 +476,7 @@ export const ModalOrchestrator = forwardRef + shouldCloseOnEscapePress={true}> {renderFlowStep} diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestratorLegacy.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestratorLegacy.tsx new file mode 100644 index 0000000000..27887ee7df --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/mainpage/ModalOrchestratorLegacy.tsx @@ -0,0 +1,514 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { Modal } from '@contentful/f36-components'; +import { ContentTypeProps } from 'contentful-management'; +import { ConfirmCancelModal } from '../modals/ConfirmCancelModal'; +import { ErrorModal, type ErrorModalConfig } from '../modals/ErrorModal'; +import SelectDocumentModal from '../modals/step_1/SelectDocumentModal'; +import { LoadingModal } from '../modals/LoadingModal'; +import { ERROR_MESSAGES } from '@constants/messages'; +import { CONTENT_TYPE_SUBMIT_LOADING_DELAY_MS } from '@constants/agent'; +import { SelectTabsModal } from '../modals/step_3/SelectTabsModal'; +import { + DocumentTabProps, + MappingReviewSuspendPayload, + CompletedWorkflowPayload, + ResumePayload, + TabsImagesSuspendPayload, + RunStatus, + WorkflowRunResult, + WorkflowFailureReason, + WorkflowRunError, +} from '@types'; +import { ContentTypePickerModal } from '../modals/step_2/ContentTypePickerModal'; +import { IncludeImagesModal } from '../modals/step_4/IncludeImagesModal'; +import { useWorkflowAgentLegacy } from '@hooks/useWorkflowAgentLegacy'; +import { isAiAccessDeniedError } from '../../../../utils/aiAccess'; + +export interface ModalOrchestratorLegacyHandle { + startFlow: () => void; + resetFlow: () => void; +} + +enum FlowStep { + CONTENT_TYPE_PICKER = 'contentTypePicker', + SELECT_TABS = 'selectTabs', + INCLUDE_IMAGES = 'includeImages', + LOADING = 'loading', +} + +interface ModalOrchestratorProps { + sdk: PageAppSDK; + oauthToken: string; + isOAuthConnected?: boolean; + isOAuthBusy?: boolean; + onReconnectGoogleDrive?: () => Promise; + onAiAccessDenied?: (message: string) => void; + onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; + onResetToMain: () => void; +} + +interface PreviewErrorState { + reason: WorkflowFailureReason; + title: string; + message: string; +} + +export const ModalOrchestratorLegacy = forwardRef( + ( + { + sdk, + oauthToken, + isOAuthConnected = false, + isOAuthBusy = false, + onReconnectGoogleDrive = async () => undefined, + onMappingReviewReady, + onResetToMain, + onAiAccessDenied, + }, + ref + ) => { + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [isConfirmCancelModalOpen, setIsConfirmCancelModalOpen] = useState(false); + const [previewErrorState, setPreviewErrorState] = useState(null); + const [isReconnectPending, setIsReconnectPending] = useState(false); + const [flowStep, setFlowStep] = useState(null); + const [documentId, setDocumentId] = useState(''); + const [selectedContentTypes, setSelectedContentTypes] = useState([]); + const [availableTabs, setAvailableTabs] = useState([]); + const [selectedTabs, setSelectedTabs] = useState([]); + const [useAllTabs, setUseAllTabs] = useState(null); + const [includeImages, setIncludeImages] = useState(null); + const [requiresImageSelection, setRequiresImageSelection] = useState(false); + const [activeRunId, setActiveRunId] = useState(null); + const { startWorkflow, resumeWorkflow } = useWorkflowAgentLegacy({ + sdk, + documentId, + oauthToken, + }); + + const hasProgressToLose = documentId.trim().length > 0; + + useImperativeHandle(ref, () => ({ + startFlow: () => setIsUploadModalOpen(true), + resetFlow: () => { + setIsConfirmCancelModalOpen(false); + setPreviewErrorState(null); + setIsReconnectPending(false); + resetProgress(); + }, + })); + + const resetDocumentScopeReview = () => { + setAvailableTabs([]); + setSelectedTabs([]); + setUseAllTabs(null); + setIncludeImages(null); + setRequiresImageSelection(false); + }; + + const resetProgress = () => { + setDocumentId(''); + setSelectedContentTypes([]); + resetDocumentScopeReview(); + setActiveRunId(null); + setFlowStep(null); + setIsUploadModalOpen(false); + }; + + const showDiscardConfirmation = () => { + if (!hasProgressToLose) return; + setIsConfirmCancelModalOpen(true); + }; + + const handleFlowModalCloseRequest = () => { + if (flowStep === FlowStep.LOADING) return; + showDiscardConfirmation(); + }; + + const closePreviewErrorAndReset = useCallback(() => { + setPreviewErrorState(null); + setIsReconnectPending(false); + resetProgress(); + onResetToMain(); + }, [onResetToMain]); + + const handleConfirmCancel = async () => { + setIsConfirmCancelModalOpen(false); + + if (activeRunId) { + try { + await resumeWorkflow(activeRunId, { cancelled: true }); + } catch (error) { + console.error(error); + } + } + + resetProgress(); + onResetToMain(); + }; + + const showWorkflowError = (error?: unknown) => { + setFlowStep(null); + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED, + title: 'Reconnect Drive to continue', + message: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND, + title: 'Document not found', + message: ERROR_MESSAGES.GOOGLE_DOCS_NOT_FOUND, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.AI_SERVICE_UNAVAILABLE + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.AI_SERVICE_UNAVAILABLE, + title: 'AI service temporarily unavailable', + message: ERROR_MESSAGES.AI_SERVICE_UNAVAILABLE, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.APP_NOT_INSTALLED + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.APP_NOT_INSTALLED, + title: 'App not installed in this environment', + message: ERROR_MESSAGES.APP_NOT_INSTALLED, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.DOCUMENT_TOO_COMPLEX + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.DOCUMENT_TOO_COMPLEX, + title: 'Document too large to import', + message: ERROR_MESSAGES.DOCUMENT_TOO_COMPLEX, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.PROCESSING_TIMEOUT + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.PROCESSING_TIMEOUT, + title: 'Import timed out', + message: ERROR_MESSAGES.PROCESSING_TIMEOUT, + }); + return; + } + + if ( + error instanceof WorkflowRunError && + error.reason === WorkflowFailureReason.OUT_OF_DOMAIN + ) { + setPreviewErrorState({ + reason: WorkflowFailureReason.OUT_OF_DOMAIN, + title: 'Document not supported', + message: ERROR_MESSAGES.OUT_OF_DOMAIN, + }); + return; + } + + setPreviewErrorState({ + reason: WorkflowFailureReason.GENERIC, + title: 'Unable to generate preview', + message: ERROR_MESSAGES.GENERIC_ERROR, + }); + }; + + useEffect(() => { + if (!isReconnectPending || isOAuthBusy || !isOAuthConnected) { + return; + } + + closePreviewErrorAndReset(); + }, [closePreviewErrorAndReset, isOAuthBusy, isOAuthConnected, isReconnectPending]); + + const handleWorkflowError = (error: unknown) => { + if (isAiAccessDeniedError(error)) { + resetProgress(); + onResetToMain(); + onAiAccessDenied?.(error.message); + return; + } + + showWorkflowError(error); + }; + + const handleUploadModalCloseRequest = (docId?: string) => { + if (docId) { + setDocumentId(docId); + setIsUploadModalOpen(false); + setFlowStep(FlowStep.CONTENT_TYPE_PICKER); + return; + } + + setIsUploadModalOpen(false); + showDiscardConfirmation(); + }; + + const showDocumentScopeReview = (suspendPayload?: TabsImagesSuspendPayload) => { + setAvailableTabs( + (suspendPayload?.tabs ?? []).map((tab) => ({ + tabId: tab.id ?? '', + tabTitle: tab.title ?? '', + })) + ); + setSelectedTabs([]); + setUseAllTabs(null); + setIncludeImages(null); + setRequiresImageSelection(Boolean(suspendPayload?.requiresImageSelection)); + + if (suspendPayload?.requiresTabSelection) { + setFlowStep(FlowStep.SELECT_TABS); + return; + } + + if (suspendPayload?.requiresImageSelection) { + setFlowStep(FlowStep.INCLUDE_IMAGES); + return; + } + + setFlowStep(null); + }; + + const handleWorkflowResult = (workflowRun: WorkflowRunResult) => { + setActiveRunId(workflowRun.runId); + + if (workflowRun.status === RunStatus.PENDING_REVIEW) { + if (workflowRun.suspendPayload.suspendStepId === 'mapping-review') { + setFlowStep(null); + onMappingReviewReady(workflowRun.suspendPayload, workflowRun.runId); + return; + } + + showDocumentScopeReview(workflowRun.suspendPayload as unknown as TabsImagesSuspendPayload); + return; + } + + setFlowStep(null); + }; + + const continueWorkflow = async (resumePayloadOverrides?: Partial) => { + if (!activeRunId) { + throw new Error('Workflow run id is missing for resume.'); + } + + const resumePayload: ResumePayload = { + ...(selectedTabs.length > 0 + ? { selectedTabIds: selectedTabs.map((tab) => tab.tabId) } + : {}), + ...(includeImages !== null ? { includeImages } : {}), + ...resumePayloadOverrides, + }; + + setFlowStep(FlowStep.LOADING); + + const workflowRun = await resumeWorkflow(activeRunId, resumePayload); + handleWorkflowResult(workflowRun); + }; + + const startWorkflowWithDelayedLoading = async (contentTypeIds: string[]) => { + let isStartPending = true; + const loadingModalTimeout = window.setTimeout(() => { + if (isStartPending) { + setFlowStep(FlowStep.LOADING); + } + }, CONTENT_TYPE_SUBMIT_LOADING_DELAY_MS); + + try { + return await startWorkflow(contentTypeIds, { selectedTabIds: [], includeImages: false }); + } finally { + isStartPending = false; + window.clearTimeout(loadingModalTimeout); + } + }; + + const handleContentTypeContinue = async (contentTypeIds: string[]) => { + if (!isOAuthConnected) { + showWorkflowError( + new WorkflowRunError( + ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, + WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED + ) + ); + return; + } + + try { + handleWorkflowResult(await startWorkflowWithDelayedLoading(contentTypeIds)); + } catch (error) { + handleWorkflowError(error); + } + }; + + const handleSelectTabsContinue = async (selectedTabs: DocumentTabProps[]) => { + setSelectedTabs(selectedTabs); + + if (requiresImageSelection) { + setFlowStep(FlowStep.INCLUDE_IMAGES); + return; + } + + try { + await continueWorkflow({ selectedTabIds: selectedTabs.map((tab) => tab.tabId) }); + } catch (error) { + handleWorkflowError(error); + } + }; + + const handleIncludeImagesContinue = async (includeImages: boolean) => { + setIncludeImages(includeImages); + + try { + await continueWorkflow({ includeImages }); + } catch (error) { + handleWorkflowError(error); + } + }; + + const handleReconnectGoogleDrive = useCallback(async () => { + setIsReconnectPending(true); + + try { + await onReconnectGoogleDrive(); + } catch (error) { + handleWorkflowError(error); + setIsReconnectPending(false); + } + }, [handleWorkflowError, onReconnectGoogleDrive]); + + const errorModalConfig = useMemo(() => { + if (previewErrorState?.reason === WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED) { + return { + title: previewErrorState.title, + message: previewErrorState.message, + primaryActionLabel: 'Reconnect Drive', + onPrimaryAction: () => void handleReconnectGoogleDrive(), + secondaryActionLabel: 'Close', + onSecondaryAction: closePreviewErrorAndReset, + isPrimaryActionLoading: isReconnectPending && isOAuthBusy, + }; + } + + return { + title: previewErrorState?.title ?? 'Unable to generate preview', + message: previewErrorState?.message ?? ERROR_MESSAGES.GENERIC_ERROR, + primaryActionLabel: 'Close', + onPrimaryAction: closePreviewErrorAndReset, + isPrimaryActionLoading: false, + }; + }, [ + closePreviewErrorAndReset, + handleReconnectGoogleDrive, + isOAuthBusy, + isReconnectPending, + previewErrorState, + ]); + + const renderFlowStep = () => { + switch (flowStep) { + case FlowStep.CONTENT_TYPE_PICKER: + return ( + + ); + case FlowStep.SELECT_TABS: + return ( + + ); + case FlowStep.INCLUDE_IMAGES: + return ( + + ); + case FlowStep.LOADING: + return ( + + ); + default: + return null; + } + }; + + return ( + <> + + + + {renderFlowStep} + + + setIsConfirmCancelModalOpen(false)} + /> + + + + ); + } +); + +ModalOrchestratorLegacy.displayName = 'ModalOrchestratorLegacy'; diff --git a/apps/drive-integration/src/locations/Page/components/mainpage/OAuthConnector.tsx b/apps/drive-integration/src/locations/Page/components/mainpage/OAuthConnector.tsx index 50aa2affc0..0bb3c33437 100644 --- a/apps/drive-integration/src/locations/Page/components/mainpage/OAuthConnector.tsx +++ b/apps/drive-integration/src/locations/Page/components/mainpage/OAuthConnector.tsx @@ -1,9 +1,8 @@ import { useState } from 'react'; import { type ComponentProps } from 'react'; -import { Button, Flex, Text, Image } from '@contentful/f36-components'; +import { Button, Flex, Text } from '@contentful/f36-components'; import tokens from '@contentful/f36-tokens'; import { CheckCircleIcon } from '@contentful/f36-icons'; -import driveLogo from '../../../../assets/drive-integration.svg'; type OAuthConnectorProps = { isOAuthConnected: boolean; @@ -49,60 +48,34 @@ export const OAuthConnector = ({ return ( { + if (isOAuthConnected) { + setIsHoveringConnected(true); + } + }} + onMouseLeave={() => { + setIsHoveringConnected(false); }}> - - - Drive Integration - - - Drive Integration + {isOAuthConnected && isHoveringConnected && ( + + Status: connected - - { - if (isOAuthConnected) { - setIsHoveringConnected(true); - } - }} - onMouseLeave={() => { - setIsHoveringConnected(false); - }}> - {isOAuthConnected && isHoveringConnected && ( - - Status: connected - - )} - - + )} + ); }; diff --git a/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx b/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx index 28e2a5cf1f..2a6ff7c0fb 100644 --- a/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx +++ b/apps/drive-integration/src/locations/Page/components/modals/step_1/SelectDocumentModal.tsx @@ -4,7 +4,7 @@ import { useGoogleDocsPicker } from '@hooks/useGoogleDocPicker'; interface SelectDocumentModalProps { oauthToken: string; isOpen: boolean; - onClose: (documentId?: string) => void; + onClose: (documentId?: string, documentTitle?: string) => void; } export default function SelectDocumentModal({ @@ -21,9 +21,9 @@ export default function SelectDocumentModal({ }, [onClose]); // Stable callbacks that use refs - const handlePicked = useCallback((files: { id: string }[]) => { + const handlePicked = useCallback((files: { id: string; name: string }[]) => { if (files.length > 0) { - onCloseRef.current(files[0].id); + onCloseRef.current(files[0].id, files[0].name); } else { onCloseRef.current(); } 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 337a7c62a9..e37ba48ef7 100644 --- a/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx +++ b/apps/drive-integration/src/locations/Page/components/review/ReviewPage.tsx @@ -7,7 +7,7 @@ import { cx } from '@emotion/css'; import type { EntryProps } from 'contentful-management'; import type { EntryBlockGraph, MappingReviewSuspendPayload, ReviewedReferenceGraph } from '@types'; import { RunStatus } from '@types'; -import { useWorkflowAgent } from '@hooks/useWorkflowAgent'; +import { resumeAndPollWorkflow } from '../../../../services/workflowService'; import { createEntriesFromPreviewPayload } from '../../../../services/entryService'; import type { ContentTypeDisplayInfoMap } from '../../../../utils/overviewEntryList'; import { @@ -35,6 +35,7 @@ interface ReviewPageProps { runId?: string; onCancelReview: (graph: EntryBlockGraph) => Promise; onExitReview: () => void; + onRunCompleted?: (entryIds: string[]) => void; } export const ReviewPage = ({ @@ -43,6 +44,7 @@ export const ReviewPage = ({ runId, onCancelReview, onExitReview, + onRunCompleted, }: ReviewPageProps) => { const [isConfirmCancelModalOpen, setIsConfirmCancelModalOpen] = useState(false); const [selectedEntryIndex, setSelectedEntryIndex] = useState(null); @@ -94,8 +96,6 @@ export const ReviewPage = ({ ); const hasSelectedEntries = selectedEntryCount > 0; - const { resumeWorkflow } = useWorkflowAgent({ sdk, documentId: '', oauthToken: '' }); - const handleToggleEntrySelection = (entryKey: string, isSelected: boolean) => { setSelectedEntryKeys((previous) => { const next = new Set(previous); @@ -125,7 +125,7 @@ export const ReviewPage = ({ entryBlockGraph, selectedEntryKeys ); - const result = await resumeWorkflow(runId, { + const result = await resumeAndPollWorkflow(sdk, runId, { entryBlockGraph: selectedEntryBlockGraph, }); @@ -143,6 +143,8 @@ export const ReviewPage = ({ return; } + const entryIds = entries.map((e) => e.sys.id); + onRunCompleted?.(entryIds); setCreatedEntries(entries); setIsSummaryModalOpen(true); return; @@ -166,9 +168,9 @@ export const ReviewPage = ({ hasSelectedEntries, entryBlockGraph, selectedEntryKeys, - resumeWorkflow, sdk, onExitReview, + onRunCompleted, ]); const handleConfirmCancel = useCallback(async () => { diff --git a/apps/drive-integration/src/locations/Page/components/runs/EntriesCreatedModal.tsx b/apps/drive-integration/src/locations/Page/components/runs/EntriesCreatedModal.tsx new file mode 100644 index 0000000000..f0aa33113c --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/runs/EntriesCreatedModal.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import { Button, EntryCard, Flex, Modal, Spinner } from '@contentful/f36-components'; +import { PageAppSDK } from '@contentful/app-sdk'; +import type { EntryProps } from 'contentful-management'; +import { fetchContentTypesInfoByIds } from '../../../../services/contentTypeService'; +import type { ContentTypeDisplayInfoMap } from '../../../../utils/overviewEntryList'; +import { getEntryDisplayTitle } from '../../../../utils/getEntryDisplayTitle'; + +interface EntriesCreatedModalProps { + isOpen: boolean; + onClose: () => void; + sdk: PageAppSDK; + entryIds: string[]; +} + +function resolveContentTypeLabel(contentTypeId: string, map?: ContentTypeDisplayInfoMap): string { + const name = map?.get(contentTypeId)?.name?.trim(); + return name && name.length > 0 ? name : 'Content type'; +} + +function entryStatus(entry: EntryProps): 'draft' | 'published' | 'changed' { + if (!entry.sys.publishedAt) return 'draft'; + if (entry.sys.version > (entry.sys.publishedVersion ?? 0) + 1) return 'changed'; + return 'published'; +} + +export function EntriesCreatedModal({ isOpen, onClose, sdk, entryIds }: EntriesCreatedModalProps) { + const [entries, setEntries] = useState([]); + const [ctMap, setCtMap] = useState(new Map()); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!isOpen || entryIds.length === 0) return; + + setIsLoading(true); + + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + Promise.all( + entryIds.map((id) => + sdk.cma.entry.get({ entryId: id, spaceId, environmentId }).catch(() => null) + ) + ) + .then((results) => { + const fetched = results.filter((e): e is EntryProps => e !== null); + setEntries(fetched); + const ctIds = fetched.map((e) => e.sys.contentType.sys.id); + return fetchContentTypesInfoByIds(sdk, ctIds).then(setCtMap); + }) + .catch(() => { + setEntries([]); + }) + .finally(() => setIsLoading(false)); + }, [isOpen, entryIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + const defaultLocale = sdk.locales.default; + + return ( + + {() => ( + <> + + + {isLoading ? ( + + + + ) : ( + + {entries.map((entry) => { + const contentTypeId = entry.sys.contentType.sys.id; + const title = getEntryDisplayTitle( + entry, + defaultLocale, + ctMap.get(contentTypeId) + ); + const contentTypeLabel = resolveContentTypeLabel(contentTypeId, ctMap); + return ( + { + void sdk.navigator.openEntry(entry.sys.id, { slideIn: true }); + }} + /> + ); + })} + + )} + + + + + + )} + + ); +} diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx new file mode 100644 index 0000000000..9dacee9441 --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import { + Badge, + Button, + Flex, + TableCell, + TableRow, + Text, + Tooltip, +} from '@contentful/f36-components'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { DisplayStatus } from '../../../../types/runs'; +import type { RunWithStatus } from '../../../../types/runs'; +import { EntriesCreatedModal } from './EntriesCreatedModal'; + +interface RunRowProps { + run: RunWithStatus; + sdk: PageAppSDK; + onReview: (runId: string) => void; + onRetry: (runId: string) => Promise; +} + +function formatDate(iso: string): string { + const date = new Date(iso); + return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +function StatusBadge({ + status, + errorMessage, + entryCount, +}: { + status: RunWithStatus['displayStatus']; + errorMessage?: string; + entryCount?: number; +}) { + switch (status) { + case DisplayStatus.LOADING: + return ( + + Loading + + ); + case DisplayStatus.RUNNING: + return ( + + In progress + + ); + case DisplayStatus.NEEDS_REVIEW: + return ( + + Ready for review + + ); + case DisplayStatus.COMPLETED: { + const label = + entryCount === undefined + ? 'Complete' + : `Complete - ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}`; + return ( + + {label} + + ); + } + case DisplayStatus.FAILED: { + const badge = ( + + Failed + + ); + return errorMessage ? ( + + {badge} + + ) : ( + badge + ); + } + case DisplayStatus.EXPIRED: + return ( + + Expired + + ); + } +} + +export function RunRow({ run, sdk, onReview, onRetry }: RunRowProps) { + const [isRetrying, setIsRetrying] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleRetry = async () => { + setIsRetrying(true); + try { + await onRetry(run.runId); + } finally { + setIsRetrying(false); + } + }; + + return ( + <> + + {/* Name */} + + {run.documentTitle} + + + {/* Created date */} + + + {formatDate(run.startedAt)} + + + + {/* Status badge */} + + + + + {/* Action */} + + {run.displayStatus === DisplayStatus.RUNNING && ( + + {[0, 1, 2].map((i) => ( +
+ ))} + + + )} + {run.displayStatus === DisplayStatus.NEEDS_REVIEW && ( + + )} + {run.displayStatus === DisplayStatus.COMPLETED && + run.createdEntryIds && + run.createdEntryIds.length > 0 && ( + + )} + {(run.displayStatus === DisplayStatus.FAILED || + run.displayStatus === DisplayStatus.EXPIRED) && ( + + )} + + + + {run.createdEntryIds && run.createdEntryIds.length > 0 && ( + setIsModalOpen(false)} + sdk={sdk} + entryIds={run.createdEntryIds} + /> + )} + + ); +} diff --git a/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx new file mode 100644 index 0000000000..a3e16ee935 --- /dev/null +++ b/apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx @@ -0,0 +1,308 @@ +import { + Box, + Button, + Checkbox, + Flex, + Heading, + Note, + Paragraph, + Popover, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Text, +} from '@contentful/f36-components'; +import { CaretDownIcon, SortAscendingIcon, SortDescendingIcon } from '@contentful/f36-icons'; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useState } from 'react'; +import { useRunsPolling } from '../../../../hooks/useRunsPolling'; +import { DisplayStatus } from '../../../../types/runs'; +import type { RunRecord, RunWithStatus } from '../../../../types/runs'; +import { RunRow } from './RunRow'; +import { OAuthConnector } from '../mainpage/OAuthConnector'; + +type SortOrder = 'newest' | 'oldest'; + +const STATUS_OPTIONS: { value: DisplayStatus; label: string }[] = [ + { value: DisplayStatus.RUNNING, label: 'In progress' }, + { value: DisplayStatus.NEEDS_REVIEW, label: 'Ready for review' }, + { value: DisplayStatus.COMPLETED, label: 'Complete' }, + { value: DisplayStatus.FAILED, label: 'Failed' }, + { value: DisplayStatus.EXPIRED, label: 'Expired' }, +]; + +const ALL_STATUSES = new Set(STATUS_OPTIONS.map((o) => o.value)); + +interface RunsPageProps { + sdk: PageAppSDK; + runs: RunRecord[]; + removeRun: (runId: string) => void; + storageError: string | null; + onStartImport: () => void; + onReviewRun: (runId: string) => void; + onRetryRun: (runId: string) => Promise; + isOAuthConnected: boolean; + isOAuthBusy: boolean; + onConnectGoogleDrive: () => Promise; + onDisconnectGoogleDrive: () => Promise; +} + +export function RunsPage({ + sdk, + runs, + storageError, + onStartImport, + onReviewRun, + onRetryRun, + isOAuthConnected, + isOAuthBusy, + onConnectGoogleDrive, + onDisconnectGoogleDrive, +}: RunsPageProps) { + const [visibleStatuses, setVisibleStatuses] = useState>(new Set(ALL_STATUSES)); + const [filterOpen, setFilterOpen] = useState(false); + const [sortOpen, setSortOpen] = useState(false); + const [sortOrder, setSortOrder] = useState('newest'); + + const toggleStatus = (status: DisplayStatus) => { + setVisibleStatuses((prev) => { + const next = new Set(prev); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + return next; + }); + }; + + const isFiltered = visibleStatuses.size < ALL_STATUSES.size; + + const { statusMap, errorMap, titleMap } = useRunsPolling(runs, sdk); + + const runsWithStatus: RunWithStatus[] = runs.map((r) => ({ + ...r, + documentTitle: titleMap.get(r.runId) ?? r.documentTitle, + displayStatus: statusMap.get(r.runId) ?? DisplayStatus.LOADING, + errorMessage: errorMap.get(r.runId), + })); + + const filtered = runsWithStatus + .filter((r) => visibleStatuses.has(r.displayStatus)) + .sort((a, b) => { + const diff = new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime(); + return sortOrder === 'newest' ? -diff : diff; + }); + + return ( + + {/* Page header */} + + + Drive Integration + + Create entries using existing content types from a Google Drive file. + + + + + + {/* Intro / select file card */} + + + Select a Google Doc file to begin. This app only creates new entries; existing entries + must be linked after drafts are created. Sheets, Slides, and PDFs aren't supported. + + + + + {/* Storage error */} + {storageError && ( + + {storageError} + + )} + + {runs.length > 0 && ( + <> + {/* Status label + filters */} + + Status + + setFilterOpen(false)}> + + + + + + {STATUS_OPTIONS.map(({ value, label }) => ( + toggleStatus(value)}> + {label} + + ))} + + + + setSortOpen(false)}> + + + + + + {(['newest', 'oldest'] as SortOrder[]).map((value) => ( + + ))} + + + + + + + + + {/* Table */} + + + + Name + Created + Status + + + + + {filtered.length === 0 ? ( + + + No imports match the selected filter. + + + ) : ( + filtered.map((run) => ( + + )) + )} + +
+ + )} + + {runs.length === 0 && ( + + No imports yet. Select a file above to get started. + + )} +
+ ); +} diff --git a/apps/drive-integration/src/services/workflowService.ts b/apps/drive-integration/src/services/workflowService.ts new file mode 100644 index 0000000000..111358351f --- /dev/null +++ b/apps/drive-integration/src/services/workflowService.ts @@ -0,0 +1,191 @@ +import { PageAppSDK } from '@contentful/app-sdk'; +import { + ResumePayload, + WorkflowRunResult, + RunStatus, + WorkflowFailureReason, + WorkflowRunError, + MappingReviewSuspendPayload, + CompletedWorkflowPayload, +} from '@types'; +import { AgentRunData, getWorkflowRun, resumeWorkflowRun } from './agents-api'; +import { validatePayloadShape } from '../utils/createEntries'; +import { ERROR_MESSAGES } from '@constants/messages'; +import { + POLL_INTERVAL_MS, + MAX_POLL_ATTEMPTS, + MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES, +} from '../utils/constants/agent'; + +// ─── Helpers (shared between workflowService and useWorkflowAgent) ─────────── + +const wait = async (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export const getRunStatus = (runData: AgentRunData): RunStatus | null => + runData.sys?.status ?? runData.metadata?.status ?? null; + +const getAgentPayload = (runData: AgentRunData): string | null => { + if (runData.payload && typeof runData.payload === 'string') return runData.payload; + if (!runData.messages || !Array.isArray(runData.messages)) return null; + const assistantMessage = runData.messages.find((m) => m.role === 'assistant'); + if (!assistantMessage?.content?.parts) return null; + const textPart = assistantMessage.content.parts.find((p) => p.type === 'text' && p.text); + return textPart?.text ?? null; +}; + +const previewPayloadFromCompletedRun = (runData: AgentRunData): CompletedWorkflowPayload => { + const googleDocPayload = runData.metadata?.googleDocPayload; + if (googleDocPayload == null) + throw new Error('Workflow completed but result payload was missing.'); + if ( + typeof googleDocPayload === 'object' && + googleDocPayload !== null && + 'cancelled' in googleDocPayload && + (googleDocPayload as { cancelled?: unknown }).cancelled === true + ) { + return { entries: [], assets: [], referenceGraph: {} }; + } + return validatePayloadShape(googleDocPayload); +}; + +const getRunErrorMessage = (runData: AgentRunData): string => { + const failureMessage = runData.metadata?.workflowFailure?.message; + if (typeof failureMessage === 'string' && failureMessage.trim().length > 0) return failureMessage; + const payload = getAgentPayload(runData); + if (payload) return payload; + return 'Workflow failed'; +}; + +const KNOWN_FAILURE_REASONS = new Set(Object.values(WorkflowFailureReason)); + +export const getBackendWorkflowFailureReason = ( + runData: AgentRunData +): WorkflowFailureReason | null => { + const workflowFailure = runData.metadata?.workflowFailure; + if (!workflowFailure) return null; + return KNOWN_FAILURE_REASONS.has(workflowFailure.code) + ? (workflowFailure.code as WorkflowFailureReason) + : null; +}; + +const FAILURE_REASON_MESSAGES: Partial> = { + [WorkflowFailureReason.GOOGLE_DRIVE_AUTH_EXPIRED]: ERROR_MESSAGES.GOOGLE_DRIVE_AUTH_ERROR, + [WorkflowFailureReason.GOOGLE_DOCS_NOT_FOUND]: ERROR_MESSAGES.GOOGLE_DOCS_NOT_FOUND, + [WorkflowFailureReason.AI_SERVICE_UNAVAILABLE]: ERROR_MESSAGES.AI_SERVICE_UNAVAILABLE, + [WorkflowFailureReason.APP_NOT_INSTALLED]: ERROR_MESSAGES.APP_NOT_INSTALLED, + [WorkflowFailureReason.DOCUMENT_TOO_COMPLEX]: ERROR_MESSAGES.DOCUMENT_TOO_COMPLEX, + [WorkflowFailureReason.PROCESSING_TIMEOUT]: ERROR_MESSAGES.PROCESSING_TIMEOUT, + [WorkflowFailureReason.OUT_OF_DOMAIN]: ERROR_MESSAGES.OUT_OF_DOMAIN, +}; + +const getWorkflowFailureMessage = ( + runData: AgentRunData, + failureReason: WorkflowFailureReason +): string => FAILURE_REASON_MESSAGES[failureReason] ?? getRunErrorMessage(runData); + +export const getSuspendPayload = (runData: AgentRunData): MappingReviewSuspendPayload | undefined => + runData.metadata?.suspendPayload; + +export const getWorkflowRunResult = ( + runData: AgentRunData, + runId: string, + pendingReviewMissingPayloadCount: number +): WorkflowRunResult | null => { + const status = getRunStatus(runData); + + switch (status) { + case RunStatus.FAILED: { + const failureReason = + getBackendWorkflowFailureReason(runData) ?? WorkflowFailureReason.GENERIC; + throw new WorkflowRunError(getWorkflowFailureMessage(runData, failureReason), failureReason); + } + + case RunStatus.PENDING_REVIEW: { + const suspendPayload = getSuspendPayload(runData); + if (!suspendPayload) { + if (pendingReviewMissingPayloadCount < MAX_PENDING_REVIEW_MISSING_PAYLOAD_RETRIES) { + return null; + } + throw new Error('Workflow paused for review, but suspend payload was missing.'); + } + return { + status, + runId, + suspendPayload, + messages: runData.messages ?? [], + }; + } + + case RunStatus.COMPLETED: { + return { + status, + runId, + messages: runData.messages ?? [], + googleDocPayload: previewPayloadFromCompletedRun(runData), + }; + } + + default: + return null; + } +}; + +const elapsedSec = (startMs: number) => `${((Date.now() - startMs) / 1000).toFixed(1)}s`; + +export const pollAgentRun = async ( + sdk: PageAppSDK, + spaceId: string, + environmentId: string, + runId: string +): Promise => { + const startMs = Date.now(); + let pendingReviewMissingPayloadCount = 0; + console.log(`⏳ Polling run [${runId}]`); + + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + const runData = await getWorkflowRun(sdk, spaceId, environmentId, runId); + + if (!runData) { + console.log(` #${attempt + 1} — not found yet (${elapsedSec(startMs)})`); + await wait(POLL_INTERVAL_MS); + continue; + } + + const status = getRunStatus(runData); + console.log(` #${attempt + 1} — status: ${status} (${elapsedSec(startMs)})`); + + if (status === RunStatus.PENDING_REVIEW && !getSuspendPayload(runData)) { + pendingReviewMissingPayloadCount++; + } else { + pendingReviewMissingPayloadCount = 0; + } + + const workflowRun = getWorkflowRunResult(runData, runId, pendingReviewMissingPayloadCount); + if (workflowRun) { + console.log(`✓ Run [${runId}] settled: ${status} in ${elapsedSec(startMs)}`); + return workflowRun; + } + + await wait(POLL_INTERVAL_MS); + } + + console.error(`✗ Run [${runId}] timed out after ${elapsedSec(startMs)}`); + throw new WorkflowRunError( + ERROR_MESSAGES.PROCESSING_TIMEOUT, + WorkflowFailureReason.PROCESSING_TIMEOUT + ); +}; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export async function resumeAndPollWorkflow( + sdk: PageAppSDK, + runId: string, + resumePayload: ResumePayload +): Promise { + const spaceId = sdk.ids.space; + const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment; + + await resumeWorkflowRun(sdk, spaceId, environmentId, runId, resumePayload); + return pollAgentRun(sdk, spaceId, environmentId, runId); +} diff --git a/apps/drive-integration/src/types/index.ts b/apps/drive-integration/src/types/index.ts index 19f2ccee9c..c9095a0211 100644 --- a/apps/drive-integration/src/types/index.ts +++ b/apps/drive-integration/src/types/index.ts @@ -3,3 +3,4 @@ export * from './entryBlockGraph'; export * from './editModal'; export * from './normalizedDocument'; export * from './workflow'; +export * from './runs'; diff --git a/apps/drive-integration/src/types/runs.ts b/apps/drive-integration/src/types/runs.ts new file mode 100644 index 0000000000..ee067dda8c --- /dev/null +++ b/apps/drive-integration/src/types/runs.ts @@ -0,0 +1,30 @@ +export interface RunRecord { + runId: string; + documentTitle: string; + documentId: string; + contentTypeIds: string[]; + documentSelection: { includeImages: boolean; selectedTabIds: string[] }; + startedAt: string; + createdEntryIds?: string[]; +} + +export enum DisplayStatus { + LOADING = 'loading', + RUNNING = 'running', + NEEDS_REVIEW = 'needs-review', + COMPLETED = 'completed', + FAILED = 'failed', + EXPIRED = 'expired', +} + +export interface RunWithStatus extends RunRecord { + displayStatus: DisplayStatus; + errorMessage?: string; +} + +export enum AppViewKind { + RUNS = 'runs', + REVIEW = 'review', +} + +export type AppView = { view: AppViewKind.RUNS } | { view: AppViewKind.REVIEW; runId: string }; diff --git a/apps/drive-integration/test/hooks/useRunStorage.test.ts b/apps/drive-integration/test/hooks/useRunStorage.test.ts new file mode 100644 index 0000000000..0aeb0cc5e1 --- /dev/null +++ b/apps/drive-integration/test/hooks/useRunStorage.test.ts @@ -0,0 +1,129 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { useRunStorage } from '../../src/hooks/useRunStorage'; +import type { RunRecord } from '../../src/types/runs'; + +const SPACE_ID = 'space-1'; +const ENV_ID = 'env-1'; +const STORAGE_KEY = `gdrive-import-runs::${SPACE_ID}::${ENV_ID}`; + +const makeRecord = (overrides?: Partial): RunRecord => ({ + runId: 'run-' + Math.random().toString(36).slice(2), + documentTitle: 'Test Doc', + documentId: 'doc-id', + contentTypeIds: ['ct-1'], + startedAt: new Date().toISOString(), + ...overrides, +}); + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('useRunStorage', () => { + it('initialises with empty runs when localStorage is empty', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + expect(result.current.runs).toEqual([]); + expect(result.current.storageError).toBeNull(); + }); + + it('initialises from existing localStorage data on mount', () => { + const existing: RunRecord[] = [makeRecord({ runId: 'existing-1' })]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + expect(result.current.runs).toHaveLength(1); + expect(result.current.runs[0].runId).toBe('existing-1'); + }); + + it('addRun prepends and persists to localStorage', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const record = makeRecord({ runId: 'new-run' }); + + act(() => result.current.addRun(record)); + + expect(result.current.runs[0].runId).toBe('new-run'); + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as RunRecord[]; + expect(stored[0].runId).toBe('new-run'); + }); + + it('addRun is idempotent on duplicate runId', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const record = makeRecord({ runId: 'dup-run' }); + + act(() => result.current.addRun(record)); + act(() => result.current.addRun(record)); + + expect(result.current.runs.filter((r) => r.runId === 'dup-run')).toHaveLength(1); + }); + + it('addRun prunes to 50 records when at capacity', () => { + // Store 50 records: run-0 is most recent (index 0), run-49 is oldest (index 49) + const existing = Array.from({ length: 50 }, (_, i) => + makeRecord({ runId: `run-${i}`, startedAt: new Date((50 - i) * 1000).toISOString() }) + ); + localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + + act(() => result.current.addRun(makeRecord({ runId: 'newest' }))); + + expect(result.current.runs).toHaveLength(50); + expect(result.current.runs[0].runId).toBe('newest'); + // last/oldest run (run-49) should be evicted + expect(result.current.runs.find((r) => r.runId === 'run-49')).toBeUndefined(); + // most recent stored run (run-0) should be retained + expect(result.current.runs.find((r) => r.runId === 'run-0')).toBeDefined(); + }); + + it('removeRun removes the correct record', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const a = makeRecord({ runId: 'a' }); + const b = makeRecord({ runId: 'b' }); + + act(() => result.current.addRun(a)); + act(() => result.current.addRun(b)); + act(() => result.current.removeRun('a')); + + expect(result.current.runs.find((r) => r.runId === 'a')).toBeUndefined(); + expect(result.current.runs.find((r) => r.runId === 'b')).toBeDefined(); + }); + + it('markCompleted writes createdEntryIds without overwriting other fields', () => { + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + const record = makeRecord({ runId: 'target', documentTitle: 'Keep me' }); + + act(() => result.current.addRun(record)); + act(() => result.current.markCompleted('target', ['entry-1', 'entry-2'])); + + const updated = result.current.runs.find((r) => r.runId === 'target'); + expect(updated?.createdEntryIds).toEqual(['entry-1', 'entry-2']); + expect(updated?.documentTitle).toBe('Keep me'); + }); + + it('key is scoped by spaceId + environmentId', () => { + const { result: hook1 } = renderHook(() => useRunStorage('space-A', 'env-X')); + const { result: hook2 } = renderHook(() => useRunStorage('space-B', 'env-X')); + + act(() => hook1.current.addRun(makeRecord({ runId: 'only-in-A' }))); + + expect(hook2.current.runs.find((r) => r.runId === 'only-in-A')).toBeUndefined(); + }); + + it('sets storageError when localStorage.setItem throws', () => { + const originalSetItem = localStorage.setItem.bind(localStorage); + vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('QuotaExceededError'); + }); + + const { result } = renderHook(() => useRunStorage(SPACE_ID, ENV_ID)); + act(() => result.current.addRun(makeRecord())); + + expect(result.current.storageError).not.toBeNull(); + + vi.restoreAllMocks(); + void originalSetItem; + }); +}); diff --git a/apps/drive-integration/test/hooks/useRunsPolling.test.ts b/apps/drive-integration/test/hooks/useRunsPolling.test.ts new file mode 100644 index 0000000000..68e571654f --- /dev/null +++ b/apps/drive-integration/test/hooks/useRunsPolling.test.ts @@ -0,0 +1,157 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useRunsPolling } from '../../src/hooks/useRunsPolling'; +import { RunStatus } from '@types'; +import { createMockSDK } from '../mocks'; +import type { RunRecord } from '../../src/types/runs'; + +const mockGetWorkflowRun = vi.fn(); + +vi.mock('../../src/services/agents-api', () => ({ + getWorkflowRun: (...args: unknown[]) => mockGetWorkflowRun(...args), +})); + +const mockSdk = createMockSDK() as any; + +// Stable references — must NOT be created inside renderHook callbacks or they +// change on every re-render, causing fetchAllStatuses to recreate and loop. +const SINGLE_RUN: RunRecord[] = [ + { + runId: 'run-1', + documentTitle: 'Test', + documentId: 'doc-1', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, +]; +const THREE_RUNS: RunRecord[] = [ + { + runId: 'run-1', + documentTitle: 'Test', + documentId: 'doc-1', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, + { + runId: 'run-2', + documentTitle: 'Test', + documentId: 'doc-2', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, + { + runId: 'run-3', + documentTitle: 'Test', + documentId: 'doc-3', + contentTypeIds: ['ct-1'], + startedAt: '2026-01-01T00:00:00.000Z', + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useRunsPolling', () => { + it('statusMap is empty before first fetch resolves', () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.COMPLETED } }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + expect(result.current.statusMap.size).toBe(0); + }); + + it('maps IN_PROGRESS to running', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.IN_PROGRESS } }); + const { result, unmount } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('running')); + unmount(); + }); + + it('maps DRAFT to running', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.DRAFT } }); + const { result, unmount } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('running')); + unmount(); + }); + + it('maps PENDING_REVIEW to needs-review', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.PENDING_REVIEW } }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('needs-review')); + }); + + it('maps COMPLETED to completed', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.COMPLETED } }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('completed')); + }); + + it('maps FAILED to failed', async () => { + mockGetWorkflowRun.mockResolvedValue({ + sys: { status: RunStatus.FAILED }, + metadata: { workflowFailure: { code: 'generic', message: 'Something went wrong' } }, + }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('failed')); + }); + + it('maps null response to expired only after MAX_CONSECUTIVE_NULLS consecutive misses', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockGetWorkflowRun.mockResolvedValue(null); + + const { result, unmount } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + + // Let first fetch resolve (1 miss, not yet expired) + await act(() => vi.runAllTicks()); + await act(() => Promise.resolve()); + expect(result.current.statusMap.get('run-1')).toBeUndefined(); + + // Drive 4 more ticks (total 5 = MAX_CONSECUTIVE_NULLS) + for (let i = 0; i < 4; i++) { + await act(async () => { + vi.advanceTimersByTime(10_000); + await vi.runAllTicks(); + await Promise.resolve(); + }); + } + + await waitFor(() => expect(result.current.statusMap.get('run-1')).toBe('expired')); + + unmount(); + vi.useRealTimers(); + }); + + it('populates errorMap for failed runs', async () => { + mockGetWorkflowRun.mockResolvedValue({ + sys: { status: RunStatus.FAILED }, + metadata: { workflowFailure: { code: 'generic', message: 'Failure detail' } }, + }); + const { result } = renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + await waitFor(() => expect(result.current.errorMap.get('run-1')).toBe('Failure detail')); + }); + + it('fetches all runs in parallel (Promise.all fires all before any await)', () => { + let callCount = 0; + mockGetWorkflowRun.mockImplementation(() => { + callCount++; + return Promise.resolve({ sys: { status: RunStatus.COMPLETED } }); + }); + + renderHook(() => useRunsPolling(THREE_RUNS, mockSdk)); + + // All 3 calls should have been initiated synchronously by Promise.all + expect(callCount).toBe(3); + }); + + it('does not re-fetch after settling when all runs complete', async () => { + mockGetWorkflowRun.mockResolvedValue({ sys: { status: RunStatus.COMPLETED } }); + renderHook(() => useRunsPolling(SINGLE_RUN, mockSdk)); + + await waitFor(() => expect(mockGetWorkflowRun.mock.calls.length).toBeGreaterThanOrEqual(1)); + + // Give time for any spurious re-fetches to occur + await new Promise((r) => setTimeout(r, 50)); + + // Only the initial fetch, no repeated polling + expect(mockGetWorkflowRun.mock.calls.length).toBe(1); + }); +}); diff --git a/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts new file mode 100644 index 0000000000..74e08b473a --- /dev/null +++ b/apps/drive-integration/test/hooks/useWorkflowAgent.test.ts @@ -0,0 +1,75 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useWorkflowAgent } from '../../src/hooks/useWorkflowAgent'; +import { createMockSDK } from '../mocks'; + +const mockStartAgentRun = vi.fn(); +const mockPollAgentRun = vi.fn(); + +vi.mock('../../src/services/agents-api', () => ({ + startAgentRun: (...args: unknown[]) => mockStartAgentRun(...args), + getWorkflowRun: vi.fn(), +})); + +vi.mock('../../src/services/workflowService', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + pollAgentRun: (...args: unknown[]) => mockPollAgentRun(...args), + }; +}); + +const mockSdk = createMockSDK() as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useWorkflowAgent', () => { + it('startWorkflow calls startAgentRun and returns runId string', async () => { + mockStartAgentRun.mockResolvedValue('run-abc-123'); + + const { result } = renderHook(() => + useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) + ); + + let returnedRunId: string | undefined; + await act(async () => { + returnedRunId = await result.current.startWorkflow(['ct-1'], { + includeImages: false, + selectedTabIds: [], + }); + }); + + expect(mockStartAgentRun).toHaveBeenCalledOnce(); + expect(returnedRunId).toBe('run-abc-123'); + }); + + it('startWorkflow does NOT call pollAgentRun', async () => { + mockStartAgentRun.mockResolvedValue('run-abc-123'); + + const { result } = renderHook(() => + useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) + ); + + await act(async () => { + await result.current.startWorkflow(['ct-1'], { includeImages: false, selectedTabIds: [] }); + }); + + expect(mockPollAgentRun).not.toHaveBeenCalled(); + }); + + it('startWorkflow throws if startAgentRun throws', async () => { + mockStartAgentRun.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => + useWorkflowAgent({ sdk: mockSdk, documentId: 'doc-1', oauthToken: 'token' }) + ); + + await expect( + act(() => + result.current.startWorkflow(['ct-1'], { includeImages: false, selectedTabIds: [] }) + ) + ).rejects.toThrow('Network error'); + }); +}); diff --git a/apps/drive-integration/test/locations/Page/Page.spec.tsx b/apps/drive-integration/test/locations/Page/Page.spec.tsx index c05f00e349..446348dd94 100644 --- a/apps/drive-integration/test/locations/Page/Page.spec.tsx +++ b/apps/drive-integration/test/locations/Page/Page.spec.tsx @@ -7,32 +7,38 @@ import Page from '../../../src/locations/Page/Page'; const mockSdk = createMockSDK(); -const mappingReviewPayloadMock: MappingReviewSuspendPayload = { - suspendStepId: 'mapping-review', - reason: 'Mapping review required before CMA payload generation continues', - documentId: 'doc-test', - documentTitle: 'Document mapping review', - normalizedDocument: { - documentId: 'doc-test', - title: 'Document mapping review', - designValues: [], - contentBlocks: [], - images: [], - tables: [], - assets: [], - }, - entryBlockGraph: { - entries: [], - excludedSourceRefs: [], - }, - referenceGraph: { - edges: [], - creationOrder: [], - deferredFields: [], - hasCircularDependency: false, - }, - contentTypes: [], -}; +const { mockResumeAndPollWorkflow, mockMarkCompleted, mockResetFlow, mappingReviewPayloadMock } = + vi.hoisted(() => { + const payload: MappingReviewSuspendPayload = { + suspendStepId: 'mapping-review', + reason: 'Mapping review required', + documentId: 'doc-test', + documentTitle: 'Document mapping review', + normalizedDocument: { + documentId: 'doc-test', + title: 'Document mapping review', + designValues: [], + contentBlocks: [], + images: [], + tables: [], + assets: [], + }, + entryBlockGraph: { entries: [], excludedSourceRefs: [] }, + referenceGraph: { + edges: [], + creationOrder: [], + deferredFields: [], + hasCircularDependency: false, + }, + contentTypes: [], + }; + return { + mockResumeAndPollWorkflow: vi.fn(), + mockMarkCompleted: vi.fn(), + mockResetFlow: vi.fn(), + mappingReviewPayloadMock: payload, + }; + }); vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, @@ -42,78 +48,94 @@ vi.mock('../../../src/locations/Page/components/mainpage/OAuthConnector', () => OAuthConnector: () =>
Mock OAuth Connector
, })); -const { mockModalOrchestrator, mockResumeWorkflow, mockResetFlow } = vi.hoisted(() => ({ - mockModalOrchestrator: vi.fn(), - mockResumeWorkflow: vi.fn(), - mockResetFlow: vi.fn(), +vi.mock('../../../src/services/workflowService', () => ({ + resumeAndPollWorkflow: (...args: unknown[]) => mockResumeAndPollWorkflow(...args), })); -vi.mock('@hooks/useWorkflowAgent', () => ({ - useWorkflowAgent: () => ({ - resumeWorkflow: mockResumeWorkflow, +vi.mock('../../../src/hooks/useRunStorage', () => ({ + useRunStorage: () => ({ + runs: [], + addRun: vi.fn(), + removeRun: vi.fn(), + markCompleted: mockMarkCompleted, + storageError: null, + }), +})); + +vi.mock('../../../src/hooks/useRunsPolling', () => ({ + useRunsPolling: () => ({ + statusMap: new Map(), + errorMap: new Map(), + titleMap: new Map(), + }), +})); + +vi.mock('../../../src/services/agents-api', () => ({ + getWorkflowRun: vi.fn().mockResolvedValue({ + sys: { status: 'PENDING_REVIEW' }, + metadata: { suspendPayload: mappingReviewPayloadMock }, }), })); vi.mock('../../../src/locations/Page/components/review/ReviewPage', () => ({ ReviewPage: ({ payload, + runId, onCancelReview, onExitReview, + onRunCompleted, }: { payload: MappingReviewSuspendPayload; + runId: string; onCancelReview: () => Promise; onExitReview: () => void; + onRunCompleted: (entryIds: string[]) => void; }) => (
-
{`Mock review page for ${payload.documentTitle}`}
+
{`Mock review page for ${payload.documentTitle} run:${runId}`}
+
), })); +const { mockModalOrchestrator } = vi.hoisted(() => ({ + mockModalOrchestrator: vi.fn(), +})); + vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () => ({ // eslint-disable-next-line @typescript-eslint/no-require-imports ModalOrchestrator: require('react').forwardRef( ( props: { onAiAccessDenied: (message: string) => void; - onMappingReviewReady: (payload: MappingReviewSuspendPayload, runId: string) => void; - onResetToMain: () => void; + onRunStarted: (runId: string) => void; oauthToken: string; }, - ref: React.ForwardedRef<{ - startFlow: () => void; - resetFlow: () => void; - }> + ref: React.ForwardedRef<{ startFlow: () => void; resetFlow: () => void }> ) => { - const handle = { - startFlow: vi.fn(), - resetFlow: mockResetFlow, - }; - if (typeof ref === 'function') { - ref(handle); - } else if (ref) { - ref.current = handle; - } + const handle = { startFlow: vi.fn(), resetFlow: mockResetFlow }; + if (typeof ref === 'function') ref(handle); + else if (ref) ref.current = handle; mockModalOrchestrator(props); return ( <> - - @@ -123,6 +145,33 @@ vi.mock('../../../src/locations/Page/components/mainpage/ModalOrchestrator', () ), })); +vi.mock('../../../src/hooks/useGoogleDocsAgentFlags', () => ({ + useGoogleDocsAgentFlags: () => ({ + 'google-docs-async-runs': true, + 'google-docs-agent-improvements': false, + }), +})); + +vi.mock('../../../src/locations/Page/components/runs/RunsPage', () => ({ + RunsPage: ({ + onStartImport, + onReviewRun, + }: { + onStartImport: () => void; + onReviewRun: (runId: string) => void; + }) => ( +
+
Runs Page
+ + +
+ ), +})); + describe('Page component', () => { afterEach(() => { cleanup(); @@ -130,94 +179,100 @@ describe('Page component', () => { beforeEach(() => { vi.clearAllMocks(); - mockResumeWorkflow.mockResolvedValue({}); + mockResumeAndPollWorkflow.mockResolvedValue({}); }); - it('renders MainPageView by default', async () => { + it('renders RunsPage by default on mount', async () => { render(); - await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); - expect(screen.queryByText(/Create from document "Selected document"/)).toBeNull(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); }); - it('returns to main view when flow reset is requested by modal orchestrator', async () => { + it('onStartImport opens the modal flow (RunsPage remains visible)', async () => { render(); - + fireEvent.click(screen.getByRole('button', { name: 'Select file' })); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); + }); - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); - }); - - fireEvent.click(screen.getByRole('button', { name: 'Trigger Reset To Main' })); - + it('onRunStarted callback keeps runs view visible', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Trigger Run Started' })); await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); - expect(screen.queryByText(/Create from document "Selected document"/)).toBeNull(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); }); - it('switches to the mapping review screen when the workflow pauses for mapping review', async () => { + it('onReviewRun transitions to review view (loading spinner, then ReviewPage)', async () => { render(); - - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); - expect(screen.queryByRole('heading', { name: 'Drive Integration' })).toBeNull(); + expect( + screen.getByText('Mock review page for Document mapping review run:run-123') + ).toBeTruthy(); }); }); - it('returns to the main view when exiting the review page after creation', async () => { + it('onExitReview callback transitions back to runs view', async () => { render(); - - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - - await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); - }); + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); + await waitFor(() => + screen.getByText('Mock review page for Document mapping review run:run-123') + ); fireEvent.click(screen.getByRole('button', { name: 'Trigger review exit' })); - await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); - - expect(mockResumeWorkflow).not.toHaveBeenCalled(); - expect(mockResetFlow).toHaveBeenCalledTimes(1); + expect(mockResetFlow).toHaveBeenCalled(); }); - it('cancels the workflow and returns to the main view when review cancel is triggered', async () => { + it('onRunCompleted calls markCompleted with correct args', async () => { render(); + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); + await waitFor(() => + screen.getByText('Mock review page for Document mapping review run:run-123') + ); - fireEvent.click(screen.getByRole('button', { name: 'Trigger Mapping Review Ready' })); - + fireEvent.click(screen.getByRole('button', { name: 'Trigger run completed' })); await waitFor(() => { - expect(screen.getByText('Mock review page for Document mapping review')).toBeTruthy(); + expect(mockMarkCompleted).toHaveBeenCalledWith('run-123', ['entry-1', 'entry-2']); }); + }); - fireEvent.click(screen.getByRole('button', { name: 'Trigger review cancel' })); + it('onCancelReview calls resumeAndPollWorkflow and returns to runs', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Review run-123' })); + await waitFor(() => + screen.getByText('Mock review page for Document mapping review run:run-123') + ); + fireEvent.click(screen.getByRole('button', { name: 'Trigger review cancel' })); await waitFor(() => { - expect(mockResumeWorkflow).toHaveBeenCalledWith('run-123', { cancelled: true }); - expect(screen.getByRole('heading', { name: 'Drive Integration' })).toBeTruthy(); + expect(mockResumeAndPollWorkflow).toHaveBeenCalledWith( + expect.anything(), + 'run-123', + expect.objectContaining({ cancelled: true }) + ); + expect(screen.getByText('Runs Page')).toBeTruthy(); }); - - expect(mockResetFlow).toHaveBeenCalledTimes(1); }); - it('renders a blocked state when AI access is denied from the workflow modal', async () => { + it('aiAccessDeniedMessage blocks all views with warning note', async () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'Trigger Modal AI Access Denied' })); - await waitFor(() => { expect(screen.getByText(/AI features are currently disabled/)).toBeTruthy(); }); }); + + it('Trigger Reset To Main returns to runs view', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Trigger Reset To Main' })); + await waitFor(() => { + expect(screen.getByText('Runs Page')).toBeTruthy(); + }); + }); }); diff --git a/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx b/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx index b06fe9f8a1..bed607ef7d 100644 --- a/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/mainpage/ModalOrchestrator.spec.tsx @@ -1,30 +1,18 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createRef } from 'react'; -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { Box, Button } from '@contentful/f36-components'; import { ModalOrchestrator, ModalOrchestratorHandle, } from '../../../../../src/locations/Page/components/mainpage/ModalOrchestrator'; -import { - MappingReviewSuspendPayload, - CompletedWorkflowPayload, - WorkflowRunResult, - RunStatus, -} from '@types'; import { mockSdk } from '../../../../mocks'; import { DocumentSelectionConfig } from '../../../../../src/utils/fetchDocumentSelection'; const mockStartWorkflow = vi.fn(); -const mockResumeWorkflow = vi.fn(); +const mockAddRun = vi.fn(); const mockFetchDocumentSelection = vi.fn(); -const mockWorkflowPayload = { - entries: [], - assets: [], - referenceGraph: {}, -} satisfies CompletedWorkflowPayload; - const mockDocumentSelectionConfig: DocumentSelectionConfig = { tabs: [ { id: 'tab-1', title: 'Introduction', index: 0 }, @@ -52,10 +40,7 @@ vi.mock('../../../../../src/locations/Page/components/modals/step_1/SelectDocume vi.mock('@hooks/useWorkflowAgent', () => ({ useWorkflowAgent: () => ({ - isAnalyzing: false, - error: null, startWorkflow: mockStartWorkflow, - resumeWorkflow: mockResumeWorkflow, }), })); @@ -76,35 +61,9 @@ const defaultProps = { sdk: mockSdk, oauthToken: 'mock-oauth-token', isOAuthConnected: true, - onMappingReviewReady: vi.fn(), - onResetToMain: vi.fn(), -}; - -const mappingReviewSuspendPayload: MappingReviewSuspendPayload = { - suspendStepId: 'mapping-review', - reason: 'Mapping review required before CMA payload generation continues', - documentId: 'mock-doc-id-123', - documentTitle: 'Mock Mapping Review', - normalizedDocument: { - documentId: 'mock-doc-id-123', - title: 'Mock Mapping Review', - designValues: [], - contentBlocks: [], - images: [], - tables: [], - assets: [], - }, - entryBlockGraph: { - entries: [], - excludedSourceRefs: [], - }, - referenceGraph: { - edges: [], - creationOrder: [], - deferredFields: [], - hasCircularDependency: false, - }, - contentTypes: [], + onRunStarted: vi.fn(), + addRun: mockAddRun, + storageError: null, }; // Helper: pick a document and reach the content type picker @@ -142,7 +101,7 @@ async function completePreflight(options: { useAllTabs: boolean; includeImages: if (options.useAllTabs) { fireEvent.click(screen.getByLabelText('No, import all tabs')); } else { - fireEvent.click(screen.getByLabelText('Yes, let me pick')); + fireEvent.click(screen.getByLabelText('Yes, select specific tabs')); } fireEvent.click(screen.getByRole('button', { name: 'Next' })); @@ -153,46 +112,22 @@ async function completePreflight(options: { useAllTabs: boolean; includeImages: if (options.includeImages) { fireEvent.click(screen.getByLabelText('Yes, include images')); } else { - fireEvent.click(screen.getByLabelText('No, skip images')); + fireEvent.click(screen.getByLabelText('No, do not include images')); } fireEvent.click(screen.getByRole('button', { name: 'Next' })); } describe('ModalOrchestrator', () => { beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); - defaultProps.onMappingReviewReady.mockReset(); - defaultProps.onResetToMain.mockReset(); + defaultProps.onRunStarted.mockReset(); mockFetchDocumentSelection.mockResolvedValue(mockDocumentSelectionConfig); vi.mocked(mockSdk.cma.contentType.getMany).mockResolvedValue({ items: mockContentTypes, total: mockContentTypes.length, }); - mockStartWorkflow.mockResolvedValue({ - status: RunStatus.COMPLETED, - runId: 'run-123', - messages: [], - googleDocPayload: mockWorkflowPayload, - } satisfies WorkflowRunResult); - mockResumeWorkflow.mockResolvedValue({ - status: RunStatus.COMPLETED, - runId: 'run-123', - messages: [], - googleDocPayload: mockWorkflowPayload, - } satisfies WorkflowRunResult); - vi.mocked(mockSdk.cma.space.get).mockResolvedValue({ sys: { id: 'test-space-id' } } as any); - vi.mocked(mockSdk.cma.environment.get).mockResolvedValue({ sys: { id: 'test-env-id' } } as any); - vi.mocked(mockSdk.cma.contentType.getMany).mockResolvedValue({ - items: mockContentTypes, - total: mockContentTypes.length, - } as any); - }); - - afterEach(() => { - cleanup(); - vi.runAllTimers(); - vi.useRealTimers(); + // startWorkflow now returns just a runId string + mockStartWorkflow.mockResolvedValue('run-123'); }); it('shows ContentTypePickerModal after document is picked', async () => { @@ -282,7 +217,6 @@ describe('ModalOrchestrator', () => { expect( screen.queryByRole('heading', { name: "You're about to lose your progress" }) ).toBeNull(); - expect(mockResumeWorkflow).not.toHaveBeenCalled(); }); }); @@ -325,7 +259,7 @@ describe('ModalOrchestrator', () => { }); }); - it('discards at pre-flight tab step without calling resumeWorkflow', async () => { + it('discards at pre-flight tab step without calling addRun', async () => { const ref = createRef(); render(); @@ -346,8 +280,7 @@ describe('ModalOrchestrator', () => { fireEvent.click(screen.getByRole('button', { name: 'Cancel without creating' })); await waitFor(() => { - // No active workflow run exists yet — nothing to resume as cancelled - expect(mockResumeWorkflow).not.toHaveBeenCalled(); + expect(mockAddRun).not.toHaveBeenCalled(); expect(screen.queryByRole('heading', { name: 'Document tabs' })).toBeNull(); }); }); @@ -366,16 +299,13 @@ describe('ModalOrchestrator', () => { ); }); - // Tabs shown because fetchDocumentSelection returned 2 tabs await waitFor(() => { expect(screen.getByRole('heading', { name: 'Document tabs' })).toBeTruthy(); }); - // "No, import all tabs" = useAllTabs=true → onContinue called with all available tabs fireEvent.click(screen.getByLabelText('No, import all tabs')); fireEvent.click(screen.getByRole('button', { name: 'Next' })); - // Images shown because imageCount > 0 await waitFor(() => { expect(screen.getByRole('heading', { name: 'Images' })).toBeTruthy(); }); @@ -391,6 +321,54 @@ describe('ModalOrchestrator', () => { }); }); + it('calls addRun with correct RunRecord fields after startWorkflow resolves', async () => { + const ref = createRef(); + render(); + + await pickDocument(ref); + await selectContentTypeAndNext(); + await completePreflight({ useAllTabs: true, includeImages: false }); + + await waitFor(() => { + expect(mockAddRun).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-123', + documentId: 'mock-doc-id-123', + contentTypeIds: ['ct-1'], + }) + ); + }); + }); + + it('calls onRunStarted with runId after startWorkflow resolves', async () => { + const ref = createRef(); + render(); + + await pickDocument(ref); + await selectContentTypeAndNext(); + await completePreflight({ useAllTabs: true, includeImages: false }); + + await waitFor(() => { + expect(defaultProps.onRunStarted).toHaveBeenCalledWith('run-123'); + }); + }); + + it('does NOT call onMappingReviewReady (prop removed)', async () => { + // onMappingReviewReady is no longer a prop — verify the component still works after workflow + const ref = createRef(); + const propsWithoutReviewReady = { ...defaultProps }; + render(); + + await pickDocument(ref); + await selectContentTypeAndNext(); + await completePreflight({ useAllTabs: true, includeImages: false }); + + await waitFor(() => { + expect(defaultProps.onRunStarted).toHaveBeenCalledWith('run-123'); + }); + // No crash, no review-ready call + }); + it('skips image step and starts workflow directly when document has no images', async () => { mockFetchDocumentSelection.mockResolvedValue({ tabs: [ @@ -440,31 +418,7 @@ describe('ModalOrchestrator', () => { }); }); - // No tab or image modal shown expect(screen.queryByRole('heading', { name: 'Document tabs' })).toBeNull(); expect(screen.queryByRole('heading', { name: 'Images' })).toBeNull(); }); - - it('routes mapping-review suspend to onMappingReviewReady after pre-flight selection', async () => { - mockStartWorkflow.mockResolvedValue({ - status: RunStatus.PENDING_REVIEW, - runId: 'run-123', - messages: [], - suspendPayload: mappingReviewSuspendPayload, - } satisfies WorkflowRunResult); - - const ref = createRef(); - render(); - - await pickDocument(ref); - await selectContentTypeAndNext(); - await completePreflight({ useAllTabs: true, includeImages: true }); - - await waitFor(() => { - expect(defaultProps.onMappingReviewReady).toHaveBeenCalledWith( - mappingReviewSuspendPayload, - 'run-123' - ); - }); - }); }); diff --git a/apps/drive-integration/test/locations/Page/components/modals/ConfirmCancelModal.spec.tsx b/apps/drive-integration/test/locations/Page/components/modals/ConfirmCancelModal.spec.tsx index a1fb14697e..a59c80d0b5 100644 --- a/apps/drive-integration/test/locations/Page/components/modals/ConfirmCancelModal.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/modals/ConfirmCancelModal.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { ConfirmCancelModal } from '../../../../../src/locations/Page/components/modals/ConfirmCancelModal'; import React from 'react'; @@ -15,6 +15,7 @@ describe('ConfirmCancelModal', () => { }); afterEach(() => { + cleanup(); vi.runAllTimers(); vi.useRealTimers(); }); diff --git a/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx b/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx index f40ed2295f..17608341a2 100644 --- a/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx +++ b/apps/drive-integration/test/locations/Page/components/review/ReviewPage.spec.tsx @@ -12,10 +12,8 @@ const { mockResumeWorkflow, mockCreateEntriesFromPreviewPayload } = vi.hoisted(( mockCreateEntriesFromPreviewPayload: vi.fn(), })); -vi.mock('@hooks/useWorkflowAgent', () => ({ - useWorkflowAgent: () => ({ - resumeWorkflow: mockResumeWorkflow, - }), +vi.mock('../../../../../src/services/workflowService', () => ({ + resumeAndPollWorkflow: (...args: unknown[]) => mockResumeWorkflow(...args), })); vi.mock('../../../../../src/services/entryService', () => ({ @@ -184,7 +182,8 @@ describe('ReviewPage entry selection', () => { await waitFor(() => expect(mockResumeWorkflow).toHaveBeenCalledTimes(1)); - const resumePayload = mockResumeWorkflow.mock.calls[0][1]; + // resumeAndPollWorkflow signature: (sdk, runId, payload) + const resumePayload = mockResumeWorkflow.mock.calls[0][2]; expect(resumePayload.entryBlockGraph.entries).toHaveLength(1); expect(resumePayload.entryBlockGraph.entries[0].tempId).toBe('page-1'); expect(resumePayload.entryBlockGraph.entries[0].fieldMappings[1].sourceEntryIds).toEqual([]); diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx new file mode 100644 index 0000000000..64f6dbc52f --- /dev/null +++ b/apps/drive-integration/test/locations/Page/components/runs/RunRow.spec.tsx @@ -0,0 +1,171 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { RunRow } from '../../../../../src/locations/Page/components/runs/RunRow'; +import type { RunWithStatus } from '../../../../../src/types/runs'; +import { DisplayStatus } from '../../../../../src/types/runs'; +import { createMockSDK } from '../../../../mocks'; + +const mockSdk = createMockSDK() as any; + +const makeRun = (overrides?: Partial): RunWithStatus => ({ + runId: 'run-1', + documentTitle: 'My Document', + documentId: 'doc-1', + contentTypeIds: ['blogPost', 'article'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + displayStatus: DisplayStatus.RUNNING, + ...overrides, +}); + +describe('RunRow', () => { + it('renders document title', () => { + render(); + expect(screen.getByText('My Document')).toBeTruthy(); + }); + + it('shows In progress badge for running status', () => { + render( + + ); + expect(screen.getByText('In progress')).toBeTruthy(); + }); + + it('shows Ready for review badge for needs-review status', () => { + render( + + ); + expect(screen.getByText('Ready for review')).toBeTruthy(); + }); + + it('shows Completed badge for completed status', () => { + render( + + ); + expect(screen.getByText(/Complete/)).toBeTruthy(); + }); + + it('shows entry count in completed badge when createdEntryIds present', () => { + render( + + ); + expect(screen.getByText('Complete - 2 entries')).toBeTruthy(); + }); + + it('shows Failed badge for failed status', () => { + render( + + ); + expect(screen.getByText('Failed')).toBeTruthy(); + }); + + it('shows Expired badge for expired status', () => { + render( + + ); + expect(screen.getByText('Expired')).toBeTruthy(); + }); + + it('shows Review button for needs-review and calls onReview', () => { + const onReview = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Review')); + expect(onReview).toHaveBeenCalledWith('run-1'); + }); + + it('shows Retry button for failed and calls onRetry', () => { + const onRetry = vi.fn().mockResolvedValue(undefined); + render( + + ); + fireEvent.click(screen.getByText('Retry')); + expect(onRetry).toHaveBeenCalledWith('run-1'); + }); + + it('shows Retry button for expired and calls onRetry', () => { + const onRetry = vi.fn().mockResolvedValue(undefined); + render( + + ); + fireEvent.click(screen.getByText('Retry')); + expect(onRetry).toHaveBeenCalledWith('run-1'); + }); + + it('shows View button for completed runs with createdEntryIds', () => { + render( + + ); + expect(screen.getByText('View')).toBeTruthy(); + }); + + it('does not show Review or Retry for running status', () => { + render( + + ); + expect(screen.queryByText('Review')).toBeNull(); + expect(screen.queryByText('Retry')).toBeNull(); + }); +}); diff --git a/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx new file mode 100644 index 0000000000..35fb2a3502 --- /dev/null +++ b/apps/drive-integration/test/locations/Page/components/runs/RunsPage.spec.tsx @@ -0,0 +1,178 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { RunRecord } from '../../../../../src/types/runs'; +import { DisplayStatus } from '../../../../../src/types/runs'; + +const mockStatusMap = new Map(); +const mockErrorMap = new Map(); +const mockTitleMap = new Map(); + +vi.mock('../../../../../src/hooks/useRunsPolling', () => ({ + useRunsPolling: () => ({ + statusMap: mockStatusMap, + errorMap: mockErrorMap, + titleMap: mockTitleMap, + }), +})); + +import { RunsPage } from '../../../../../src/locations/Page/components/runs/RunsPage'; +import { createMockSDK } from '../../../../mocks'; + +const mockSdk = createMockSDK() as any; +const mockRemoveRun = vi.fn(); + +let mockRuns: RunRecord[] = []; +let mockStorageError: string | null = null; + +beforeEach(() => { + mockRuns = []; + mockStatusMap.clear(); + mockErrorMap.clear(); + mockTitleMap.clear(); + mockStorageError = null; + vi.clearAllMocks(); +}); + +function renderRunsPage( + overrides: { onStartImport?: () => void; onReviewRun?: (id: string) => void } = {} +) { + return render( + + ); +} + +describe('RunsPage', () => { + it('renders empty state when no runs', () => { + renderRunsPage(); + expect(screen.getByText(/no imports yet/i)).toBeTruthy(); + }); + + it('shows Select file button', () => { + renderRunsPage(); + expect(screen.getByText('Select file')).toBeTruthy(); + }); + + it('renders a row per run', () => { + mockRuns.push( + { + runId: 'run-1', + documentTitle: 'Doc A', + documentId: 'd1', + contentTypeIds: ['ct-1'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + }, + { + runId: 'run-2', + documentTitle: 'Doc B', + documentId: 'd2', + contentTypeIds: ['ct-2'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + } + ); + mockStatusMap.set('run-1', DisplayStatus.RUNNING); + mockStatusMap.set('run-2', DisplayStatus.COMPLETED); + + renderRunsPage(); + expect(screen.getByText('Doc A')).toBeTruthy(); + expect(screen.getByText('Doc B')).toBeTruthy(); + }); + + it('clicking Review on a needs-review run calls onReviewRun', () => { + mockRuns.push({ + runId: 'run-review', + documentTitle: 'Review Me', + documentId: 'd1', + contentTypeIds: ['ct-1'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + }); + mockStatusMap.set('run-review', DisplayStatus.NEEDS_REVIEW); + + const onReviewRun = vi.fn(); + renderRunsPage({ onReviewRun }); + fireEvent.click(screen.getByText('Review')); + expect(onReviewRun).toHaveBeenCalledWith('run-review'); + }); + + it('shows storage error note when storageError is set', () => { + mockStorageError = 'Storage full'; + renderRunsPage(); + expect(screen.getByText(/storage full/i)).toBeTruthy(); + }); + + it('renders two concurrent runs with independent statuses', () => { + mockRuns.push( + { + runId: 'concurrent-1', + documentTitle: 'First Import', + documentId: 'd1', + contentTypeIds: ['ct-1'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + }, + { + runId: 'concurrent-2', + documentTitle: 'Second Import', + documentId: 'd2', + contentTypeIds: ['ct-2'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + } + ); + mockStatusMap.set('concurrent-1', DisplayStatus.RUNNING); + mockStatusMap.set('concurrent-2', DisplayStatus.NEEDS_REVIEW); + + renderRunsPage(); + + expect(screen.getByText('First Import')).toBeTruthy(); + expect(screen.getByText('Second Import')).toBeTruthy(); + expect(screen.getByText('In progress')).toBeTruthy(); + expect(screen.getByText('Ready for review')).toBeTruthy(); + // Only the needs-review run has a Review button + expect(screen.getAllByText('Review').length).toBe(1); + }); + + it('one run transitioning status does not affect the other', () => { + mockRuns.push( + { + runId: 'stable-run', + documentTitle: 'Stable Doc', + documentId: 'd1', + contentTypeIds: ['ct-1'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + }, + { + runId: 'transitioning-run', + documentTitle: 'Transitioning Doc', + documentId: 'd2', + contentTypeIds: ['ct-2'], + documentSelection: { includeImages: false, selectedTabIds: [] }, + startedAt: new Date().toISOString(), + } + ); + mockStatusMap.set('stable-run', DisplayStatus.COMPLETED); + mockStatusMap.set('transitioning-run', DisplayStatus.RUNNING); + + renderRunsPage(); + + expect(screen.getByText(/Complete/)).toBeTruthy(); + expect(screen.getByText('In progress')).toBeTruthy(); + // No Review or Retry buttons for these states + expect(screen.queryByText('Review')).toBeNull(); + }); +}); diff --git a/apps/drive-integration/test/services/workflowService.test.ts b/apps/drive-integration/test/services/workflowService.test.ts new file mode 100644 index 0000000000..c5f8a9fffd --- /dev/null +++ b/apps/drive-integration/test/services/workflowService.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { resumeAndPollWorkflow } from '../../src/services/workflowService'; +import { RunStatus, WorkflowRunError, WorkflowFailureReason } from '@types'; +import { createMockSDK } from '../mocks'; + +const mockResumeWorkflowRun = vi.fn(); +const mockGetWorkflowRun = vi.fn(); + +vi.mock('../../src/services/agents-api', () => ({ + resumeWorkflowRun: (...args: unknown[]) => mockResumeWorkflowRun(...args), + getWorkflowRun: (...args: unknown[]) => mockGetWorkflowRun(...args), +})); + +const mockSdk = createMockSDK() as any; + +const makeSuspendPayload = () => ({ + suspendStepId: 'mapping-review' as const, + documentId: 'doc-1', + normalizedDocument: { + documentId: 'doc-1', + title: 'Test', + designValues: [], + contentBlocks: [], + images: [], + tables: [], + assets: [], + }, + entryBlockGraph: { entries: [], excludedSourceRefs: [] }, + referenceGraph: {}, + contentTypes: [], +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('resumeAndPollWorkflow', () => { + it('calls resumeWorkflowRun with correct args', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.PENDING_REVIEW }, + metadata: { suspendPayload: makeSuspendPayload() }, + messages: [], + }); + + await resumeAndPollWorkflow(mockSdk, 'run-1', { + entryBlockGraph: { entries: [], excludedSourceRefs: [] }, + }); + + expect(mockResumeWorkflowRun).toHaveBeenCalledWith( + mockSdk, + 'test-space-id', + 'test-environment-id', + 'run-1', + expect.objectContaining({ entryBlockGraph: expect.any(Object) }) + ); + }); + + it('polls after resume and returns WorkflowRunResult on PENDING_REVIEW', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.PENDING_REVIEW }, + metadata: { suspendPayload: makeSuspendPayload() }, + messages: [], + }); + + const result = await resumeAndPollWorkflow(mockSdk, 'run-1', {}); + + expect(result.status).toBe(RunStatus.PENDING_REVIEW); + expect(result.runId).toBe('run-1'); + expect(mockGetWorkflowRun).toHaveBeenCalled(); + }); + + it('returns COMPLETED WorkflowRunResult', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.COMPLETED }, + metadata: { + googleDocPayload: { entries: [], assets: [], referenceGraph: {} }, + }, + messages: [], + }); + + const result = await resumeAndPollWorkflow(mockSdk, 'run-1', {}); + + expect(result.status).toBe(RunStatus.COMPLETED); + }); + + it('throws WorkflowRunError on FAILED status', async () => { + mockResumeWorkflowRun.mockResolvedValue(undefined); + mockGetWorkflowRun.mockResolvedValue({ + sys: { id: 'run-1', status: RunStatus.FAILED }, + metadata: { + workflowFailure: { + code: WorkflowFailureReason.PROCESSING_TIMEOUT, + message: 'Timed out', + }, + }, + messages: [], + }); + + await expect(resumeAndPollWorkflow(mockSdk, 'run-1', {})).rejects.toBeInstanceOf( + WorkflowRunError + ); + }); + + it('throws if resumeWorkflowRun itself throws', async () => { + mockResumeWorkflowRun.mockRejectedValue(new Error('Network error')); + + await expect(resumeAndPollWorkflow(mockSdk, 'run-1', {})).rejects.toThrow('Network error'); + }); +});