Commit 78ca2a6fd77a268dfa54b00caeaa1ec8153e6c6c
1 parent
a2f70192
feat(graph-console): T10 fan-in 等待态可视化 + 下一步不再是单数(27-P4)
新增 join-status.js(+ .check.js 可运行检查,web/ 不在 vitest include 里, 同款手法照抄 io-model.check.js):resolveJoinWait/joinWaitLabel,判据与引擎 activateSuccessTargets/allIncomingSatisfied 同源——只看 forward 的 success 入边,backward/fail 边不算「等待对象」。只有 2+ 条入边的真 fan-in 才出徽标, 单一入边的普通顺序等待不加噪声。 WorkGraphCanvas.vue:pending 节点在等哪几个上游时画一枚「⏳done/total」徽标, 悬停看完整明细(谁 success / 谁还在 running / 谁 fail 了)。 nextNodeId(单数)→ nextNodeIds(数组):advanceBatch(27-P4 T4)一次最多 认领 max_concurrency 个 ready 节点,并行分支下真实会被同批拾取的可能是好几个, 标一个会误导。这个 prop 之前在全仓库没有任何调用方真正传值(NEXT 徽标从未 渲染过),顺手把它接上:GraphRunPane 按 run.max_concurrency 截取候选, 手动模式仍显示单数提示,自动模式显示批量提示。 并行运行中的多节点高亮不需要新代码——.dot.running/.node.running 已经是 按每个节点各自状态渲染,多个节点同时 running 时天然一起亮,这条在 T4/T6 落地时就已经生效。 验证:join-status.check.js 6 条断言全过;`npm run web:build` 编译通过、 零告警。没有做的:没有起真实浏览器点一遍——搭一张真的并行 fan-in 图需要 起完整服务 + 建公司 + 跑真图,这次没做,逻辑侧的验证到 check.js + 编译为止。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
187 additions
and
6 deletions
web/src/components/WorkGraphCanvas.vue
| ... | ... | @@ -86,10 +86,21 @@ |
| 86 | 86 | <text v-if="elapsedLabels.get(n.key)" :x="NW - 10" :y="38" text-anchor="end" class="elapsed-label"> |
| 87 | 87 | ⏱ {{ elapsedLabels.get(n.key) }} |
| 88 | 88 | </text> |
| 89 | + <!-- fan-in 等待态(27-P4 T10 / §4.3 决策):pending 节点在等哪几个上游, | |
| 90 | + 紧凑徽标 + title 悬停看明细。只有 2+ 条入边的真 fan-in 才画, | |
| 91 | + 单一入边的普通顺序等待看紧邻上游状态就够了,不加这个徽标制造噪声。 --> | |
| 92 | + <g v-if="joinWaitLabels.get(n.key)" class="join-wait"> | |
| 93 | + <text :x="NW - 10" :y="38" text-anchor="end" class="join-wait-label"> | |
| 94 | + {{ joinWaitLabels.get(n.key).text }} | |
| 95 | + <title>{{ joinWaitLabels.get(n.key).title }}</title> | |
| 96 | + </text> | |
| 97 | + </g> | |
| 89 | 98 | <circle :cx="NW - 12" :cy="12" r="5" :class="['dot', n.status]" /> |
| 90 | 99 | <!-- node_kind='subgraph' 的下钻角标 --> |
| 91 | 100 | <text v-if="n.nodeKind === 'subgraph'" :x="NW - 8" :y="NH - 7" text-anchor="end" class="sub-badge">⊞</text> |
| 92 | - <text v-if="n.key === nextNodeId" :x="10" :y="NH - 6" class="next-badge">NEXT ▶</text> | |
| 101 | + <!-- 「下一步」不再是单数:advanceBatch(27-P4 T4)一次最多认领 maxConcurrency 个 | |
| 102 | + ready 节点,并行分支下真实会被同批拾取的可能是好几个,标一个会误导。 --> | |
| 103 | + <text v-if="nextNodeIdSet.has(n.key)" :x="10" :y="NH - 6" class="next-badge">NEXT ▶</text> | |
| 93 | 104 | </g> |
| 94 | 105 | </svg> |
| 95 | 106 | <div v-else class="empty"> |
| ... | ... | @@ -102,6 +113,7 @@ |
| 102 | 113 | import { computed, ref, watch, onBeforeUnmount } from 'vue'; |
| 103 | 114 | import { fmtDuration } from './graph-console/node-signals'; |
| 104 | 115 | import { edgeContractLabel } from './graph-console/io-model'; |
| 116 | +import { resolveJoinWait, joinWaitLabel } from './graph-console/join-status'; | |
| 105 | 117 | |
| 106 | 118 | /** |
| 107 | 119 | * 图画布(T3)——从 devloop/UnitsDAG.vue 抽取并扩展。 |
| ... | ... | @@ -121,11 +133,15 @@ const props = defineProps({ |
| 121 | 133 | // graph_run_loop_records 原始行(snake_case),徽标数字取同一 edge_id 的最新一条 |
| 122 | 134 | loopRecords: { type: Array, default: () => [] }, |
| 123 | 135 | selectedId: { type: String, default: null }, // defNodeId |
| 124 | - nextNodeId: { type: String, default: null }, // 下一步会拾取哪个(§四.C:并行 ready 时必须标出来) | |
| 136 | + // 下一批 advanceBatch 会拾取哪些(defNodeId 数组)——27-P4 T4 之后不再是单数, | |
| 137 | + // 并行分支下真实会被同一批认领的可能是好几个节点。 | |
| 138 | + nextNodeIds: { type: Array, default: () => [] }, | |
| 125 | 139 | hardCap: { type: Number, default: 3 }, // 单边回退硬顶(§四.D.2.1) |
| 126 | 140 | }); |
| 127 | 141 | defineEmits(['select']); |
| 128 | 142 | |
| 143 | +const nextNodeIdSet = computed(() => new Set((props.nextNodeIds || []).map(String))); | |
| 144 | + | |
| 129 | 145 | const uid = `wg${++instanceCount}`; |
| 130 | 146 | |
| 131 | 147 | /** |
| ... | ... | @@ -159,6 +175,20 @@ watch( |
| 159 | 175 | ); |
| 160 | 176 | onBeforeUnmount(() => clearInterval(ticker)); |
| 161 | 177 | |
| 178 | +/** | |
| 179 | + * fan-in 等待态徽标(27-P4 T10):defNodeId → {text, title} | undefined。 | |
| 180 | + * 与 elapsedLabels 同款理由单独拆 computed——它依赖全部节点+全部边而不是单个节点, | |
| 181 | + * 混进 laidOut 会让布局 computed 背上一份不相关的依赖。 | |
| 182 | + */ | |
| 183 | +const joinWaitLabels = computed(() => { | |
| 184 | + const m = new Map(); | |
| 185 | + for (const n of props.nodes) { | |
| 186 | + const label = joinWaitLabel(resolveJoinWait(n, props.nodes, props.edges)); | |
| 187 | + if (label) m.set(String(n.defNodeId), label); | |
| 188 | + } | |
| 189 | + return m; | |
| 190 | +}); | |
| 191 | + | |
| 162 | 192 | const NW = 158, NH = 54, GAP_X = 52, GAP_Y = 18, PAD = 20; |
| 163 | 193 | const LANE_H = 34, BADGE_W = 34; |
| 164 | 194 | |
| ... | ... | @@ -377,6 +407,7 @@ let instanceCount = 0; |
| 377 | 407 | .name-label { font-size:12.5px; font-weight:600; font-family: ui-monospace, monospace; pointer-events:none; } |
| 378 | 408 | .status-label { font-size:10px; fill:#666; text-transform:uppercase; pointer-events:none; } |
| 379 | 409 | .elapsed-label { font-size:10px; fill:#004085; font-weight:600; font-variant-numeric: tabular-nums; pointer-events:none; } |
| 410 | +.join-wait-label { font-size:10px; fill:#8a4b00; font-weight:600; font-variant-numeric: tabular-nums; cursor:help; } | |
| 380 | 411 | .sub-badge { font-size:13px; fill:#5a67d8; pointer-events:none; } |
| 381 | 412 | .next-badge { font-size:9px; fill:#0069d9; font-weight:700; pointer-events:none; } |
| 382 | 413 | ... | ... |
web/src/components/graph-console/GraphRunPane.vue
| ... | ... | @@ -28,7 +28,9 @@ |
| 28 | 28 | <button class="btn" :disabled="busy || !can('pause')" @click="act('pause')">⏸ 暂停</button> |
| 29 | 29 | <button class="btn" :disabled="busy || !can('stop')" @click="act('stop')">⏹ 停止</button> |
| 30 | 30 | <button class="btn retry" :disabled="busy || !canRetry" @click="onRetry">🔁 重试</button> |
| 31 | - <span v-if="nextNode" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span> | |
| 31 | + <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span> | |
| 32 | + <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个: | |
| 33 | + <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span> | |
| 32 | 34 | </div> |
| 33 | 35 | |
| 34 | 36 | <p v-if="err" class="err">{{ err }}</p> |
| ... | ... | @@ -40,7 +42,7 @@ |
| 40 | 42 | :edges="edges" |
| 41 | 43 | :loop-records="loopRecords" |
| 42 | 44 | :selected-id="selectedDefId" |
| 43 | - :next-node-id="nextNode ? String(nextNode.defNodeId) : null" | |
| 45 | + :next-node-ids="nextNodes.map((n) => String(n.defNodeId))" | |
| 44 | 46 | @select="onSelect" |
| 45 | 47 | > |
| 46 | 48 | <template #empty><slot name="empty">这张图还没有节点</slot></template> |
| ... | ... | @@ -115,8 +117,19 @@ let refreshTimer = null; |
| 115 | 117 | let pollTimer = null; |
| 116 | 118 | const MAX_EVENTS = 400; |
| 117 | 119 | |
| 118 | -/** 引擎按 (created_at, id) 取第一个 ready 节点;getRunState 用同样的排序,取首个 ready 即「下一步会跑哪个」 */ | |
| 119 | -const nextNode = computed(() => nodes.value.find((n) => n.status === 'ready') || null); | |
| 120 | +// 27-P4 T4:手动模式的「下一步」(nextStep→advanceOne)仍然只推一个,但自动模式的 | |
| 121 | +// advanceBatch 一次最多认领 run.max_concurrency 个 ready 节点——「下一步会跑哪个」 | |
| 122 | +// 不再是单数。引擎按 (created_at, id) 取候选,getRunState 用同样的排序返回 nodes, | |
| 123 | +// 取前 N 个 ready 即「下一批大概率会认领谁」。 | |
| 124 | +// ponytail: 这是提示,不是承诺——真实拾取时若跟别的 advanceBatch 撞了车(§4.1 决策), | |
| 125 | +// 实际认领的集合可能有出入;纯展示用途,不影响任何控制逻辑,不值得为了精确复刻 | |
| 126 | +// 后端排序再传一次 max_concurrency 的默认值同步逻辑。 | |
| 127 | +const DEFAULT_MAX_CONCURRENCY_HINT = 5; // 与 work-graph-db.DEFAULT_MAX_CONCURRENCY 对齐,纯展示兜底 | |
| 128 | +const nextNodes = computed(() => { | |
| 129 | + const cap = (run.value && run.value.max_concurrency) || DEFAULT_MAX_CONCURRENCY_HINT; | |
| 130 | + return nodes.value.filter((n) => n.status === 'ready').slice(0, cap); | |
| 131 | +}); | |
| 132 | +const nextNode = computed(() => nextNodes.value[0] || null); // 手动模式「下一步」提示仍用单数 | |
| 120 | 133 | const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null); |
| 121 | 134 | const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status)); |
| 122 | 135 | ... | ... |
web/src/components/graph-console/join-status.check.js
0 → 100644
| 1 | +/** | |
| 2 | + * join-status 的可运行检查(`node web/src/components/graph-console/join-status.check.js`)。 | |
| 3 | + * 同款手法见 io-model.check.js 的头注——web/ 不在 vitest include 里,用 assert 自跑。 | |
| 4 | + */ | |
| 5 | +import assert from 'node:assert/strict'; | |
| 6 | +import { resolveJoinWait, joinWaitLabel } from './join-status.js'; | |
| 7 | + | |
| 8 | +const nodes = [ | |
| 9 | + { defNodeId: 'entry', name: 'entry', status: 'success' }, | |
| 10 | + { defNodeId: 'a', name: 'a', status: 'success' }, | |
| 11 | + { defNodeId: 'b', name: 'b', status: 'running' }, | |
| 12 | + { defNodeId: 'c', name: 'c', status: 'fail' }, | |
| 13 | + { defNodeId: 'join', name: 'join', status: 'pending' }, | |
| 14 | + { defNodeId: 'chain2', name: 'chain2', status: 'pending' }, | |
| 15 | +]; | |
| 16 | +const edges = [ | |
| 17 | + { from: 'entry', to: 'a', condition: 'success', edgeKind: 'forward' }, | |
| 18 | + { from: 'entry', to: 'b', condition: 'success', edgeKind: 'forward' }, | |
| 19 | + { from: 'entry', to: 'c', condition: 'success', edgeKind: 'forward' }, | |
| 20 | + { from: 'a', to: 'join', condition: 'success', edgeKind: 'forward' }, | |
| 21 | + { from: 'b', to: 'join', condition: 'success', edgeKind: 'forward' }, | |
| 22 | + { from: 'c', to: 'join', condition: 'success', edgeKind: 'forward' }, | |
| 23 | + { from: 'a', to: 'chain2', condition: 'success', edgeKind: 'forward' }, | |
| 24 | + // backward 边不参与等待判定,即使指向 join 也不该被数进 waiting | |
| 25 | + { from: 'join', to: 'a', condition: 'fail', edgeKind: 'backward' }, | |
| 26 | +]; | |
| 27 | + | |
| 28 | +// ① 真正的 fan-in:join 在等 b(running)/c(fail),a 已经 success | |
| 29 | +{ | |
| 30 | + const wait = resolveJoinWait(nodes.find((n) => n.defNodeId === 'join'), nodes, edges); | |
| 31 | + assert.equal(wait.total, 3); | |
| 32 | + assert.equal(wait.done, 1); | |
| 33 | + assert.equal(wait.isFanIn, true); | |
| 34 | + assert.equal(wait.satisfied, false); | |
| 35 | + assert.deepEqual(wait.blockedBy.map((w) => w.defNodeId).sort(), ['b', 'c']); | |
| 36 | + const label = joinWaitLabel(wait); | |
| 37 | + assert.equal(label.text, '⏳1/3'); | |
| 38 | + assert.match(label.title, /a\(success\)/); | |
| 39 | + assert.match(label.title, /b\(running\)/); | |
| 40 | + assert.match(label.title, /c\(fail\)/); | |
| 41 | +} | |
| 42 | + | |
| 43 | +// ② 单一入边的普通顺序等待:不是 fan-in,不出徽标(避免给每一条链都加噪声) | |
| 44 | +{ | |
| 45 | + const wait = resolveJoinWait(nodes.find((n) => n.defNodeId === 'chain2'), nodes, edges); | |
| 46 | + assert.equal(wait.total, 1); | |
| 47 | + assert.equal(wait.isFanIn, false); | |
| 48 | + assert.equal(joinWaitLabel(wait), null); | |
| 49 | +} | |
| 50 | + | |
| 51 | +// ③ 非 pending 节点:不适用(已经跑过或正在跑,没有「在等谁」这回事) | |
| 52 | +{ | |
| 53 | + assert.equal(resolveJoinWait(nodes.find((n) => n.defNodeId === 'a'), nodes, edges), null); | |
| 54 | +} | |
| 55 | + | |
| 56 | +// ④ 已满足(引擎下一拍就会点亮,这一刻不该显示「在等」) | |
| 57 | +{ | |
| 58 | + const satisfiedNodes = nodes.map((n) => (n.defNodeId === 'b' || n.defNodeId === 'c' | |
| 59 | + ? { ...n, status: 'success' } : n)); | |
| 60 | + const wait = resolveJoinWait(satisfiedNodes.find((n) => n.defNodeId === 'join'), satisfiedNodes, edges); | |
| 61 | + assert.equal(wait.satisfied, true); | |
| 62 | + assert.equal(joinWaitLabel(wait), null); | |
| 63 | +} | |
| 64 | + | |
| 65 | +// ⑤ 没有 forward success 入边(图的入口节点):不适用 | |
| 66 | +{ | |
| 67 | + assert.equal(resolveJoinWait({ defNodeId: 'entry', status: 'pending' }, nodes, edges), null); | |
| 68 | +} | |
| 69 | + | |
| 70 | +console.log('join-status.check.js: 全部通过'); | ... | ... |
web/src/components/graph-console/join-status.js
0 → 100644
| 1 | +/** | |
| 2 | + * fan-in 等待态(27-P4 T10):一个 pending 节点在等哪几个上游、各自是什么状态。 | |
| 3 | + * | |
| 4 | + * 并行(27-P4 T4/T6)落地之后一张图可能同时有好几个分支在跑,「这个节点为什么还没轮到」 | |
| 5 | + * 不再是一眼能看出来的事——以前只有一条链,pending 的下一个必然是紧邻上游那一个; | |
| 6 | + * 现在它可能在等三个并行分支里最慢的那一个。§4.3 决策要求「run 层面要能回答『我卡在等 | |
| 7 | + * 哪几个节点』」,这里就是那句话的数据源。 | |
| 8 | + * | |
| 9 | + * 判据与引擎 `graph-engine.activateSuccessTargets`/`allIncomingSatisfied` 同源: | |
| 10 | + * 只看 forward 的 success 入边——backward 边是异常流程,不参与「汇合点」这道闸, | |
| 11 | + * fail 边同理。跟 io-model.js 的 inboundEdges 用的是同一条筛选条件,别改出分叉。 | |
| 12 | + */ | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * @param {object} node getRunState 的合并节点(要 status 与 defNodeId 两个键) | |
| 16 | + * @param {Array} nodes 同图全部节点(拿名字与上游状态用) | |
| 17 | + * @param {Array} edges 同图全部边 | |
| 18 | + * @returns {null | { | |
| 19 | + * waiting: Array<{defNodeId, name, status}>, | |
| 20 | + * total: number, done: number, satisfied: boolean, | |
| 21 | + * isFanIn: boolean, // 2+ 条 forward success 入边才算真正的 fan-in;单一入边只是普通顺序等待 | |
| 22 | + * blockedBy: Array<{defNodeId, name, status}>, | |
| 23 | + * }} | |
| 24 | + */ | |
| 25 | +export function resolveJoinWait(node, nodes = [], edges = []) { | |
| 26 | + if (!node || node.status !== 'pending') return null; | |
| 27 | + const incoming = (edges || []).filter((e) => ( | |
| 28 | + String(e.to) === String(node.defNodeId) | |
| 29 | + && (e.condition || 'success') === 'success' | |
| 30 | + && (e.edgeKind || 'forward') !== 'backward' | |
| 31 | + )); | |
| 32 | + if (!incoming.length) return null; // 没有 forward success 入边:不是在等 fan-in,是别的原因还没轮到 | |
| 33 | + | |
| 34 | + const byId = new Map((nodes || []).map((n) => [String(n.defNodeId), n])); | |
| 35 | + const waiting = incoming.map((e) => { | |
| 36 | + const src = byId.get(String(e.from)); | |
| 37 | + return { | |
| 38 | + defNodeId: e.from, | |
| 39 | + name: (src && (src.name || src.defNodeId)) || e.from, | |
| 40 | + status: (src && src.status) || 'pending', | |
| 41 | + }; | |
| 42 | + }); | |
| 43 | + | |
| 44 | + const done = waiting.filter((w) => w.status === 'success').length; | |
| 45 | + return { | |
| 46 | + waiting, | |
| 47 | + total: waiting.length, | |
| 48 | + done, | |
| 49 | + satisfied: done === waiting.length, | |
| 50 | + isFanIn: waiting.length > 1, | |
| 51 | + blockedBy: waiting.filter((w) => w.status !== 'success'), | |
| 52 | + }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** | |
| 56 | + * 画布上的紧凑摘要——克制原则同 `edgeContractLabel`:只占一个小徽标的位置, | |
| 57 | + * 完整明细留给 title。`satisfied` 时不显示(该节点这一刻就会被点亮,不算「在等」); | |
| 58 | + * 非 fan-in(只有一条入边)时也不显示——普通顺序等待,看紧邻上游的状态就够了, | |
| 59 | + * 加一个「1/1」徽标只会制造噪声。 | |
| 60 | + */ | |
| 61 | +export function joinWaitLabel(wait) { | |
| 62 | + if (!wait || !wait.isFanIn || wait.satisfied) return null; | |
| 63 | + return { | |
| 64 | + text: `⏳${wait.done}/${wait.total}`, | |
| 65 | + title: `在等:${wait.waiting.map((w) => `${w.name}(${w.status})`).join(' · ')}`, | |
| 66 | + }; | |
| 67 | +} | ... | ... |