mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:01:58 +00:00
驳回出口与子工作流证据(前端):画布多一个「驳回」出口、走了驳回出口的那一步画「被驳回」、子工作流里的步骤和证据有名字
接后端两个提交(docs/design/orchestration-round/00-总方案.md §2、§3): - canvas/nodeDefs:会被人驳回的节点多一个「驳回」出口(id rejected,和后端 REJECT_HANDLE 同一个,中性色):人工节点三种 模式、双盲复算一律给;沙箱代码只在审批策略是每次审批时给;调用工具选了工具、审批策略不是从不审批时给(数据源的查询 只读,只在明说每次审批时给)。出口跟着配置算:改成不会被驳回时,studio 原有的出口同步把连在驳回出口上的线收掉;补充输入 改成审批时普通出口的线跟到「批准」 - run/trace:node.finished 带 outcome=rejected 时这一步收成 rejected(exited,记下转到哪几步),下一次执行开始时清掉; 子工作流里的步骤不另算一路并行 - run/derive:接了驳回出口的关卡按出口路由(驳回出口 / 其余是普通出口,同 compiler._make_router);走了驳回出口的算 「送出了数据」,下游照常排队、跑完,普通出口那一侧未到达;退回修改时两条出口都算走过;withSubSteps 把子工作流里的 步骤排在子工作流那一步后面(泳道、航迹的节点列表) - run/decode:走了驳回出口的那一步状态 rejected、副标题写谁驳回、备注、转到哪一步;终态写「完成(途经驳回)」和途经的 那几步(run.finished 的 rejected_via),没有途经的一字不变;子工作流里的步骤名接上子工作流的名字 - run/useNodeView:回放时「走了驳回出口」只说被驳回的那一刻 - canvas/NodeCard、pages/runs/TracePane:走了驳回出口的节点卡写「被驳回,转到「X」」,航迹节点卡写出口和转到哪,关键时刻 多一条「「审批」被驳回,转到「X」」、结局写「运行完成(途经驳回)」 - run/EvidencePanel、types:输入来源写明经哪个子工作流的哪个出口字段;子工作流里的节点(「sub/…」,画布上没有)照证据接口 给的名字写(node_label、subgraph_label),悬停写 id;停在子工作流入口的写「子工作流的运行输入」 - lib/terms:REJECTED_TEXT 加出口、走出口那一步、途经驳回的说法;EVIDENCE_TEXT 加经子工作流的说法 检查脚本:check-decode(驳回出口的步骤、航迹、走过的边、跑到一半、批准走普通出口;子工作流步骤名)、check-run-states (reject-exit:卡片状态与剪影、槽里写转到哪、驳回边走过批准边没走、运行成功)、check-studio(各类节点的出口、能连能画、 改配置收掉驳回线、改模式线跟过去)、check-evidence(subgraph:输入来源写明经哪个子工作流、子图节点写名字)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<s
|
||||
states: Projection['nodes'] | null): Row[] {
|
||||
const topo = topology(graph as GraphLike)
|
||||
const byId = new Map(graph.nodes.map((n) => [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
|
||||
|
||||
@@ -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<NodeType, NodeDef> = {
|
||||
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<NodeType, NodeDef> = {
|
||||
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<NodeType, NodeDef> = {
|
||||
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<string, any>): 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<string, any>): 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
|
||||
}
|
||||
|
||||
@@ -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: '途经驳回',
|
||||
}
|
||||
|
||||
/** 来源标签:老运行没有来源时写「来源未记录」 */
|
||||
|
||||
@@ -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(['模型', <span className="mono">{n.model}</span>])
|
||||
if (n?.takenHandle && (state === 'done' || state === 'failed')) rows.push(['出口', <span className="mono">{n.takenHandle}</span>])
|
||||
if (n?.takenHandle && (state === 'done' || state === 'failed' || (state === 'rejected' && n.exited))) {
|
||||
rows.push(['出口', <span className="mono">{n.takenHandle}</span>])
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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 }) {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return <span className="mono" data-ev-node={id}>{prefix}{id}</span>
|
||||
// 画布上没有这个节点(子工作流里的那一步「子工作流节点 id/…」):写证据接口给的名字,悬停写 id
|
||||
return <span className="mono" data-ev-node={id} title={fallback ? id : undefined}>{prefix}{fallback || id}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<string, string> = { agent_field: 'Agent 字段', tool_cell: '单元格取数' }
|
||||
const VIA_TEXT: Record<string, string> = {
|
||||
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
|
||||
</div>
|
||||
{(inp.node_id || where) && (
|
||||
<div className="mono text-2xs text-faint [overflow-wrap:anywhere]">
|
||||
{inp.node_id && <NodeChip id={inp.node_id} prefix="节点 " />}
|
||||
{inp.node_id && <NodeChip id={inp.node_id} fallback={inp.node_label} prefix="节点 " />}
|
||||
{inp.node_id && where && ' · '}{where}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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(' · ')
|
||||
|
||||
@@ -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<string, string[]>()
|
||||
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<string>): 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<string>,
|
||||
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<string>): boolean {
|
||||
outHandles: Set<string>, 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<string>()
|
||||
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<string> {
|
||||
const hs = ctx.handles.get(e.source) ?? new Set<string>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, NodeTrace>): 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 ?? '')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 版
|
||||
|
||||
Reference in New Issue
Block a user