From b5bacee331a6e49083ce884bde289c9a296da1af Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 14:00:38 +0000 Subject: [PATCH] refactor(control-center): remove legacy engineering-dashboard, superseded by control-center - delete cmd/engineering-dashboard, internal/engineeringdashboard, scripts/engineering-dashboard.sh - archive old design doc to .doc/archive/design/ with deprecation banner - FEATURE-MATRIX: mark old row removed, add removed to status contract - system-design README: section 8 now points to Control Center SSOT --- ...rsonal-engineering-control-center-plan.md} | 2 + .../2026-09-17-control-center-server.md | 2 +- .doc/status/FEATURE-MATRIX.md | 4 +- .doc/system-design/README.md | 12 +- .doc/system-design/module-boundaries.yaml | 1 - cmd/engineering-dashboard/main.go | 115 ---- internal/engineeringdashboard/assets/app.css | 95 ---- internal/engineeringdashboard/assets/app.js | 317 ----------- .../engineeringdashboard/assets/index.html | 43 -- internal/engineeringdashboard/assets_embed.go | 10 - internal/engineeringdashboard/collector.go | 257 --------- internal/engineeringdashboard/docs_test.go | 111 ---- internal/engineeringdashboard/export.go | 156 ------ internal/engineeringdashboard/export_test.go | 56 -- internal/engineeringdashboard/git_test.go | 80 --- internal/engineeringdashboard/model.go | 499 ------------------ internal/engineeringdashboard/model_test.go | 152 ------ internal/engineeringdashboard/pathsafe.go | 109 ---- .../engineeringdashboard/reconcile_test.go | 171 ------ internal/engineeringdashboard/runner.go | 175 ------ internal/engineeringdashboard/runtime_test.go | 117 ---- internal/engineeringdashboard/server.go | 218 -------- internal/engineeringdashboard/server_test.go | 170 ------ internal/engineeringdashboard/source_docs.go | 367 ------------- internal/engineeringdashboard/source_git.go | 160 ------ .../engineeringdashboard/source_runtime.go | 351 ------------ scripts/engineering-dashboard.sh | 7 - 27 files changed, 12 insertions(+), 3745 deletions(-) rename .doc/{system-design/project-control-center/01-personal-engineering-control-center-plan.md => archive/design/2026-09-17-personal-engineering-control-center-plan.md} (99%) delete mode 100644 cmd/engineering-dashboard/main.go delete mode 100644 internal/engineeringdashboard/assets/app.css delete mode 100644 internal/engineeringdashboard/assets/app.js delete mode 100644 internal/engineeringdashboard/assets/index.html delete mode 100644 internal/engineeringdashboard/assets_embed.go delete mode 100644 internal/engineeringdashboard/collector.go delete mode 100644 internal/engineeringdashboard/docs_test.go delete mode 100644 internal/engineeringdashboard/export.go delete mode 100644 internal/engineeringdashboard/export_test.go delete mode 100644 internal/engineeringdashboard/git_test.go delete mode 100644 internal/engineeringdashboard/model.go delete mode 100644 internal/engineeringdashboard/model_test.go delete mode 100644 internal/engineeringdashboard/pathsafe.go delete mode 100644 internal/engineeringdashboard/reconcile_test.go delete mode 100644 internal/engineeringdashboard/runner.go delete mode 100644 internal/engineeringdashboard/runtime_test.go delete mode 100644 internal/engineeringdashboard/server.go delete mode 100644 internal/engineeringdashboard/server_test.go delete mode 100644 internal/engineeringdashboard/source_docs.go delete mode 100644 internal/engineeringdashboard/source_git.go delete mode 100644 internal/engineeringdashboard/source_runtime.go delete mode 100755 scripts/engineering-dashboard.sh diff --git a/.doc/system-design/project-control-center/01-personal-engineering-control-center-plan.md b/.doc/archive/design/2026-09-17-personal-engineering-control-center-plan.md similarity index 99% rename from .doc/system-design/project-control-center/01-personal-engineering-control-center-plan.md rename to .doc/archive/design/2026-09-17-personal-engineering-control-center-plan.md index a39f8b18..f1722db5 100644 --- a/.doc/system-design/project-control-center/01-personal-engineering-control-center-plan.md +++ b/.doc/archive/design/2026-09-17-personal-engineering-control-center-plan.md @@ -7,6 +7,8 @@ product_contract_source: ce-plan-bootstrap execution: code --- +> **已废弃(2026-09-17)**:旧版只读工程中控台(`cmd/engineering-dashboard` / `internal/engineeringdashboard`)代码已移除,由独立的可写中控台 Control Center 承接(见 `02-control-center-design.md`)。本文仅作历史证据保留。 + # 个人工程中控台:实时项目看板与离线 HTML 快照 ## Goal Capsule diff --git a/.doc/ops/short-term/2026-09-17-control-center-server.md b/.doc/ops/short-term/2026-09-17-control-center-server.md index b625ea83..6e0d3994 100644 --- a/.doc/ops/short-term/2026-09-17-control-center-server.md +++ b/.doc/ops/short-term/2026-09-17-control-center-server.md @@ -27,7 +27,7 @@ mutex: controlcenter - 不接产品 sysadmin、不动 internal/server 路由 - 不做多仓库、不做远端 CI 集成 -- 不删旧 `internal/engineeringdashboard`(共存,对齐后另行弃用) +- 旧 `internal/engineeringdashboard`:用户已授权移除(2026-09-17 执行:代码删除,设计文档归档 `.doc/archive/design/`,验收报告保留为历史证据) - 不做 agent 自动推进阶段(任何推进必须人工批准) ## 步骤 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 17087750..b6edb3bb 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -16,7 +16,7 @@ | 字段 | 含义 | |------|------| | **声明范围** | 本行冻结的能力边界;`done` 只对该边界负责 | -| **状态** | `done` \| `partial` \| `planned` \| `deferred` \| `blocked` | +| **状态** | `done` \| `partial` \| `planned` \| `deferred` \| `blocked` \| `removed`(已下线,仅留历史证据) | | **发布关系** | 与当前首发/已签门禁的关系:`non_blocker` / `补证` / `加固` / `deferred_enhancement` | | **关闭条件** | 何时可将本行升为 `done`(或关闭 `partial`);stub/SKIP/fallback 不得冒充 | | **证据** | 可追溯到报告/commit/环境/schema;代码或 artifact 变化后须重验 | @@ -68,7 +68,7 @@ | 可靠事件核心 | PG dispatcher、notify/advance/projection live、生产闭包 hook 退出、Phase2 选择性解耦已合入 | done | non_blocker | [E0A–E2](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md) 等;[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[shared live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[phase2 closeout](../test-reports/2026-09-15-phase2-closeout-test-report.md) | **before**:与 FAV off 混成 partial → **after**:FAV 独立行。选择性后续解耦按收益触发 | `architecture-docs` / `events` | | Final Artifact Vault(FAV) | 不可变终态制品库;`FAV_DEGRADED_READ=false` | partial | deferred_enhancement | [Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md);[U7 报告](../test-reports/2026-09-16-fav-u7-incremental-test-report.md);[W6 shadow](../test-reports/2026-09-17-w6-fav-subscriber-shadow-test-report.md);[short-term](../ops/short-term/2026-09-17-w6-fav-subscriber-shadow.md) | **after W6**:subscriber `ObserveShadow` 只计算 intended outcome,不写副作用。仍**禁止**整支 merge;`FAV_DEGRADED_READ` 默认 false;**live 未放量**(staging→artifact→publication 各 ≥7 天观察窗) | `events` | | 系统管理台 | 全屏系统级管理视图(总览/KB/特性开关/系统设置/工作流/角色/用户/文档 8 分组);`/api/v1/admin/system/*`;`system_settings` 白名单可写;吸收下线 AdminSettingsModal | done | non_blocker | [short-term](../ops/short-term/2026-09-17-system-admin-console.md);[测试报告](../test-reports/2026-09-17-system-admin-console-test-report.md);commit `f1404d5`/`0eaca61`、merge `dc7c31e`(dev);dev 冒烟 schema 49 + 四端点 + 写读回环 PASS | 二期:Playwright 实跑、细粒度 `system:*` 权限、设置审计日志、KB 运维动作、白名单项接消费方 | `web` + `sysadmin` | -| 个人工程中控台 | 独立本地只读 CLI/`serve`/`export`;四轴状态;`.doc`/Git/Docker/`/ready` 聚合;不接产品后台 | done | deferred_enhancement(工具,非产品门禁) | [设计](../system-design/project-control-center/01-personal-engineering-control-center-plan.md);[报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 一期已交付。跨快照历史、远端 CI、富渲染另立后续 | `engineering-dashboard` | +| 个人工程中控台(旧,已移除) | 原独立本地只读 CLI/`serve`/`export`;四轴状态;`.doc`/Git/Docker/`/ready` 聚合 | removed | 已被 Control Center 取代 | [设计(已归档)](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);[报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 2026-09-17 代码移除(`cmd/engineering-dashboard`、`internal/engineeringdashboard`、`scripts/engineering-dashboard.sh`);能力由下一行 Control Center 承接 | `engineering-dashboard`(已删) | | 个人中控台 Control Center | 独立 Go 服务(loopback,独立 pgvector 库):8 阶段流水线 + agent 协作 API(禁自动推进)+ 决策上报 + 租约/SSE + `.doc` 向量检索 + 导入导出 + 每日审查 + React SPA | partial | deferred_enhancement(个人工具,非产品门禁) | [设计](../system-design/project-control-center/02-control-center-design.md);[agent 接入规范](../system-design/project-control-center/agent-integration.md);[任务](../ops/short-term/2026-09-17-control-center-server.md);[报告](../test-reports/2026-09-17-control-center-test-report.md);commit `2be43ef`→`07a8e12`(P0–P5) | 后端 P0–P5 + 构建/单测/curl 冒烟已签。**未签**:前端人工走查(SPA 实机操作)。关闭:走查 PASS 后升 done;多仓库/远端 CI 另立 | `controlcenter` | --- diff --git a/.doc/system-design/README.md b/.doc/system-design/README.md index b74e53df..3336b29a 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -138,15 +138,17 @@ --- -### 8. 个人工程中控台 +### 8. 个人中控台(Control Center) | 项 | 链接 | |-----|------| -| 设计与实施方案 | [project-control-center/01-personal-engineering-control-center-plan.md](project-control-center/01-personal-engineering-control-center-plan.md) | -| 任务入口 | [../ops/short-term/2026-09-17-engineering-dashboard.md](../ops/short-term/2026-09-17-engineering-dashboard.md) | -| 一句话总结 | 独立于产品后台的本地只读中控台,以同一不可变快照驱动实时项目看板、Agent JSON 和离线 HTML | +| 设计与架构 SSOT | [project-control-center/02-control-center-design.md](project-control-center/02-control-center-design.md) | +| Agent 接入规范 | [project-control-center/agent-integration.md](project-control-center/agent-integration.md) | +| 任务入口 | [../ops/short-term/2026-09-17-control-center-server.md](../ops/short-term/2026-09-17-control-center-server.md) | +| 一句话总结 | 独立于产品系统的个人项目管理中控台:8 阶段流水线(agent 禁自动推进)+ 决策上报 + 向量文档库 + 每日审查 + React SPA | +| 旧版(已移除) | 只读工程中控台设计已归档:[../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);任务:[../archive/ops/short-term/2026-09-17-engineering-dashboard.md](../archive/ops/short-term/2026-09-17-engineering-dashboard.md) | -**核心边界**:计划、Git 实施、验证、环境实装四轴独立;冲突保留来源证据;不写文档、不操作 Git/Docker/部署/Herdr,不读取凭证。 +**核心边界**:个人工具、不接产品后台 sysadmin;agent 只能上报与 advance-request,阶段推进与模糊决策必须人工。 --- diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 38bd41f7..a650805a 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -275,7 +275,6 @@ imports: - controlcenter/tasks - controlcenter/webui controlcenter/webui: [] - engineeringdashboard: [] table_owners: document_records: diff --git a/cmd/engineering-dashboard/main.go b/cmd/engineering-dashboard/main.go deleted file mode 100644 index 43edb2a3..00000000 --- a/cmd/engineering-dashboard/main.go +++ /dev/null @@ -1,115 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "chat-agent-system/internal/engineeringdashboard" -) - -func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - cmd := os.Args[1] - args := os.Args[2:] - switch cmd { - case "serve": - if err := runServe(args); err != nil { - fmt.Fprintf(os.Stderr, "serve: %v\n", err) - os.Exit(1) - } - case "export": - if err := runExport(args); err != nil { - fmt.Fprintf(os.Stderr, "export: %v\n", err) - os.Exit(1) - } - case "help", "-h", "--help": - usage() - default: - fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd) - usage() - os.Exit(2) - } -} - -func usage() { - fmt.Fprintf(os.Stderr, `engineering-dashboard — local read-only personal engineering control center - -Usage: - engineering-dashboard serve [--repo PATH] [--addr 127.0.0.1:PORT] - engineering-dashboard export [--repo PATH] [--output FILE] - -Does not write docs, operate git/docker, deploy, or control Herdr. -`) -} - -func parseFlags(args []string) (repo, addr, output string) { - addr = "127.0.0.1:8787" - output = "engineering-dashboard-snapshot.html" - for i := 0; i < len(args); i++ { - switch args[i] { - case "--repo": - i++ - if i < len(args) { - repo = args[i] - } - case "--addr": - i++ - if i < len(args) { - addr = args[i] - } - case "--output": - i++ - if i < len(args) { - output = args[i] - } - } - } - return repo, addr, output -} - -func runServe(args []string) error { - repo, addr, _ := parseFlags(args) - root, err := engineeringdashboard.ResolveRepoRoot(repo) - if err != nil { - return err - } - if !strings.HasPrefix(addr, "127.0.0.1:") && !strings.HasPrefix(addr, "localhost:") && !strings.HasPrefix(addr, "[::1]:") { - return fmt.Errorf("addr must be loopback (got %s)", addr) - } - c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) - ctx := context.Background() - if _, err := c.Refresh(ctx); err != nil { - fmt.Fprintf(os.Stderr, "warning: initial refresh: %v\n", err) - } - srv, err := engineeringdashboard.NewServer(c) - if err != nil { - return err - } - fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token) - fmt.Printf("repo=%s\n", root) - return engineeringdashboard.ListenAndServe(addr, srv.Handler()) -} - -func runExport(args []string) error { - repo, _, output := parseFlags(args) - root, err := engineeringdashboard.ResolveRepoRoot(repo) - if err != nil { - return err - } - if !filepath.IsAbs(output) { - wd, _ := os.Getwd() - output = filepath.Join(wd, output) - } - c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) - snap, err := c.Refresh(context.Background()) - if err != nil { - return err - } - return engineeringdashboard.ExportHTML(*snap, output) -} diff --git a/internal/engineeringdashboard/assets/app.css b/internal/engineeringdashboard/assets/app.css deleted file mode 100644 index 36febf7d..00000000 --- a/internal/engineeringdashboard/assets/app.css +++ /dev/null @@ -1,95 +0,0 @@ -:root { - --bg: #0f1419; - --panel: #1a222c; - --text: #e7ecf1; - --muted: #9aa7b5; - --accent: #3d8bfd; - --warn: #d4a017; - --bad: #d64545; - --ok: #3d9a6a; - --border: #2b3642; - --font: "IBM Plex Sans", "Noto Sans SC", sans-serif; - --mono: "IBM Plex Mono", ui-monospace, monospace; -} -* { box-sizing: border-box; } -body { - margin: 0; - font-family: var(--font); - background: radial-gradient(1200px 600px at 10% -10%, #1b2735, var(--bg)); - color: var(--text); - min-height: 100vh; -} -.skip { - position: absolute; left: -9999px; -} -.skip:focus { left: 1rem; top: 1rem; background: #fff; color: #000; padding: .5rem; z-index: 9; } -.top { - display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; - padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); - background: rgba(26,34,44,.85); backdrop-filter: blur(6px); -} -.top h1 { margin: 0; font-size: 1.35rem; letter-spacing: .02em; } -.meta { margin: .35rem 0 0; color: var(--muted); font-size: .85rem; font-family: var(--mono); } -.banner { margin: 0; color: var(--warn); font-size: .85rem; max-width: 28rem; } -.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: calc(100vh - 5rem); } -.nav-toggle { display: none; margin: .75rem; } -nav { - border-right: 1px solid var(--border); padding: 1rem; background: var(--panel); -} -nav ul { list-style: none; margin: 0; padding: 0; } -nav li { margin: 0 0 .35rem; } -.nav-btn { - width: 100%; text-align: left; background: transparent; color: var(--text); - border: 1px solid transparent; padding: .5rem .65rem; border-radius: .35rem; cursor: pointer; -} -.nav-btn.active, .nav-btn:hover, .nav-btn:focus-visible { - border-color: var(--accent); background: #243041; -} -.search-label { display: block; margin-top: 1rem; font-size: .8rem; color: var(--muted); } -#q { - width: 100%; margin-top: .35rem; padding: .45rem .55rem; border-radius: .35rem; - border: 1px solid var(--border); background: #10161d; color: var(--text); -} -.source-strip { margin-top: 1rem; font-size: .75rem; color: var(--muted); } -.source-strip .chip { - display: inline-block; margin: .15rem .2rem 0 0; padding: .15rem .4rem; - border: 1px solid var(--border); border-radius: .25rem; -} -.chip.ok { border-color: var(--ok); } -.chip.stale, .chip.degraded { border-color: var(--warn); color: var(--warn); } -.chip.error { border-color: var(--bad); color: var(--bad); } -main { padding: 1rem 1.25rem 2rem; overflow: auto; } -.view-status { min-height: 1.25rem; color: var(--muted); font-size: .85rem; margin-bottom: .75rem; } -.panel { - background: rgba(26,34,44,.7); border: 1px solid var(--border); border-radius: .5rem; - padding: 1rem; margin-bottom: 1rem; -} -.panel h2 { margin: 0 0 .75rem; font-size: 1.05rem; } -.table-wrap { overflow-x: auto; } -table { width: 100%; border-collapse: collapse; font-size: .9rem; } -th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; } -th { color: var(--muted); font-weight: 600; } -.badge { - display: inline-block; padding: .1rem .4rem; border-radius: .25rem; - border: 1px solid var(--border); font-size: .75rem; margin-right: .25rem; -} -.badge.warn { border-color: var(--warn); color: var(--warn); } -.badge.bad { border-color: var(--bad); color: var(--bad); } -.pre { - white-space: pre-wrap; word-break: break-word; font-family: var(--mono); - font-size: .82rem; background: #0c1117; padding: .75rem; border-radius: .35rem; - border: 1px solid var(--border); max-height: 28rem; overflow: auto; -} -.axes { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .5rem; } -.axis { border: 1px solid var(--border); border-radius: .35rem; padding: .5rem; font-size: .8rem; } -.axis strong { display: block; color: var(--muted); margin-bottom: .25rem; } -a, button.linkish { - color: var(--accent); background: none; border: none; cursor: pointer; padding: 0; font: inherit; -} -@media (max-width: 860px) { - .layout { grid-template-columns: 1fr; } - .nav-toggle { display: inline-block; } - nav { display: none; border-right: 0; border-bottom: 1px solid var(--border); } - nav.open { display: block; } - .axes { grid-template-columns: 1fr 1fr; } -} diff --git a/internal/engineeringdashboard/assets/app.js b/internal/engineeringdashboard/assets/app.js deleted file mode 100644 index db7f347c..00000000 --- a/internal/engineeringdashboard/assets/app.js +++ /dev/null @@ -1,317 +0,0 @@ -(function () { - function resolveToken() { - const fromURL = new URL(window.location.href).searchParams.get("token"); - if (fromURL) return fromURL; - const meta = document.querySelector('meta[name="engineering-dashboard-token"]'); - const fromMeta = meta && meta.getAttribute("content"); - if (fromMeta && fromMeta !== "__TOKEN__") return fromMeta; - return ""; - } - const token = resolveToken(); - const state = { - snap: null, - view: "overview", - q: "", - selectedDoc: "", - scrollY: 0, - }; - - const el = { - content: document.getElementById("content"), - meta: document.getElementById("repo-meta"), - sources: document.getElementById("source-strip"), - status: document.getElementById("view-status"), - q: document.getElementById("q"), - navToggle: document.getElementById("nav-toggle"), - nav: document.getElementById("nav"), - }; - - function qs() { - const u = new URL(window.location.href); - return u.searchParams; - } - - function syncURL() { - const u = new URL(window.location.href); - if (token) u.searchParams.set("token", token); - u.searchParams.set("view", state.view); - if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q"); - if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc"); - history.replaceState(null, "", u.toString()); - } - - function loadURL() { - const p = qs(); - state.view = p.get("view") || "overview"; - state.q = p.get("q") || ""; - state.selectedDoc = p.get("doc") || ""; - el.q.value = state.q; - document.querySelectorAll(".nav-btn").forEach((b) => { - b.classList.toggle("active", b.dataset.view === state.view); - }); - } - - async function fetchSnapshot(force) { - if (!token) { - el.status.textContent = "缺少访问令牌"; - renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面"); - return false; - } - el.status.textContent = state.snap ? "刷新中…" : "首次加载中…"; - const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : ""); - const headers = {}; - if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"'; - const res = await fetch(url, { headers, credentials: "same-origin" }); - if (res.status === 304) { - el.status.textContent = "已是最新 revision " + state.snap.revision_id; - return false; - } - if (!res.ok) { - el.status.textContent = "加载失败 HTTP " + res.status; - if (!state.snap) renderHardError("无法获取快照"); - return false; - } - state.snap = await res.json(); - el.status.textContent = "generation " + state.snap.generation_id + " · revision " + state.snap.revision_id; - return true; - } - - function escapeText(s) { - const d = document.createElement("div"); - d.textContent = s == null ? "" : String(s); - return d.innerHTML; - } - - function badge(text, cls) { - return '' + escapeText(text) + ""; - } - - function renderMeta() { - const s = state.snap; - if (!s) return; - el.meta.textContent = [ - s.repository.root, - "branch=" + s.repository.branch, - "HEAD=" + (s.repository.head || "").slice(0, 12), - s.repository.dirty ? "dirty" : "clean", - "at " + s.generated_at, - ].join(" · "); - el.sources.innerHTML = (s.sources || []).map((src) => { - const cls = src.freshness === "stale" || src.status === "degraded" ? "stale" : - src.status === "error" ? "error" : "ok"; - return '' + escapeText(src.name + ":" + src.status + "/" + src.freshness) + ""; - }).join(""); - } - - function matchQuery(text) { - if (!state.q) return true; - return String(text || "").toLowerCase().includes(state.q.toLowerCase()); - } - - function renderOverview() { - const s = state.snap; - const tasks = (s.tasks || []).filter((t) => ["planned", "in_progress", "blocked"].includes(t.doc_status) || matchQuery(t.title + t.path)); - const conflicts = s.conflicts || []; - let html = '

焦点任务

'; - tasks.slice(0, 40).forEach((t) => { - html += ""; - html += ""; - html += ""; - html += ""; - const dep = t.deployment || {}; - html += ""; - }); - html += "
任务计划Git验证实装
" + escapeText(t.title) + "
" + escapeText(t.path) + "
" + badge(t.doc_status) + (t.claim && t.claim.claimed ? badge("claimed") : badge("unclaimed", "warn")) + "" + escapeText((t.git && t.git.branch) || "—") + " " + escapeText((t.git && t.git.cleanliness) || "") + "" + escapeText((t.verification && t.verification.result) || "unknown") + "" + escapeText(Object.keys(dep).map((k) => k + ":" + (dep[k].app_readiness || "unknown")).join(" ")) + "
"; - html += '

漂移 / 冲突

'; - if (!conflicts.length) html += "

无冲突

"; - else { - html += ""; - } - html += "
"; - el.content.innerHTML = html; - } - - function renderModules() { - const rows = (state.snap.modules || []).filter((m) => matchQuery(m.name + (m.mutex || ""))); - let html = '

模块

'; - rows.forEach((m) => { - html += ""; - }); - html += "
模块状态mutex
" + escapeText(m.name) + "" + escapeText(m.status) + "" + escapeText(m.mutex) + "
"; - el.content.innerHTML = html; - } - - function renderTasks() { - const rows = (state.snap.tasks || []).filter((t) => matchQuery(t.title + t.path + (t.claim && t.claim.owner || ""))); - let html = ""; - rows.forEach((t) => { - html += '

' + escapeText(t.title) + "

"; - html += '
'; - html += '
计划' + escapeText(t.doc_status) + "
" + escapeText((t.claim && t.claim.owner) || "unassigned") + " · " + escapeText((t.claim && t.claim.agent_kind) || "") + " · activity=" + escapeText((t.claim && t.claim.activity) || "unknown") + "
"; - html += '
Git 实施' + escapeText(JSON.stringify(t.git || {})) + "
"; - html += '
验证' + escapeText(JSON.stringify(t.verification || {})) + "
"; - html += '
实装' + escapeText(JSON.stringify(t.deployment || {})) + "
"; - html += "
"; - if (t.evidence_chain && t.evidence_chain.length) { - html += "

证据链

"; - } - html += "
"; - }); - el.content.innerHTML = html || "

无匹配任务

"; - } - - function renderGit() { - const rows = (state.snap.git || []).filter((g) => matchQuery(g.name + (g.worktree || ""))); - let html = '

分支 / worktree

远端状态基于本地 refs,未联网刷新

'; - rows.forEach((g) => { - html += ""; - }); - html += "
分支ahead/behindworktree备注
" + escapeText(g.name) + (g.is_current ? " *" : "") + "" + escapeText(g.ahead + "/" + g.behind) + "" + escapeText(g.worktree || "") + "" + escapeText(g.remote_note || "") + "
"; - el.content.innerHTML = html; - } - - function renderDeploy() { - const rows = state.snap.environments || []; - let html = ""; - rows.forEach((e) => { - html += '

环境 ' + escapeText(e.name) + "

"; - html += "

" + badge("container:" + (e.facets && e.facets.container_status)) + badge("ready:" + (e.facets && e.facets.app_readiness)) + badge("services:" + (e.facets && e.facets.services_ok), e.facets && e.facets.services_ok === "partial" ? "warn" : "") + "

"; - if (e.drift_notes && e.drift_notes.length) { - html += ""; - } - if (e.containers && e.containers.length) { - html += '
'; - e.containers.forEach((c) => { - html += ""; - }); - html += "
容器状态healthimage
" + escapeText(c.name) + "" + escapeText(c.status) + "" + escapeText(c.health) + "" + escapeText(c.image) + "
"; - } - html += "
"; - }); - el.content.innerHTML = html; - } - - function renderDocs() { - const rows = (state.snap.documents || []).filter((d) => { - if (!state.q) return true; - return matchQuery(d.title + d.path + (d.body || "")); - }); - rows.sort((a, b) => { - const score = (d) => { - let s = 0; - if ((d.status || "") === "in_progress" || (d.status || "") === "blocked") s += 2; - if ((d.path || "").includes("short-term")) s += 1; - return -s; - }; - const d = score(a) - score(b); - return d !== 0 ? d : String(a.path).localeCompare(String(b.path)); - }); - let html = '

文档

'; - rows.slice(0, 200).forEach((d) => { - html += '"; - }); - html += "
标题路径状态
" + escapeText(d.path) + "" + escapeText(d.status || "") + "
"; - if (state.selectedDoc) { - const doc = (state.snap.documents || []).find((d) => d.id === state.selectedDoc || d.path === state.selectedDoc); - html += '

正文(纯文本)

'; - if (!doc) html += "

未找到文档

"; - else { - html += "

" + escapeText(doc.path) + (doc.body_truncated ? " · truncated" : "") + "

"; - html += '
' + escapeText(doc.body || "") + "
"; - } - html += "
"; - } - el.content.innerHTML = html; - } - - function renderEvidence() { - const tasks = (state.snap.tasks || []).filter((t) => (t.evidence_chain || []).length && matchQuery(t.title)); - let html = '

证据链导航

'; - tasks.forEach((t) => { - html += "

" + escapeText(t.title) + "

    " + (t.evidence_chain || []).map((p) => '
  1. ").join("") + "
"; - }); - html += "
"; - el.content.innerHTML = html; - } - - function renderHardError(msg) { - el.content.innerHTML = '

错误

' + escapeText(msg) + "

无可用缓存快照。

"; - } - - function render() { - if (!state.snap) return; - renderMeta(); - const y = window.scrollY; - switch (state.view) { - case "modules": renderModules(); break; - case "tasks": renderTasks(); break; - case "git": renderGit(); break; - case "deploy": renderDeploy(); break; - case "docs": renderDocs(); break; - case "evidence": renderEvidence(); break; - default: renderOverview(); - } - window.scrollTo(0, state.scrollY || y); - syncURL(); - } - - document.querySelectorAll(".nav-btn").forEach((btn) => { - btn.addEventListener("click", () => { - state.view = btn.dataset.view; - state.scrollY = 0; - document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b === btn)); - if (el.nav) el.nav.classList.remove("open"); - if (el.navToggle) el.navToggle.setAttribute("aria-expanded", "false"); - render(); - }); - }); - - el.content.addEventListener("click", (ev) => { - const t = ev.target.closest("[data-doc]"); - if (!t) return; - state.selectedDoc = t.getAttribute("data-doc"); - state.view = "docs"; - document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b.dataset.view === "docs")); - render(); - }); - - el.q.addEventListener("input", () => { - state.q = el.q.value.trim(); - render(); - }); - - if (el.navToggle) { - el.navToggle.addEventListener("click", () => { - const open = el.nav.classList.toggle("open"); - el.navToggle.setAttribute("aria-expanded", open ? "true" : "false"); - }); - } - - window.addEventListener("popstate", () => { - loadURL(); - render(); - }); - - async function loop() { - loadURL(); - if (!token) { - renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面"); - return; - } - try { - const changed = await fetchSnapshot(true); - if (changed || state.snap) render(); - } catch (e) { - el.status.textContent = "网络错误"; - if (!state.snap) renderHardError(String(e)); - } - setInterval(async () => { - try { - const changed = await fetchSnapshot(false); - if (changed) render(); - } catch (_) { /* keep last snapshot */ } - }, 4000); - } - - loop(); -})(); diff --git a/internal/engineeringdashboard/assets/index.html b/internal/engineeringdashboard/assets/index.html deleted file mode 100644 index 63d53af2..00000000 --- a/internal/engineeringdashboard/assets/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - 个人工程中控台 - - - - -
-
-

个人工程中控台

-

加载中…

-
- -
-
- - -
-
-
-
-
- - - diff --git a/internal/engineeringdashboard/assets_embed.go b/internal/engineeringdashboard/assets_embed.go deleted file mode 100644 index 21e971a7..00000000 --- a/internal/engineeringdashboard/assets_embed.go +++ /dev/null @@ -1,10 +0,0 @@ -package engineeringdashboard - -import "embed" - -//go:embed assets/index.html assets/app.js assets/app.css -var assetFS embed.FS - -func readAsset(name string) ([]byte, error) { - return assetFS.ReadFile(name) -} diff --git a/internal/engineeringdashboard/collector.go b/internal/engineeringdashboard/collector.go deleted file mode 100644 index 2be3051a..00000000 --- a/internal/engineeringdashboard/collector.go +++ /dev/null @@ -1,257 +0,0 @@ -package engineeringdashboard - -import ( - "context" - "fmt" - "sort" - "sync" - "sync/atomic" - "time" -) - -// Collector coordinates parallel source collection and atomic snapshot publish. -type Collector struct { - RepoRoot string - Runner CommandRunner - TTL time.Duration - - mu sync.Mutex - current *Snapshot - lastKnown map[string]SourceState - gen atomic.Uint64 - collecting atomic.Bool - singleflight chan struct{} -} - -// NewCollector builds a coordinator for one repo root. -func NewCollector(repoRoot string, runner CommandRunner) *Collector { - if runner == nil { - runner = ExecRunner{} - } - return &Collector{ - RepoRoot: repoRoot, - Runner: runner, - TTL: 3 * time.Second, - lastKnown: map[string]SourceState{}, - singleflight: make(chan struct{}, 1), - } -} - -// Current returns the latest published snapshot (may be nil on cold start). -func (c *Collector) Current() *Snapshot { - c.mu.Lock() - defer c.mu.Unlock() - return c.current -} - -// Refresh builds and publishes a new snapshot. Concurrent callers share one flight. -func (c *Collector) Refresh(ctx context.Context) (*Snapshot, error) { - select { - case c.singleflight <- struct{}{}: - defer func() { <-c.singleflight }() - default: - // another collection in flight — return current - if s := c.Current(); s != nil { - return s, nil - } - // wait briefly for first - select { - case c.singleflight <- struct{}{}: - defer func() { <-c.singleflight }() - case <-ctx.Done(): - return nil, ctx.Err() - } - } - return c.collectAndPublish(ctx) -} - -func (c *Collector) collectAndPublish(ctx context.Context) (*Snapshot, error) { - c.collecting.Store(true) - defer c.collecting.Store(false) - - start := time.Now() - now := start.UTC() - deadline, cancel := context.WithTimeout(ctx, 6*time.Second) - defer cancel() - - var docs DocsCollectResult - var git GitCollectResult - var runtime RuntimeCollectResult - var wg sync.WaitGroup - wg.Add(3) - go func() { - defer wg.Done() - docs = CollectDocs(c.RepoRoot, now) - }() - go func() { - defer wg.Done() - git = CollectGit(deadline, c.RepoRoot, c.Runner, now) - }() - go func() { - defer wg.Done() - runtime = CollectRuntime(deadline, c.RepoRoot, c.Runner, now) - }() - wg.Wait() - - docs.Source = c.applyLastKnown(docs.Source, now) - git.Source = c.applyLastKnown(git.Source, now) - runtime.Source = c.applyLastKnown(runtime.Source, now) - - snap := Reconcile(ReconcileInput{ - RepoRoot: c.RepoRoot, - Now: now, - Duration: time.Since(start), - Docs: docs, - Git: git, - Runtime: runtime, - Generation: c.gen.Add(1), - }) - c.mu.Lock() - c.current = &snap - c.mu.Unlock() - return &snap, nil -} - -func (c *Collector) applyLastKnown(src SourceState, now time.Time) SourceState { - c.mu.Lock() - defer c.mu.Unlock() - if src.Status == SourceOK || src.Status == SourceDegraded { - cp := src - cp.LastSuccessAt = &now - c.lastKnown[src.Name] = cp - return cp - } - prev, ok := c.lastKnown[src.Name] - if !ok { - src.Freshness = FreshnessError - if src.Status == "" { - src.Status = SourceError - } - return src - } - // retain last-known markers without rewriting observed_at of cached facts - src.Status = SourceDegraded - src.Freshness = FreshnessStale - src.LastSuccessAt = prev.LastSuccessAt - if src.Error == "" { - src.Error = "using last-known; current collection failed" - } - return src -} - -// ReconcileInput feeds the pure reconcile function. -type ReconcileInput struct { - RepoRoot string - Now time.Time - Duration time.Duration - Docs DocsCollectResult - Git GitCollectResult - Runtime RuntimeCollectResult - Generation uint64 -} - -// Reconcile merges source facts into an immutable snapshot without picking winners on conflicts. -func Reconcile(in ReconcileInput) Snapshot { - repo := in.Git.Repository - if repo.Root == "" { - repo.Root = in.RepoRoot - } - - tasks := append([]TaskState(nil), in.Docs.Tasks...) - gitByName := map[string]GitBranchView{} - for _, g := range in.Git.Branches { - gitByName[g.Name] = g - } - - var conflicts []Conflict - var facts []Fact - facts = append(facts, in.Docs.Facts...) - facts = append(facts, in.Git.Facts...) - facts = append(facts, in.Runtime.Facts...) - - for i := range tasks { - t := &tasks[i] - declaredBranch := t.Claim.Branch - if declaredBranch != "" { - if g, ok := gitByName[declaredBranch]; ok { - t.Git.Branch = g.Name - t.Git.Worktree = g.Worktree - t.Git.Ahead = g.Ahead - t.Git.Behind = g.Behind - t.Git.RemoteNote = g.RemoteNote - if g.Dirty { - t.Git.Cleanliness = "dirty" - } else { - t.Git.Cleanliness = "clean" - } - if t.Claim.Worktree != "" && g.Worktree != "" && t.Claim.Worktree != g.Worktree && t.Claim.Worktree != "-" { - f1 := FactID(t.ID, "worktree", "docs") - f2 := FactID(t.ID, "worktree", "git") - facts = append(facts, - Fact{ID: f1, Subject: t.ID, Field: "worktree", Value: t.Claim.Worktree, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now}, - Fact{ID: f2, Subject: t.ID, Field: "worktree", Value: g.Worktree, Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: in.Now}, - ) - conflicts = append(conflicts, NewConflict(t.ID, "worktree", ConflictTaskVsGit, []string{f1, f2}, "declared worktree differs from observed")) - } - } else if t.DocStatus == DocStatusInProgress || t.Claim.Claimed { - f1 := FactID(t.ID, "branch", "docs") - facts = append(facts, Fact{ID: f1, Subject: t.ID, Field: "branch", Value: declaredBranch, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now}) - conflicts = append(conflicts, NewConflict(t.ID, "branch", ConflictMissingEvidence, []string{f1}, "declared branch not observed locally")) - } - } - // claimed but no runtime activity source - if t.Claim.Claimed && t.Claim.Activity == "" { - t.Claim.Activity = "unknown" - } - // never promote doc done into deploy - if t.DocStatus == DocStatusDone { - for env, d := range t.Deployment { - if d.AppReadiness == "" { - d.AppReadiness = "unknown" - t.Deployment[env] = d - } - } - } - } - - envs := append([]EnvironmentState(nil), in.Runtime.Environments...) - for i := range envs { - e := &envs[i] - if e.Facets.AppReadiness == "ok" && e.Facets.ContainerStatus == "partial" { - f1 := FactID("env:"+e.Name, "readiness", "ready") - f2 := FactID("env:"+e.Name, "container_status", "docker") - conflicts = append(conflicts, NewConflict("env:"+e.Name, "health", ConflictDeclaredVsObserved, []string{f1, f2}, - "app /ready ok while containers partial — no aggregate green")) - } - if e.Receipt != "" && e.Facets.ArtifactID != "" { - // receipt present; identity note already set by runtime collector when mismatch unknown - } - } - - sources := []SourceState{in.Docs.Source, in.Git.Source, in.Runtime.Source} - sort.Slice(sources, func(i, j int) bool { return sources[i].Name < sources[j].Name }) - - var warnings []string - warnings = append(warnings, in.Docs.Warnings...) - warnings = append(warnings, in.Git.Warnings...) - warnings = append(warnings, in.Runtime.Warnings...) - - snap := Snapshot{ - SchemaVersion: SchemaVersion, - GenerationID: fmt.Sprintf("gen_%d", in.Generation), - GeneratedAt: in.Now, - CollectionDuration: in.Duration, - Repository: repo, - Sources: sources, - Facts: facts, - Conflicts: conflicts, - Documents: in.Docs.Documents, - Modules: in.Docs.Modules, - Tasks: tasks, - Git: in.Git.Branches, - Environments: envs, - Warnings: warnings, - } - snap.RevisionID = ComputeRevisionID(snap) - return snap -} diff --git a/internal/engineeringdashboard/docs_test.go b/internal/engineeringdashboard/docs_test.go deleted file mode 100644 index a3a75e6b..00000000 --- a/internal/engineeringdashboard/docs_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package engineeringdashboard - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestDocs_IndexAndBacklinks(t *testing.T) { - root := t.TempDir() - writeDoc(t, root, ".doc/ops/CURRENT.md", "---\nstatus: in_progress\n---\n# Current\n\nSee [task](./short-term/task.md).\n") - writeDoc(t, root, ".doc/ops/short-term/task.md", "---\ntitle: Demo Task\nstatus: planned\nowner: unassigned\nmutex: demo\n---\n# Demo Task\n\nLink to [current](../CURRENT.md).\n") - writeDoc(t, root, ".doc/ops/short-term/orphan.md", "# Orphan\n\nNo links.\n") - // assets must be skipped - mustMkdir(t, filepath.Join(root, ".doc/test-reports/assets")) - if err := os.WriteFile(filepath.Join(root, ".doc/test-reports/assets/secret.md"), []byte("# secret"), 0o600); err != nil { - t.Fatal(err) - } - - now := time.Now().UTC() - res := CollectDocs(root, now) - if res.Source.Status != SourceOK { - t.Fatalf("docs source: %+v", res.Source) - } - var foundAssets bool - var task *DocumentNode - for i := range res.Documents { - if strings.Contains(res.Documents[i].Path, "assets/") { - foundAssets = true - } - if strings.HasSuffix(res.Documents[i].Path, "short-term/task.md") { - task = &res.Documents[i] - } - } - if foundAssets { - t.Fatal("assets markdown must not be indexed as body") - } - if task == nil { - t.Fatal("task doc missing") - } - if len(task.LinksIn) == 0 { - t.Fatalf("expected backlink into task, got %+v", task) - } - var planned bool - for _, tk := range res.Tasks { - if strings.Contains(tk.Path, "task.md") { - if tk.DocStatus != DocStatusPlanned || tk.Claim.Claimed { - t.Fatalf("rdev-like task should stay planned/unclaimed: %+v", tk) - } - planned = true - } - } - if !planned { - t.Fatal("task not parsed into TaskState") - } -} - -func TestDocs_BrokenLinkDoesNotFailIndex(t *testing.T) { - root := t.TempDir() - writeDoc(t, root, ".doc/ops/note.md", "# Note\n\n[missing](./nope.md)\n") - res := CollectDocs(root, time.Now().UTC()) - if res.Source.Status != SourceOK { - t.Fatalf("index should succeed: %+v", res.Source) - } - found := false - for _, d := range res.Documents { - if strings.HasSuffix(d.Path, "note.md") && len(d.BrokenLinks) > 0 { - found = true - } - } - if !found { - t.Fatal("expected broken link recorded") - } -} - -func TestPathSafe_RejectsTraversalAndSymlink(t *testing.T) { - root := t.TempDir() - writeDoc(t, root, ".doc/ops/ok.md", "# ok\n") - if _, err := SafeDocPath(root, ".doc/../.env"); err == nil { - t.Fatal("expected traversal deny") - } - target := filepath.Join(root, ".doc/ops/ok.md") - link := filepath.Join(root, ".doc/ops/link.md") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - if _, err := SafeDocPath(root, ".doc/ops/link.md"); err == nil { - t.Fatal("expected symlink deny") - } - if _, err := SafeDocPath(root, ".doc/ops/ok.md"); err != nil { - t.Fatal(err) - } -} - -func writeDoc(t *testing.T, root, rel, body string) { - t.Helper() - abs := filepath.Join(root, filepath.FromSlash(rel)) - mustMkdir(t, filepath.Dir(abs)) - if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { - t.Fatal(err) - } -} - -func mustMkdir(t *testing.T, dir string) { - t.Helper() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } -} diff --git a/internal/engineeringdashboard/export.go b/internal/engineeringdashboard/export.go deleted file mode 100644 index db7ede24..00000000 --- a/internal/engineeringdashboard/export.go +++ /dev/null @@ -1,156 +0,0 @@ -package engineeringdashboard - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" -) - -// ExportManifest describes what was embedded in an offline HTML. -type ExportManifest struct { - Included []string `json:"included"` - Truncated []string `json:"truncated"` - MetadataOnly []string `json:"metadata_only"` - SensitiveWarn []string `json:"sensitive_warnings,omitempty"` -} - -// ExportHTML writes a self-contained offline snapshot (mode 0600). -func ExportHTML(snap Snapshot, outputPath string) error { - css, err := readAsset("assets/app.css") - if err != nil { - return err - } - - manifest := ExportManifest{} - docs := make([]DocumentNode, 0, len(snap.Documents)) - for _, d := range snap.Documents { - cp := d - if IsAssetsPath(cp.Path) { - cp.Body = "" - manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) - } else if cp.BodyTruncated { - manifest.Truncated = append(manifest.Truncated, cp.Path) - manifest.Included = append(manifest.Included, cp.Path) - } else if cp.Body != "" { - manifest.Included = append(manifest.Included, cp.Path) - } else { - manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) - } - // strip any accidental absolute secret-looking paths from body display only — keep relative - if strings.Contains(strings.ToLower(cp.Body), "/.secrets/") { - manifest.SensitiveWarn = append(manifest.SensitiveWarn, cp.Path+": contains .secrets path mention") - } - docs = append(docs, cp) - } - exportSnap := snap - exportSnap.Documents = docs - - payload, err := MarshalSnapshotJSON(exportSnap) - if err != nil { - return err - } - // HTML-safe JSON: break sequences - safeJSON := strings.ReplaceAll(string(payload), "<", "\\u003c") - safeJSON = strings.ReplaceAll(safeJSON, ">", "\\u003e") - safeJSON = strings.ReplaceAll(safeJSON, "&", "\\u0026") - safeJSON = strings.ReplaceAll(safeJSON, " - - - - -工程中控台快照 %s - - - - -
-
-

个人工程中控台(离线快照)

-

repo=%s · HEAD=%s · generation=%s · revision=%s · generated_at=%s

-
- -
-
-
-

覆盖清单

-

-  
-
-
- - - - - -`, snap.RevisionID, string(css), snap.Repository.Root, snap.Repository.Head, snap.GenerationID, snap.RevisionID, snap.GeneratedAt.Format(timeRFC3339), safeJSON, manSafe) - - dir := filepath.Dir(outputPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - tmp, err := os.CreateTemp(dir, ".engdash-*.html") - if err != nil { - return err - } - tmpName := tmp.Name() - if _, err := tmp.Write([]byte(html)); err != nil { - tmp.Close() - _ = os.Remove(tmpName) - return err - } - if err := tmp.Chmod(0o600); err != nil { - tmp.Close() - _ = os.Remove(tmpName) - return err - } - if err := tmp.Close(); err != nil { - _ = os.Remove(tmpName) - return err - } - if err := os.Rename(tmpName, outputPath); err != nil { - _ = os.Remove(tmpName) - return err - } - return nil -} - -const timeRFC3339 = "2006-01-02T15:04:05Z07:00" diff --git a/internal/engineeringdashboard/export_test.go b/internal/engineeringdashboard/export_test.go deleted file mode 100644 index f6785f8d..00000000 --- a/internal/engineeringdashboard/export_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package engineeringdashboard - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestExport_OfflineReadableAndSafe(t *testing.T) { - dir := t.TempDir() - out := filepath.Join(dir, "snap.html") - snap := Snapshot{ - SchemaVersion: SchemaVersion, - GenerationID: "gen_export", - RevisionID: "rev_export", - GeneratedAt: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC), - Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "deadbeef"}, - Tasks: []TaskState{SampleRDevTaskFixture()}, - Conflicts: []Conflict{NewConflict("env:main", "health", ConflictDeclaredVsObserved, []string{"a", "b"}, "no aggregate green")}, - Documents: []DocumentNode{{ - ID: ".doc/ops/note.md", Path: ".doc/ops/note.md", Title: "Note", - Body: "hello and more", - }}, - Environments: []EnvironmentState{SampleMainEventsForkFixture()}, - Git: []GitBranchView{SampleDevAheadGitFixture()}, - } - if err := ExportHTML(snap, out); err != nil { - t.Fatal(err) - } - fi, err := os.Stat(out) - if err != nil { - t.Fatal(err) - } - if fi.Mode().Perm() != 0o600 { - t.Fatalf("perm %o", fi.Mode().Perm()) - } - raw, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - html := string(raw) - if !strings.Contains(html, "gen_export") || !strings.Contains(html, "rev_export") { - t.Fatal("missing snapshot identity") - } - if strings.Contains(html, "