@tea-agent/loop-agent 0.13.0-beta.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +56 -305
  3. package/README.md +13 -19
  4. package/dist/commands/init.js +92 -23
  5. package/dist/executors/pi-event-serializer.js +33 -11
  6. package/dist/executors/shell-executor.js +200 -21
  7. package/dist/infrastructure/evaluation/candidate-store.js +5 -1
  8. package/dist/worker/observe/spec-evidence.js +19 -10
  9. package/dist/worker/observe/static/app.js +4 -3
  10. package/dist/worker/observe/static/constants.js +10 -2
  11. package/dist/worker/observe/static/dag-helpers.js +37 -8
  12. package/dist/worker/observe/static/dom.js +159 -0
  13. package/dist/worker/observe/static/format-pool.d.ts +71 -0
  14. package/dist/worker/observe/static/format-pool.js +67 -0
  15. package/dist/worker/observe/static/format.js +27 -2
  16. package/dist/worker/observe/static/index.html +76 -34
  17. package/dist/worker/observe/static/kpi.js +12 -6
  18. package/dist/worker/observe/static/markdown-render.js +124 -0
  19. package/dist/worker/observe/static/shell-chrome.js +8 -2
  20. package/dist/worker/observe/static/state.js +20 -0
  21. package/dist/worker/observe/static/styles.css +662 -60
  22. package/dist/worker/observe/static/views/dag-inspector.js +65 -142
  23. package/dist/worker/observe/static/views/dag.js +9 -0
  24. package/dist/worker/observe/static/views/dashboard.js +512 -269
  25. package/dist/worker/observe/static/views/pool.js +595 -237
  26. package/dist/worker/observe/static/views/session-timeline.js +577 -11
  27. package/dist/workflows/dag/backend-test-case-manifest.js +503 -0
  28. package/dist/workflows/dag/backend-test-execution-contract.js +353 -0
  29. package/dist/workflows/dag/backend-test-result-contract.js +568 -0
  30. package/dist/workflows/dag/decision-envelope.js +57 -2
  31. package/dist/workflows/dag/frontend-implementation-contract.js +240 -0
  32. package/dist/workflows/dag/frontend-project-capability.js +309 -0
  33. package/dist/workflows/dag/frontend-repair.js +341 -0
  34. package/dist/workflows/dag/frontend-risk.js +161 -0
  35. package/dist/workflows/dag/frontend-verification-trace.js +190 -0
  36. package/dist/workflows/dag/init-hybrid.js +1020 -125
  37. package/dist/workflows/dag/repair-artifact.js +43 -3
  38. package/dist/workflows/dag/skill-instructions.js +4 -2
  39. package/dist/workflows/dag/types.js +29 -8
  40. package/docs/README.md +2 -0
  41. package/docs/agent-dag-recovery-playbook.md +3 -3
  42. package/docs/agent-dag-runner.md +3 -3
  43. package/docs/architecture/README.md +3 -3
  44. package/docs/architecture/dag-execution.md +1 -1
  45. package/docs/architecture/evolution.md +13 -13
  46. package/docs/architecture/facts-and-state.md +1 -1
  47. package/docs/architecture/runtime-boundaries.md +7 -7
  48. package/docs/architecture/system-overview.md +3 -3
  49. package/docs/architecture/worker-and-feature.md +3 -3
  50. package/docs/design/README.md +7 -7
  51. package/docs/development-principles.md +4 -4
  52. package/docs/exec-plans/active/README.md +2 -4
  53. package/docs/exec-plans/completed/README.md +29 -6
  54. package/docs/feature-workflow.md +57 -32
  55. package/docs/init-surface.manifest.json +21 -3
  56. package/docs/loop-agent-harness.md +8 -8
  57. package/docs/production-readiness.md +1 -1
  58. package/docs/progress/README.md +20 -3
  59. package/docs/reports/README.md +53 -7
  60. package/docs/templates/agent-dag.supervised-implementation.json +127 -8
  61. package/docs/templates/backend-test-case-manifest.schema.json +190 -0
  62. package/docs/templates/backend-test-dag.classify.prompt.md +75 -0
  63. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +6 -4
  64. package/docs/templates/backend-test-dag.json +269 -21
  65. package/docs/templates/backend-test-dag.retrospect.prompt.md +44 -30
  66. package/docs/templates/backend-test-dag.review-cases.prompt.md +6 -4
  67. package/docs/templates/backend-test-execution.schema.json +133 -0
  68. package/docs/templates/backend-test-result.schema.json +99 -0
  69. package/docs/templates/branch-merge-report.md +93 -0
  70. package/docs/templates/frontend-eval/fixtures/failures/01-type-build-error.md +17 -0
  71. package/docs/templates/frontend-eval/fixtures/failures/02-unit-component-test-fail.md +16 -0
  72. package/docs/templates/frontend-eval/fixtures/failures/03-fixture-schema-drift.md +16 -0
  73. package/docs/templates/frontend-eval/fixtures/failures/04-missing-loading-empty-error-state.md +16 -0
  74. package/docs/templates/frontend-eval/fixtures/failures/05-forbidden-write-writeset-expansion.md +16 -0
  75. package/docs/templates/frontend-eval/fixtures/failures/06-unapproved-dependency-add.md +16 -0
  76. package/docs/templates/frontend-eval/fixtures/failures/07-mock-production-on.md +21 -0
  77. package/docs/templates/frontend-eval/fixtures/functional/01-simple-component-style.md +29 -0
  78. package/docs/templates/frontend-eval/fixtures/functional/02-form-validation.md +28 -0
  79. package/docs/templates/frontend-eval/fixtures/functional/03-list-detail-page.md +28 -0
  80. package/docs/templates/frontend-eval/fixtures/functional/04-api-mock.md +29 -0
  81. package/docs/templates/frontend-eval/fixtures/functional/05-permission-auth-gated-ui.md +27 -0
  82. package/docs/templates/frontend-eval/fixtures/functional/06-ssr-server-client-boundary.md +28 -0
  83. package/docs/templates/frontend-eval/fixtures/functional/07-shared-public-component-api.md +28 -0
  84. package/docs/templates/frontend-eval/fixtures/functional/08-pure-local-no-remote.md +27 -0
  85. package/docs/templates/frontend-eval/metrics.md +138 -0
  86. package/docs/templates/frontend-eval/smoke-targets.md +53 -0
  87. package/docs/templates/frontend-implementation-contract.schema.json +27 -0
  88. package/docs/verification-matrix.md +1 -1
  89. package/examples/decision-gate-agent-dag.json +4 -4
  90. package/examples/hybrid-loop-agent-dag.json +1 -1
  91. package/package.json +2 -2
  92. package/skills/ai-engineering-context/SKILL.md +2 -2
  93. package/skills/browser-tools/SKILL.md +196 -0
  94. package/skills/browser-tools/browser-content.js +103 -0
  95. package/skills/browser-tools/browser-cookies.js +35 -0
  96. package/skills/browser-tools/browser-eval.js +53 -0
  97. package/skills/browser-tools/browser-hn-scraper.js +108 -0
  98. package/skills/browser-tools/browser-nav.js +44 -0
  99. package/skills/browser-tools/browser-pick.js +162 -0
  100. package/skills/browser-tools/browser-screenshot.js +34 -0
  101. package/skills/browser-tools/browser-start.js +86 -0
  102. package/skills/browser-tools/package-lock.json +2556 -0
  103. package/skills/browser-tools/package.json +19 -0
  104. package/skills/frontend-implementation/SKILL.md +3 -1
  105. package/skills/frontend-implementation/references/node-contracts.md +17 -66
  106. package/skills/frontend-verification/SKILL.md +1 -1
  107. package/skills/grill-with-docs/SKILL.md +5 -5
  108. package/skills/grill-with-docs/adr-format.md +3 -3
  109. package/skills/init-capability-evolution/SKILL.md +5 -5
  110. package/skills/loop-agent/SKILL.md +5 -5
  111. package/skills/loop-agent/references/README.md +3 -3
  112. package/skills/loop-agent/references/command-reference.md +39 -17
  113. package/skills/loop-agent/references/docs-converge.md +15 -15
  114. package/skills/loop-agent/references/harness-policy.md +2 -2
  115. package/skills/loop-agent/references/hybrid-dag.md +20 -15
  116. package/skills/loop-agent/references/multi-worktree.md +1 -1
  117. package/skills/loop-agent/references/orchestrator-and-interventions.md +8 -8
  118. package/skills/loop-agent/references/task-workflow.md +1 -1
  119. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -4
  120. package/skills/requesting-code-review/SKILL.md +1 -1
  121. package/skills/systematic-debugging/CREATION-LOG.md +3 -3
  122. package/skills/systematic-debugging/SKILL.md +1 -1
  123. package/skills/systematic-debugging/test-academic.md +1 -1
  124. package/skills/systematic-debugging/test-pressure-1.md +1 -1
  125. package/skills/systematic-debugging/test-pressure-2.md +1 -1
  126. package/skills/systematic-debugging/test-pressure-3.md +1 -1
  127. package/skills/verification-before-completion/SKILL.md +1 -1
@@ -16,7 +16,7 @@ import {
16
16
  DAG_EFFECTIVE_STATUS_LABELS,
17
17
  STATUS_LABELS,
18
18
  } from "../constants.js";
19
- import { clearNode, el, metaGrid, buildTable } from "../dom.js";
19
+ import { el, reconcileKeyed, syncCompatibleNode } from "../dom.js";
20
20
  import {
21
21
  badge,
22
22
  badgeClass,
@@ -65,8 +65,22 @@ import {
65
65
  } from "../dag-model.js";
66
66
  import { layoutDag } from "../dag-layout.js";
67
67
  import { showView, setBreadcrumb, updateHeaderRefresh } from "../shell-chrome.js";
68
- import { kpiCard, renderRepoBanner, renderProjectionFault, failuresInboxLink, artifactLink } from "../kpi.js";
69
- import { dagSortTime, renderActiveDagCard, dagProgress } from "../dag-helpers.js";
68
+ import { kpiCard, renderRepoBanner, renderProjectionFault, failuresInboxLink } from "../kpi.js";
69
+ import {
70
+ dagSortTime,
71
+ compareActiveDagUrgency,
72
+ renderActiveDagCard,
73
+ dagProgress,
74
+ } from "../dag-helpers.js";
75
+
76
+ // Cached KPI fieldsets so successful polls keep node identity instead of
77
+ // rebuilding the whole KPI block each cycle (REQ-OBS-POLL-002). Reset to null
78
+ // when a projection error replaces the block with a degraded/fault node.
79
+ let kpiWorkerGroup = null;
80
+ let kpiDagGroup = null;
81
+ // The loading placeholder mounted before the first successful snapshot. It is
82
+ // removed once on first success so subsequent polls never see it again.
83
+ let dashboardLoadingNode = null;
70
84
 
71
85
  export async function renderDashboard(scrollTo) {
72
86
  showView("dashboard");
@@ -80,7 +94,11 @@ export async function renderDashboard(scrollTo) {
80
94
  const batchesEl = document.getElementById("dashboard-batches");
81
95
  const repoEl = document.getElementById("dashboard-repo");
82
96
  const host = kpiEl || featureEl || activeEl;
83
- if (host) mountViewState(host, "loading", "加载看板…");
97
+ // Loading only mounts before the first successful snapshot. Subsequent
98
+ // successful polls reuse the stable DOM and never flash the loading state.
99
+ if (host && !uiState.lastSnapshot) {
100
+ dashboardLoadingNode = mountViewState(host, "loading", "加载看板…");
101
+ }
84
102
 
85
103
  const snapshot = await fetchJson("/api/snapshot");
86
104
  if (!snapshot) {
@@ -89,6 +107,10 @@ export async function renderDashboard(scrollTo) {
89
107
  }
90
108
  uiState.lastSnapshot = snapshot;
91
109
  updateHeaderRefresh(snapshot.generatedAt);
110
+ if (dashboardLoadingNode?.parentNode) {
111
+ dashboardLoadingNode.parentNode.removeChild(dashboardLoadingNode);
112
+ }
113
+ dashboardLoadingNode = null;
92
114
 
93
115
  const dagRuns = snapshot.dagRuns ?? [];
94
116
  const activeDags = dagRuns.filter(isDagRunActive);
@@ -100,60 +122,97 @@ export async function renderDashboard(scrollTo) {
100
122
 
101
123
  const features = dashboardVisibleFeatures(snapshot.features);
102
124
  featureEl.hidden = features.length === 0;
103
- clearNode(featureEl);
104
- if (features.length > 0) {
105
- featureEl.appendChild(el("h3", "section-heading", "Feature 决策"));
106
- featureEl.appendChild(
107
- buildTable(
108
- ["Feature", "状态", "下一步(建议)", "原因", "required AC", "证据"],
109
- features.map((feature) => {
110
- const idCell = objectRouteLink(
111
- "feature",
112
- feature.featureId,
113
- snapshot,
114
- );
115
- // Prefer planner-selected next task; never re-rank ready pool order here.
116
- const selected = feature.planning?.selected?.[0];
117
- const nextHint = selected
118
- ? `Next Task: ${selected.taskId} (${selected.priority})`
119
- : feature.nextAction?.label ??
120
- (feature.nextAction?.command
121
- ? truncateText(feature.nextAction.command, 48)
122
- : "—");
123
- return {
124
- onClick: feature.featureId
125
- ? () =>
126
- navigate(`/feature/${encodeURIComponent(feature.featureId)}`)
127
- : undefined,
128
- cells: [
129
- idCell,
130
- feature.statusLabel ?? feature.status,
131
- nextHint,
132
- feature.blockingItems?.[0]?.message ??
133
- `${feature.summary?.tasksSucceeded ?? 0}/${feature.summary?.tasksTotal ?? 0} tasks completed`,
134
- `${feature.summary?.requiredAcCovered ?? 0}/${feature.summary?.requiredAcTotal ?? 0}`,
135
- artifactLink(
136
- "查看",
137
- feature.evidence?.delivery ??
138
- feature.evidence?.closeout ??
139
- feature.evidence?.morningReport ??
140
- feature.evidence?.observeSnapshot,
141
- ),
142
- ],
143
- };
144
- }),
145
- ),
146
- );
147
- }
148
- if (features.length > 0 && (snapshot.projectionWarnings ?? []).length > 0) {
149
- featureEl.appendChild(
150
- el("p", "empty", `投影警告:${snapshot.projectionWarnings.join("")}`),
151
- );
152
- }
125
+ if (features.length > 0) {
126
+ reconcileKeyed(featureEl, [{ key: "__heading__" }], {
127
+ getKey: () => "__heading__",
128
+ scope: "heading",
129
+ create: () => el("h3", "section-heading", "Feature 决策"),
130
+ update: () => {},
131
+ });
132
+ reconcileKeyed(featureEl, [{ key: "__list__" }], {
133
+ getKey: () => "__list__",
134
+ scope: "list",
135
+ create: () => el("div", "feature-decision-list"),
136
+ update: (list) => {
137
+ reconcileKeyed(list, features, {
138
+ getKey: (feature) => feature.featureId ?? "__nofeature__",
139
+ create: (feature) => {
140
+ const row = el("div", "feature-decision-row");
141
+ row.classList.add("clickable");
142
+ if (feature.featureId) {
143
+ row.addEventListener("click", () =>
144
+ navigate(`/feature/${encodeURIComponent(feature.featureId)}`),
145
+ );
146
+ }
147
+ row.appendChild(el("div", "feature-decision-id"));
148
+ row.appendChild(el("div", "feature-decision-status"));
149
+ row.appendChild(el("div", "feature-decision-next"));
150
+ row.appendChild(el("div", "feature-decision-open", "查看详情 →"));
151
+ return row;
152
+ },
153
+ update: (row, feature) => {
154
+ const idCell = row.querySelector(".feature-decision-id");
155
+ const statusCell = row.querySelector(".feature-decision-status");
156
+ const nextCell = row.querySelector(".feature-decision-next");
157
+ if (idCell) {
158
+ const link = objectRouteLink(
159
+ "feature",
160
+ feature.featureId,
161
+ snapshot,
162
+ );
163
+ while (idCell.firstChild) idCell.removeChild(idCell.firstChild);
164
+ idCell.appendChild(
165
+ link instanceof Node
166
+ ? link
167
+ : document.createTextNode(feature.featureId ?? "—"),
168
+ );
169
+ }
170
+ if (statusCell) {
171
+ statusCell.textContent =
172
+ feature.statusLabel ?? feature.status ?? "";
173
+ }
174
+ // Prefer planner-selected next task; never re-rank ready pool order here.
175
+ const selected = feature.planning?.selected?.[0];
176
+ const nextHint = selected
177
+ ? `Next Task: ${selected.taskId} (${selected.priority})`
178
+ : feature.nextAction?.label ??
179
+ (feature.nextAction?.command
180
+ ? truncateText(feature.nextAction.command, 64)
181
+ : feature.blockingItems?.[0]?.message ?? "—");
182
+ if (nextCell) nextCell.textContent = nextHint;
183
+ },
184
+ });
185
+ },
186
+ });
187
+ } else {
188
+ reconcileKeyed(featureEl, [], {
189
+ getKey: (item) => item.key,
190
+ scope: "list",
191
+ create: () => el("div"),
192
+ });
193
+ }
194
+ reconcileKeyed(
195
+ featureEl,
196
+ features.length > 0 && (snapshot.projectionWarnings ?? []).length > 0
197
+ ? [
198
+ {
199
+ key: "__warning__",
200
+ text: `投影警告:${snapshot.projectionWarnings.join(";")}`,
201
+ },
202
+ ]
203
+ : [],
204
+ {
205
+ getKey: (item) => item.key,
206
+ scope: "warning",
207
+ create: (item) => el("p", "empty", item.text),
208
+ update: (node, item) => {
209
+ node.textContent = item.text;
210
+ },
211
+ },
212
+ );
153
213
 
154
214
  renderRepoBanner(repoEl, snapshot);
155
215
 
156
- clearNode(kpiEl);
157
216
  const dagHealth = snapshot.health?.dag;
158
217
  const dagActiveRuns = dagHealth?.activeRuns ?? activeDags.length;
159
218
  const dagRunningNodes =
@@ -191,242 +250,427 @@ export async function renderDashboard(scrollTo) {
191
250
  const failuresCount = snapshot.health?.failuresCount ?? 0;
192
251
 
193
252
  if (snapshot.projectionError) {
194
- mountViewState(kpiEl, "degraded", "指标暂不可用:数据投影失败。", {
195
- detail: snapshot.projectionError.message ?? null,
196
- });
197
- // keep legacy banner for non-color / structure parity
198
- kpiEl.appendChild(renderProjectionFault(snapshot.projectionError));
199
- } else {
200
- const dagGroup = el("fieldset", "kpi-group");
201
- dagGroup.appendChild(el("legend", "kpi-group-legend", "DAG 运行"));
202
- const dagCards = el("div", "kpi-grid");
203
- dagCards.appendChild(
204
- kpiCard(
205
- "活跃 DAG",
206
- dagActiveRuns,
207
- "kpi-highlight",
208
- "当前处于运行、静默运行、远端状态未知或等待执行的 DAG 数量(来源:.harness/dag-runs 的有效状态)。",
209
- ),
210
- );
211
- dagCards.appendChild(
212
- kpiCard(
213
- "执行中节点",
214
- dagRunningNodes,
215
- "",
216
- "活跃 DAG 中状态为 running started、正在执行的节点数量;DAG 内执行单元统一称为节点。",
217
- ),
218
- );
219
- dagCards.appendChild(
220
- kpiCard(
221
- "等待节点",
222
- dagPendingNodes,
223
- "",
224
- "活跃 DAG 中状态为 pending 或 queued、尚未开始执行的节点数量。",
225
- ),
226
- );
227
- dagCards.appendChild(
228
- kpiCard(
229
- "暂停 DAG",
230
- dagPausedRuns,
231
- dagPausedRuns > 0 ? "kpi-muted" : "",
232
- "生命周期被暂停的 DAG 运行数量(来源:.harness/dag-runs 下的 lifecycle 目录)。",
233
- ),
234
- );
235
- dagCards.appendChild(
236
- kpiCard(
237
- "需处理 DAG",
238
- dagAttentionRuns,
239
- dagAttentionRuns > 0 ? "kpi-risk" : "",
240
- "未完成且暂停、失联、中断、远端状态未知或状态不一致的 DAG 数量;同一个 DAG 只计一次。",
241
- ),
253
+ // Drop any cached KPI groups so a later recovery rebuilds them cleanly.
254
+ kpiWorkerGroup = null;
255
+ kpiDagGroup = null;
256
+ reconcileKeyed(
257
+ kpiEl,
258
+ [{ key: "__fault__", error: snapshot.projectionError }],
259
+ {
260
+ getKey: (item) => item.key,
261
+ scope: "kpi",
262
+ create: (item) => {
263
+ const node = renderViewState(
264
+ "degraded",
265
+ "指标暂不可用:数据投影失败。",
266
+ { detail: item.error.message ?? null },
267
+ );
268
+ node.appendChild(renderProjectionFault(item.error));
269
+ return node;
270
+ },
271
+ update: (node, item) => {
272
+ const fresh = renderViewState(
273
+ "degraded",
274
+ "指标暂不可用:数据投影失败。",
275
+ { detail: item.error.message ?? null },
276
+ );
277
+ fresh.appendChild(renderProjectionFault(item.error));
278
+ syncCompatibleNode(node, fresh);
279
+ },
280
+ },
242
281
  );
243
- dagGroup.appendChild(dagCards);
282
+ } else {
283
+ const workerGroup = kpiWorkerGroup ?? (kpiWorkerGroup = el("fieldset", "kpi-group"));
284
+ syncKpiGroup(workerGroup, "Worker 执行", [
285
+ {
286
+ label: "活跃 Worker Task",
287
+ value: snapshot.health?.activeTasks ?? 0,
288
+ extraClass: "",
289
+ helpText:
290
+ "当前运行或等待执行的 Worker Task Pool 任务数量(来源:.harness/task-pool 的状态、运行记录与事件;不含 .harness/tasks 下的 task.json)。",
291
+ },
292
+ {
293
+ label: "无输出",
294
+ value: quietCount,
295
+ extraClass: quietCount > 0 ? "kpi-muted" : "",
296
+ helpText: "心跳仍正常,但超过无输出判定时长仍没有新输出的 Worker Task 数量。",
297
+ },
298
+ {
299
+ label: "心跳失联",
300
+ value: staleCount,
301
+ extraClass: staleCount > 0 ? "kpi-risk" : "",
302
+ helpText: "超过心跳失联判定时长仍没有新心跳的 Worker Task 数量。",
303
+ },
304
+ {
305
+ label: "即将超时",
306
+ value: timeoutRiskCount,
307
+ extraClass: timeoutRiskCount > 0 ? "kpi-risk" : "",
308
+ helpText: "命令已执行时长超过 80% 超时的 Worker Task 数量。",
309
+ },
310
+ {
311
+ label: "失败",
312
+ value: failuresCount > 0 ? failuresInboxLink(failuresCount) : failuresCount,
313
+ extraClass: failuresCount > 0 ? "kpi-danger" : "",
314
+ helpText: "状态为 failed 的 Worker Task 数量。",
315
+ },
316
+ ]);
244
317
 
245
- const workerGroup = el("fieldset", "kpi-group");
246
- workerGroup.appendChild(el("legend", "kpi-group-legend", "Worker 执行"));
247
- const workerCards = el("div", "kpi-grid");
248
- workerCards.appendChild(
249
- kpiCard(
250
- "活跃 Worker Task",
251
- snapshot.health?.activeTasks ?? 0,
252
- "",
253
- "当前运行或等待执行的 Worker Task Pool 任务数量(来源:.harness/task-pool 的状态、运行记录与事件;不含 .harness/tasks 下的 task.json)。",
254
- ),
255
- );
256
- workerCards.appendChild(
257
- kpiCard(
258
- "无输出",
259
- quietCount,
260
- quietCount > 0 ? "kpi-muted" : "",
261
- "心跳仍正常,但超过无输出判定时长仍没有新输出的 Worker Task 数量。",
262
- ),
263
- );
264
- workerCards.appendChild(
265
- kpiCard(
266
- "心跳失联",
267
- staleCount,
268
- staleCount > 0 ? "kpi-risk" : "",
269
- "超过心跳失联判定时长仍没有新心跳的 Worker Task 数量。",
270
- ),
271
- );
272
- workerCards.appendChild(
273
- kpiCard(
274
- "即将超时",
275
- timeoutRiskCount,
276
- timeoutRiskCount > 0 ? "kpi-risk" : "",
277
- "命令已执行时长超过 80% 超时的 Worker Task 数量。",
278
- ),
279
- );
280
- workerCards.appendChild(
281
- kpiCard(
282
- "失败",
283
- failuresCount > 0 ? failuresInboxLink(failuresCount) : failuresCount,
284
- failuresCount > 0 ? "kpi-danger" : "",
285
- "状态为 failed Worker Task 数量。",
286
- ),
318
+ const dagGroup = kpiDagGroup ?? (kpiDagGroup = el("fieldset", "kpi-group"));
319
+ syncKpiGroup(dagGroup, "DAG 运行", [
320
+ {
321
+ label: "活跃 DAG",
322
+ value: dagActiveRuns,
323
+ extraClass: "kpi-highlight",
324
+ helpText:
325
+ "当前处于运行、静默运行、远端状态未知或等待执行的 DAG 数量(来源:.harness/dag-runs 的有效状态)。",
326
+ },
327
+ {
328
+ label: "执行中节点",
329
+ value: dagRunningNodes,
330
+ extraClass: "",
331
+ helpText:
332
+ "活跃 DAG 中状态为 running 或 started、正在执行的节点数量;DAG 内执行单元统一称为节点。",
333
+ },
334
+ {
335
+ label: "等待节点",
336
+ value: dagPendingNodes,
337
+ extraClass: "",
338
+ helpText: "活跃 DAG 中状态为 pending 或 queued、尚未开始执行的节点数量。",
339
+ },
340
+ {
341
+ label: "暂停 DAG",
342
+ value: dagPausedRuns,
343
+ extraClass: dagPausedRuns > 0 ? "kpi-muted" : "",
344
+ helpText: "生命周期被暂停的 DAG 运行数量(来源:.harness/dag-runs 下的 lifecycle 目录)。",
345
+ },
346
+ {
347
+ label: "需处理 DAG",
348
+ value: dagAttentionRuns,
349
+ extraClass: dagAttentionRuns > 0 ? "kpi-risk" : "",
350
+ helpText:
351
+ "未完成且暂停、失联、中断、远端状态未知或状态不一致的 DAG 数量;同一个 DAG 只计一次。",
352
+ },
353
+ ]);
354
+
355
+ reconcileKeyed(
356
+ kpiEl,
357
+ [
358
+ { key: "worker-group", node: workerGroup },
359
+ { key: "dag-group", node: dagGroup },
360
+ ],
361
+ {
362
+ getKey: (item) => item.key,
363
+ scope: "kpi",
364
+ create: (item) => item.node,
365
+ update: () => {},
366
+ },
287
367
  );
288
- workerGroup.appendChild(workerCards);
289
- kpiEl.appendChild(workerGroup);
290
- kpiEl.appendChild(dagGroup);
368
+ if (workerGroup.parentNode !== kpiEl) kpiEl.appendChild(workerGroup);
369
+ if (dagGroup.parentNode !== kpiEl) kpiEl.appendChild(dagGroup);
291
370
  }
292
371
 
293
372
  const nowMs = Date.now();
294
373
 
295
- clearNode(activeEl);
296
- activeEl.appendChild(el("h3", "section-heading", UI_TEXT.activeDags));
297
- if (activeDags.length === 0) {
298
- activeEl.appendChild(el("p", "empty", UI_TEXT.noActiveDags));
299
- } else {
300
- const list = el("div", "active-dag-list");
301
- for (const dag of activeDags.sort((a, b) =>
302
- dagSortTime(b).localeCompare(dagSortTime(a)),
303
- )) {
304
- list.appendChild(renderActiveDagCard(dag, nowMs));
305
- }
306
- activeEl.appendChild(list);
307
- }
374
+ reconcileKeyed(activeEl, [{ key: "__heading__" }], {
375
+ getKey: () => "__heading__",
376
+ scope: "heading",
377
+ create: () => el("h3", "section-heading", UI_TEXT.activeDags),
378
+ update: () => {},
379
+ });
380
+ reconcileKeyed(
381
+ activeEl,
382
+ activeDags.length === 0
383
+ ? [{ key: "__empty__", text: UI_TEXT.noActiveDags }]
384
+ : [{ key: "__list__" }],
385
+ {
386
+ getKey: (item) => item.key,
387
+ create: (item) => {
388
+ if (item.key === "__empty__") return el("p", "empty", item.text);
389
+ const list = el("div", "active-dag-list");
390
+ return list;
391
+ },
392
+ update: (list) => {
393
+ reconcileKeyed(
394
+ list,
395
+ activeDags.slice().sort(compareActiveDagUrgency),
396
+ {
397
+ getKey: (dag) => dag.dagRunId,
398
+ create: (dag) => renderActiveDagCard(dag, nowMs),
399
+ update: (card, dag) =>
400
+ syncCompatibleNode(card, renderActiveDagCard(dag, nowMs)),
401
+ },
402
+ );
403
+ },
404
+ },
405
+ );
308
406
 
309
- clearNode(riskEl);
310
407
  const dagRiskCount = dagAttentionRuns;
311
408
  const workerRiskCount = failuresCount + staleCount + timeoutRiskCount;
312
409
  riskEl.classList.toggle(
313
410
  "panel-risk-attention",
314
411
  dagRiskCount > 0 || workerRiskCount > 0,
315
412
  );
316
- riskEl.appendChild(el("h3", "section-heading", "运行风险"));
317
- if (dagRiskCount === 0 && workerRiskCount === 0) {
318
- riskEl.appendChild(el("p", "risk-empty", "当前没有需要处理的异常。"));
319
- } else {
320
- const riskBlocks = el("div", "risk-blocks");
321
- if (dagRiskCount > 0) {
322
- const dagBlock = el("div", "risk-block risk-block-dag");
323
- dagBlock.appendChild(el("div", "risk-block-label", "需处理 DAG"));
324
- dagBlock.appendChild(el("div", "risk-total", String(dagRiskCount)));
325
- dagBlock.appendChild(
326
- el(
327
- "p",
328
- "risk-summary",
329
- `去重运行数 ${dagAttentionRuns} · 中断 ${dagInterruptedRuns} · 失联 ${dagStaleRuns} · 状态不一致 ${dagInconsistentRuns} · 暂停 ${dagPausedRuns}`,
330
- ),
331
- );
332
- riskBlocks.appendChild(dagBlock);
333
- }
334
- if (workerRiskCount > 0) {
335
- const workerBlock = el("div", "risk-block risk-block-worker");
336
- workerBlock.appendChild(el("div", "risk-block-label", "Worker 风险"));
337
- workerBlock.appendChild(el("div", "risk-total", String(workerRiskCount)));
338
- workerBlock.appendChild(
339
- el(
340
- "p",
341
- "risk-summary",
342
- `失败 ${failuresCount} · 心跳失联 ${staleCount} · 即将超时 ${timeoutRiskCount}`,
343
- ),
344
- );
345
- const link = el("a", "risk-link", "查看异常 Task →");
346
- link.href = "#/failures";
347
- link.addEventListener("click", (event) => {
348
- event.preventDefault();
349
- navigate("/failures");
350
- });
351
- workerBlock.appendChild(link);
352
- riskBlocks.appendChild(workerBlock);
353
- }
354
- riskEl.appendChild(riskBlocks);
355
- }
413
+ reconcileKeyed(riskEl, [{ key: "__heading__" }], {
414
+ getKey: () => "__heading__",
415
+ scope: "heading",
416
+ create: () => el("h3", "section-heading", "运行风险"),
417
+ update: () => {},
418
+ });
419
+ reconcileKeyed(
420
+ riskEl,
421
+ dagRiskCount === 0 && workerRiskCount === 0
422
+ ? [{ key: "__empty__", text: "当前没有需要处理的异常。" }]
423
+ : [{ key: "__blocks__", dagRiskCount, workerRiskCount, failuresCount, staleCount, timeoutRiskCount, dagAttentionRuns, dagInterruptedRuns, dagStaleRuns, dagInconsistentRuns, dagPausedRuns }],
424
+ {
425
+ getKey: (item) => item.key,
426
+ create: (item) => {
427
+ if (item.key === "__empty__") {
428
+ return el("p", "risk-empty", item.text);
429
+ }
430
+ return el("div", "risk-blocks");
431
+ },
432
+ update: (riskBlocks, item) => {
433
+ if (item.key === "__empty__") return;
434
+ reconcileKeyed(
435
+ riskBlocks,
436
+ item.dagRiskCount > 0
437
+ ? [
438
+ {
439
+ key: "dag",
440
+ total: item.dagRiskCount,
441
+ summary: `去重运行数 ${item.dagAttentionRuns} · 中断 ${item.dagInterruptedRuns} · 失联 ${item.dagStaleRuns} · 状态不一致 ${item.dagInconsistentRuns} · 暂停 ${item.dagPausedRuns}`,
442
+ },
443
+ ]
444
+ : [],
445
+ {
446
+ getKey: (block) => block.key,
447
+ scope: "dag",
448
+ create: (block) => {
449
+ const dagBlock = el("div", "risk-block risk-block-dag");
450
+ dagBlock.appendChild(el("div", "risk-block-label", "需处理 DAG"));
451
+ dagBlock.appendChild(el("div", "risk-total", String(block.total)));
452
+ dagBlock.appendChild(el("p", "risk-summary", block.summary));
453
+ return dagBlock;
454
+ },
455
+ update: (dagBlock, block) => {
456
+ const total = dagBlock.querySelector(".risk-total");
457
+ if (total) total.textContent = String(block.total);
458
+ const summary = dagBlock.querySelector(".risk-summary");
459
+ if (summary) summary.textContent = block.summary;
460
+ },
461
+ },
462
+ );
463
+ reconcileKeyed(
464
+ riskBlocks,
465
+ item.workerRiskCount > 0
466
+ ? [
467
+ {
468
+ key: "worker",
469
+ total: item.workerRiskCount,
470
+ summary: `失败 ${item.failuresCount} · 心跳失联 ${item.staleCount} · 即将超时 ${item.timeoutRiskCount}`,
471
+ },
472
+ ]
473
+ : [],
474
+ {
475
+ getKey: (block) => block.key,
476
+ scope: "worker",
477
+ create: (block) => {
478
+ const workerBlock = el("div", "risk-block risk-block-worker");
479
+ workerBlock.appendChild(el("div", "risk-block-label", "Worker 风险"));
480
+ workerBlock.appendChild(el("div", "risk-total", String(block.total)));
481
+ workerBlock.appendChild(el("p", "risk-summary", block.summary));
482
+ const link = el("a", "risk-link", "查看异常 Task →");
483
+ link.href = "#/failures";
484
+ link.addEventListener("click", (event) => {
485
+ event.preventDefault();
486
+ navigate("/failures");
487
+ });
488
+ workerBlock.appendChild(link);
489
+ return workerBlock;
490
+ },
491
+ update: (workerBlock, block) => {
492
+ const total = workerBlock.querySelector(".risk-total");
493
+ if (total) total.textContent = String(block.total);
494
+ const summary = workerBlock.querySelector(".risk-summary");
495
+ if (summary) summary.textContent = block.summary;
496
+ },
497
+ },
498
+ );
499
+ },
500
+ },
501
+ );
356
502
 
357
- clearNode(dagsEl);
358
503
  dagsEl.id = "dashboard-dags";
359
- dagsEl.appendChild(el("h3", "section-heading", UI_TEXT.recentDags));
360
- if (recentDags.length === 0 && activeDags.length === 0) {
361
- dagsEl.appendChild(el("p", "empty", UI_TEXT.noRecentDags));
362
- } else if (recentDags.length === 0) {
363
- dagsEl.appendChild(el("p", "empty", "暂无历史 DAG 记录"));
364
- } else {
365
- const timeline = el("div", "dag-run-timeline");
366
- for (const dag of recentDags) {
367
- const progress = dagProgress(dag);
368
- const item = el("a", "dag-run-timeline-item");
369
- item.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
370
- item.title = dag.title || dag.dagRunId;
371
- item.addEventListener("click", (event) => {
372
- event.preventDefault();
373
- navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
374
- });
375
-
376
- const rail = el("span", "dag-run-timeline-rail");
377
- rail.appendChild(
378
- el(
379
- "span",
380
- `dag-run-timeline-dot status-${badgeClass(dag.effectiveStatus ?? dag.status)}`,
381
- ),
382
- );
383
- item.appendChild(rail);
504
+ reconcileKeyed(dagsEl, [{ key: "__heading__" }], {
505
+ getKey: () => "__heading__",
506
+ scope: "heading",
507
+ create: () => el("h3", "section-heading", UI_TEXT.recentDags),
508
+ update: () => {},
509
+ });
510
+ reconcileKeyed(
511
+ dagsEl,
512
+ recentDags.length === 0
513
+ ? activeDags.length === 0
514
+ ? [{ key: "__empty__", text: UI_TEXT.noRecentDags }]
515
+ : [{ key: "__empty__", text: "暂无历史 DAG 记录" }]
516
+ : [{ key: "__timeline__" }],
517
+ {
518
+ getKey: (item) => item.key,
519
+ create: (item) => {
520
+ if (item.key === "__empty__") return el("p", "empty", item.text);
521
+ return el("div", "dag-run-timeline");
522
+ },
523
+ update: (timeline) => {
524
+ reconcileKeyed(timeline, recentDags, {
525
+ getKey: (dag) => dag.dagRunId,
526
+ create: (dag) => buildDagTimelineItem(dag),
527
+ update: (item, dag) => syncDagTimelineItem(item, dag),
528
+ });
529
+ },
530
+ },
531
+ );
384
532
 
385
- const content = el("span", "dag-run-timeline-content");
386
- content.appendChild(
387
- el("span", "dag-run-timeline-title", dag.title || dag.dagRunId),
388
- );
389
- content.appendChild(
390
- el(
391
- "span",
392
- "dag-run-timeline-meta",
393
- `${formatTs(dag.startedAt)} · ${progress.finished}/${progress.total} · ${formatDuration(dag.durationMs)}`,
394
- ),
395
- );
396
- item.appendChild(content);
533
+ reconcileKeyed(batchesEl, [{ key: "__heading__" }], {
534
+ getKey: () => "__heading__",
535
+ scope: "heading",
536
+ create: () => el("h3", "section-heading", UI_TEXT.batches),
537
+ update: () => {},
538
+ });
539
+ const batches = snapshot.batches ?? [];
540
+ reconcileKeyed(
541
+ batchesEl,
542
+ batches.length === 0 ? [{ key: "__empty__", text: UI_TEXT.noBatches }] : [{ key: "__table__" }],
543
+ {
544
+ getKey: (item) => item.key,
545
+ create: (item) => {
546
+ if (item.key === "__empty__") return el("p", "empty", item.text);
547
+ const table = document.createElement("table");
548
+ const thead = document.createElement("thead");
549
+ const headRow = document.createElement("tr");
550
+ for (const h of ["批次 ID", "功能", "状态", "开始时间", "摘要"]) {
551
+ const th = document.createElement("th");
552
+ th.textContent = h;
553
+ headRow.appendChild(th);
554
+ }
555
+ thead.appendChild(headRow);
556
+ table.appendChild(thead);
557
+ table.appendChild(document.createElement("tbody"));
558
+ return table;
559
+ },
560
+ update: (table) => {
561
+ let tbody = table.querySelector("tbody");
562
+ if (!tbody) {
563
+ tbody = document.createElement("tbody");
564
+ table.appendChild(tbody);
565
+ }
566
+ reconcileKeyed(tbody, batches, {
567
+ getKey: (batch) => batch.batchRunId,
568
+ create: (batch) => {
569
+ const tr = document.createElement("tr");
570
+ tr.classList.add("clickable");
571
+ tr.addEventListener("click", () =>
572
+ navigate(`/batch/${encodeURIComponent(batch.batchRunId)}`),
573
+ );
574
+ return tr;
575
+ },
576
+ update: (tr, batch) => {
577
+ const cells = [
578
+ batch.batchRunId,
579
+ batch.featureId ?? "—",
580
+ badge(batch.status),
581
+ formatTs(batch.startedAt),
582
+ summaryText(batch.summary),
583
+ ];
584
+ syncTableRow(tr, cells);
585
+ },
586
+ });
587
+ },
588
+ },
589
+ );
397
590
 
398
- const state = el("span", "dag-run-timeline-status");
399
- state.appendChild(dagStatusBadge(dag.effectiveStatus ?? dag.status));
400
- item.appendChild(state);
401
- timeline.appendChild(item);
402
- }
403
- dagsEl.appendChild(timeline);
591
+ if (scrollTo === "dags") {
592
+ dagsEl.scrollIntoView({ behavior: "smooth" });
404
593
  }
594
+ }
405
595
 
406
- clearNode(batchesEl);
407
- batchesEl.appendChild(el("h3", "section-heading", UI_TEXT.batches));
408
- const batches = snapshot.batches ?? [];
409
- if (batches.length === 0) {
410
- batchesEl.appendChild(el("p", "empty", UI_TEXT.noBatches));
411
- } else {
412
- const rows = batches.map((batch) => ({
413
- onClick: () => navigate(`/batch/${encodeURIComponent(batch.batchRunId)}`),
414
- cells: [
415
- batch.batchRunId,
416
- batch.featureId ?? "—",
417
- badge(batch.status),
418
- formatTs(batch.startedAt),
419
- summaryText(batch.summary),
420
- ],
421
- }));
422
- batchesEl.appendChild(
423
- buildTable(["批次 ID", "功能", "状态", "开始时间", "摘要"], rows),
424
- );
596
+ function syncKpiGroup(group, legendText, cards) {
597
+ let legend = group.querySelector(".kpi-group-legend");
598
+ if (!legend) legend = el("legend", "kpi-group-legend", legendText);
599
+ legend.textContent = legendText;
600
+ let grid = group.querySelector(".kpi-grid");
601
+ if (!grid) grid = el("div", "kpi-grid");
602
+ reconcileKeyed(grid, cards, {
603
+ getKey: (card) => card.label,
604
+ create: (card) =>
605
+ kpiCard(card.label, card.value, card.extraClass, card.helpText),
606
+ update: (node, card) =>
607
+ syncCompatibleNode(
608
+ node,
609
+ kpiCard(card.label, card.value, card.extraClass, card.helpText),
610
+ ),
611
+ });
612
+ if (legend.parentNode !== group) group.appendChild(legend);
613
+ if (grid.parentNode !== group) group.appendChild(grid);
614
+ }
615
+
616
+ function syncTableRow(row, cells) {
617
+ const fresh = document.createElement("tr");
618
+ for (const cell of cells) {
619
+ const td = document.createElement("td");
620
+ if (cell instanceof Node) td.appendChild(cell);
621
+ else td.textContent = cell ?? "—";
622
+ fresh.appendChild(td);
425
623
  }
624
+ syncCompatibleNode(row, fresh);
625
+ }
426
626
 
427
- if (scrollTo === "dags") {
428
- dagsEl.scrollIntoView({ behavior: "smooth" });
627
+ function buildDagTimelineItem(dag) {
628
+ const progress = dagProgress(dag);
629
+ const item = el("a", "dag-run-timeline-item");
630
+ item.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
631
+ item.title = dag.title || dag.dagRunId;
632
+ item.addEventListener("click", (event) => {
633
+ event.preventDefault();
634
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
635
+ });
636
+
637
+ const rail = el("span", "dag-run-timeline-rail");
638
+ rail.appendChild(
639
+ el(
640
+ "span",
641
+ `dag-run-timeline-dot status-${badgeClass(dag.effectiveStatus ?? dag.status)}`,
642
+ ),
643
+ );
644
+ item.appendChild(rail);
645
+
646
+ const content = el("span", "dag-run-timeline-content");
647
+ content.appendChild(
648
+ el("span", "dag-run-timeline-title", dag.title || dag.dagRunId),
649
+ );
650
+ content.appendChild(
651
+ el(
652
+ "span",
653
+ "dag-run-timeline-meta",
654
+ `${formatTs(dag.startedAt)} · ${progress.finished}/${progress.total} · ${formatDuration(dag.durationMs)}`,
655
+ ),
656
+ );
657
+ item.appendChild(content);
658
+
659
+ const state = el("span", "dag-run-timeline-status");
660
+ state.appendChild(dagStatusBadge(dag.effectiveStatus ?? dag.status));
661
+ item.appendChild(state);
662
+ return item;
663
+ }
664
+
665
+ function syncDagTimelineItem(item, dag) {
666
+ const progress = dagProgress(dag);
667
+ item.title = dag.title || dag.dagRunId;
668
+ const meta = item.querySelector(".dag-run-timeline-meta");
669
+ if (meta) {
670
+ meta.textContent = `${formatTs(dag.startedAt)} · ${progress.finished}/${progress.total} · ${formatDuration(dag.durationMs)}`;
429
671
  }
672
+ const title = item.querySelector(".dag-run-timeline-title");
673
+ if (title) title.textContent = dag.title || dag.dagRunId;
430
674
  }
431
675
 
432
676
  export function startDashboardPolling(scrollTo) {
@@ -442,4 +686,3 @@ export function startDashboardPolling(scrollTo) {
442
686
  };
443
687
  void poll(scrollTo);
444
688
  }
445
-