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