fix: 统一工作流三个出口的错误反馈
- 关联 EASY-2,补齐安全错误、节点展示与执行标识 - 保留历史可读摘要并验证接口、SSE 与界面兼容
This commit is contained in:
@@ -25,7 +25,11 @@ import {
|
||||
} from '#/utils/workflow-share-context';
|
||||
|
||||
import { refreshTokenApi } from './core';
|
||||
import { isInactiveSseRequest } from './sseRequestLifecycle';
|
||||
import {
|
||||
isInactiveSseRequest,
|
||||
readSseRequestError,
|
||||
SseRequestError,
|
||||
} from './sseRequestLifecycle';
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
|
||||
@@ -237,7 +241,7 @@ export class SseClient {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
const error = await readSseRequestError(res);
|
||||
options?.onError?.(error);
|
||||
return;
|
||||
}
|
||||
@@ -260,7 +264,7 @@ export class SseClient {
|
||||
}
|
||||
}
|
||||
showErrorOnce(errorMessage);
|
||||
options?.onError?.(new Error(errorMessage));
|
||||
options?.onError?.(new SseRequestError(res.status, errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isInactiveSseRequest } from './sseRequestLifecycle';
|
||||
import {
|
||||
isInactiveSseRequest,
|
||||
readSseRequestError,
|
||||
SseRequestError,
|
||||
} from './sseRequestLifecycle';
|
||||
|
||||
describe('sseRequestLifecycle', () => {
|
||||
it('treats an explicit abort as an inactive request', () => {
|
||||
@@ -17,3 +21,19 @@ describe('sseRequestLifecycle', () => {
|
||||
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps HTTP rejection distinguishable from transport interruption', async () => {
|
||||
for (const status of [400, 403, 500]) {
|
||||
const error = await readSseRequestError(
|
||||
new Response(JSON.stringify({ message: '运行请求被拒绝' }), { status }),
|
||||
);
|
||||
expect(error).toBeInstanceOf(SseRequestError);
|
||||
expect(error.status).toBe(status);
|
||||
expect(error.message).toBe('运行请求被拒绝');
|
||||
}
|
||||
const error = await readSseRequestError(
|
||||
new Response('<html>PRIVATE_GATEWAY_BODY</html>', { status: 502 }),
|
||||
);
|
||||
expect(error.message).toContain('502');
|
||||
expect(error.message).not.toContain('PRIVATE_GATEWAY_BODY');
|
||||
});
|
||||
|
||||
@@ -8,3 +8,24 @@ export function isInactiveSseRequest(
|
||||
) {
|
||||
return signal.aborted || currentRequestId !== requestId;
|
||||
}
|
||||
/** 服务端明确拒绝请求,与已经建立的事件流断线区分。 */
|
||||
export class SseRequestError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SseRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSseRequestError(response: Response) {
|
||||
let message = `请求失败(HTTP ${response.status})`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (typeof body?.message === 'string') message = body.message;
|
||||
} catch {
|
||||
// 网关可能返回 HTML;只展示状态,不把原始正文当作错误文案。
|
||||
}
|
||||
return new SseRequestError(response.status, message);
|
||||
}
|
||||
|
||||
@@ -987,7 +987,7 @@ const apiDocMarkdown = computed(() => {
|
||||
lines.push(`| 413 | 41301 | 文件、文件数量或请求总量超限 |`);
|
||||
lines.push(`| 415 | 41501 | 顶层 Content-Type 缺失或不支持 |`);
|
||||
lines.push(`| 415 | 41502 | metadata Part 未使用 application/json |`);
|
||||
lines.push(`| 500 | 50001 | 服务端内部错误;携带 requestId 联系管理员 |`);
|
||||
lines.push(`| 500 | 50001 | 服务端内部错误 |`);
|
||||
lines.push(`| 503 | 50301 | 文件存储暂时不可用,可稍后重试 |`);
|
||||
lines.push(``);
|
||||
lines.push(
|
||||
|
||||
@@ -52,8 +52,7 @@ watch(
|
||||
success.value = true;
|
||||
}
|
||||
if (newVal.status === 21) {
|
||||
ElMessage.error($t('message.fail'));
|
||||
result.value = newVal.message;
|
||||
result.value = newVal.result || '';
|
||||
success.value = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Position } from '@element-plus/icons-vue';
|
||||
@@ -10,8 +12,9 @@ import { api } from '#/api/request';
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
import { $t } from '#/locales';
|
||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||
|
||||
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||
import WorkflowErrorDetail from './WorkflowErrorDetail.vue';
|
||||
import { resolveWorkflowParameterDisplayName } from './workflowFormParameters';
|
||||
|
||||
interface Props {
|
||||
@@ -25,6 +28,9 @@ const singleRunForm = ref<FormInstance>();
|
||||
const runParams = ref<any>({});
|
||||
const submitLoading = ref(false);
|
||||
const result = ref<any>('');
|
||||
const runError = ref<WorkflowExecutionError>();
|
||||
const runErrorMessage = ref('');
|
||||
const executeId = ref<string>();
|
||||
const singleRunModel = computed(() => buildSingleRunModel(props.node));
|
||||
const isFieldMode = computed(() => singleRunModel.value.mode === 'fields');
|
||||
const singleRunParameters = computed(() => singleRunModel.value.parameters);
|
||||
@@ -41,7 +47,7 @@ const parameterDisplayNameMap = computed(() => {
|
||||
function buildFieldSegments(value: string) {
|
||||
const source = String(value || '');
|
||||
const segments: Array<{ text: string; token: boolean }> = [];
|
||||
const regex = /\{\{\s*([^{}]+?)\s*}}/g;
|
||||
const regex = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of source.matchAll(regex)) {
|
||||
@@ -80,15 +86,29 @@ function submit() {
|
||||
variables: runParams.value,
|
||||
};
|
||||
submitLoading.value = true;
|
||||
api.post('/api/v1/workflow/singleRun', params).then((res) => {
|
||||
submitLoading.value = false;
|
||||
result.value = res.data;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
});
|
||||
result.value = '';
|
||||
runError.value = undefined;
|
||||
runErrorMessage.value = '';
|
||||
executeId.value = undefined;
|
||||
api
|
||||
.post('/api/v1/workflow/singleRun', params)
|
||||
.then((res) => {
|
||||
result.value = res.data;
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message);
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const body = error?.response?.data ?? error;
|
||||
runError.value = body?.data?.error;
|
||||
executeId.value = body?.data?.executeId;
|
||||
runErrorMessage.value = body?.message || '节点执行失败';
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -129,16 +149,10 @@ function submit() {
|
||||
v-for="(segment, index) in buildFieldSegments(field.value)"
|
||||
:key="`${field.key}-${index}`"
|
||||
>
|
||||
<span
|
||||
v-if="segment.token"
|
||||
class="single-run-token-chip"
|
||||
>
|
||||
<span v-if="segment.token" class="single-run-token-chip">
|
||||
{{ segment.text }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="single-run-field-card__text"
|
||||
>
|
||||
<span v-else class="single-run-field-card__text">
|
||||
{{ segment.text }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -185,6 +199,11 @@ function submit() {
|
||||
</ElForm>
|
||||
<section class="single-run-result">
|
||||
<div class="single-run-result__title">{{ $t('workflow.result') }}:</div>
|
||||
<WorkflowErrorDetail
|
||||
:error="runError"
|
||||
:execute-id="executeId"
|
||||
:message="runErrorMessage"
|
||||
/>
|
||||
<ShowJson class="single-run-result__viewer" :value="result" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
WorkflowExecutionStepStatus,
|
||||
WorkflowExecutionStepView,
|
||||
} from './workflowExecutionDetails';
|
||||
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||
|
||||
import {
|
||||
computed,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
} from 'element-plus';
|
||||
|
||||
import { api, SseClient } from '#/api/request';
|
||||
import { SseRequestError } from '#/api/sseRequestLifecycle';
|
||||
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
resolveWorkflowShareVisitorId,
|
||||
} from '#/utils/workflow-share-context';
|
||||
|
||||
import WorkflowErrorDetail from './WorkflowErrorDetail.vue';
|
||||
import {
|
||||
finalizeWorkflowExecutionSteps,
|
||||
formatExecutionValue,
|
||||
@@ -94,7 +97,6 @@ import {
|
||||
import {
|
||||
formatWorkflowElapsed,
|
||||
formatWorkflowProgressLabel,
|
||||
summarizeWorkflowActiveNodes,
|
||||
} from './workflowRunProgress';
|
||||
import {
|
||||
buildWorkflowShareConversationKey,
|
||||
@@ -167,6 +169,10 @@ const detailVisible = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detailLoadError = ref('');
|
||||
const executionDetail = ref<Record<string, any>>();
|
||||
const liveExecutionError = ref<WorkflowExecutionError>();
|
||||
const executionError = computed(
|
||||
() => liveExecutionError.value || executionDetail.value?.runtime?.error,
|
||||
);
|
||||
const liveExecutionSteps = ref<WorkflowExecutionStepView[]>([]);
|
||||
const expandedExecutionStepKeys = ref<string[]>([]);
|
||||
const detailExpansionTouched = ref(false);
|
||||
@@ -271,7 +277,10 @@ const emptyText = computed(
|
||||
() => descriptor.value.description || '输入问题开始运行',
|
||||
);
|
||||
const persistedExecutionSteps = computed(() =>
|
||||
hydrateWorkflowExecutionSteps(executionDetail.value?.steps),
|
||||
hydrateWorkflowExecutionSteps(
|
||||
executionDetail.value?.steps,
|
||||
executionError.value,
|
||||
),
|
||||
);
|
||||
const executionSteps = computed(() =>
|
||||
liveExecutionSteps.value.length > 0
|
||||
@@ -719,13 +728,6 @@ function clearProgressStatusTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
function activeNodeSummary() {
|
||||
return summarizeWorkflowActiveNodes(
|
||||
liveExecutionSteps.value,
|
||||
lastRunningNodeName.value,
|
||||
);
|
||||
}
|
||||
|
||||
function progressLabel(prefix: string) {
|
||||
return formatWorkflowProgressLabel(
|
||||
prefix,
|
||||
@@ -791,6 +793,7 @@ async function handleSend() {
|
||||
executeId.value = '';
|
||||
runStatusKey.value = `run-${Date.now()}-${userMessageSequence}`;
|
||||
executionDetail.value = undefined;
|
||||
liveExecutionError.value = undefined;
|
||||
detailLoadError.value = '';
|
||||
liveExecutionSteps.value = [];
|
||||
expandedExecutionStepKeys.value = [];
|
||||
@@ -817,27 +820,21 @@ async function handleSend() {
|
||||
if (manualAbort.value) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof SseRequestError) {
|
||||
finishExecution('failed', error.message);
|
||||
return;
|
||||
}
|
||||
if (beginExecutionRecovery()) {
|
||||
return;
|
||||
}
|
||||
finishExecution(
|
||||
'failed',
|
||||
error?.message || '工作流执行失败',
|
||||
undefined,
|
||||
`stream-error-${Date.now()}`,
|
||||
);
|
||||
showDisconnectedStatus(error?.message);
|
||||
},
|
||||
onFinished: () => {
|
||||
if (running.value && !manualAbort.value) {
|
||||
if (beginExecutionRecovery()) {
|
||||
return;
|
||||
}
|
||||
finishExecution(
|
||||
'failed',
|
||||
'运行连接已结束,请重试',
|
||||
undefined,
|
||||
`stream-finished-${Date.now()}`,
|
||||
);
|
||||
showDisconnectedStatus();
|
||||
}
|
||||
},
|
||||
onMessage: (message) => {
|
||||
@@ -853,6 +850,14 @@ async function handleSend() {
|
||||
);
|
||||
}
|
||||
|
||||
function showDisconnectedStatus(message?: string) {
|
||||
running.value = false;
|
||||
executionState.value = 'idle';
|
||||
clearProgressStatusTimer();
|
||||
appendStatus('连接已断开,运行结果尚未确认', 'done', runStatusKey.value);
|
||||
detailLoadError.value = message || '可在运行记录中查看最终结果';
|
||||
}
|
||||
|
||||
function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope {
|
||||
try {
|
||||
return JSON.parse(raw) as WorkflowStreamEnvelope;
|
||||
@@ -862,6 +867,8 @@ function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope {
|
||||
}
|
||||
|
||||
function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
||||
if (!executeId.value && event.executeId)
|
||||
executeId.value = String(event.executeId);
|
||||
updateLiveExecutionSteps(event);
|
||||
const data = event.data || {};
|
||||
switch (event.type) {
|
||||
@@ -870,14 +877,15 @@ function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
||||
break;
|
||||
}
|
||||
case 'execution_error': {
|
||||
appendError(
|
||||
data.message || '工作流执行失败',
|
||||
`execution-error-${event.executeId}`,
|
||||
);
|
||||
liveExecutionError.value = data.error;
|
||||
break;
|
||||
}
|
||||
case 'execution_failed': {
|
||||
finishExecution('failed', data.message);
|
||||
liveExecutionError.value = data.error || liveExecutionError.value;
|
||||
finishExecution(
|
||||
'failed',
|
||||
data.message || liveExecutionError.value?.message,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'execution_finished': {
|
||||
@@ -931,7 +939,7 @@ function finishExecution(
|
||||
output?: unknown,
|
||||
eventId = executeId.value || String(Date.now()),
|
||||
) {
|
||||
const failedNodeName = activeNodeSummary();
|
||||
const failedNodeName = executionError.value?.nodeName;
|
||||
executionRecoveryActive = false;
|
||||
clearExecutionRecoveryTimer();
|
||||
clearProgressStatusTimer();
|
||||
@@ -974,11 +982,18 @@ function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
|
||||
latestStep?.nodeName || lastRunningNodeName.value;
|
||||
}
|
||||
if (
|
||||
event.type === 'node_started' &&
|
||||
(event.type === 'node_started' || event.type === 'node_finished') &&
|
||||
!detailExpansionTouched.value &&
|
||||
next.length > 0
|
||||
) {
|
||||
expandedExecutionStepKeys.value = [next[next.length - 1]!.key];
|
||||
const target = [...next]
|
||||
.reverse()
|
||||
.find((step) =>
|
||||
event.data?.attemptKey
|
||||
? step.attemptKey === event.data.attemptKey
|
||||
: step.nodeId === event.data?.nodeId,
|
||||
);
|
||||
if (target) expandedExecutionStepKeys.value = [target.key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,6 +1005,7 @@ function finalizeLiveExecutionSteps(
|
||||
liveExecutionSteps.value,
|
||||
status,
|
||||
finishedAt,
|
||||
executionError.value,
|
||||
);
|
||||
executionElapsed.value =
|
||||
executionStartedAt.value === undefined
|
||||
@@ -1092,6 +1108,7 @@ async function resetConversation() {
|
||||
runStatusKey.value = '';
|
||||
question.value = '';
|
||||
executionDetail.value = undefined;
|
||||
liveExecutionError.value = undefined;
|
||||
executionState.value = 'idle';
|
||||
executionStartedAt.value = undefined;
|
||||
executionElapsed.value = undefined;
|
||||
@@ -1115,7 +1132,7 @@ function clearExecutionRecoveryTimer() {
|
||||
}
|
||||
|
||||
function beginExecutionRecovery() {
|
||||
if (!props.shareMode || !executeId.value) {
|
||||
if (!executeId.value) {
|
||||
return false;
|
||||
}
|
||||
executionRecoveryActive = true;
|
||||
@@ -1137,7 +1154,6 @@ function beginExecutionRecovery() {
|
||||
function scheduleExecutionRecovery() {
|
||||
if (
|
||||
!executionRecoveryActive ||
|
||||
!props.shareMode ||
|
||||
!executeId.value ||
|
||||
executionState.value === 'waiting'
|
||||
) {
|
||||
@@ -1172,7 +1188,10 @@ async function recoverExecutionAfterRefresh() {
|
||||
}
|
||||
|
||||
function syncRecoveredExecution(detail: Record<string, any>) {
|
||||
liveExecutionSteps.value = hydrateWorkflowExecutionSteps(detail.steps);
|
||||
liveExecutionSteps.value = hydrateWorkflowExecutionSteps(
|
||||
detail.steps,
|
||||
detail.runtime?.error,
|
||||
);
|
||||
const activeStep = [...liveExecutionSteps.value]
|
||||
.reverse()
|
||||
.find((step) => step.status === 'running' || step.status === 'waiting');
|
||||
@@ -1221,6 +1240,7 @@ function syncRecoveredExecution(detail: Record<string, any>) {
|
||||
return;
|
||||
}
|
||||
if (status === 'FAILED') {
|
||||
liveExecutionError.value = detail.runtime?.error;
|
||||
finishExecution(
|
||||
'failed',
|
||||
detail.runtime?.message || detail.record?.errorInfo,
|
||||
@@ -1355,6 +1375,7 @@ function executionStepStatusText(status: WorkflowExecutionStepStatus) {
|
||||
cancelled: '已中止',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
retrying: '等待重试',
|
||||
running: '运行中',
|
||||
waiting: '等待确认',
|
||||
};
|
||||
@@ -1715,13 +1736,33 @@ function executionTraceText(
|
||||
<span class="workflow-chat__detail-id">{{ executeId }}</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem
|
||||
v-if="executionDetail?.record?.errorInfo"
|
||||
v-if="
|
||||
executionDetail?.record?.errorInfo &&
|
||||
!executionError &&
|
||||
!executionSteps.some(
|
||||
(step) => step.error === executionDetail?.record?.errorInfo,
|
||||
)
|
||||
"
|
||||
label="错误"
|
||||
>
|
||||
{{ executionDetail.record.errorInfo }}
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<WorkflowErrorDetail
|
||||
v-if="
|
||||
!executionSteps.some(
|
||||
(step) =>
|
||||
step.errorDetail?.reasonCode === executionError?.reasonCode &&
|
||||
executionError?.nodeId &&
|
||||
(step.nodeId === executionError.nodeId ||
|
||||
step.errorDetail?.nodeId === executionError.nodeId),
|
||||
)
|
||||
"
|
||||
:error="executionError"
|
||||
:execute-id="executeId"
|
||||
/>
|
||||
|
||||
<div v-if="detailLoadError" class="workflow-chat__detail-load-error">
|
||||
<span>{{ detailLoadError }}</span>
|
||||
<ElButton text type="primary" @click="loadExecutionDetail()">
|
||||
@@ -1791,9 +1832,13 @@ function executionTraceText(
|
||||
<h3>输出</h3>
|
||||
<pre>{{ formatExecutionValue(step.output, true) }}</pre>
|
||||
</section>
|
||||
<section v-if="step.error">
|
||||
<section v-if="step.error || step.errorDetail">
|
||||
<h3>错误</h3>
|
||||
<p class="workflow-chat__detail-error">{{ step.error }}</p>
|
||||
<WorkflowErrorDetail
|
||||
:error="step.errorDetail"
|
||||
:execute-id="executeId"
|
||||
:message="step.error"
|
||||
/>
|
||||
</section>
|
||||
<p
|
||||
v-if="
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||
|
||||
import { ElAlert, ElButton } from 'element-plus';
|
||||
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
|
||||
import { formatWorkflowErrorContext } from './workflowExecutionError';
|
||||
|
||||
defineProps<{
|
||||
error?: WorkflowExecutionError;
|
||||
executeId?: string;
|
||||
message?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElAlert
|
||||
v-if="error || message"
|
||||
:closable="false"
|
||||
:title="error?.message || message"
|
||||
:type="error?.retryable ? 'warning' : 'error'"
|
||||
show-icon
|
||||
>
|
||||
<template v-if="error">
|
||||
<div v-if="error.nodeName">节点:{{ error.nodeName }}</div>
|
||||
<div>原因码:{{ error.reasonCode }}</div>
|
||||
<div v-if="error.retryable">正在等待重试</div>
|
||||
<ElButton
|
||||
text
|
||||
type="primary"
|
||||
@click="
|
||||
copyTextWithFeedback(
|
||||
formatWorkflowErrorContext(error, executeId),
|
||||
'排查信息已复制',
|
||||
)
|
||||
"
|
||||
>
|
||||
复制排查信息
|
||||
</ElButton>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ElAlert>
|
||||
</template>
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
VideoPause,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||
|
||||
import WorkflowErrorDetail from './WorkflowErrorDetail.vue';
|
||||
|
||||
export interface WorkflowStepsProps {
|
||||
workflowId: any;
|
||||
nodeJson: any;
|
||||
@@ -42,7 +43,7 @@ const confirmBtnLoading = ref(false);
|
||||
const chainErrMsg = ref('');
|
||||
|
||||
function shouldAutoExpandStatus(status: unknown) {
|
||||
return [1, 5, 20, 21].includes(Number(status));
|
||||
return [1, 5, 10, 20, 21].includes(Number(status));
|
||||
}
|
||||
|
||||
function handleManualExpansionChange() {
|
||||
@@ -75,6 +76,7 @@ function hasNodeStateChanged(previous: any, current: any) {
|
||||
if (hasNodePayloadChanged(previous?.result, current?.result)) {
|
||||
return true;
|
||||
}
|
||||
if (hasNodePayloadChanged(previous?.error, current?.error)) return true;
|
||||
return hasNodePayloadChanged(
|
||||
previous?.suspendForParameters,
|
||||
current?.suspendForParameters,
|
||||
@@ -93,9 +95,11 @@ watch(
|
||||
confirmBtnLoading.value = false;
|
||||
}
|
||||
let autoExpandNodeId: string | undefined;
|
||||
const failedNodeId = Object.keys(currentNodes).find(
|
||||
(nodeId) => Number(currentNodes[nodeId]?.status) === 21,
|
||||
);
|
||||
const failedNodeId =
|
||||
newVal.error?.nodeId ||
|
||||
Object.keys(currentNodes).find(
|
||||
(nodeId) => Number(currentNodes[nodeId]?.status) === 21,
|
||||
);
|
||||
for (const nodeId in currentNodes) {
|
||||
const previousNodeState = nodeStatusMap.value[nodeId];
|
||||
const currentNodeState = currentNodes[nodeId];
|
||||
@@ -162,6 +166,18 @@ const displayNodes = computed(() => {
|
||||
...nodeStatusMap.value[node.key],
|
||||
}));
|
||||
});
|
||||
const showChainError = computed(() => {
|
||||
const failedNodeId = props.pollingData?.error?.nodeId;
|
||||
return (
|
||||
chainErrMsg.value &&
|
||||
(!failedNodeId ||
|
||||
!displayNodes.value.some(
|
||||
(node) =>
|
||||
(node.key === failedNodeId || node.error?.nodeId === failedNodeId) &&
|
||||
(node.error || node.message),
|
||||
))
|
||||
);
|
||||
});
|
||||
// 动态设置 Ref 的辅助函数
|
||||
const setFormRef = (el: any, key: string) => {
|
||||
if (el) {
|
||||
@@ -213,13 +229,11 @@ function handleConfirm(node: any) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-1">
|
||||
<ElAlert
|
||||
v-if="chainErrMsg"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="chainErrMsg"
|
||||
type="error"
|
||||
<div v-if="showChainError" class="mb-1">
|
||||
<WorkflowErrorDetail
|
||||
:error="pollingData?.error"
|
||||
:execute-id="pollingData?.executeId"
|
||||
:message="chainErrMsg"
|
||||
/>
|
||||
</div>
|
||||
<ElCollapse
|
||||
@@ -249,6 +263,9 @@ function handleConfirm(node: any) {
|
||||
<ElIcon v-if="node.status === 5" color="orange" size="20">
|
||||
<VideoPause />
|
||||
</ElIcon>
|
||||
<span v-if="node.status === 10">{{
|
||||
node.error?.retryable ? '等待重试' : '已停止'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -288,7 +305,12 @@ function handleConfirm(node: any) {
|
||||
</ElForm>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ShowJson :value="node.result || node.message" />
|
||||
<WorkflowErrorDetail
|
||||
:error="node.error"
|
||||
:execute-id="pollingData?.executeId"
|
||||
:message="node.message"
|
||||
/>
|
||||
<ShowJson v-if="node.result != null" :value="node.result" />
|
||||
</div>
|
||||
</template>
|
||||
</ElCollapseItem>
|
||||
|
||||
@@ -1,50 +1,33 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import ExecResult from '../ExecResult.vue';
|
||||
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
describe('execResult', () => {
|
||||
it('结束节点执行失败时展示工作流错误信息', async () => {
|
||||
it('失败时保留部分输出,结果区不重复显示节点错误', async () => {
|
||||
const wrapper = mount(ExecResult, {
|
||||
props: {
|
||||
initSignal: false,
|
||||
nodeJson: [
|
||||
{
|
||||
original: {
|
||||
data: {
|
||||
outputDefs: [],
|
||||
},
|
||||
type: 'endNode',
|
||||
},
|
||||
},
|
||||
],
|
||||
pollingData: undefined,
|
||||
workflowId: 'workflow-1',
|
||||
},
|
||||
props: { workflowId: 'test', nodeJson: [] },
|
||||
global: {
|
||||
stubs: {
|
||||
ShowJson: {
|
||||
props: ['value'],
|
||||
template: '<div data-test="show-json">{{ value }}</div>',
|
||||
},
|
||||
ShowJson: { props: ['value'], template: '<pre>{{ value }}</pre>' },
|
||||
ElEmpty: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.setProps({
|
||||
pollingData: { status: 21, message: '模型不存在' },
|
||||
});
|
||||
expect(wrapper.text()).not.toContain('模型不存在');
|
||||
await wrapper.setProps({
|
||||
pollingData: {
|
||||
message: 'JavaScript 执行失败(第 3 行,第 5 列):boom',
|
||||
status: 21,
|
||||
message: '模型不存在',
|
||||
result: { output: '部分输出' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('[data-test="show-json"]').text()).toContain(
|
||||
'JavaScript 执行失败(第 3 行,第 5 列):boom',
|
||||
);
|
||||
expect(wrapper.text()).toContain('部分输出');
|
||||
expect(wrapper.text()).not.toContain('模型不存在');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SingleRun from '../SingleRun.vue';
|
||||
|
||||
const { post, copy } = vi.hoisted(() => ({ post: vi.fn(), copy: vi.fn() }));
|
||||
vi.mock('#/api/request', () => ({ api: { post } }));
|
||||
vi.mock('#/utils/clipboard-feedback', () => ({ copyTextWithFeedback: copy }));
|
||||
|
||||
describe('singleRun', () => {
|
||||
it('copies the current execution id and clears it before a new run', async () => {
|
||||
const detail = {
|
||||
code: 'NODE_EXECUTION_FAILED',
|
||||
reasonCode: 'MODEL_NOT_FOUND',
|
||||
message: '模型不存在',
|
||||
nodeId: 'llm',
|
||||
nodeName: '模型分析',
|
||||
retryable: false,
|
||||
};
|
||||
const rejectWith = (executeId?: string) => ({
|
||||
errorCode: 500,
|
||||
message: detail.message,
|
||||
data: { error: detail, executeId },
|
||||
});
|
||||
post.mockRejectedValueOnce(rejectWith('execution-first'));
|
||||
const wrapper = mount(SingleRun, {
|
||||
props: {
|
||||
workflowId: 'test',
|
||||
node: { id: 'llm', type: 'llmNode', data: { userPrompt: 'test' } },
|
||||
},
|
||||
global: { stubs: { ShowJson: true, WorkflowFormItem: true } },
|
||||
});
|
||||
const findButton = (copyButton: boolean) => {
|
||||
const button = wrapper
|
||||
.findAll('button')
|
||||
.find((item) => item.text().includes('复制排查信息') === copyButton);
|
||||
if (!button) throw new Error('Expected button was not rendered');
|
||||
return button;
|
||||
};
|
||||
const run = () => findButton(false);
|
||||
const copyButton = () => findButton(true);
|
||||
const copiedContext = () => {
|
||||
const call = copy.mock.lastCall;
|
||||
if (!call)
|
||||
throw new Error('Expected diagnostic information to be copied');
|
||||
return JSON.parse(call[0]);
|
||||
};
|
||||
await run().trigger('click');
|
||||
await flushPromises();
|
||||
await copyButton().trigger('click');
|
||||
expect(copiedContext()).toMatchObject({
|
||||
executeId: 'execution-first',
|
||||
reasonCode: 'MODEL_NOT_FOUND',
|
||||
});
|
||||
|
||||
let rejectNext!: (error: unknown) => void;
|
||||
post.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectNext = reject;
|
||||
}),
|
||||
);
|
||||
await run().trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).not.toContain('复制排查信息');
|
||||
rejectNext(rejectWith('execution-second'));
|
||||
await flushPromises();
|
||||
await copyButton().trigger('click');
|
||||
expect(copiedContext().executeId).toBe('execution-second');
|
||||
|
||||
post.mockRejectedValueOnce(rejectWith());
|
||||
await run().trigger('click');
|
||||
await flushPromises();
|
||||
await copyButton().trigger('click');
|
||||
expect(copiedContext()).not.toHaveProperty('executeId');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -323,4 +323,79 @@ describe('workflowSteps', () => {
|
||||
expect(wrapper.findAll('workflow-form-item-stub')).toHaveLength(1);
|
||||
expect(wrapper.findAll('button.el-button')).toHaveLength(1);
|
||||
});
|
||||
it('错误仅在失败节点内展示一次,同时保留部分输出和自动展开', async () => {
|
||||
const wrapper = mountWorkflowSteps();
|
||||
const error = {
|
||||
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||
reasonCode: 'MODEL_TIMEOUT',
|
||||
message: '模型响应超时',
|
||||
nodeId: 'node-b',
|
||||
nodeName: '节点 B',
|
||||
retryable: false,
|
||||
};
|
||||
await wrapper.setProps({
|
||||
pollingData: {
|
||||
executeId: 'run-1',
|
||||
status: 21,
|
||||
message: error.message,
|
||||
error,
|
||||
nodes: {
|
||||
'node-b': {
|
||||
status: 21,
|
||||
error,
|
||||
message: error.message,
|
||||
result: { partial: '保留输出' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('MODEL_TIMEOUT');
|
||||
expect(wrapper.text()).toContain('模型响应超时');
|
||||
expect(wrapper.findComponent({ name: 'ShowJson' }).exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('复制排查信息');
|
||||
expect(wrapper.text().match(/MODEL_TIMEOUT/g)).toHaveLength(1);
|
||||
expect(wrapper.text()).not.toContain('定位失败节点');
|
||||
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
|
||||
wrapper.unmount();
|
||||
});
|
||||
it('没有可展示的失败节点时仍保留工作流错误', async () => {
|
||||
const wrapper = mountWorkflowSteps();
|
||||
await wrapper.setProps({
|
||||
pollingData: {
|
||||
status: 21,
|
||||
message: '工作流内部执行异常',
|
||||
error: {
|
||||
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||
reasonCode: 'WORKFLOW_INTERNAL_ERROR',
|
||||
message: '工作流内部执行异常',
|
||||
retryable: false,
|
||||
},
|
||||
nodes: {},
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('WORKFLOW_INTERNAL_ERROR');
|
||||
wrapper.unmount();
|
||||
});
|
||||
it('工作流终止后不把遗留错误尝试展示为等待重试', async () => {
|
||||
const wrapper = mountWorkflowSteps();
|
||||
await wrapper.setProps({
|
||||
pollingData: {
|
||||
status: 21,
|
||||
nodes: {
|
||||
'node-b': {
|
||||
status: 10,
|
||||
error: {
|
||||
code: 'NODE_EXECUTION_FAILED',
|
||||
reasonCode: 'MODEL_TIMEOUT',
|
||||
message: '模型响应超时',
|
||||
retryable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain('已停止');
|
||||
expect(wrapper.text()).not.toContain('等待重试');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,38 @@ import {
|
||||
} from './workflowExecutionDetails';
|
||||
|
||||
describe('workflowExecutionDetails', () => {
|
||||
it('restores runtime error detail only on the final failed attempt', () => {
|
||||
const error = {
|
||||
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||
reasonCode: 'MODEL_NOT_FOUND',
|
||||
message: '模型不存在',
|
||||
nodeId: 'llm',
|
||||
nodeName: '模型分析',
|
||||
retryable: false,
|
||||
};
|
||||
const steps = hydrateWorkflowExecutionSteps(
|
||||
[
|
||||
{ nodeId: 'llm', attemptKey: 'old', status: 10, errorInfo: '早先超时' },
|
||||
{
|
||||
nodeId: 'llm',
|
||||
attemptKey: 'final',
|
||||
status: 21,
|
||||
errorInfo: '模型不存在',
|
||||
},
|
||||
],
|
||||
error,
|
||||
);
|
||||
expect(steps[0]?.errorDetail).toBeUndefined();
|
||||
expect(steps[1]?.errorDetail).toEqual(error);
|
||||
expect(
|
||||
hydrateWorkflowExecutionSteps([{ nodeId: 'llm', status: 21 }], error)[0],
|
||||
).toMatchObject({ error: error.message, errorDetail: error });
|
||||
expect(
|
||||
hydrateWorkflowExecutionSteps([
|
||||
{ nodeId: 'llm', status: 21, errorInfo: '模型不存在' },
|
||||
])[0]?.error,
|
||||
).toBe('模型不存在');
|
||||
});
|
||||
it('keeps loop attempts separate and completes each output', () => {
|
||||
const first = reduceWorkflowExecutionSteps([], {
|
||||
data: {
|
||||
@@ -132,4 +164,78 @@ describe('workflowExecutionDetails', () => {
|
||||
expect(formatExecutionValue(value, true)).toContain('最终答案');
|
||||
expect(formatExecutionValue(value, true)).not.toContain('内部推理');
|
||||
});
|
||||
it('keeps retrying distinct and clears current error on success', () => {
|
||||
const retry = reduceWorkflowExecutionSteps([], {
|
||||
data: {
|
||||
nodeId: 'llm',
|
||||
status: 'ERROR',
|
||||
error: '限流',
|
||||
errorDetail: { retryable: true },
|
||||
},
|
||||
eventId: 'retry',
|
||||
type: 'node_finished',
|
||||
});
|
||||
expect(retry[0]?.status).toBe('retrying');
|
||||
const success = reduceWorkflowExecutionSteps(retry, {
|
||||
data: { nodeId: 'llm', status: 'SUCCEEDED', output: { text: 'ok' } },
|
||||
eventId: 'success',
|
||||
type: 'node_finished',
|
||||
});
|
||||
expect(success[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
output: { text: 'ok' },
|
||||
});
|
||||
expect(success[0]?.error).toBeUndefined();
|
||||
expect(success[0]?.errorDetail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not blame parallel siblings for the first failure', () => {
|
||||
let steps: ReturnType<typeof reduceWorkflowExecutionSteps> = [];
|
||||
for (const nodeId of ['a', 'b']) {
|
||||
steps = reduceWorkflowExecutionSteps(steps, {
|
||||
data: { nodeId },
|
||||
eventId: nodeId,
|
||||
type: 'node_started',
|
||||
});
|
||||
}
|
||||
const finalized = finalizeWorkflowExecutionSteps(steps, 'failed', 200, {
|
||||
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||
reasonCode: 'MODEL_TIMEOUT',
|
||||
message: '超时',
|
||||
nodeId: 'a',
|
||||
retryable: false,
|
||||
});
|
||||
expect(finalized[0]?.status).toBe('failed');
|
||||
expect(finalized[1]?.status).toBe('cancelled');
|
||||
});
|
||||
it('preserves a failed attempt when a new retry succeeds', () => {
|
||||
const retry = reduceWorkflowExecutionSteps([], {
|
||||
data: {
|
||||
nodeId: 'llm',
|
||||
attemptKey: 'llm:1',
|
||||
status: 'ERROR',
|
||||
error: '限流',
|
||||
errorDetail: { retryable: true },
|
||||
},
|
||||
eventId: '1',
|
||||
type: 'node_finished',
|
||||
});
|
||||
const started = reduceWorkflowExecutionSteps(retry, {
|
||||
data: { nodeId: 'llm', attemptKey: 'llm:2' },
|
||||
eventId: '2',
|
||||
type: 'node_started',
|
||||
});
|
||||
const success = reduceWorkflowExecutionSteps(started, {
|
||||
data: { nodeId: 'llm', attemptKey: 'llm:2', status: 'SUCCEEDED' },
|
||||
eventId: '3',
|
||||
type: 'node_finished',
|
||||
});
|
||||
const completed = finalizeWorkflowExecutionSteps(success, 'completed', 200);
|
||||
expect(completed[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
errorDetail: { retryable: false },
|
||||
});
|
||||
expect(completed[1]?.status).toBe('completed');
|
||||
expect(completed[1]?.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||
|
||||
import { sanitizeWorkflowOutputForDisplay } from './workflowFinalOutput';
|
||||
|
||||
export interface WorkflowExecutionTrace {
|
||||
@@ -10,6 +12,7 @@ export type WorkflowExecutionStepStatus =
|
||||
| 'cancelled'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'retrying'
|
||||
| 'running'
|
||||
| 'waiting';
|
||||
|
||||
@@ -18,6 +21,7 @@ export interface WorkflowExecutionStepView {
|
||||
duration?: number;
|
||||
endTime?: number;
|
||||
error?: string;
|
||||
errorDetail?: WorkflowExecutionError;
|
||||
hasInput: boolean;
|
||||
hasOutput: boolean;
|
||||
input?: unknown;
|
||||
@@ -58,6 +62,17 @@ export function reduceWorkflowExecutionSteps(
|
||||
const stepIndex = findStepIndex(current, attemptKey, nodeId);
|
||||
|
||||
if (event.type === 'node_started') {
|
||||
current = current.map((step) =>
|
||||
step.nodeId === nodeId && step.status === 'retrying'
|
||||
? {
|
||||
...step,
|
||||
status: 'failed',
|
||||
errorDetail: step.errorDetail
|
||||
? { ...step.errorDetail, retryable: false }
|
||||
: undefined,
|
||||
}
|
||||
: step,
|
||||
);
|
||||
const startTime = numberValue(data.startedAt) ?? now;
|
||||
const nextStep: WorkflowExecutionStepView = {
|
||||
attemptKey: attemptKey || undefined,
|
||||
@@ -83,6 +98,8 @@ export function reduceWorkflowExecutionSteps(
|
||||
next[stepIndex] = {
|
||||
...existingStep,
|
||||
...nextStep,
|
||||
error: undefined,
|
||||
errorDetail: undefined,
|
||||
traces: existingStep.traces,
|
||||
};
|
||||
return next;
|
||||
@@ -120,6 +137,7 @@ export function reduceWorkflowExecutionSteps(
|
||||
startTime === undefined ? undefined : Math.max(0, endTime - startTime),
|
||||
endTime,
|
||||
error: textValue(data.error) || undefined,
|
||||
errorDetail: data.errorDetail,
|
||||
hasOutput: hasOwn(data, 'output'),
|
||||
output: data.output,
|
||||
status: resolveLiveStatus(data.status, data.error),
|
||||
@@ -139,30 +157,43 @@ export function reduceWorkflowExecutionSteps(
|
||||
*/
|
||||
export function hydrateWorkflowExecutionSteps(
|
||||
steps: unknown,
|
||||
error?: WorkflowExecutionError,
|
||||
): WorkflowExecutionStepView[] {
|
||||
if (!Array.isArray(steps)) {
|
||||
return [];
|
||||
}
|
||||
return steps.map((step: Record<string, any>, index) => ({
|
||||
attemptKey: textValue(step.attemptKey) || undefined,
|
||||
duration: numberValue(step.execTime),
|
||||
endTime: timeValue(step.endTime),
|
||||
error: textValue(step.errorInfo) || undefined,
|
||||
hasInput: step.input !== undefined && step.input !== null,
|
||||
hasOutput: step.output !== undefined && step.output !== null,
|
||||
input: parseWorkflowExecutionValue(step.input),
|
||||
key:
|
||||
textValue(step.attemptKey) ||
|
||||
textValue(step.id) ||
|
||||
`${textValue(step.nodeId) || 'node'}:${index}`,
|
||||
nodeId: textValue(step.nodeId),
|
||||
nodeName:
|
||||
textValue(step.nodeName) || textValue(step.nodeId) || '工作流节点',
|
||||
output: parseWorkflowExecutionValue(step.output),
|
||||
startTime: timeValue(step.startTime),
|
||||
status: resolvePersistedStatus(step.status),
|
||||
traces: [],
|
||||
}));
|
||||
const result: WorkflowExecutionStepView[] = steps.map(
|
||||
(step: Record<string, any>, index) => ({
|
||||
attemptKey: textValue(step.attemptKey) || undefined,
|
||||
duration: numberValue(step.execTime),
|
||||
endTime: timeValue(step.endTime),
|
||||
error: textValue(step.errorInfo) || undefined,
|
||||
hasInput: step.input !== undefined && step.input !== null,
|
||||
hasOutput: step.output !== undefined && step.output !== null,
|
||||
input: parseWorkflowExecutionValue(step.input),
|
||||
key:
|
||||
textValue(step.attemptKey) ||
|
||||
textValue(step.id) ||
|
||||
`${textValue(step.nodeId) || 'node'}:${index}`,
|
||||
nodeId: textValue(step.nodeId),
|
||||
nodeName:
|
||||
textValue(step.nodeName) || textValue(step.nodeId) || '工作流节点',
|
||||
output: parseWorkflowExecutionValue(step.output),
|
||||
startTime: timeValue(step.startTime),
|
||||
status: resolvePersistedStatus(step.status),
|
||||
traces: [],
|
||||
}),
|
||||
);
|
||||
if (error?.nodeId) {
|
||||
const failedStep = [...result]
|
||||
.reverse()
|
||||
.find((step) => step.nodeId === error.nodeId && step.status === 'failed');
|
||||
if (failedStep) {
|
||||
failedStep.errorDetail = error;
|
||||
failedStep.error ||= error.message;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,13 +203,22 @@ export function finalizeWorkflowExecutionSteps(
|
||||
steps: WorkflowExecutionStepView[],
|
||||
status: 'cancelled' | 'completed' | 'failed',
|
||||
now = Date.now(),
|
||||
error?: WorkflowExecutionError,
|
||||
): WorkflowExecutionStepView[] {
|
||||
let changed = false;
|
||||
const next = steps.map((step) => {
|
||||
if (step.status !== 'running' && step.status !== 'waiting') {
|
||||
if (!['retrying', 'running', 'waiting'].includes(step.status)) {
|
||||
return step;
|
||||
}
|
||||
changed = true;
|
||||
let finalStatus: WorkflowExecutionStepStatus = status;
|
||||
if (step.status === 'retrying') finalStatus = 'failed';
|
||||
else if (status === 'failed' && error?.nodeId !== step.nodeId)
|
||||
finalStatus = 'cancelled';
|
||||
let detail = step.errorDetail
|
||||
? { ...step.errorDetail, retryable: false }
|
||||
: undefined;
|
||||
if (error?.nodeId === step.nodeId) detail = error;
|
||||
return {
|
||||
...step,
|
||||
duration:
|
||||
@@ -186,7 +226,9 @@ export function finalizeWorkflowExecutionSteps(
|
||||
? step.duration
|
||||
: Math.max(0, now - step.startTime),
|
||||
endTime: now,
|
||||
status,
|
||||
status: finalStatus,
|
||||
error: error?.nodeId === step.nodeId ? error.message : step.error,
|
||||
errorDetail: detail,
|
||||
};
|
||||
});
|
||||
return changed ? next : steps;
|
||||
@@ -272,7 +314,9 @@ function resolveLiveStatus(
|
||||
error: unknown,
|
||||
): WorkflowExecutionStepStatus {
|
||||
const normalized = textValue(status).toUpperCase();
|
||||
if (error || normalized === 'ERROR' || normalized === 'FAILED') {
|
||||
if (normalized === 'ERROR') return 'retrying';
|
||||
if (normalized === 'SUCCEEDED') return 'completed';
|
||||
if (error || normalized === 'FAILED') {
|
||||
return 'failed';
|
||||
}
|
||||
if (normalized === 'SUSPEND') {
|
||||
@@ -289,7 +333,9 @@ function resolvePersistedStatus(status: unknown): WorkflowExecutionStepStatus {
|
||||
case '5': {
|
||||
return 'waiting';
|
||||
}
|
||||
case '10':
|
||||
case '10': {
|
||||
return 'failed';
|
||||
}
|
||||
case '21': {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface WorkflowExecutionError {
|
||||
code: string;
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
reasonCode: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
/** 只复制排查所需的公开字段,避免带出输入、输出及原始异常。 */
|
||||
export function formatWorkflowErrorContext(
|
||||
error: WorkflowExecutionError,
|
||||
executeId?: string,
|
||||
) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
executeId,
|
||||
nodeId: error.nodeId,
|
||||
nodeName: error.nodeName,
|
||||
reasonCode: error.reasonCode,
|
||||
message: error.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user