diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts index aaf3cb990..ba15d5dce 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts @@ -386,6 +386,7 @@ const workflowActivityVNextMessages = { 'Choose a creation method now. Your input stays on this page while you restore access.', 'workflowActivityVNext.new.yaml': 'Workflow YAML', 'workflowActivityVNext.run.backAria': 'Back to Activity', + 'workflowActivityVNext.run.changeConfiguration': 'Change configuration', 'workflowActivityVNext.run.commandId': 'Request ID', 'workflowActivityVNext.run.code': 'Code', 'workflowActivityVNext.run.completedSteps': 'Completed steps', @@ -395,13 +396,17 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.run.confirmTitle': 'Confirm new run', 'workflowActivityVNext.run.correlationId': 'Tracking ID', 'workflowActivityVNext.run.cost': 'Cost', + 'workflowActivityVNext.run.costWarning': + 'This action may incur new model or tool cost.', + 'workflowActivityVNext.run.definitionRevision': 'Definition revision', 'workflowActivityVNext.run.diagnostics': 'Diagnostics', 'workflowActivityVNext.run.error': 'Final error', 'workflowActivityVNext.run.failedSummary': 'The run did not complete.', + 'workflowActivityVNext.run.fixAccess': 'Fix access', 'workflowActivityVNext.run.description': 'Review the result, steps, and history for this run.', 'workflowActivityVNext.run.title': 'Run details', - 'workflowActivityVNext.run.forkAccepted': 'New run started', + 'workflowActivityVNext.run.forkAccepted': 'New run accepted', 'workflowActivityVNext.run.forkAcceptedDescription': 'Open Activity to follow its progress.', 'workflowActivityVNext.run.graph': 'Graph', @@ -420,10 +425,20 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.run.promptTokens': 'Prompt tokens', 'workflowActivityVNext.run.requestedSteps': 'Requested steps', 'workflowActivityVNext.run.requestParameters': 'Request parameters', + 'workflowActivityVNext.run.priorOutputs': 'Prior outputs', + 'workflowActivityVNext.run.priorOutputsNotReused': + 'Prior step outputs will not be reused.', + 'workflowActivityVNext.run.priorOutputsReused': + 'Prior step outputs will be reused.', + 'workflowActivityVNext.run.relatedRuns': 'Related runs', 'workflowActivityVNext.run.retry': 'Retry failed step', + 'workflowActivityVNext.run.retryAuthorityMissing': + 'Retry eligibility was not provided by the run service.', 'workflowActivityVNext.run.retryUnavailable': 'Retry is available when one step has failed.', 'workflowActivityVNext.run.runAgain': 'Run again', + 'workflowActivityVNext.run.runAgainAuthorityMissing': + 'Run-again eligibility was not provided by the run service.', 'workflowActivityVNext.run.runAgainUnavailable': "Run again isn't available for this run.", 'workflowActivityVNext.run.roleReplies': 'Role replies', diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts index 764f2a4b6..591a4ad40 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts @@ -361,6 +361,7 @@ const workflowActivityVNextMessages: Record = '现在可以先选择创建方式;恢复访问权限期间,你的输入会保留在此页面。', 'workflowActivityVNext.new.yaml': '工作流 YAML', 'workflowActivityVNext.run.backAria': '返回活动列表', + 'workflowActivityVNext.run.changeConfiguration': '更改配置', 'workflowActivityVNext.run.commandId': '请求 ID', 'workflowActivityVNext.run.code': '代码', 'workflowActivityVNext.run.completedSteps': '已完成步骤', @@ -370,12 +371,16 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.run.confirmTitle': '确认新运行', 'workflowActivityVNext.run.correlationId': '跟踪 ID', 'workflowActivityVNext.run.cost': '费用', + 'workflowActivityVNext.run.costWarning': + '此操作可能产生新的模型或工具费用。', + 'workflowActivityVNext.run.definitionRevision': '定义版本', 'workflowActivityVNext.run.diagnostics': '诊断', 'workflowActivityVNext.run.error': '最终错误', 'workflowActivityVNext.run.failedSummary': '此次运行未完成。', + 'workflowActivityVNext.run.fixAccess': '修复访问权限', 'workflowActivityVNext.run.description': '查看此次运行的结果、步骤和历史。', 'workflowActivityVNext.run.title': '运行详情', - 'workflowActivityVNext.run.forkAccepted': '新运行已开始', + 'workflowActivityVNext.run.forkAccepted': '新运行已接受', 'workflowActivityVNext.run.forkAcceptedDescription': '打开活动记录查看进度。', 'workflowActivityVNext.run.graph': '图', @@ -394,10 +399,18 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.run.promptTokens': '输入 Token', 'workflowActivityVNext.run.requestedSteps': '已请求步骤', 'workflowActivityVNext.run.requestParameters': '请求参数', + 'workflowActivityVNext.run.priorOutputs': '先前输出', + 'workflowActivityVNext.run.priorOutputsNotReused': '不会复用先前步骤输出。', + 'workflowActivityVNext.run.priorOutputsReused': '将复用先前步骤输出。', + 'workflowActivityVNext.run.relatedRuns': '相关运行', 'workflowActivityVNext.run.retry': '重试失败步骤', + 'workflowActivityVNext.run.retryAuthorityMissing': + '运行服务未提供重试资格。', 'workflowActivityVNext.run.retryUnavailable': '存在一个失败步骤时才能重试。', 'workflowActivityVNext.run.runAgain': '再次运行', + 'workflowActivityVNext.run.runAgainAuthorityMissing': + '运行服务未提供再次运行资格。', 'workflowActivityVNext.run.runAgainUnavailable': '此次运行暂时无法重新开始。', 'workflowActivityVNext.run.roleReplies': '角色回复数', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx index 4223018da..da049d6d6 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx @@ -103,6 +103,28 @@ function buildRunDetail() { input: 'Investigate checkout latency', finalOutput: '', finalError: 'Approval timed out', + recovery: { + recommendedAction: 'retry_failed_step', + definitionRevision: 'revision-42', + retry: { + eligible: true, + unavailableReason: '', + startAtStepId: 'step-failed', + reusesPriorStepOutputs: true, + mayIncurCost: true, + }, + runAgain: { + eligible: true, + unavailableReason: '', + startAtStepId: 'step-root', + reusesPriorStepOutputs: false, + mayIncurCost: true, + }, + lineage: { + parentRunId: 'run-parent-beta', + childRunIds: ['run-child-gamma'], + }, + }, diagnostics: [ { timestampUtc: '2026-08-04T10:01:00Z', @@ -229,6 +251,15 @@ describe('Workflow Activity vNext run detail recovery', () => { expect( within(confirmation).getByText('Investigate checkout latency'), ).toBeInTheDocument(); + expect(within(confirmation).getByText('revision-42')).toBeInTheDocument(); + expect( + within(confirmation).getByText('Prior step outputs will be reused.'), + ).toBeInTheDocument(); + expect( + within(confirmation).getByText( + 'This action may incur new model or tool cost.', + ), + ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Confirm retry' })); @@ -239,7 +270,7 @@ describe('Workflow Activity vNext run detail recovery', () => { input: 'Investigate checkout latency', }), ); - expect(await screen.findByText('New run started')).toBeInTheDocument(); + expect(await screen.findByText('New run accepted')).toBeInTheDocument(); expect(screen.queryByText('actor-new-alpha')).not.toBeVisible(); expect(screen.queryByText('command-alpha')).not.toBeVisible(); expect(screen.queryByText('correlation-alpha')).not.toBeVisible(); @@ -249,6 +280,143 @@ describe('Workflow Activity vNext run detail recovery', () => { expect(screen.queryByText(/state version/i)).not.toBeInTheDocument(); }); + it('does not infer retry eligibility from failed steps or diagnostic strings', async () => { + const run = buildRunDetail(); + mockWorkflowActivityApi.getRun.mockResolvedValue({ + ...run, + recovery: null, + diagnostics: [ + { + ...run.diagnostics[0], + code: 'AUTHORIZATION_FAILED', + message: 'Access must be restored before another attempt.', + }, + ], + }); + + renderWithQueryClient( + , + ); + + const retry = await screen.findByRole('button', { + name: 'Retry failed step', + }); + expect(retry).toHaveAttribute('aria-disabled', 'true'); + expect(retry).not.toBeDisabled(); + retry.focus(); + expect(retry).toHaveFocus(); + const reason = screen.getByText( + 'Retry eligibility was not provided by the run service.', + ); + expect(reason).toBeVisible(); + expect(retry).toHaveAttribute('aria-describedby', reason.id); + + fireEvent.click(retry); + expect( + screen.queryByRole('dialog', { name: 'Confirm new run' }), + ).not.toBeInTheDocument(); + expect(mockWorkflowActivityApi.forkRun).not.toHaveBeenCalled(); + }); + + it('uses the typed access recommendation instead of diagnostic text', async () => { + const run = buildRunDetail(); + mockWorkflowActivityApi.getRun.mockResolvedValue({ + ...run, + recovery: { + ...run.recovery, + recommendedAction: 'fix_access', + retry: { + eligible: false, + unavailableReason: 'Restore service access before retrying.', + startAtStepId: null, + reusesPriorStepOutputs: false, + mayIncurCost: false, + }, + runAgain: { + eligible: false, + unavailableReason: 'Restore service access before running again.', + startAtStepId: null, + reusesPriorStepOutputs: false, + mayIncurCost: false, + }, + }, + diagnostics: [ + { + ...run.diagnostics[0], + code: 'RATE_LIMIT_TEXT_ONLY', + message: 'Retry this request later.', + }, + ], + }); + + renderWithQueryClient( + , + ); + + expect( + await screen.findByRole('link', { name: 'Fix access' }), + ).toHaveAttribute( + 'href', + '/scopes/scope-alpha/workflow-activity-vnext/settings?section=account', + ); + expect( + screen.getByRole('button', { name: 'Retry failed step' }), + ).toHaveAttribute('aria-disabled', 'true'); + expect( + screen.getByText('Restore service access before retrying.'), + ).toBeVisible(); + }); + + it('does not present an unavailable recommended retry as primary', async () => { + const run = buildRunDetail(); + mockWorkflowActivityApi.getRun.mockResolvedValue({ + ...run, + recovery: { + ...run.recovery, + recommendedAction: 'retry_failed_step', + retry: { + eligible: false, + unavailableReason: 'This failure cannot be retried safely.', + startAtStepId: null, + reusesPriorStepOutputs: false, + mayIncurCost: false, + }, + }, + }); + + renderWithQueryClient( + , + ); + + const retry = await screen.findByRole('button', { + name: 'Retry failed step', + }); + expect(retry).toHaveAttribute('aria-disabled', 'true'); + expect(retry).not.toHaveClass('ant-btn-primary'); + }); + + it('renders related runs only from typed lineage run identities', async () => { + renderWithQueryClient( + , + ); + + expect( + await screen.findByRole('link', { name: 'run-parent-beta' }), + ).toHaveAttribute( + 'href', + '/scopes/scope-alpha/workflow-activity-vnext/activity/run-parent-beta', + ); + expect( + screen.getByRole('link', { name: 'run-child-gamma' }), + ).toHaveAttribute( + 'href', + '/scopes/scope-alpha/workflow-activity-vnext/activity/run-child-gamma', + ); + expect( + screen.queryByRole('link', { name: 'actor-new-alpha' }), + ).not.toBeInTheDocument(); + }); + it('reports a retry failure with a toast and keeps server detail out of the page', async () => { mockWorkflowActivityApi.forkRun.mockRejectedValue( new Error('POST /api/workflow/runs/fork returned 503'), @@ -276,7 +444,7 @@ describe('Workflow Activity vNext run detail recovery', () => { ).not.toBeInTheDocument(); }); - it('keeps committed detail visible and disables run again when graph evidence fails', async () => { + it('keeps typed run-again eligibility when graph presentation fails', async () => { mockWorkflowActivityApi.getRunGraph.mockRejectedValue( new Error('graph offline'), ); @@ -290,7 +458,10 @@ describe('Workflow Activity vNext run detail recovery', () => { expect( await screen.findByText('Run graph unavailable'), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Run again' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Run again' })).toHaveAttribute( + 'aria-disabled', + 'false', + ); }); it('keeps raw run and step errors behind technical details', async () => { diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx index 9b74a0f53..14024c3a0 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx @@ -9,6 +9,7 @@ import { import { t } from '@/shared/i18n/messages'; import type { WorkflowActivityRunDetail, + WorkflowActivityRunRecoveryAction, WorkflowRunForkAcceptedReceipt, } from '@/shared/models/workflowActivity'; import { history } from '@/shared/navigation/history'; @@ -134,8 +135,8 @@ const RunDetailPage: React.FC<{ const [receipt, setReceipt] = React.useState(null); const [pendingRecovery, setPendingRecovery] = React.useState<{ + readonly action: WorkflowActivityRunRecoveryAction; readonly kind: 'retry' | 'run_again'; - readonly stepId: string; } | null>(null); const shownFailureKeys = React.useRef(new Set()); @@ -195,7 +196,6 @@ const RunDetailPage: React.FC<{ ); }, [failurePresentation, performFailureAction, runId, toast]); - const recovery = resolveRunRecovery(detail.data?.steps ?? [], graph.data); const fork = async (startAtStepId: string): Promise => { if (forking) return false; setForking(true); @@ -222,8 +222,9 @@ const RunDetailPage: React.FC<{ }; const confirmFork = async () => { - if (!pendingRecovery) return; - if (await fork(pendingRecovery.stepId)) setPendingRecovery(null); + const startAtStepId = pendingRecovery?.action.startAtStepId; + if (!startAtStepId) return; + if (await fork(startAtStepId)) setPendingRecovery(null); }; if (detail.isPending) @@ -274,6 +275,28 @@ const RunDetailPage: React.FC<{ ); const run = detail.data; + const recovery = run.recovery; + const resolvedRecovery = resolveRunRecovery(recovery); + const retryAvailable = Boolean(resolvedRecovery.retryAction); + const runAgainAvailable = Boolean(resolvedRecovery.runAgainAction); + const retryReason = + resolvedRecovery.retryUnavailableReason || + t( + 'workflowActivityVNext.run.retryAuthorityMissing', + 'Retry eligibility was not provided by the run service.', + ); + const runAgainReason = + resolvedRecovery.runAgainUnavailableReason || + t( + 'workflowActivityVNext.run.runAgainAuthorityMissing', + 'Run-again eligibility was not provided by the run service.', + ); + const recommendationHref = + recovery?.recommendedAction === 'fix_access' + ? `${buildWorkflowActivitySectionHref(scopeId, 'settings')}?section=account` + : recovery?.recommendedAction === 'change_configuration' + ? `${buildWorkflowActivitySectionHref(scopeId, 'settings')}?section=ai` + : null; const statusPresentation = getRunStatusPresentation(run.summary.status); return ( {getRunOriginLabel(run.summary.runOrigin)} - - - - +
+ + {recommendationHref ? ( + + ) : null} + + + + {!retryAvailable ?

{retryReason}

: null} + {!runAgainAvailable ? ( +

{runAgainReason}

+ ) : null} +
{receipt ? ( + {recovery && + (recovery.lineage.parentRunId || recovery.lineage.childRunIds.length) ? ( +
+ + + {recovery.lineage.parentRunId ? ( + + {recovery.lineage.parentRunId} + + ) : null} + {recovery.lineage.childRunIds.map((childRunId) => ( + + {childRunId} + + ))} + +
+ ) : null} {pendingRecovery.stepId} + children: pendingRecovery?.action.startAtStepId ? ( + + {pendingRecovery.action.startAtStepId} + ) : ( t('workflowActivityVNext.common.unavailable', 'Unavailable') ), }, + { + key: 'revision', + label: t( + 'workflowActivityVNext.run.definitionRevision', + 'Definition revision', + ), + children: recovery?.definitionRevision, + }, { key: 'input', label: t('workflowActivityVNext.run.input', 'Input'), children: run.input || t('workflowActivityVNext.common.empty', 'Empty'), }, + { + key: 'reuse', + label: t( + 'workflowActivityVNext.run.priorOutputs', + 'Prior outputs', + ), + children: pendingRecovery?.action.reusesPriorStepOutputs + ? t( + 'workflowActivityVNext.run.priorOutputsReused', + 'Prior step outputs will be reused.', + ) + : t( + 'workflowActivityVNext.run.priorOutputsNotReused', + 'Prior step outputs will not be reused.', + ), + }, ]} /> + {pendingRecovery?.action.mayIncurCost ? ( + + ) : null}
); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts index 0a7628efa..366176ad3 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts @@ -1,54 +1,48 @@ +import type { WorkflowActivityRunRecovery } from '@/shared/models/workflowActivity'; import { resolveRunRecovery } from './runRecovery'; -describe('resolveRunRecovery', () => { - it('enables retry only for one explicit failed step and run again for an explicit graph root step', () => { - expect( - resolveRunRecovery( - [ - { stepId: 'step-second', success: false }, - { stepId: 'step-first', success: true }, - ], - { - rootNodeId: 'node-root', - nodes: [ - { nodeId: 'node-root', stepId: 'step-first' }, - { nodeId: 'node-second', stepId: 'step-second' }, - ], - }, - ), - ).toEqual({ retryStepId: 'step-second', runAgainStepId: 'step-first' }); - }); +function buildRecovery(): WorkflowActivityRunRecovery { + return { + recommendedAction: 'retry_failed_step', + definitionRevision: 'revision-42', + retry: { + eligible: true, + unavailableReason: '', + startAtStepId: 'step-failed', + reusesPriorStepOutputs: true, + mayIncurCost: true, + }, + runAgain: { + eligible: false, + unavailableReason: 'Run again is not safe for this record.', + startAtStepId: null, + reusesPriorStepOutputs: false, + mayIncurCost: false, + }, + lineage: { + parentRunId: null, + childRunIds: [], + }, + }; +} - it('does not guess a failed step or first step from array order', () => { - expect( - resolveRunRecovery([ - { stepId: 'step-first', success: false }, - { stepId: 'step-second', success: false }, - ]), - ).toEqual({ retryStepId: null, runAgainStepId: null }); - }); - - it('does not enable run again when the graph root is missing or lacks an explicit step id', () => { - const steps = [{ stepId: 'step-first', success: true }] as const; +describe('resolveRunRecovery', () => { + it('exposes only explicitly eligible typed actions', () => { + const resolved = resolveRunRecovery(buildRecovery()); - expect( - resolveRunRecovery(steps, { - rootNodeId: 'node-missing', - nodes: [{ nodeId: 'node-first', stepId: 'step-first' }], - }), - ).toEqual({ retryStepId: null, runAgainStepId: null }); - expect( - resolveRunRecovery(steps, { - rootNodeId: 'node-root', - nodes: [{ nodeId: 'node-root', stepId: '' }], - }), - ).toEqual({ retryStepId: null, runAgainStepId: null }); + expect(resolved.retryAction?.startAtStepId).toBe('step-failed'); + expect(resolved.runAgainAction).toBeNull(); + expect(resolved.runAgainUnavailableReason).toBe( + 'Run again is not safe for this record.', + ); }); - it('does not invent a first step for an empty run', () => { - expect(resolveRunRecovery([])).toEqual({ - retryStepId: null, - runAgainStepId: null, + it('does not invent actions when the recovery contract is absent', () => { + expect(resolveRunRecovery(null)).toEqual({ + retryAction: null, + retryUnavailableReason: null, + runAgainAction: null, + runAgainUnavailableReason: null, }); }); }); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts index 924119eb9..54a9e9657 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts @@ -1,38 +1,32 @@ -export type RecoveryStepCandidate = { - readonly stepId: string; - readonly success: boolean | null; -}; +import type { + WorkflowActivityRunRecovery, + WorkflowActivityRunRecoveryAction, +} from '@/shared/models/workflowActivity'; -export type RecoveryGraphCandidate = { - readonly nodes: readonly { - readonly nodeId: string; - readonly stepId: string; - }[]; - readonly rootNodeId: string; -}; +function resolveAction( + action: WorkflowActivityRunRecoveryAction | undefined, +): WorkflowActivityRunRecoveryAction | null { + return action?.eligible && action.startAtStepId?.trim() ? action : null; +} export function resolveRunRecovery( - steps: readonly RecoveryStepCandidate[], - graph?: RecoveryGraphCandidate, + recovery: WorkflowActivityRunRecovery | null | undefined, ): { - readonly retryStepId: string | null; - readonly runAgainStepId: string | null; + readonly retryAction: WorkflowActivityRunRecoveryAction | null; + readonly retryUnavailableReason: string | null; + readonly runAgainAction: WorkflowActivityRunRecoveryAction | null; + readonly runAgainUnavailableReason: string | null; } { - const failed = steps.filter( - (step) => step.success === false && step.stepId.trim(), - ); - const rootNodeId = graph?.rootNodeId.trim() ?? ''; - const rootStepId = rootNodeId - ? (graph?.nodes - .find((node) => node.nodeId.trim() === rootNodeId) - ?.stepId.trim() ?? '') - : ''; - const explicitRootStepId = - rootStepId && steps.some((step) => step.stepId.trim() === rootStepId) - ? rootStepId - : null; return { - retryStepId: failed.length === 1 ? failed[0].stepId : null, - runAgainStepId: explicitRootStepId, + retryAction: resolveAction(recovery?.retry), + retryUnavailableReason: + recovery?.retry.eligible === false + ? recovery.retry.unavailableReason.trim() || null + : null, + runAgainAction: resolveAction(recovery?.runAgain), + runAgainUnavailableReason: + recovery?.runAgain.eligible === false + ? recovery.runAgain.unavailableReason.trim() || null + : null, }; } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.test.ts index f7ed17b35..7588ad9ae 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.test.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.test.ts @@ -20,6 +20,7 @@ function createRun(runId: string): WorkflowActivityRunDetail { input: 'Review this order', finalOutput: '', finalError: '', + recovery: null, diagnostics: [], steps: [], timeline: [], diff --git a/apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts index a4082f6fb..c8caa466c 100644 --- a/apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts +++ b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts @@ -81,6 +81,29 @@ describe('workflowActivityApi', () => { input: 'ticket-42', finalOutput: '', finalError: 'Connector unavailable', + recovery: { + recommendedAction: 'retry_failed_step', + definitionRevision: 'revision-42', + retry: { + eligible: true, + unavailableReason: '', + startAtStepId: 'step-failed', + reusesPriorStepOutputs: true, + mayIncurCost: true, + }, + runAgain: { + eligible: false, + unavailableReason: + 'The run cannot be started from its first step.', + startAtStepId: null, + reusesPriorStepOutputs: false, + mayIncurCost: false, + }, + lineage: { + parentRunId: 'run-parent-beta', + childRunIds: ['run-child-gamma'], + }, + }, diagnostics: [], steps: [ { @@ -147,6 +170,28 @@ describe('workflowActivityApi', () => { ); expect(detail.steps[0]?.stepId).toBe('step-failed'); + expect(detail.recovery).toEqual({ + recommendedAction: 'retry_failed_step', + definitionRevision: 'revision-42', + retry: { + eligible: true, + unavailableReason: '', + startAtStepId: 'step-failed', + reusesPriorStepOutputs: true, + mayIncurCost: true, + }, + runAgain: { + eligible: false, + unavailableReason: 'The run cannot be started from its first step.', + startAtStepId: null, + reusesPriorStepOutputs: false, + mayIncurCost: false, + }, + lineage: { + parentRunId: 'run-parent-beta', + childRunIds: ['run-child-gamma'], + }, + }); expect(graph.nodes[0]?.stepId).toBe('step-failed'); expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ '/api/workflow/observatory/runs/run-alpha?scope=scope-alpha', diff --git a/apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts index 333e97a11..753b05428 100644 --- a/apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts +++ b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts @@ -14,9 +14,12 @@ import type { WorkflowActivityDiagnostic, WorkflowActivityGraphEdge, WorkflowActivityGraphNode, + WorkflowActivityRecoveryRecommendation, WorkflowActivityRunDetail, WorkflowActivityRunFilter, WorkflowActivityRunGraph, + WorkflowActivityRunRecovery, + WorkflowActivityRunRecoveryAction, WorkflowActivityRunStatistics, WorkflowActivityRunSummary, WorkflowActivityStep, @@ -309,6 +312,100 @@ function decodeStatistics( }; } +const RECOVERY_RECOMMENDATIONS: readonly WorkflowActivityRecoveryRecommendation[] = + [ + 'retry_failed_step', + 'run_again', + 'fix_access', + 'change_configuration', + 'edit_workflow', + 'edit_input', + 'technical_details', + ]; + +function isRecoveryRecommendation( + value: string, +): value is WorkflowActivityRecoveryRecommendation { + return RECOVERY_RECOMMENDATIONS.some((candidate) => candidate === value); +} + +function decodeRecoveryAction( + value: unknown, + label: string, +): WorkflowActivityRunRecoveryAction { + const record = expectRecord(value, label); + const eligible = readBoolean(record, 'eligible', `${label}.eligible`); + const unavailableReason = readString( + record, + 'unavailableReason', + `${label}.unavailableReason`, + ); + const startAtStepId = readNullableStringValue( + record.startAtStepId, + `${label}.startAtStepId`, + ); + if (eligible && !startAtStepId?.trim()) { + throw new Error(`${label}.startAtStepId is required when eligible.`); + } + if (!eligible && !unavailableReason.trim()) { + throw new Error(`${label}.unavailableReason is required when ineligible.`); + } + return { + eligible, + unavailableReason, + startAtStepId: startAtStepId?.trim() || null, + reusesPriorStepOutputs: readBoolean( + record, + 'reusesPriorStepOutputs', + `${label}.reusesPriorStepOutputs`, + ), + mayIncurCost: readBoolean(record, 'mayIncurCost', `${label}.mayIncurCost`), + }; +} + +function decodeRecovery( + value: unknown, + label: string, +): WorkflowActivityRunRecovery | null { + if (value === null || value === undefined) return null; + const record = expectRecord(value, label); + const recommendedAction = readString( + record, + 'recommendedAction', + `${label}.recommendedAction`, + ); + if (!isRecoveryRecommendation(recommendedAction)) { + throw new Error(`${label}.recommendedAction is not supported.`); + } + const lineage = expectRecord(record.lineage, `${label}.lineage`); + return { + recommendedAction, + definitionRevision: readNonBlank( + record, + 'definitionRevision', + `${label}.definitionRevision`, + ), + retry: decodeRecoveryAction(record.retry, `${label}.retry`), + runAgain: decodeRecoveryAction(record.runAgain, `${label}.runAgain`), + lineage: { + parentRunId: + readNullableStringValue( + lineage.parentRunId, + `${label}.lineage.parentRunId`, + )?.trim() || null, + childRunIds: expectArray( + lineage.childRunIds, + `${label}.lineage.childRunIds`, + (entry, index) => + requireNonBlank( + expectString(entry, `${label}.lineage.childRunIds[${index}]`), + `${label}.lineage.childRunIds[${index}]`, + ), + ), + }, + }; +} + function decodeDetail(value: unknown): WorkflowActivityRunDetail { const record = expectRecord(value, 'WorkflowActivityRunDetail'); return { @@ -324,6 +421,10 @@ function decodeDetail(value: unknown): WorkflowActivityRunDetail { 'finalError', 'WorkflowActivityRunDetail.finalError', ), + recovery: decodeRecovery( + record.recovery, + 'WorkflowActivityRunDetail.recovery', + ), diagnostics: expectArray( record.diagnostics, 'WorkflowActivityRunDetail.diagnostics', diff --git a/apps/aevatar-console-web/src/shared/models/workflowActivity.ts b/apps/aevatar-console-web/src/shared/models/workflowActivity.ts index fe9fdc73c..6984ec1a7 100644 --- a/apps/aevatar-console-web/src/shared/models/workflowActivity.ts +++ b/apps/aevatar-console-web/src/shared/models/workflowActivity.ts @@ -97,11 +97,40 @@ export interface WorkflowActivityRunStatistics { readonly stepTypeCounts: Readonly>; } +export type WorkflowActivityRecoveryRecommendation = + | 'retry_failed_step' + | 'run_again' + | 'fix_access' + | 'change_configuration' + | 'edit_workflow' + | 'edit_input' + | 'technical_details'; + +export interface WorkflowActivityRunRecoveryAction { + readonly eligible: boolean; + readonly unavailableReason: string; + readonly startAtStepId: string | null; + readonly reusesPriorStepOutputs: boolean; + readonly mayIncurCost: boolean; +} + +export interface WorkflowActivityRunRecovery { + readonly recommendedAction: WorkflowActivityRecoveryRecommendation; + readonly definitionRevision: string; + readonly retry: WorkflowActivityRunRecoveryAction; + readonly runAgain: WorkflowActivityRunRecoveryAction; + readonly lineage: { + readonly parentRunId: string | null; + readonly childRunIds: readonly string[]; + }; +} + export interface WorkflowActivityRunDetail { readonly summary: WorkflowActivityRunSummary; readonly input: string; readonly finalOutput: string; readonly finalError: string; + readonly recovery: WorkflowActivityRunRecovery | null; readonly diagnostics: readonly WorkflowActivityDiagnostic[]; readonly steps: readonly WorkflowActivityStep[]; readonly timeline: readonly WorkflowActivityTimelineEvent[];