NodeDetailPanel.vue
36.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
<template>
<div class="detail">
<div v-if="!node" class="ph">
在中间画布上点一个节点,这里显示它的详情。
</div>
<template v-else>
<header class="head">
<h3>{{ node.name || node.defNodeId }}</h3>
<span class="badge" :class="node.status">{{ node.status }}</span>
</header>
<div class="sub">
<span v-if="node.systemAgentId">本体 <code>{{ node.systemAgentId }}</code></span>
<span>attempt <b>{{ (node.attempt || 0) + 1 }}</b></span>
<span>{{ node.nodeKind === 'subgraph' ? '⊞ 子图节点' : 'agent 调用' }}</span>
<!-- R-17.1:子 run 是复用的还是新起的,直接标在头上 —— 复用之后「这一层跑到哪了」
不再等于「子图整个重来」,不标出来用户会以为子图从头跑了一遍 -->
<span v-if="reusedChildRun" class="tag-reuse">子 run 复用中(服务 attempt #{{ (node.childRunAttempt || 0) + 1 }})</span>
</div>
<!-- 「它还活着」条(W12-UI)。真 CLI 一个节点是 3-7 分钟量级,点完「下一步」之后
界面上原本几分钟没有任何动静,只能盯状态灯。这里给两个**真实**信号:
已运行多久 + 本节点最近一条事件是什么、多久之前。
**刻意不做进度条**:引擎不知道 agent 还要跑多久,匀速条是骗人。
放在 tabs 之上 = 四个 tab 都看得见(在「运行日志」tab 里翻日志时也不会丢)。 -->
<div v-if="live" class="live">
<span class="pulse" />
<b class="clock">运行中 {{ live.elapsed }}</b>
<span class="last">
<template v-if="live.last">最近事件 <code>{{ live.last.kind }}</code> · {{ live.last.text }}
<em v-if="live.last.ago">({{ live.last.ago }} 前)</em>
</template>
<template v-else>SSE 已接上,但本节点还没有任何事件</template>
</span>
<em class="why" :title="LIVE_HINT">?</em>
</div>
<nav class="tabs">
<button v-for="t in tabs" :key="t.key" :class="{ on: tab === t.key }" @click="tab = t.key">
{{ t.label }}
</button>
</nav>
<!-- 1 概览 -->
<section v-if="tab === 'overview'" class="body">
<dl class="kv">
<dt>status</dt><dd><span class="badge" :class="node.status">{{ node.status }}</span></dd>
<dt>attempt</dt><dd>{{ node.attempt || 0 }}</dd>
<dt>started_at</dt><dd>{{ node.startedAt || '—' }}</dd>
<dt>completed_at</dt><dd>{{ node.completedAt || '—' }}</dd>
<dt>耗时</dt><dd>{{ duration }}</dd>
<!-- 失败原因(W12-UI)。以前这里只读 node.failReason 一处,取不到就是一个「—」——
而 fail_reason=null 在这套引擎里是**合法且常见**的(= 未匹配精确 fail 边,走兜底边),
于是「失败、无原因」成了失败节点的常态显示。现在三级回落,来源逐条标明。 -->
<dt>fail_reason</dt>
<dd class="fail-cell">
<template v-if="failInfo">
<span class="src-badge" :class="failInfo.kind">{{ failInfo.source }}</span>
<code v-if="failInfo.reason">{{ failInfo.reason }}</code>
<span v-else class="muted small">null(没有匹配上精确 fail 边 → 走兜底边)</span>
<p v-if="failInfo.text" class="excerpt">{{ failInfo.text }}</p>
<em class="hint block">{{ failInfo.hint }}</em>
</template>
<template v-else-if="isFailed">
—
<em class="hint block">
引擎没给 fail_reason,事件轨里也没有本轮(attempt #{{ (node.attempt || 0) + 1 }})的
<code>node.output</code> / <code>node.error</code> / <code>node.triage</code>。
去「运行日志」tab 看完整事件轨。
</em>
</template>
<template v-else>—</template>
</dd>
<!-- R-17.1 跨层地址:归因把根因指到了「哪个 org node 的哪个 work graph node」。
这是回退到底会退到哪一层的唯一依据,落库了就要在概览里看得见 -->
<dt>fail_node_ref</dt>
<dd>
<template v-if="failRef">
<code>{{ failRef.orgNode || '(按回退边的目标)' }}</code> / <code>{{ failRef.workNode }}</code>
<em class="hint">归因指向的 work graph 节点(跨层)</em>
</template>
<template v-else>—</template>
</dd>
<dt>agent_run_id</dt><dd class="mono">{{ node.agentRunId || '—' }}</dd>
<dt>child_run_id</dt>
<dd class="mono">
{{ node.childRunId || '—' }}
<em v-if="node.childRunId" class="hint">
{{ reusedChildRun ? '复用中:本轮不重新规划,只重跑被点名的节点' : `为 attempt #${(node.childRunAttempt || 0) + 1} 起` }}
</em>
</dd>
<!-- 节点行上的判词(graph_run_nodes.remediation_note)。落库了就要在概览里看得见,
否则「产生 → 传递 → 落库 → 读回」的最后一环还是空的(R5-F2) -->
<!-- 同 fail_reason:节点行上没有时回落到本轮 node.triage 事件里的判词,并标来源 -->
<dt>remediation_note</dt>
<dd class="wrap">
<template v-if="noteInfo">
<span class="src-badge" :class="noteInfo.kind">{{ noteInfo.source }}</span>
{{ noteInfo.text }}
</template>
<template v-else>—</template>
</dd>
</dl>
<h4>回退记录({{ myLoops.length }})</h4>
<ul v-if="myLoops.length" class="loops">
<li v-for="r in myLoops" :key="r.id" :class="r.verdict">
<div class="loop-head">
<span class="lc">{{ r.loop_count }}/3</span>
<span class="verdict">{{ r.verdict }}</span>
<span class="dir">{{ r.from_node_run_id === node.id ? '从这里退出' : '退回到这里' }}</span>
</div>
<!-- R-17.1 回退粒度:退到 org node 整张图,还是跨层退到子图里的某一个节点。
账本上有 target_node_ref 就是后者(引擎真的只重置了那一个 node) -->
<p class="scope" :class="{ cross: refOf(r) }">
<template v-if="refOf(r)">
跨层定点重置 → <code>{{ refOf(r).orgNode }}</code> / <code>{{ refOf(r).workNode }}</code>
<span class="muted small">(复用子 run {{ refOf(r).childRunId }},只重跑这一个节点,
contract 未变的下游不动)</span>
</template>
<template v-else>整张 work graph 重跑(归因没点名具体节点,或地址解析不了)</template>
</p>
<p v-if="r.remediation_note" class="note">{{ r.remediation_note }}</p>
</li>
</ul>
<p v-else class="muted">这个节点没有参与过回退。</p>
</section>
<!-- 2 输入输出 —— R-4「边的输入输出」的落地端(R-19 之后这些是有实体的) -->
<section v-else-if="tab === 'io'" class="body">
<!-- 后端还没随 run 状态吐产物索引时**整块降级**,不做任何推断:
「数据流是推断的」正是第四次真跑栽的那个坑(27-P4 §4.6) -->
<div v-if="!io.hasIndex" class="degrade">
<div class="tag">产物索引未随响应下发</div>
<p>
这块要读 R-19 的两张表(<code>graph_edge_contracts</code> 声明的契约 ×
<code>graph_files</code> 落盘的实例)。<code>GET /api/graph-runs/:id</code>
还没返回 <code v-for="f in IO_BACKEND_FIELDS" :key="f">{{ f }}</code>,
所以下面只有节点级指针 <code>output_ref</code> 那一行是真的,
输入解析与出边契约都显示不了 —— 不猜、不推断。
</p>
</div>
<!-- 降级时这两节整块不出现(连标题都不留):一个空标题比没有标题更容易被读成
「查过了,没有」,而实际情况是「没查」—— 上面那个降级框才是这时候唯一的真话 -->
<h4 v-if="io.hasIndex">输入 · 入边声明的契约 × 索引里的实例</h4>
<ul v-if="io.inputs.length" class="io-list">
<li v-for="i in io.inputs" :key="i.source + ':' + i.kind" :class="{ miss: !i.resolved }">
<div class="io-row">
<span class="kind">{{ i.kind }}</span>
<span class="src-badge" :class="i.source === 'task' ? 'triage' : 'engine'">
{{ i.source === 'task' ? '任务级共享' : '沿边传递' }}
</span>
<span v-if="i.source === 'edge' && !i.required" class="opt">可选</span>
<span v-if="i.froms.length" class="from">
{{ i.relay ? 'needs · 声明在' : '来自' }}
<code v-for="f in i.froms" :key="f.edgeId">{{ f.fromName }}</code>
<template v-if="i.relay">的出边,产出方不必是它</template>
</span>
<span v-else-if="i.source === 'task'" class="from">不受拓扑相邻性限制(R-9.3)</span>
</div>
<button
v-if="i.resolved"
class="mono path as-link"
:title="`预览 ${i.file && i.file.relPath}`"
@click="preview(i.file, i.kind, i.path)"
>{{ i.path }}</button>
<p v-else class="warn">
⚠️ 图上声明了这份输入,索引里查不到实例 —— 先确认是不是上游没产出
</p>
<em v-if="!i.inPrompt" class="hint block">
可选 + 无实例 → 引擎<b>不会</b>把这一行拼进提示词,agent 看不到它(describeInputs 的取舍)
</em>
</li>
</ul>
<p v-else-if="io.hasIndex" class="muted small">
这个节点的入边上<b>没有任何数据契约声明</b>(<code>graph_edge_contracts</code> 里没有它的行)。
规划这张图的 agent 没声明「这条边传什么」,于是引擎不会给它拼上游输入 ——
第四次真跑 <code>org-planner-prd</code> 说的「不是上游没产出,是它的产出结构上到不了我手上」就是这个形态。
</p>
<h4>输出 · 本节点产出并登记进索引的实例</h4>
<p class="muted small">
<code>output_ref</code> 是节点级指针(我这次跑出了什么),
<code>graph_files</code> 是公司级索引(这家公司有哪些产物、谁能用);后者由前者派生。
</p>
<p v-if="node.outputRef" class="mono path">{{ node.outputRef }}</p>
<p v-else class="muted">还没有产出(output_ref 为空)。</p>
<ul v-if="io.outputs.length" class="io-list">
<li v-for="o in io.outputs" :key="o.id || o.relPath">
<div class="io-row">
<span class="kind">{{ o.kind }}</span>
<span class="src-badge engine">已登记</span>
<span v-if="o.supersededBy" class="opt">已被新版本取代</span>
<span class="from">{{ o.createdAt || '' }}</span>
</div>
<button
class="mono path as-link"
:title="`预览 ${o.relPath}`"
@click="preview(o, o.kind, o.path)"
>{{ o.path }}</button>
</li>
</ul>
<p v-else-if="io.hasIndex && node.outputRef" class="muted small">
索引里没有本节点名下的产物实例:要么产出目录里只有引擎自读的
<code>attempt-*.json</code>(不算给下游的产物),要么同路径产物已被后续 attempt 取代
(<code>taskFiles</code> 只回当前有效行)。
</p>
<h4 v-if="io.hasIndex">出边契约 · 我的产出给了谁</h4>
<ul v-if="io.outbound.length" class="io-list">
<li v-for="e in io.outbound" :key="e.edgeId">
<div class="io-row">
<span class="arrow-to">→ <code>{{ e.toName }}</code></span>
<span class="cond" :class="e.condition">{{ e.condition }}</span>
</div>
<p class="c-list">
<span
v-for="c in e.contracts"
:key="c.kind"
class="c-pill"
:class="[c.state, { opt: !c.required }]"
:title="c.state === 'elsewhere' ? `实例由 ${c.by} 产出,引擎按 task + kind 检索,不看是谁产的` : ''"
>
{{ c.kind }}{{ c.required ? '' : '?' }}
{{ c.state === 'missing' ? '⚠️' : '✓' }}
<template v-if="c.state === 'elsewhere'">· 由 {{ c.by }} 产出</template>
</span>
</p>
<em v-if="e.contracts.some((c) => c.state === 'missing')" class="hint block">
标 ⚠️ 的 kind 在这条边上声明了,而索引里<b>整个 task 都没有</b>这个 kind 的实例 ——
下游到那一步只会拿到「⚠️ 查不到实例」,这是跑之前就能看出来的图结构错误(R-19.4)。
</em>
</li>
</ul>
<p v-else-if="io.hasIndex" class="muted small">
本节点的出边上没有数据契约声明,产出不会沿边递给任何下游节点
(<code>acceptance</code> / <code>contract</code> 这两类仍会按任务级共享被下游检索到)。
</p>
<h4>实际被下游消费的形态</h4>
<p class="muted small">
正向边上与原始产出相同;经过回退后会拼上上一轮的 remediation_note,两者不能假设相等(§四.B.3)。
</p>
<div v-if="consumedNote" class="consumed">
<div class="tag">原始产出 + 上一轮修复意见 <em class="src">来源:{{ consumedSource }}</em></div>
<p class="note">{{ consumedNote }}</p>
</div>
<p v-else class="muted">本节点没有收到上一轮判词,消费形态 = 原始产出。</p>
</section>
<!-- 3 运行日志 -->
<section v-else-if="tab === 'log'" class="body">
<NodeRunLog :node="node" :events="events" />
</section>
<!-- 4 子图 -->
<section v-else-if="tab === 'sub'" class="body">
<div class="sub-head">
<span class="mono">{{ node.childRunId }}</span>
<button class="btn go" @click="$emit('dive', { runId: node.childRunId, label: node.name })">
进入子图 →
</button>
</div>
<p class="muted small">
下面是子 run 的只读缩略图;「进入子图」把它推到中间画布,那一层的五个控制按钮会作用在子 run 上,
面包屑可以逐层返回(§四.B.3)。
</p>
<!-- R-17.1 明确接受的代价:跨层寻址破坏了层级封装,「暂停 / 下一步」作用于哪一层
的语义因此变含糊,**必须在 UI 上显式说清楚**,否则用户会以为在这一层点单步
也能推动子图里的节点。 -->
<div class="layer-note">
<div class="tag">层级语义(R-17.1 跨层寻址)</div>
<ul>
<li>
<b>「下一步」只推你当前所在的这一层。</b>在本层单步只会推进本层节点;
这个节点是 <code>subgraph</code>,它的一步 = 「把子 run 交给引擎」,
子图内部的节点要<b>进入子图后</b>再单步。
</li>
<li>
<b>「暂停 / 停止」是全局意图,向下级联</b>到这张子 run 和它的所有后代;
<b>「启动」是它的镜像</b>,会把被级联暂停的后代一起拉回来。
</li>
<li>
<b>这张子 run 会被跨层复用。</b>上一层的回退可能只重置子图里<b>某一个</b>节点
(见「概览 → 回退记录」那一行的回退粒度),
<em>不是</em>整张子图重跑——所以子图里各节点的 attempt 可能不一样。
</li>
</ul>
</div>
<WorkGraphCanvas
:nodes="child.nodes"
:edges="child.edges"
:loop-records="child.loopRecords"
@select="$emit('dive', { runId: node.childRunId, label: node.name })"
>
<template #empty>子图还没有节点</template>
</WorkGraphCanvas>
</section>
</template>
<FilePreviewModal
:open="pv.open"
:company-id="companyId"
:rel-path="pv.relPath"
:abs-path="pv.absPath"
:kind="pv.kind"
@close="pv.open = false"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue';
import WorkGraphCanvas from '../WorkGraphCanvas.vue';
import NodeRunLog from './NodeRunLog.vue';
import FilePreviewModal from './FilePreviewModal.vue';
import { nodeEvents, attemptOf, lastWhere, describeEvent, fmtDuration, since } from './node-signals';
import { resolveNodeIo, IO_BACKEND_FIELDS } from './io-model';
/** 节点详情四 tab(T4 / §四.B.3) */
const props = defineProps({
node: { type: Object, default: null }, // getRunState 的合并节点
loopRecords: { type: Array, default: () => [] },
events: { type: Array, default: () => [] },
// R-19 产物索引:「输入输出」tab 要的四份数据。同图的 nodes/edges 用来把 defNodeId
// 翻成人看的节点名,files/companyRoot 是产物实例那一半(后端不给就整块降级)
nodes: { type: Array, default: () => [] },
edges: { type: Array, default: () => [] },
files: { type: Array, default: null },
companyRoot: { type: String, default: null },
// 文件预览要它拼端点(GET /api/company/:companyId/files/content)。
// 不从 companyRoot 里截 basename:那是猜,公司 id 与目录名将来不一定同名。
companyId: { type: String, default: '' },
});
defineEmits(['dive']);
const tab = ref('overview');
const child = ref({ nodes: [], edges: [], loopRecords: [] });
// 文件预览。relPath 是端点唯一认的入参(相对公司根),absPath 只用于展示与复制。
const pv = reactive({ open: false, relPath: '', absPath: '', kind: '' });
function preview(file, kind, abs) {
// 索引行里没有 relPath 就开不了:宁可不响应,也不要拿绝对路径去撞那个只认相对路径的端点
const rel = file && (file.relPath || file.rel_path);
if (!rel) return;
pv.relPath = rel;
// abs 由调用方显式给:输入侧的绝对路径挂在**输入项**上(i.path),不在 i.file 里
pv.absPath = abs || rel;
pv.kind = kind || file.kind || '';
pv.open = true;
}
const hasSub = computed(() => props.node && props.node.nodeKind === 'subgraph' && props.node.childRunId);
/**
* 「输入输出」tab 的数据:入边契约 × 产物实例的那次 join(io-model.js)。
* 与引擎 describeInputs 同一套取值规则 —— 界面上显示的必须是**引擎真的递给 agent 的那份**。
*/
const io = computed(() => (props.node
? resolveNodeIo({
node: props.node, nodes: props.nodes, edges: props.edges,
files: props.files, companyRoot: props.companyRoot,
})
: { hasIndex: false, inputs: [], outputs: [], outbound: [], missingRequired: 0, declaredNone: true }));
const tabs = computed(() => [
{ key: 'overview', label: '概览' },
// 必需输入没解析到 = 这个节点大概率会说「我拿不到输入」,把数字标在 tab 上,
// 不用点进去才发现(这条断点第四次真跑里花了整轮才归因出来)
{ key: 'io', label: io.value.missingRequired ? `输入输出 ⚠️${io.value.missingRequired}` : '输入输出' },
{ key: 'log', label: '运行日志' },
...(hasSub.value ? [{ key: 'sub', label: '子图' }] : []),
]);
/**
* R-17.1 跨层地址的读回端。两处来源、两种语义,不要混:
* node.failNodeRef = 归因的**主张**(graph_run_nodes.fail_node_ref)
* loopRecord.target_node_ref = 引擎真的退到了哪(解析后,含 childRunId / nodeRunId)
* 坏值一律当没有:面板不该因为一条 JSON 解析不了就整块白掉。
*/
function readRef(raw) {
if (!raw) return null;
try {
const v = typeof raw === 'string' ? JSON.parse(raw) : raw;
return v && v.workNode ? v : null;
} catch { return null; }
}
const failRef = computed(() => readRef(props.node?.failNodeRef));
const refOf = (rec) => readRef(rec && rec.target_node_ref);
/** 子 run 服务的是更早的一轮 = 本轮在复用它(executeSubgraph 的代次判据在前端的镜像) */
const reusedChildRun = computed(() => {
const n = props.node;
return Boolean(n && n.childRunId && n.childRunAttempt != null && n.childRunAttempt < (n.attempt || 0));
});
const myLoops = computed(() =>
props.node
? props.loopRecords.filter((r) => r.from_node_run_id === props.node.id || r.to_node_run_id === props.node.id)
: []);
/**
* 本节点这一轮实际拿到的「应该如何修复」——它就是下一轮输入与原始产出的差量。
*
* **权威来源是节点行上的 remediation_note,loop record 只是回落**(R5-F2):
* loop record 仅在命中 backward 边时才有,而 forward fail 边 / 死路 blocked /
* infra_flake 原地重跑这三条路径上一条记录都不会有 —— 只按 loop record 读,
* 恰恰在这三条路径上会显示「本节点未经过回退」,而节点其实是拿到了判词的。
* 判词由 graph_run_nodes.remediation_note 落库、getRunState 读回成 node.remediationNote。
*/
const consumedNote = computed(() => {
if (noteInfo.value) return noteInfo.value.text;
const rec = myLoops.value.find((r) => r.to_node_run_id === props.node?.id && r.remediation_note);
return rec ? rec.remediation_note : null;
});
const consumedSource = computed(() => {
if (!noteInfo.value) return '回退账本 loop record';
return noteInfo.value.kind === 'engine' ? '节点行 remediation_note' : '事件轨 node.triage(节点行上还没有)';
});
// ── 秒表 / 「还活着」条(W12-UI) ─────────────────────────────────
//
// 真 CLI 一个节点是 3-7 分钟量级。在此之前 duration 用的是 Date.now(),
// 而 Date.now() 不是响应式的 —— 节点在跑的整段时间里这一格是**冻住的**。
// 只在节点 running 时开 ticker,跑完就停(不给已收敛的节点白烧一个 1s 定时器)。
const nowTs = ref(Date.now());
let ticker = null;
watch(() => props.node?.status === 'running', (on) => {
clearInterval(ticker);
ticker = null;
if (!on) return;
nowTs.value = Date.now();
ticker = setInterval(() => { nowTs.value = Date.now(); }, 1000);
}, { immediate: true });
onBeforeUnmount(() => clearInterval(ticker));
const LIVE_HINT = '这里只报「已经跑了多久」和「最近一条事件」两个真实信号。'
+ '引擎不知道 agent 还要跑多久(CLI 不上报进度),所以刻意不画进度条 —— 匀速条是骗人的。';
const duration = computed(() => {
const n = props.node;
if (!n?.startedAt) return '—';
const end = n.completedAt ? Date.parse(n.completedAt) : nowTs.value;
return fmtDuration(end - Date.parse(n.startedAt)) || '—';
});
/** 本节点的事件(全部 attempt)与本轮 attempt 的事件 —— 三个 computed 共用 */
const myEvents = computed(() => nodeEvents(props.events, props.node));
const attemptEvents = computed(() => {
const n = props.node;
if (!n) return [];
return myEvents.value.filter((e) => attemptOf(e, n) === (n.attempt || 0));
});
const live = computed(() => {
const n = props.node;
if (!n || n.status !== 'running') return null;
const last = myEvents.value[myEvents.value.length - 1] || null;
return {
elapsed: (n.startedAt && fmtDuration(nowTs.value - Date.parse(n.startedAt))) || '—',
last: last ? { kind: last.kind, text: describeEvent(last), ago: since(last.ts, nowTs.value) } : null,
};
});
// ── 失败原因的三级回落(W12-UI) ──────────────────────────────────
//
// 「引擎把 fail_reason 落库」与「界面读回它」是两件并行推进的事,所以**两条路都要能走**:
// 1. 引擎判定 —— graph_run_nodes.fail_reason(getRunState 读回成 node.failReason)。
// 它同时是**选边的匹配键**,所以取值一定在引擎的域内(ATTRIBUTION_REASONS)。
// 2. 归因产出 —— 本轮 node.triage 事件里的 fail_reason(validator 写的 attempt-N.json)。
// 引擎还没把它落到节点行上时(或落库那一环还没接通)走这条。
// 3. 摘自输出 —— node.output.output_excerpt / node.error.error。
// fail_reason 合法地为 null(走兜底边)时,这是**唯一**能告诉人「到底出了什么事」的东西;
// 真跑时「Agent not found」正是从 node.error 这条出来的。
const FAIL_HINT = {
engine: '来自 graph_run_nodes.fail_reason —— 它同时是引擎选 fail 边用的匹配键',
triage: '来自本轮 node.triage 事件(validator 的归因文件);节点行上还没有这个值',
output: '引擎没有给出 fail_reason(= 走兜底边),下面是本轮 agent 输出 / 执行器异常的摘要',
};
const isFailed = computed(() => ['fail', 'blocked'].includes(props.node?.status));
function excerptOf(list) {
const e = lastWhere(list, (x) => {
const d = x.data || {};
if (x.kind === 'node.output') return Boolean(d.output_excerpt);
if (x.kind === 'node.error') return Boolean(d.error);
return x.kind === 'node.boundary' && d.violation === 'boundary_escape';
});
if (!e) return null;
const d = e.data || {};
if (e.kind === 'node.error') return `[${d.error_type || 'Error'}] ${d.error}`;
if (e.kind === 'node.boundary') return `[边界守卫] 本次调用在宿主仓库留下 ${d.count || 0} 处改动,按越界判失败`;
return d.output_excerpt;
}
const failInfo = computed(() => {
const n = props.node;
if (!n) return null;
const text = excerptOf(attemptEvents.value);
if (n.failReason) {
return { kind: 'engine', source: '引擎判定', reason: n.failReason, text, hint: FAIL_HINT.engine };
}
const tri = lastWhere(attemptEvents.value, (e) => e.kind === 'node.triage' && e.data && e.data.fail_reason);
if (tri) {
return { kind: 'triage', source: '归因产出', reason: tri.data.fail_reason, text, hint: FAIL_HINT.triage };
}
if (text) return { kind: 'output', source: '摘自输出', reason: null, text, hint: FAIL_HINT.output };
return null;
});
/** remediation_note 同款回落:节点行 → 本轮 node.triage 事件 */
const noteInfo = computed(() => {
if (props.node?.remediationNote) {
return { kind: 'engine', source: '引擎判定', text: props.node.remediationNote };
}
const tri = lastWhere(attemptEvents.value, (e) => e.kind === 'node.triage' && e.data && e.data.remediation_note);
return tri ? { kind: 'triage', source: '归因产出', text: tri.data.remediation_note } : null;
});
// tab 数量随节点变化,切到别的节点时若当前 tab 不存在要回落,否则面板会空白
watch(() => props.node?.id, () => {
if (!tabs.value.some((t) => t.key === tab.value)) tab.value = 'overview';
loadChild();
});
watch(tab, (t) => { if (t === 'sub') loadChild(); });
async function loadChild() {
child.value = { nodes: [], edges: [], loopRecords: [] };
if (!hasSub.value) return;
try {
const r = await fetch(`/api/graph-runs/${props.node.childRunId}`);
if (!r.ok) return;
const j = await r.json();
child.value = { nodes: j.nodes || [], edges: j.edges || [], loopRecords: j.loopRecords || [] };
} catch { /* 缩略图拉不到不该影响其它 tab */ }
}
</script>
<style scoped>
.detail { display:flex; flex-direction:column; min-height:0; height:100%; }
.ph { color:#999; font-size:13px; padding:24px 8px; text-align:center; }
.head { display:flex; align-items:center; gap:8px; }
.head h3 { margin:0; font-size:14px; font-family: ui-monospace, monospace; word-break:break-all; }
.sub { display:flex; flex-wrap:wrap; gap:10px; font-size:11px; color:#777; margin:6px 0 10px; }
.sub code { background:#f0f0f2; padding:1px 5px; border-radius:3px; }
/* 「还活着」条:呼吸点 + 秒表 + 最近一条事件。刻意没有进度条(见 LIVE_HINT) */
.live {
display:flex; align-items:baseline; gap:8px; flex-wrap:wrap;
border:1px solid #bcd6f2; background:#f2f8ff; border-radius:6px;
padding:6px 9px; margin-bottom:8px; font-size:11px; color:#2c5282;
}
.live .pulse {
width:7px; height:7px; border-radius:50%; background:#0069d9; flex:0 0 auto;
align-self:center; animation: live-pulse 1.6s ease-in-out infinite;
}
@keyframes live-pulse { 0%,100% { opacity:1; } 50% { opacity:.2; } }
@media (prefers-reduced-motion: reduce) { .live .pulse { animation:none; } }
.live .clock { font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.live .last { color:#4a6c8f; flex:1 1 100%; line-height:1.6; overflow-wrap:anywhere; }
.live .last code { background:#e2edfa; padding:0 4px; border-radius:3px; }
.live .last em { color:#7a97b5; font-style:normal; }
.live .why {
cursor:help; font-style:normal; color:#7a97b5; border:1px solid #bcd6f2;
border-radius:50%; width:14px; height:14px; line-height:13px; text-align:center;
font-size:10px; align-self:center; margin-left:auto; flex:0 0 auto;
}
/* 失败原因的来源标:三条回落路径必须一眼分得清(W12-UI) */
.src-badge {
font-size:9px; padding:1px 6px; border-radius:8px; margin-right:6px;
border:1px solid transparent; white-space:nowrap;
}
.src-badge.engine { background:#e8f0fb; color:#2b6cb0; border-color:#c9dcf3; }
.src-badge.triage { background:#f3ecfd; color:#6b46c1; border-color:#ddd0f5; }
.src-badge.output { background:#fdf3e6; color:#a35b00; border-color:#f0dcc0; }
.fail-cell code { background:#fdecec; color:#a02020; padding:1px 5px; border-radius:3px; }
.excerpt {
margin:5px 0 0; padding:6px 8px; background:#0f1115; color:#e6e6e6; border-radius:4px;
font-family: ui-monospace, monospace; font-size:10.5px; line-height:1.6;
white-space:pre-wrap; overflow-wrap:anywhere; max-height:170px; overflow-y:auto;
}
.hint.block { display:block; margin:4px 0 0; line-height:1.6; }
.hint.block code { background:#f0f0f2; padding:0 3px; border-radius:3px; }
.tabs { display:flex; gap:4px; border-bottom:1px solid #eee; margin-bottom:10px; }
.tabs button {
border:none; background:none; padding:6px 10px; font-size:12px; cursor:pointer;
color:#666; border-bottom:2px solid transparent;
}
.tabs button.on { color:#1a1a1a; font-weight:600; border-bottom-color:#2b6cb0; }
.body { flex:1; overflow-y:auto; min-height:0; }
h4 { font-size:11px; color:#888; text-transform:uppercase; margin:14px 0 6px; }
.kv { display:grid; grid-template-columns:104px 1fr; gap:4px 10px; font-size:12px; margin:0; }
.kv dt { color:#888; }
.kv dd { margin:0; word-break:break-all; }
.mono { font-family: ui-monospace, monospace; font-size:11px; }
.path { background:#f7f7f9; padding:5px 8px; border-radius:4px; word-break:break-all; }
/* 路径改成按钮(可点开预览)但要保持原来那副「一行代码」的样子:
display:block + 左对齐 + 满宽,否则它会缩成一个居中的小按钮,
和上下文里其它 .path 行对不齐 */
button.path.as-link {
display:block; width:100%; text-align:left; margin:4px 0 0;
border:1px solid transparent; font:inherit; font-size:11.5px;
color:#1d4ed8; cursor:pointer;
}
button.path.as-link:hover { border-color:#c7d2fe; background:#eef2ff; }
button.path.as-link:focus-visible { outline:2px solid #1d4ed8; outline-offset:1px; }
.badge { font-size:10px; padding:2px 8px; border-radius:9px; background:#eee; color:#555; text-transform:uppercase; }
.badge.running { background:#cce5ff; color:#004085; }
.badge.success { background:#d4edda; color:#155724; }
.badge.fail { background:#f8d7da; color:#721c24; }
.badge.blocked { background:#ffe8cc; color:#a35b00; }
.badge.skipped { background:#e9e9ec; color:#777; }
.loops { list-style:none; padding:0; margin:0; }
.loops li { border:1px solid #f0e0c8; background:#fffaf3; border-radius:5px; padding:7px 9px; margin-bottom:6px; }
.loops li.force_terminated { border-color:#e0a0a0; background:#fff5f5; }
.loop-head { display:flex; gap:8px; align-items:center; font-size:11px; }
.lc { font-weight:700; color:#8a4b00; }
.verdict { text-transform:uppercase; color:#555; }
.dir { color:#999; margin-left:auto; }
.note { font-size:12px; color:#444; margin:5px 0 0; white-space:pre-wrap; }
.consumed { border:1px solid #d6e3f5; background:#f7fbff; border-radius:5px; padding:7px 9px; }
.consumed .tag { font-size:10px; color:#2b6cb0; font-weight:600; }
.consumed .src { color:#8aa; font-style:normal; font-weight:400; margin-left:6px; }
.hint { color:#8aa; font-style:normal; font-size:10px; margin-left:6px; }
.tag-reuse { color:#2b6cb0; background:#eef4fb; border-radius:3px; padding:1px 6px; }
.scope { font-size:11px; color:#777; margin:5px 0 0; }
.scope.cross { color:#2b6cb0; }
.scope code { background:#eef4fb; padding:1px 4px; border-radius:3px; }
.layer-note { border:1px solid #e3dcf5; background:#faf8ff; border-radius:5px; padding:8px 10px; margin:8px 0; }
.layer-note .tag { font-size:10px; color:#6b46c1; font-weight:600; margin-bottom:4px; }
.layer-note ul { margin:0; padding-left:16px; }
.layer-note li { font-size:11px; color:#555; line-height:1.7; }
.layer-note code { background:#f0f0f2; padding:1px 4px; border-radius:3px; }
/* ── 输入输出 tab(R-19:契约 × 实例)───────────────────────────── */
.degrade { border:1px solid #e6e6ea; background:#fafafb; border-radius:5px; padding:8px 10px; margin-bottom:6px; }
.degrade .tag { font-size:10px; color:#777; font-weight:600; margin-bottom:4px; }
.degrade p { font-size:11px; color:#777; line-height:1.8; margin:0; }
.degrade code { background:#f0f0f2; padding:1px 4px; border-radius:3px; margin-right:3px; }
.io-list { list-style:none; padding:0; margin:0; }
.io-list > li {
border:1px solid #e4ecf6; background:#f9fcff; border-radius:5px;
padding:6px 9px; margin-bottom:6px;
}
.io-list > li.miss { border-color:#f0dcc0; background:#fffaf3; }
.io-row { display:flex; flex-wrap:wrap; align-items:center; gap:6px; font-size:11px; }
.io-row .kind {
font-family: ui-monospace, monospace; font-weight:700; font-size:11px;
color:#2b6cb0; background:#e8f0fb; border-radius:3px; padding:1px 6px;
}
.io-row .opt { font-size:9px; color:#8a4b00; background:#fff4e5; border-radius:8px; padding:1px 6px; }
.io-row .from { color:#999; margin-left:auto; }
.io-row .from code { background:#f0f0f2; padding:1px 4px; border-radius:3px; margin-left:3px; }
.io-row .arrow-to { color:#555; }
.io-row .arrow-to code { background:#f0f0f2; padding:1px 5px; border-radius:3px; font-weight:600; }
.io-row .cond { font-size:9px; color:#555; background:#eee; border-radius:8px; padding:1px 6px; text-transform:uppercase; }
.io-row .cond.fail { background:#fdecec; color:#a02020; }
.io-list .warn { font-size:11px; color:#8a4b00; margin:4px 0 0; line-height:1.6; }
.io-list .path { margin:4px 0 0; }
.c-list { margin:5px 0 0; display:flex; flex-wrap:wrap; gap:5px; }
.c-pill {
font-size:10px; font-family: ui-monospace, monospace; border-radius:8px; padding:1px 7px;
background:#e8f6ec; color:#1a6b38; border:1px solid #cbe7d5;
}
.c-pill.missing { background:#fff4e5; color:#8a4b00; border-color:#f0dcc0; }
/* 「实例存在、只是不是我产的」是合法形态(引擎按 task+kind 检索),不能与缺失同色 */
.c-pill.elsewhere { background:#f4f4f6; color:#666; border-color:#dcdce2; cursor:help; }
.c-pill.opt { opacity:.85; }
.kv dd.wrap { white-space:pre-wrap; color:#444; }
.list { padding-left:16px; margin:0; }
.muted { color:#999; font-size:12px; }
.muted.small { font-size:11px; line-height:1.6; }
.muted code { background:#f0f0f2; padding:1px 4px; border-radius:3px; }
.sub-head { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
.btn { border:1px solid #ddd; background:#fff; border-radius:5px; cursor:pointer; font-size:12px; padding:4px 10px; }
.btn.go { background:#2b6cb0; border-color:#2b6cb0; color:#fff; margin-left:auto; }
</style>