diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 00747e0a..5798cc83 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -561,7 +561,11 @@ function readingOf({ state, view, type, config, skewMs, timed, fanIn, facts, sin } case 'rejected': // 被人驳回(node.failed 带 reason=rejected):人的决定,不是失败。状态字已经写了「被驳回」,槽里写原话的第一句 - // (「人工驳回:数字对不上」),整句放悬停;不按失败去归类、讲处理建议 + // (「人工驳回:数字对不上」),整句放悬停;不按失败去归类、讲处理建议。接了「驳回」出口的(node.finished 带 + // outcome=rejected,n.exited)这一步照常结束、运行接着走:槽里写转到了哪几步 + if (n?.exited) { + return { main: formatDuration(n.lastDurationMs ?? runtime?.durationMs), note: REJECTED_TEXT.exitCard(n.exitTo ?? []) } + } return { main: formatDuration(n?.lastDurationMs ?? runtime?.durationMs), note: error ? firstSentence(error) : REJECTED_TEXT.card, diff --git a/frontend/src/canvas/RunTimeline.tsx b/frontend/src/canvas/RunTimeline.tsx index 2b2a731c..776b8249 100644 --- a/frontend/src/canvas/RunTimeline.tsx +++ b/frontend/src/canvas/RunTimeline.tsx @@ -5,7 +5,7 @@ import { create } from 'zustand' import { StatusBadge } from '../components/ui' import { formatClock, formatDuration, formatLapse, formatOffset, formatSpan, formatTokens, NONE } from '../lib/format' import { statusMeta } from '../lib/status' -import { topology, type GraphLike } from '../run/derive' +import { topology, withSubSteps, type GraphLike } from '../run/derive' import { isActivePhase, isTerminal, lastEventAt, liveAt, project, type NodeState, type NodeTrace, type Projection, type Segment, type Trace, @@ -339,7 +339,8 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map [n.id, n])) - const ids = [...topo.order, ...Object.keys(trace.nodes).filter((id) => !byId.has(id))] + // 子工作流里的步骤排在子工作流那一行下面(withSubSteps),老运行里改过图的照旧排在最后 + const ids = withSubSteps(topo.order, Object.keys(trace.nodes).filter((id) => !byId.has(id))) const failedLabel = trace.failedNodeId ? labels.get(trace.failedNodeId) ?? trace.failedNodeId : '' const rows: Row[] = [] let top = 0 diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index 7b6e0d28..3f3d5939 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -4,7 +4,7 @@ import { Gauge, GitBranch, GitCompareArrows, Hand, Repeat, Search, ShieldAlert, Shuffle, Users, Wrench, } from 'lucide-react' import { - APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL, + APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, REJECTED_TEXT, UPGRADE_POLICY_LABEL, } from '../lib/terms' import type { NodeType } from '../types' import { dualCheckFields, dualCheckSummary } from './DualCheckFields' @@ -323,7 +323,8 @@ export const NODE_DEFS: Record = { tool: { type: 'tool', label: NODE_TYPE_LABEL.tool, category: '执行', icon: Wrench, description: '直接调用一个指定工具', - hasTarget: true, sources: [{ id: 'out', label: '' }], + // 出口由配置决定:会请人审批(工具调用审批、写入前的动作确认)时多一个「驳回」出口,见 sourceHandles + hasTarget: true, sources: null, fields: [ { key: 'tool', label: '工具', type: 'tools', help: '只能选一个' }, { key: 'args', label: '参数', type: 'json', syntax: 'template', help: '值中可用 {{ }} 引用上游数据' }, @@ -354,7 +355,8 @@ export const NODE_DEFS: Record = { code: { type: 'code', label: NODE_TYPE_LABEL.code, category: '执行', icon: Code2, description: '在 microVM 或系统沙箱中执行代码', - hasTarget: true, sources: [{ id: 'out', label: '' }], + // 审批策略设成每次审批时多一个「驳回」出口,见 sourceHandles + hasTarget: true, sources: null, fields: [ { key: 'language', label: '语言', type: 'select', @@ -699,7 +701,8 @@ export const NODE_DEFS: Record = { dual_check: { type: 'dual_check', label: NODE_TYPE_LABEL.dual_check, category: '把关', icon: GitCompareArrows, description: DUAL_CHECK_TEXT.description, - hasTarget: true, sources: [{ id: 'out', label: '' }], + // 两路不一致时请人裁决,裁决可以驳回:普通出口之外有一个「驳回」出口,见 sourceHandles + hasTarget: true, sources: null, // 配置的键和缺省见 types.ts 的 DualCheckConfig(期 3 约定)。口径卡、指标、口径说明要看画布上别的节点, // 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看。 // 盲算模型不放进高级选项:时间线上「模型相同」那一行的「打开设置」要定位到它,高级选项收着时定位不到 @@ -821,10 +824,38 @@ export function sourceHandles(type: NodeType, config: Record): Hand if (config.mode === 'approve') { return [ { id: 'approved', label: '批准', color: 'var(--ok)' }, - { id: 'rejected', label: '驳回', color: 'var(--err)' }, + REJECT_EXIT, ] } - return [{ id: 'out', label: '' }] + return [{ id: 'out', label: '' }, REJECT_EXIT] } + if (canReject(type, config)) return [{ id: 'out', label: '' }, REJECT_EXIT] return [{ id: 'out', label: '' }] } + +/** + * 「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):接了它,驳回(含超时驳回)时这一步正常结束、沿它往下走; + * 没接,驳回让运行结束。和后端 engine/schema.REJECT_HANDLE 同一个 id。颜色是中性的:驳回是人的决定,不是故障 + */ +export const REJECT_HANDLE = 'rejected' +const REJECT_EXIT: HandleDef = { id: REJECT_HANDLE, label: REJECTED_TEXT.exit, color: 'var(--text-dim)' } + +/** 数据源的查询、看表结构:只读,不会请人审批(除非节点上明说了每次都审批) */ +const READ_ONLY_TOOL = /^db_(?:query|schema)__/ + +/** + * 这个节点会不会被人驳回,画布上给不给「驳回」出口(后端认的六类关卡:engine/schema.GATE_TYPES)。人工节点、双盲复算一律 + * 给(审批、补充输入、编辑稿件、裁决都能驳回);沙箱代码只在审批策略是每次审批时问人;调用工具选了工具、审批策略不是 + * 从不审批时可能问人(工具调用审批、写入前的动作确认),数据源的查询只读,只在明说了每次审批时问。不给出口的节点上连着的 + * 驳回边,改配置时由 studio 收掉(出口跟着配置算) + */ +export function canReject(type: NodeType, config: Record): boolean { + if (type === 'human' || type === 'dual_check') return true + if (type === 'code') return config.approval === 'always' + if (type === 'tool') { + if (config.approval === 'always') return true + const tool = String(config.tool ?? '') + return config.approval !== 'never' && !!tool && !READ_ONLY_TOOL.test(tool) + } + return false +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8f01ebb6..2c5b1c90 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -313,6 +313,10 @@ export const EVIDENCE_TEXT = { originChipHint: '这个数由口径卡确定性算出、复算一致,但算式用到的输入没有一路追到查询快照', /** 按位置取的格穿过了原样解析查询结果的整形节点 */ through: (label: string) => `经「${label}」原样解析`, + /** 读子工作流出口的字段,出处接上子图里的那一格(docs/design/orchestration-round/00-总方案.md §2) */ + viaSubgraph: (label: string, field: string) => (field ? `经子工作流「${label}」的出口字段「${field}」` : `经子工作流「${label}」`), + /** 停在子工作流的入口:子图的运行输入是父图按「输入」模板拼出来的,不是原始数据 */ + subgraphInput: '子工作流的运行输入', /** * 转抄另一张口径卡的指标(对账卡、召回卡的「口径」那一项):出处跨卡往下追,接上那个指标自己的一段,一直到查询格子 */ @@ -5500,6 +5504,25 @@ export const REJECTED_TEXT = { }, /** 画布节点卡上被驳回的那一步:一句话,悬停写原话 */ card: '被驳回', + /** + * 「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):审批、补充输入、编辑稿件、动作确认、代码和工具审批、 + * 双盲裁决接了它时,驳回之后沿它往下走。画布上出口的字 + */ + exit: '驳回', + /** 节点卡、航迹上走了驳回出口的那一步:「被驳回,转到「通知申请人」」(转到哪几步取 node.finished 的 to) */ + exitCard: (to: string[]): string => (to.length ? `被驳回,转到${to.map((t) => `「${t}」`).join('、')}` : '被驳回,走驳回出口'), + /** 时间线、问数据过程里这一步的副标题:谁、备注,再接上转到哪几步 */ + exitStep: (d: { actor?: unknown; note?: unknown; decision?: unknown; to?: unknown }): string => { + const to = Array.isArray(d.to) ? d.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean) : [] + return `${REJECTED_TEXT.step(d)},${to.length ? `转到${to.map((t) => `「${t}」`).join('、')}` : '走驳回出口'}` + }, + /** 运行照驳回那条路结束:终态事件的 rejected_via,一步一句「「审批」被驳回,转到「通知申请人」」 */ + via: (items: unknown): string => (Array.isArray(items) ? items : []) + .map((v: any) => `${typeof v?.label === 'string' && v.label ? `「${v.label}」` : '这一步'}${REJECTED_TEXT.exitCard( + Array.isArray(v?.to) ? v.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean) : [])}`) + .join(';'), + /** 途经驳回的那一行的标题 */ + viaTitle: '途经驳回', } /** 来源标签:老运行没有来源时写「来源未记录」 */ diff --git a/frontend/src/pages/runs/TracePane.tsx b/frontend/src/pages/runs/TracePane.tsx index c165b23c..027074bf 100644 --- a/frontend/src/pages/runs/TracePane.tsx +++ b/frontend/src/pages/runs/TracePane.tsx @@ -12,7 +12,7 @@ import { NONE, formatCost, formatDateTime, formatNumber, formatOffset, formatSpan, formatTokens, shortId, } from '../../lib/format' import { COST_TEXT, REJECTED_TEXT, runClassLabel, nodeTypeLabel, runCostText } from '../../lib/terms' -import { topology, type GraphLike } from '../../run/derive' +import { topology, withSubSteps, type GraphLike } from '../../run/derive' import { isActivePhase, isSettled, liveAt, project, type NodeState, type Projection, type RunPhase, type Trace, } from '../../run/trace' @@ -123,8 +123,8 @@ export function TracePane({ // 回放时的节点状态要按图再推一遍(排队、阻断、未到达),和画布卡片读同一份 const view = useMemo(() => (live ? trace : replayTraceCached(trace, replayAt!, g as GraphLike)), [live, trace, replayAt, g]) const order = useMemo(() => { - const ids = topology(g as GraphLike).order - return [...ids, ...Object.keys(trace.nodes).filter((id) => !ids.includes(id))] + // 子工作流里的步骤(「子工作流节点 id/…」)排在子工作流那一步后面 + return withSubSteps(topology(g as GraphLike).order, Object.keys(trace.nodes)) }, [g, trace.nodes]) const stateOf = (id: string): NodeState => view.nodes[id]?.state ?? 'idle' const facts = useMemo(() => factsOf(events), [events]) @@ -414,6 +414,10 @@ function momentsOf( } } else if (s.kind === 'retry') { out.push({ key: `${id}:t${i}`, at: s.start, status: 'running', text: `${name(id)}重试`, nodeId: id }) + } else if (s.kind === 'run' && s.status === 'rejected' && s.end != null && n.exited) { + // 被驳回、走了「驳回」出口:运行接着走,这一下是路上的一个时刻(没接出口的驳回由结局那一条说) + out.push({ key: `${id}:x${i}`, at: s.end, status: 'rejected', nodeId: id, + text: `${name(id)}${REJECTED_TEXT.exitCard(n.exitTo ?? [])}` }) } else if (s.kind === 'run' && s.status === 'failed' && s.end != null) { // 让整次运行失败的那一下由结局那一条说,这里只列失败了还接着走下去的 if (ended && trace.phase === 'failed' && id === trace.failedNodeId && i === lastFail) return @@ -447,16 +451,20 @@ function momentsOf( }) if (ended) { const failed = trace.phase === 'failed' ? trace.failedNodeId : undefined + // 照驳回出口那条路走完的:结局照实说途经驳回(和终态事件的 rejected_via 同一件事) + const via = order.filter((id) => trace.nodes[id]?.exited) const why = trace.phase === 'failed' ? explainRunError((failed && trace.nodes[failed]?.error) || error).title : code === 'rejected' ? error?.trim() || undefined : trace.phase === 'cancelled' ? cancelReason(error) ?? undefined - : trace.phase === 'suspended' ? error?.trim() || undefined : undefined + : trace.phase === 'suspended' ? error?.trim() || undefined + : via.length ? via.map((v) => `${name(v)}${REJECTED_TEXT.exitCard(trace.nodes[v]?.exitTo ?? [])}`).join(';') + : undefined out.push({ key: 'end', at: trace.endedAt!, end: true, // 挂起分两种(服务重启、可续跑),详情头已经分好了,照它的说 status: trace.phase === 'suspended' ? code : trace.phase, - text: trace.phase === 'succeeded' ? '运行完成' + text: trace.phase === 'succeeded' ? (via.length ? `运行完成(${REJECTED_TEXT.viaTitle})` : '运行完成') : failed ? `失败于${name(failed)}` : trace.phase === 'failed' ? '运行失败' : code === 'rejected' ? REJECTED_TEXT.title @@ -609,11 +617,15 @@ function NodeCard({ id, trace, proj, state, graph, facts, at, labelOf, href, gon const error = state === 'failed' && n?.error ? explainRunError(n.error) : null const rows: [string, ReactNode][] = [] if (n?.model) rows.push(['模型', {n.model}]) - if (n?.takenHandle && (state === 'done' || state === 'failed')) rows.push(['出口', {n.takenHandle}]) + if (n?.takenHandle && (state === 'done' || state === 'failed' || (state === 'rejected' && n.exited))) { + rows.push(['出口', {n.takenHandle}]) + } if (n?.reason) rows.push(['理由', n.reason]) if (state === 'skipped' && n?.skippedReason) rows.push(['跳过', n.skippedReason]) - // 被人驳回的那一步(node.failed 带 reason=rejected):写原话(谁驳回、为什么),不按失败去讲原因和处理建议 - if (state === 'rejected' && n?.error) rows.push([REJECTED_TEXT.title, n.error]) + // 被人驳回的那一步(node.failed 带 reason=rejected):写原话(谁驳回、为什么),不按失败去讲原因和处理建议。走了驳回 + // 出口的写转到了哪几步 + if (state === 'rejected' && n?.exited) rows.push([REJECTED_TEXT.title, REJECTED_TEXT.exitCard(n.exitTo ?? [])]) + else if (state === 'rejected' && n?.error) rows.push([REJECTED_TEXT.title, n.error]) // 卡片说的是游标那一刻所在的那一次执行(和画布卡片同一个划分):循环上一轮、接着跑之前 // 的事不挂到这一次干净的执行上;回放到更早的一次时,也不因为后面还有一件就看不到这一次的 const since = execStart(n, at, facts) diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index a1a27d27..bb33cc52 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -1013,7 +1013,7 @@ type InputLink = { index: number; alias: string; go: () => void; text?: string } * 面板里提到的节点。画布右栏里是按钮:点一下在画布上选中并对准它(画布上已经没有这个节点 * 时不给按钮,点了会落空);别处照旧写节点 id */ -function NodeChip({ id, prefix }: { id?: string | null; prefix?: string }) { +function NodeChip({ id, prefix, fallback }: { id?: string | null; prefix?: string; fallback?: string }) { const host = useEvidenceHost() if (!id) return null const label = host.nodeLabel?.(id) @@ -1025,7 +1025,8 @@ function NodeChip({ id, prefix }: { id?: string | null; prefix?: string }) { ) } - return {prefix}{id} + // 画布上没有这个节点(子工作流里的那一步「子工作流节点 id/…」):写证据接口给的名字,悬停写 id + return {prefix}{fallback || id} } /** @@ -1559,10 +1560,14 @@ function InputChip({ input, link }: { input: EvidenceInput; link?: InputLink }) const masked = !missing && (maskedOf(input).length > 0 || !!unverified) // Agent 交来、被截断切开的数组字段:对它计数、求和只算到了取回的那部分 const cut = !missing && input.truncated === true - const node = input.node_id ? host.nodeLabel?.(input.node_id) || input.node_id : '' + const node = input.node_id ? host.nodeLabel?.(input.node_id) || input.node_label || input.node_id : '' const via = input.via ? VIA_TEXT[input.via] ?? nodeTypeLabel(input.via) : '' const through = throughText(input, host.nodeLabel) - const from = [node && `来自节点「${node}」`, via && `(${via})`, through && ` · ${through}`, + // 读子工作流出口的字段:写明经过哪个子工作流的哪个字段(出处接上的是子图里的那一格) + const sub = input.subgraph + ? EVIDENCE_TEXT.viaSubgraph(host.nodeLabel?.(input.subgraph) || input.subgraph_label || input.subgraph, input.field ?? '') + : '' + const from = [node && `来自节点「${node}」`, via && `(${via})`, sub && ` · ${sub}`, through && ` · ${through}`, input.role && ` · ${ROLE_TEXT[input.role] ?? input.role}`, cut && ` · ${EVIDENCE_TEXT.inputTruncated}`, masked && ` · ${unverified ? EVIDENCE_TEXT.unverifiedInput(unverified) : EVIDENCE_TEXT.maskedInput}`].filter(Boolean).join('') @@ -1597,7 +1602,9 @@ function InputChip({ input, link }: { input: EvidenceInput; link?: InputLink }) } /** 取值来源的方式:能追到快照的两种写中文,其余是产出节点的类型 */ -const VIA_TEXT: Record = { agent_field: 'Agent 字段', tool_cell: '单元格取数' } +const VIA_TEXT: Record = { + agent_field: 'Agent 字段', tool_cell: '单元格取数', subgraph_input: EVIDENCE_TEXT.subgraphInput, +} /** 按位置取的格穿过了原样解析查询结果的整形节点:「经「解析查询结果」原样解析」。cell() 取的格、别的输入没有 */ function throughText(input: EvidenceInput, nodeLabel?: (id: string) => string | undefined): string { @@ -1713,7 +1720,9 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp const alias = (link && !link.text ? link.alias : '') || inp.query || (typeof inp.cell === 'string' ? inp.cell.split('.')[0] : '') const where = [ - inp.field && (inp.via === 'agent_field' ? `字段 ${inp.field}` : inp.field), + inp.subgraph + ? EVIDENCE_TEXT.viaSubgraph(host.nodeLabel?.(inp.subgraph) || inp.subgraph_label || inp.subgraph, inp.field ?? '') + : inp.field && (inp.via === 'agent_field' ? `字段 ${inp.field}` : inp.field), throughText(inp, host.nodeLabel), [alias, locatorText(inp.locator)].filter(Boolean).join(' · '), ].filter(Boolean).join(' · ') @@ -1732,7 +1741,7 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp {(inp.node_id || where) && (
- {inp.node_id && } + {inp.node_id && } {inp.node_id && where && ' · '}{where}
)} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 002b6261..0ab3ccfd 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -1892,7 +1892,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt // 节点类型的中文名,至少能看出这步在干嘛 const label = String(d.label ?? '') const typeName = TYPE_LABEL[String(d.node_type ?? '')] - const title = (label && label !== nodeId ? label : typeName) || nodeId || '执行步骤' + // 子工作流里的一步(节点 id「子工作流节点 id/…」,docs/design/orchestration-round/00-总方案.md §2):前面接上 + // 子工作流那一步的名字,看得出这是哪个子工作流里的 + const parentTitle = nodeId?.includes('/') ? nodeSteps.get(nodeId.slice(0, nodeId.lastIndexOf('/')))?.title : undefined + const own = (label && label !== nodeId?.slice(nodeId.lastIndexOf('/') + 1) && label !== nodeId ? label : typeName) + || nodeId || '执行步骤' + const title = parentTitle ? `${parentTitle} › ${own}` : own const iteration = num(d.iteration) if (nodeId && !spans.has(nodeId)) { spans.set(nodeId, { start: event.ts ?? 0, end: event.ts ?? 0, ms: 0 }) @@ -1981,11 +1986,15 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt } closeLlm(nodeId, 'done', at) const step = nodeId ? nodeSteps.get(nodeId) : undefined + // 被驳回、走了「驳回」出口(node.finished 带 outcome=rejected):这一步照常结束、运行接着往下走,状态写「被驳回」, + // 副标题写谁、备注、转到哪几步;不画红、不当成出错 + const exited = d.outcome === 'rejected' if (step) { const ms = num(d.duration_ms) const cur = execOf(nodeId) - if (cur) { cur.status = 'done'; if (ms != null) cur.ms = ms } - step.status = 'done' + if (cur) { cur.status = exited ? 'rejected' : 'done'; if (ms != null) cur.ms = ms } + step.status = exited ? 'rejected' : 'done' + if (exited) { step.level = undefined; step.sub = REJECTED_TEXT.exitStep(d) } step.artifact = d.artifact || step.artifact const execs = step.execs ?? [] if (execs.length > 1) { @@ -2801,9 +2810,13 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt const timing = d.timing ?? {} const active = num(timing.active_ms) ?? num(d.duration_ms) const wait = num(timing.wait_ms) + // 途经驳回(终态事件带 rejected_via,docs/design/orchestration-round/00-总方案.md §3):照驳回那条路走完的,照实说 + // 在哪一步被驳回、转到了哪里。没有途经的和以前一字不差 + const via = REJECTED_TEXT.via(d.rejected_via) out.push({ id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done', - title: '完成', ms: active, + title: via ? `完成(${REJECTED_TEXT.viaTitle})` : '完成', ms: active, + ...(via ? { sub: via } : {}), // 等人的时间单独说:审批停了三分钟的运行,"执行 1.2 s"和"用了 3 分钟" // 都对,混成一个数就哪个都不对了 meta: [dur(active), wait ? `等待审批 ${formatDuration(wait)}` : ''].filter(Boolean).join(' · ') diff --git a/frontend/src/run/derive.ts b/frontend/src/run/derive.ts index 3d669e68..895fc828 100644 --- a/frontend/src/run/derive.ts +++ b/frontend/src/run/derive.ts @@ -121,19 +121,63 @@ export function topology(graph: GraphLike): Topology { return topo } +/** + * 拓扑序里插上图上没有的那几个节点:子工作流里的步骤(节点 id「子工作流节点 id/…」,docs/design/orchestration-round/00-总方案.md + * §2)排在它们的子工作流节点后面(嵌套的接在上一层后面),别的(老运行里改过图的)照旧排在最后。泳道、航迹的节点列表用它 + */ +export function withSubSteps(order: string[], ids: string[]): string[] { + const known = new Set(order) + const extra = ids.filter((id) => !known.has(id)) + if (!extra.length) return order + const under = new Map() + const rest: string[] = [] + for (const id of extra) { + const cut = id.lastIndexOf('/') + const parent = cut > 0 ? id.slice(0, cut) : '' + if (parent && (known.has(parent) || extra.includes(parent))) { + const list = under.get(parent) + if (list) list.push(id) + else under.set(parent, [id]) + } else rest.push(id) + } + const out: string[] = [] + const put = (id: string) => { + out.push(id) + for (const child of under.get(id) ?? []) put(child) + } + for (const id of order) put(id) + for (const id of rest) put(id) + return out +} + const continueOnError = (n: GraphLike['nodes'][number] | undefined): boolean => n?.data?.config?.on_error === 'continue' +/** 会被人驳回、能接「驳回」出口的节点(后端 engine/schema.GATE_TYPES)。出口 id 同 canvas/nodeDefs 的 REJECT_HANDLE */ +const GATE_TYPES = new Set(['human', 'tool', 'code', 'dual_check']) +const REJECT = 'rejected' + +/** + * 审批模式以外的关卡接了驳回出口:只有两条路,驳回出口和其余的(普通出口)。和 compiler.py 的 _make_router 一致: + * 驳回出口以外的出口一律当 default + */ +function gateRouted(node: GraphLike['nodes'][number] | undefined, outHandles: Set): boolean { + const type = node ? typeOf(node) : undefined + if (!type || !GATE_TYPES.has(type) || !outHandles.has(REJECT)) return false + return !(type === 'human' && (node?.data?.config?.mode ?? 'approve') === 'approve') +} + /** * 这个节点是不是按出口路由的。和 compiler.py 的 needs_routing 一致:分支、循环, - * 以及出口里有通过/驳回的人工审批。图里没带类型时,看它做过的决定能不能对上 + * 出口里有通过/驳回的人工审批,以及接了「驳回」出口的关卡。图里没带类型时,看它做过的决定能不能对上 * 某个出口。 */ function isRouting(node: GraphLike['nodes'][number] | undefined, outHandles: Set, n: NodeTrace | undefined): boolean { const type = node ? typeOf(node) : undefined if (type === 'branch' || type === 'loop') return true - if (type === 'human') return outHandles.has('approved') || outHandles.has('rejected') + if (type === 'human') return outHandles.has('approved') || outHandles.has(REJECT) + if (type && GATE_TYPES.has(type)) return outHandles.has(REJECT) if (type) return false const decision = n?.takenHandle return decision != null && (outHandles.has(decision) || outHandles.has('default')) @@ -166,22 +210,24 @@ function contextOf(graph: GraphLike): Ctx { return ctx } -/** 出口 handle 是否放行:decision 是节点做的某一次决定(undefined = 没做决定) */ +/** 出口 handle 是否放行:decision 是节点做的某一次决定(undefined = 没做决定)。gate:接了驳回出口的关卡(gateRouted) */ function handleOpen(e: GraphEdgeLike, routing: boolean, decision: string | undefined, - outHandles: Set): boolean { + outHandles: Set, gate = false): boolean { if (!routing) return true - const h = e.sourceHandle || 'default' + const raw = e.sourceHandle || 'default' + const h = gate && raw !== REJECT ? 'default' : raw // 路由节点没做出决定(被 skip_if 跳过):路由函数拿到 None,只走 default if (decision == null) return h === 'default' if (h === decision) return true return h === 'default' && !outHandles.has(decision) } -/** 源节点已经把数据送出来了:跑完、被跳过、容错失败,或者循环容器正放行 body */ +/** 源节点已经把数据送出来了:跑完、被跳过、容错失败、被驳回后走了驳回出口,或者循环容器正放行 body */ function sourcePassed(n: NodeTrace | undefined, node: GraphLike['nodes'][number] | undefined): boolean { if (!n) return false if (n.looping) return true return n.state === 'done' || n.state === 'skipped' + || (n.state === 'rejected' && n.exited === true) || (n.state === 'failed' && continueOnError(node)) } @@ -192,7 +238,7 @@ export function edgeFired(trace: Trace, graph: GraphLike, e: GraphEdgeLike): boo const node = ctx.byId.get(e.source) if (!sourcePassed(src, node)) return false const hs = ctx.handles.get(e.source) ?? new Set() - return handleOpen(e, isRouting(node, hs, src), src?.takenHandle, hs) + return handleOpen(e, isRouting(node, hs, src), src?.takenHandle, hs, gateRouted(node, hs)) } /** @@ -241,8 +287,13 @@ export function walkedEdges(trace: Trace, graph: GraphLike): Set { const hs = ctx.handles.get(e.source) ?? new Set() const node = ctx.byId.get(e.source) const routing = isRouting(node, hs, src) - const decisions = src.taken?.length ? src.taken : [undefined] - if (decisions.some((dec) => handleOpen(e, routing, dec, hs))) walked.add(edgeIdOf(e)) + const gate = gateRouted(node, hs) + // 接了驳回出口的关卡:有时走驳回出口(taken 里有 rejected),有时正常结束(没有决定,或者补充输入写的 approved)。 + // 「退回修改」里同一个关卡先被驳回、再被批准,两条出口都走过 + const decisions = src.taken?.length + ? (gate && src.count > src.taken.filter((t) => t === REJECT).length ? [...src.taken, undefined] : src.taken) + : [undefined] + if (decisions.some((dec) => handleOpen(e, routing, dec, hs, gate))) walked.add(edgeIdOf(e)) } return walked } diff --git a/frontend/src/run/trace.ts b/frontend/src/run/trace.ts index b9a29860..08315bb8 100644 --- a/frontend/src/run/trace.ts +++ b/frontend/src/run/trace.ts @@ -84,6 +84,13 @@ export interface NodeTrace { taken?: string[] /** 分支命中的理由 */ reason?: string + /** + * 被驳回、走了「驳回」出口(node.finished 带 outcome=rejected,docs/design/orchestration-round/00-总方案.md §3):这一步正常 + * 结束了、下游照常往下走,状态记 rejected(中性色,不是失败)。exitTo 是驳回出口转到的那几步的名字(node.finished 的 to)。 + * 下一次执行开始时清掉 + */ + exited?: boolean + exitTo?: string[] /** 用量随时间的累计,回放时按游标取那一刻的 tokens 和工具数。没调过模型和工具的节点没有 */ marks?: UsageMark[] } @@ -279,6 +286,8 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb function parallelOf(nodes: Record): number { let total = 0 for (const n of Object.values(nodes)) { + // 子工作流里的那几步(「子工作流节点 id/…」)在子工作流节点那一格里跑,不另算一路并行 + if (n.id.includes('/')) continue let inner = 0 let run = 0 for (const s of n.segments) { @@ -531,6 +540,8 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { n.endedAt = undefined n.lastDurationMs = undefined n.error = undefined + n.exited = undefined + n.exitTo = undefined if (n.toolsRunning) { n.toolsRunning = 0; mark(n) } if (num(d.iteration) != null) n.iteration = num(d.iteration) book().dispatchFrom[nodeId] = at @@ -572,8 +583,10 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { if (!nodeId) break const n = node(nodeId) const failed = type === 'node.failed' - // 被人驳回的那一步(服务端在 node.failed 上带 reason=rejected):这一段收成「被驳回」,不是失败 - const rejected = failed && d.reason === 'rejected' + // 被人驳回的那一步(服务端在 node.failed 上带 reason=rejected):这一段收成「被驳回」,不是失败。接了「驳回」出口的 + // 驳回是 node.finished 带 outcome=rejected:这一步正常结束、沿驳回出口往下走(exited),同样画「被驳回」 + const exited = !failed && d.outcome === 'rejected' + const rejected = (failed && d.reason === 'rejected') || exited // 节点跑完了,决定已经生效(兜底 human.resolved 没发出来的老运行) if (!failed && next.book.asking[nodeId]) delete book().asking[nodeId] const ms = num(d.duration_ms) @@ -604,6 +617,10 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { if (n.toolsRunning) { n.toolsRunning = 0; mark(n) } n.looping = looping n.state = rejected ? 'rejected' : failed ? 'failed' : looping ? 'running' : 'done' + if (exited) { + n.exited = true + n.exitTo = Array.isArray(d.to) ? d.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean) : [] + } if (failed) { // 驳回的原话照样留着(卡片、航迹悬停写得出是谁驳回、为什么),但它不是「最近一次失败」:不拿它当失败的节点 n.error = String(d.error ?? '') diff --git a/frontend/src/run/useNodeView.ts b/frontend/src/run/useNodeView.ts index 7bfc74cc..e914feed 100644 --- a/frontend/src/run/useNodeView.ts +++ b/frontend/src/run/useNodeView.ts @@ -96,6 +96,8 @@ export function replayTraceCached(trace: Trace, at: number, graph: GraphLike): T nodes[id] = { ...n, state, count: p?.count ?? 0, iteration: p?.iteration, taken: decided, takenHandle: decided[decided.length - 1], looping: state === 'running' && looping, + // 走了驳回出口只说那一刻被驳回的那一次(游标停在它之前、它还在等人时不算):下游据此按驳回出口放行 + exited: state === 'rejected' ? n.exited : undefined, } } const out = applyDerived({ ...trace, phase: proj.phase, nodes }, graph) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cce85f52..580db5db 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2176,6 +2176,15 @@ export interface EvidenceInput { * 指标集工件、转抄了几层、那个指标的输入是不是都追到了原始数据(服务端 engine/lineage.metric_copy)。老运行、别的输入没有 */ upstream?: EvidenceUpstreamRef | null + /** + * 子工作流(docs/design/orchestration-round/00-总方案.md §2):读子工作流出口的字段、接上子图里那一格的出处时,经过的子工作流 + * 节点和出口字段(subgraph、field);node_id 落在子工作流里(「子工作流节点 id/…」)时证据接口另给图上的名字 + * (node_label、subgraph_label:「子工作流 › 节点」),画布、记录页认不出这个 id 时照它写。via 为 subgraph_input:停在 + * 子工作流的入口(子图的运行输入不是父图的原始数据) + */ + subgraph?: string + node_label?: string + subgraph_label?: string } /** 转抄的输入跨卡往下追的出处(EvidenceInput.upstream) */ diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 64aacf5c..43d202a9 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2959,5 +2959,78 @@ await section('被驳回的那一步:node.failed 带 reason=rejected 时画「 && old.nodes.out?.state === 'blocked', `${old.nodes.pay?.state} / ${old.failedNodeId} / ${old.nodes.out?.state}`) }) +await section('驳回出口:node.finished 带 outcome=rejected 时这一步画「被驳回」、写转到哪一步,下游照常往下走;终态写「途经驳回」(编排完善 §3)', async () => { + const trace = await import(await load('/src/run/trace.ts')) + const derive = await import(await load('/src/run/derive.ts')) + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data }) + const to = [{ node_id: 'tell', label: '通知申请人' }] + const events = [E(1, 'run.started', null, { nodes: 4 }), E(2, 'node.started', 'pay', { label: '付款' }), + E(3, 'human.requested', 'pay', { title: '确认发送:付款' }), E(4, 'run.interrupted', 'pay', { payload: { node_id: 'pay' } }), + E(5, 'run.resumed', null, {}), E(6, 'node.started', 'pay', { label: '付款', resumed: true }), + E(7, 'human.resolved', 'pay', { approved: false, note: '金额不对', actor: '张工' }), + E(8, 'node.finished', 'pay', { duration_ms: 900, outcome: 'rejected', kind: 'action_confirm', decision: 'reject', + actor: '张工', note: '金额不对', to, + preview: { rejected: true, decision: 'reject', kind: 'action_confirm', __decision__: 'rejected', + text: '「付款」未确认(被驳回),没有发送:金额不对' } }), + E(9, 'node.started', 'tell', { label: '通知申请人' }), E(10, 'node.finished', 'tell', { duration_ms: 5 }), + E(11, 'run.finished', null, { output: {}, rejected_via: [{ node_id: 'pay', label: '付款', kind: 'action_confirm', + decision: 'reject', actor: '张工', note: '金额不对', to }] })] + const steps = flatten(mod.decodeRun(events)) + const pay = steps.find((s) => s.kind === 'node' && s.nodeId === 'pay') + check('这一步写「被驳回」:状态 rejected,不是 failed、不是 error', pay?.status === 'rejected' && pay?.level !== 'error', + `${pay?.status} / ${pay?.level}`) + check('副标题写谁驳回、备注、转到哪一步', pay?.sub === '张工 驳回:金额不对,转到「通知申请人」', pay?.sub) + check('这一次执行也记成被驳回', pay?.execs?.at(-1)?.status === 'rejected', pay?.execs?.at(-1)?.status) + const tell = steps.find((s) => s.kind === 'node' && s.nodeId === 'tell') + check('驳回出口那一步照常跑完', tell?.status === 'done', tell?.status) + const done = steps.find((s) => s.kind === 'lifecycle' && s.id === 'rd-11') + check('终态写「完成(途经驳回)」,副标题写在哪一步被驳回、转到哪', done?.title === '完成(途经驳回)' + && done?.sub === '「付款」被驳回,转到「通知申请人」' && done?.status === 'done', `${done?.title} / ${done?.sub}`) + const plain = flatten(mod.decodeRun([...events.slice(0, 10), E(11, 'run.finished', null, { output: {} })])) + .find((s) => s.kind === 'lifecycle' && s.id === 'rd-11') + check('没有途经驳回的终态和以前一字不差', plain?.title === '完成' && plain?.sub === undefined, `${plain?.title} / ${plain?.sub}`) + + // 航迹与图:关卡状态 rejected、走了驳回出口;普通出口那一步没走到(未到达),驳回出口的边走过了 + const graph = { nodes: [{ id: 'start', type: 'input' }, { id: 'pay', type: 'tool' }, { id: 'out', type: 'output' }, + { id: 'tell', type: 'output' }], + edges: [{ source: 'start', target: 'pay' }, { source: 'pay', target: 'out', sourceHandle: 'out' }, + { source: 'pay', target: 'tell', sourceHandle: 'rejected' }] } + const t = derive.applyDerived(events.reduce(trace.foldEvent, trace.emptyTrace()), graph) + check('航迹:关卡状态 rejected、走了驳回出口', t.nodes.pay?.state === 'rejected' && t.nodes.pay?.exited === true + && t.nodes.pay?.takenHandle === 'rejected', `${t.nodes.pay?.state} / ${t.nodes.pay?.exited} / ${t.nodes.pay?.takenHandle}`) + check('航迹:转到哪一步记着(卡片照它写)', t.nodes.pay?.exitTo?.join() === '通知申请人', t.nodes.pay?.exitTo?.join()) + check('航迹:不记成失败的节点', t.failedNodeId === undefined, t.failedNodeId) + check('航迹:普通出口那一步「未到达」,不是「被阻断」', t.nodes.out?.state === 'unreached', t.nodes.out?.state) + const walked = derive.walkedEdges(t, graph) + check('走过的边:驳回出口那条在,普通出口那条不在', walked.has('pay|tell|rejected') && !walked.has('pay|out|out'), + [...walked].join(' , ')) + // 跑到一半(驳回出口那一步还没开始):它在排队,普通出口那一步没有 + const mid = derive.applyDerived(events.slice(0, 8).reduce(trace.foldEvent, trace.emptyTrace()), graph) + // 没有事件、推导下来也还是 idle 的节点不进航迹(applyDerived 只补变了的) + check('跑到一半:驳回出口那一步排队,普通出口那一步没动', mid.nodes.tell?.state === 'queued' + && (mid.nodes.out?.state ?? 'idle') === 'idle', `${mid.nodes.tell?.state} / ${mid.nodes.out?.state ?? 'idle'}`) + // 批准了:正常结束(没有 outcome),走普通出口,驳回出口那一步未到达 + const ok = [...events.slice(0, 7), E(8, 'node.finished', 'pay', { duration_ms: 900, preview: { receipt: { id: 'R1' } } }), + E(9, 'node.started', 'out', { label: '成果' }), E(10, 'node.finished', 'out', {}), E(11, 'run.finished', null, { output: {} })] + const t2 = derive.applyDerived(ok.reduce(trace.foldEvent, trace.emptyTrace()), graph) + check('批准了:关卡完成、驳回出口那一步未到达', t2.nodes.pay?.state === 'done' && !t2.nodes.pay?.exited + && t2.nodes.tell?.state === 'unreached', `${t2.nodes.pay?.state} / ${t2.nodes.tell?.state}`) + const walked2 = derive.walkedEdges(t2, graph) + check('批准了:走过的是普通出口那条', walked2.has('pay|out|out') && !walked2.has('pay|tell|rejected'), [...walked2].join(' , ')) +}) + +await section('子工作流里的每一步进了同一个运行(节点 id「子工作流节点 id/…」):时间线的步骤名接上子工作流的名字(编排完善 §2)', async () => { + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data }) + const steps = flatten(mod.decodeRun([E(1, 'run.started', null, { nodes: 2 }), + E(2, 'node.started', 'sub', { label: '取数子流程', node_type: 'subgraph' }), + E(3, 'node.started', 'sub/fetch', { label: '查订单', node_type: 'tool' }), E(4, 'node.finished', 'sub/fetch', {}), + E(5, 'node.started', 'sub/o', { label: 'o', node_type: 'output' }), E(6, 'node.finished', 'sub/o', {}), + E(7, 'node.finished', 'sub', {}), E(8, 'run.finished', null, { output: {} })])) + const title = (id) => steps.find((s) => s.kind === 'node' && s.nodeId === id)?.title + check('子工作流里的一步:「取数子流程 › 查订单」', title('sub/fetch') === '取数子流程 › 查订单', title('sub/fetch')) + check('没起名字的(名字就是子图里的 id)退到节点类型', title('sub/o') === '取数子流程 › 成果', title('sub/o')) + check('子工作流节点自己照旧', title('sub') === '取数子流程', title('sub')) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index 9c06ecbd..a84c534a 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -974,6 +974,35 @@ await section('sources', '输入来源:与快照一致、模型报 X 快照是 check('Esc 关掉面板,焦点回到原片段', await panel(page).count() === 0 && await active(page) === qseg('8.7%'), await active(page)) }) +await section('subgraph', '子工作流交回的证据:输入来源写明经哪个子工作流的哪个出口字段,子图里的节点写图上的名字(编排完善 §2)', async () => { + // 父图口径卡读子工作流出口的字段、出处接上子图里那一格时,证据接口在输入步骤上另给 subgraph / field、 + // node_label / subgraph_label(api/evidence 的 _scoped_ids)。画布、记录页认不出「sub/fetch」这种 id:照接口给的名字写 + const target = qseg('37.0元') + const { ctx, page: p, errors } = await probeQ((id, body) => { + if (id !== target) return body + for (const st of body.chain ?? []) { + if (st.step === 'input' && st.path === 'vars.kpi.gmv') { + Object.assign(st, { path: 'nodes.sub.output.gmv', node_id: 'sub/fetch', node_label: '取数子流程 › 查订单', + subgraph: 'sub', subgraph_label: '取数子流程', field: 'gmv' }) + } + } + return body + }) + try { + await openQ(p, '37.0元', { wait: '[data-ev-sources]' }) + const row = p.locator('[data-evidence-panel] [data-ev-source="nodes.sub.output.gmv"]') + const text = await row.innerText().catch(() => '') + check('写明经哪个子工作流的哪个出口字段', text.includes('经子工作流「取数子流程」的出口字段「gmv」'), text) + check('子图里的节点写图上的名字,不写裸 id', text.includes('取数子流程 › 查订单') && !text.includes('sub/fetch'), text) + check('核对状态照常(与快照一致)', text.includes('与快照一致'), text) + const chip = await row.locator('[data-ev-node]').first().getAttribute('title').catch(() => null) + check('悬停照样看得到 id', chip === 'sub/fetch', String(chip)) + check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | ')) + } finally { + await ctx.close() + } +}) + await section('table', '整表:每格是可点的片段,方向键按行列走,值和数同一套线型', async () => { const cells = page.locator(`${Q} table td [data-seg]`) check('整表每个数据格都是可点的片段(3 行 × 3 列)', await cells.count() === 9, String(await cells.count())) diff --git a/scripts/check-run-states.mjs b/scripts/check-run-states.mjs index 64afc46f..a6f04647 100644 --- a/scripts/check-run-states.mjs +++ b/scripts/check-run-states.mjs @@ -35,7 +35,7 @@ const SHOTS = process.env.RUN_STATES_SHOTS ?? '/tmp/agentlab-run-states' // 截图的像素密度:要看清卡片里 11px 的字时设成 2 const DSF = Number(process.env.RUN_STATES_DSF ?? 1) || 1 mkdirSync(SHOTS, { recursive: true }) -// 只跑其中几段(逗号分隔:themes, interact, team, endings, replay, history, issues, exits, reduced),调样式时省时间。 +// 只跑其中几段(逗号分隔:themes, interact, team, endings, replay, history, issues, exits, rejected, reject-exit, reduced),调样式时省时间。 // RUN_STATES_SKIP 反过来,跳过这几段:check-all 把这个检查拆成几份分到不同的道上,最后一份用它 // 兜住「其余全部」,以后新加的段不用改 check-all 也跑得到 const ONLY = (process.env.RUN_STATES_ONLY ?? '').split(',').filter(Boolean) @@ -1562,6 +1562,61 @@ await section('rejected', async () => { await browser.close() }) +// ---------------------------------------------------------------- 驳回出口 + +await section('reject-exit', async () => { + // 驳回出口(编排完善 §3):审批接了「驳回」出口,被驳回时这一步正常结束(node.finished 带 outcome=rejected),沿驳回 + // 那条边往下走。画布上它画「被驳回」(中性色、停止的剪影),槽里写转到哪一步;驳回出口的边走过了、下游照常跑完, + // 普通出口那一侧未到达;运行收成成功(途经驳回),不是取消 + console.log('\n=== 驳回出口 ===') + const EXIT_TAIL = [ + [3, 'human.resolved', 'human', { approved: false, note: '数字对不上', actor: '李工' }], + [0.05, 'run.resumed', null, { actor: '李工' }], + [0.05, 'node.started', 'human', { resumed: true }], + [0.1, 'node.finished', 'human', { duration_ms: 40, outcome: 'rejected', kind: 'approve', decision: 'reject', + actor: '李工', note: '数字对不上', to: [{ node_id: 'fix', label: '补数工单' }], + preview: { rejected: true, approved: false, decision: 'reject', kind: 'approve', note: '数字对不上', + text: '人工驳回:数字对不上', __decision__: 'rejected' } }], + [0.1, 'node.started', 'fix', {}], + [0.1, 'node.finished', 'fix', { duration_ms: 5, preview: { 缺口清单: '退回:数字对不上' } }], + [0.05, 'run.finished', null, { output: { 缺口清单: '退回:数字对不上' }, + rejected_via: [{ node_id: 'human', label: '班长审批', kind: 'approve', decision: 'reject', actor: '李工', + note: '数字对不上', to: [{ node_id: 'fix', label: '补数工单' }] }], + timing: { wall_ms: 60000, active_ms: 40000, wait_ms: 20000 } }], + ] + const EXITED = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE, ...EXIT_TAIL]) + const { browser, page, errors } = await open({ theme: 'dark' }) + await setLod(page, 'full') + await feed(page, EXITED(Date.now())) + await page.waitForTimeout(400) + const all = await cards(page) + check('走驳回出口的那一步:卡片状态是 rejected', all.human?.state === 'rejected', all.human?.state) + check('……剪影是停止,不是失败的三角', all.human?.shape === 'stop', all.human?.shape) + check('……槽里写转到哪一步', /被驳回,转到「补数工单」/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text)) + check('……不写「失败」', !/失败/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text)) + check('驳回出口那一步照常跑完', all.fix?.state === 'done', all.fix?.state) + check('普通出口那一步「未到达」', all.out?.state === 'unreached', all.out?.state) + const edges = await page.evaluate(() => Object.fromEntries(window.__studio.getState().edges + .filter((e) => e.source === 'human').map((e) => { + const el = document.querySelector(`.react-flow__edge[data-id="${CSS.escape(e.id)}"]`) + return [e.sourceHandle, el ? [...el.classList].filter((c) => c.startsWith('sf-e-')).join(' ') : '没画出来'] + }))) + check('驳回出口的边走过了,批准那条没走', /sf-e-walked/.test(edges.rejected ?? '') && /sf-e-unwalked/.test(edges.approved ?? ''), + JSON.stringify(edges)) + const state = await page.evaluate(() => ({ phase: window.__studio.getState().runPhase, + failed: window.__studio.getState().trace.failedNodeId ?? null })) + check('运行收成成功(途经驳回),没有「失败的节点」', state.phase === 'succeeded' && state.failed === null, JSON.stringify(state)) + // 出口:驳回出口是中性色,写「驳回」 + const exit = await page.evaluate(() => { + const labels = [...document.querySelectorAll('.react-flow__node[data-id="human"] .nc-exit')].map((el) => el.textContent) + return labels + }) + check('审批的两个出口:批准、驳回', exit.join() === '批准,驳回', exit.join()) + shots.push(await shot(page, 'dark-reject-exit')) + check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | ')) + await browser.close() +}) + // ---------------------------------------------------------------- 关掉动效 await section('reduced', async () => { diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index 2e890c13..87a80fd4 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -3025,6 +3025,77 @@ await section('应用平台:人工节点的流程配置、入口字段的类 } }) +await section('驳回出口:会被人驳回的节点多一个「驳回」出口,能连、能显示;改配置不再会被驳回时连线一并收掉(编排完善 §3)', async () => { + FAKES['st-reject'] = wf('st-reject', { name: '__studio_check_reject__', version: 1, graph: { + nodes: [ + node('in', 'input', 0, 200, '入口', { fields: [{ name: 'q' }] }), + node('ask', 'human', 300, 60, '补充材料', { mode: 'input', title: '补一下' }), + node('ok', 'human', 300, 260, '主管审批', { mode: 'approve', title: '可以吗' }), + node('send', 'tool', 600, 60, '发通知', { tool: 'http_request', args: {}, approval: 'always' }), + node('query', 'tool', 600, 260, '查订单', { tool: 'db_query__shop', args: { sql: 'SELECT 1' } }), + node('run', 'code', 600, 460, '跑脚本', { code: 'print(1)', language: 'python', approval: 'always' }), + node('calc', 'code', 600, 660, '算一下', { code: 'print(2)', language: 'python' }), + node('out', 'output', 900, 200, '成果', { fields: [{ name: '结果', value: '{{ last_message }}' }] }), + node('back', 'output', 900, 460, '退回', { fields: [{ name: '结果', value: '退回了' }] }), + ], + edges: [edge('in', 'ask'), edge('in', 'ok'), edge('ask', 'send', 'out'), edge('ok', 'query', 'approved'), + edge('send', 'out', 'out'), edge('query', 'out', 'out'), edge('ok', 'run', 'rejected'), edge('run', 'out', 'out'), + edge('calc', 'out', 'out')], + } }) + try { + const { ctx, page, errors } = await open({ path: '/studio/st-reject' }) + await page.waitForTimeout(500) + const exits = (id) => page.evaluate((x) => [...document.querySelectorAll( + `.react-flow__node[data-id="${x}"] .react-flow__handle.source`)].map((h) => h.getAttribute('data-handleid')), id) + const labels = (id) => page.evaluate((x) => [...document.querySelectorAll( + `.react-flow__node[data-id="${x}"] .nc-exit`)].map((el) => el.textContent), id) + check('补充输入:普通出口 + 驳回', (await exits('ask')).join() === 'out,rejected', (await exits('ask')).join()) + check('……出口上写「驳回」', (await labels('ask')).join() === '驳回', (await labels('ask')).join()) + check('审批:批准 + 驳回(同一个 id)', (await exits('ok')).join() === 'approved,rejected', (await exits('ok')).join()) + check('调用工具(每次审批):普通出口 + 驳回', (await exits('send')).join() === 'out,rejected', (await exits('send')).join()) + check('数据源查询(只读、不问人):只有普通出口', (await exits('query')).join() === 'out', (await exits('query')).join()) + check('沙箱代码(每次审批):普通出口 + 驳回', (await exits('run')).join() === 'out,rejected', (await exits('run')).join()) + check('沙箱代码(不审批):只有普通出口', (await exits('calc')).join() === 'out', (await exits('calc')).join()) + + // 连:从补充输入的驳回出口连到「退回」 + await S(page, () => window.__studio.getState().onConnect({ source: 'ask', target: 'back', sourceHandle: 'rejected', targetHandle: null })) + await page.waitForTimeout(300) + const wired = await S(page, () => window.__studio.getState().edges + .filter((e) => e.source === 'ask').map((e) => `${e.sourceHandle}->${e.target}`).sort()) + check('驳回出口连上了(普通出口那条照旧)', wired.join() === 'out->send,rejected->back', wired.join()) + const drawn = await page.evaluate(() => { + const e = window.__studio.getState().edges.find((x) => x.source === 'ask' && x.sourceHandle === 'rejected') + return !!e && !!document.querySelector(`.react-flow__edge[data-id="${CSS.escape(e.id)}"] .react-flow__edge-path`) + }) + check('……画得出来', drawn) + // 改配置:审批策略改成从不审批,工具不再会被驳回,驳回出口和连在上面的线一起收掉 + await S(page, () => window.__studio.getState().onConnect({ source: 'send', target: 'back', sourceHandle: 'rejected', targetHandle: null })) + await page.waitForTimeout(200) + await S(page, () => { + const n = window.__studio.getState().nodes.find((x) => x.id === 'send') + window.__studio.getState().updateNode('send', { config: { ...n.data.config, approval: 'never' } }) + }) + await page.waitForTimeout(300) + check('改成从不审批:驳回出口没了', (await exits('send')).join() === 'out', (await exits('send')).join()) + const left = await S(page, () => window.__studio.getState().edges + .filter((e) => e.source === 'send').map((e) => `${e.sourceHandle}->${e.target}`)) + check('……连在驳回出口上的线一并收掉,普通出口的线还在', left.join() === 'out->out', left.join()) + // 补充输入改成审批:普通出口的线跟到「批准」,驳回出口的线还在 + await S(page, () => { + const n = window.__studio.getState().nodes.find((x) => x.id === 'ask') + window.__studio.getState().updateNode('ask', { config: { ...n.data.config, mode: 'approve' } }) + }) + await page.waitForTimeout(300) + const moved = await S(page, () => window.__studio.getState().edges + .filter((e) => e.source === 'ask').map((e) => `${e.sourceHandle}->${e.target}`).sort()) + check('补充输入改成审批:普通出口的线跟到批准,驳回的线还在', moved.join() === 'approved->send,rejected->back', moved.join()) + check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | ')) + await ctx.close() + } finally { + delete FAKES['st-reject'] + } +}) + await section('受管工作流在画布上改过之后:页头、版本历史、选择器照当前发布版的级别写「受管」;和发布版内容相同时不写领先', async () => { // 画布上改过之后 status 回到草稿,当前发布版仍是受管级(published_level)。st-govs:撤回改动后保存出来的 v8, // 服务端按图哈希比出和 v6 相同(same_as_published);st-gova:真的改过、领先 2 版