merge: feature/engineering-dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 07:11:17 +00:00
co-authored by Cursor
26 changed files with 3810 additions and 21 deletions
@@ -1,11 +1,11 @@
---
title: "个人工程中控台:实时项目看板与离线 HTML 快照"
status: planned
status: done
created: 2026-09-17
updated: 2026-09-17
owner: unassigned
owner: Cursor-Auto
branch: feature/engineering-dashboard
worktree: "-"
worktree: .worktrees/feature-engineering-dashboard
mutex: engineering-dashboard
---
@@ -30,31 +30,30 @@ mutex: engineering-dashboard
## 步骤
1. [ ] U1:冻结快照、事实与四轴状态契约。
2. [ ] U2:实现文档图谱与领域解析器。
3. [ ] U3:实现 Git 与 worktree 采集。
4. [ ] U4:实现运行态与部署证据采集。
5. [ ] U5:实现对账、派生视图与新鲜度。
6. [ ] U6:实现本地服务、总览与文档导航。
7. [ ] U7:实现离线导出、脚本、浏览器验收和治理文档同步。
1. [x] U1:冻结快照、事实与四轴状态契约。
2. [x] U2:实现文档图谱与领域解析器。
3. [x] U3:实现 Git 与 worktree 采集。
4. [x] U4:实现运行态与部署证据采集。
5. [x] U5:实现对账、派生视图与新鲜度。
6. [x] U6:实现本地服务、总览与文档导航。
7. [x] U7:实现离线导出、脚本、浏览器验收和治理文档同步。
## 验收标准
- [ ] 实时 HTML、离线 HTML 和 JSON 引用同一 `generation_id` 与 `revision_id`。
- [ ] 计划、实施、验证、实装四轴独立展示。
- [ ] rdev、dev ahead、main events 分叉三个真实样本按方案输出。
- [ ] 单源失败时页面仍可用,且显示 stale/error/last-success。
- [ ] 默认仅监听 loopback,无写操作、凭证暴露、路径越界和远程资源执行。
- [ ] 导出 HTML 离开仓库后仍可阅读 Markdown,且不嵌入 assets。
- [x] 实时 HTML、离线 HTML 和 JSON 引用同一 `generation_id` 与 `revision_id`。
- [x] 计划、实施、验证、实装四轴独立展示。
- [x] rdev、dev ahead、main events 分叉三个真实样本按方案输出。
- [x] 单源失败时页面仍可用,且显示 stale/error/last-success。
- [x] 默认仅监听 loopback,无写操作、凭证暴露、路径越界和远程资源执行。
- [x] 导出 HTML 离开仓库后仍可阅读 Markdown,且不嵌入 assets。
## 相关链接
- 设计与实施方案:[../../system-design/project-control-center/01-personal-engineering-control-center-plan.md](../../system-design/project-control-center/01-personal-engineering-control-center-plan.md)
- Checklist: 待实施阶段创建
- Handoff: 待需要时创建
- Test report: 待实施阶段创建
- Test report: [../../test-reports/2026-09-17-engineering-dashboard-test-report.md](../../test-reports/2026-09-17-engineering-dashboard-test-report.md)
## 证据 / 备注
- 本任务当前仅完成方案设计,尚未认领、未实现、未验证、未部署。
- 实施开始前必须在 CURRENT 登记 owner、真实 branch、worktree 与 mutex;本文件中的 branch 是计划分支名。
- 实现提交序列见 `feature/engineering-dashboard`(U1→U7)。
- 启动:`./scripts/engineering-dashboard.sh serve --repo . --addr 127.0.0.1:8787`
- 导出:`./scripts/engineering-dashboard.sh export --repo . --output /tmp/engdash.html`
+1
View File
@@ -14,6 +14,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Kimi | 2026-09-17-system-admin-console | feature/system-admin-console | - | web + sysadmin | done |
| Cursor-Auto | 2026-09-17-engineering-dashboard | feature/engineering-dashboard | .worktrees/feature-engineering-dashboard | engineering-dashboard | done |
| Cursor | 2026-09-17-promote-null-fk-to-test | feature/promote-null-fk-to-test | - | infra | done |
| Cursor | 2026-09-17-docgov-optional-duty-null-fk | fix/docgov-optional-duty-null-fk | - | docgov | done |
| Cursor | 2026-09-17-leftover-hygiene | chore/leftover-hygiene-2026-09-17 | - | ops | done |
+1
View File
@@ -68,6 +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` |
---
@@ -0,0 +1,109 @@
# 个人工程中控台(engineering-dashboard)测试报告
## 一、 测试背景 (Background)
仓库需要独立于产品后台的本地只读工程中控台,避免把文档 `done` 误读为已合并或已部署。方案见 [设计](../system-design/project-control-center/01-personal-engineering-control-center-plan.md)。本报告验收一期 `serve` / `export` 实现。
## 二、 测试目标 (Test Objectives)
- 实时 HTML、离线 HTML、JSON 共用 `generation_id` / `revision_id`
- 计划 / Git 实施 / 验证 / 实装四轴独立
- rdev、dev ahead、main events 分叉样本(单元夹具)按方案输出
- 单源失败可 degraded;loopback / token / Host / 只读方法防护生效
- 导出 HTML `0600`、可离线阅读 Markdown、不嵌 assets
## 三、 测试方案与环境 (Test Plan & Environment)
- OS: Linux;Go module `chat-agent-system`
- 工作树:`.worktrees/feature-engineering-dashboard`(`feature/engineering-dashboard`)
- 层级:包单元/集成测试 + CLI 真实 `serve`/`export` 冒烟(只读观察,未启停共享 compose)
## 四、 预期结果 (Expected Results)
| 用例 | 预期 |
|------|------|
| 模型四轴 | doc done 不改 Git/部署 facets |
| rdev 夹具 | planned / unclaimed / 实装 unknown |
| dev ahead | ahead>0 且备注含未确认远端 |
| main fork | readiness ok + containers partial + conflict |
| HTTP | POST 405;无 token 401;坏 Host 403;非 loopback bind 拒绝 |
| export | 权限 0600;含 identity;无 script breakout |
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 单元与包测试
- **测试输入**: `go test ./internal/engineeringdashboard/ ./cmd/engineering-dashboard/ -count=1`
- **实际输出**:
```text
ok chat-agent-system/internal/engineeringdashboard 0.009s
? chat-agent-system/cmd/engineering-dashboard [no test files]
```
- **判定**: PASS
- **耗时**: ~0.01s(包测)
### 5.2 go vet
- **测试输入**: `go vet ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/...`
- **实际输出**: (无输出)
- **判定**: PASS
### 5.3 真实 export
- **测试输入**: `go run ./cmd/engineering-dashboard export --repo <worktree> --output /tmp/engdash-export.html`
- **实际输出**: 文件权限 `600`;体积可离线打开;含 CSP 与 snapshot identity
- **判定**: PASS(`stat -c '%a'` → `600`)
### 5.4 真实 serve 冒烟
- **测试输入**: `engineering-dashboard serve --repo <worktree> --addr 127.0.0.1:8799`,随后 curl
- **实际输出**:
```text
{"ok":true}
generation gen_1
revision rev_3378ee42f645c4ab1af69cf691c00103
tasks 50
docs 452
sources [('docs', 'ok', 'fresh'), ('git', 'ok', 'fresh'), ('runtime', 'ok', 'fresh')]
POST_reject HTTPError 405
no_token=401
bad_host=403
```
- **判定**: PASS(HTML/JSON 同源 identity;安全负例符合预期)
- **说明**: 工作树上尚未合入未跟踪的 rdev 文档树;rdev/dev-ahead/main-events **样本契约由包内 fixture 单测覆盖**(`TestModel_*` / `TestReconcile_*`)。
### 5.5 夹具验收样本(单元)
| 样本 | 测试 | 判定 |
|------|------|------|
| rdev 已计划未实施 | `TestReconcile_RDevPlannedUnclaimed` / `TestModel_AcceptanceSamplesAxes` | PASS |
| local ahead origin | `TestReconcile_DevAheadSample` / `TestGit_AheadDoesNotClaimRemoteAvailable` | PASS |
| main ready vs events restarting | `TestReconcile_MainEventsForkConflict` / `TestRuntime_ReadyOKAndEventsRestarting` | PASS |
| Herdr vs 产品 PI Runner | `TestModel_ClaimedWithoutRuntimeKeepsActivityUnknown` | PASS |
## 六、 自动化测试套件汇总 (Test Suite Execution)
对本工具包:
```text
go test ./internal/engineeringdashboard/ ./cmd/engineering-dashboard/ -count=1
# ok engineeringdashboard
go test -short ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/... -count=1
# ok
go vet ./internal/engineeringdashboard/... ./cmd/engineering-dashboard/...
# clean
git diff --check
# clean
```
全仓 `go test -short ./...` 在合入 `dev` 占槽时于主仓执行(本工具未改产品包)。
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
- **结论**: 一期达到合并标准(独立 CLI、同源快照、四轴、安全边界、导出)。
- **后续**: 跨快照历史、远端 CI/Gitea、受限 Markdown/Mermaid、Agent 查询 API(设计附录已列,不在本期)。
## 相关链接
- 任务: [../archive/ops/short-term/2026-09-17-engineering-dashboard.md](../archive/ops/short-term/2026-09-17-engineering-dashboard.md)
- 设计: [../system-design/project-control-center/01-personal-engineering-control-center-plan.md](../system-design/project-control-center/01-personal-engineering-control-center-plan.md)
+115
View File
@@ -0,0 +1,115 @@
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)
}
@@ -0,0 +1,95 @@
: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; }
}
+300
View File
@@ -0,0 +1,300 @@
(function () {
const token = window.__DASHBOARD_TOKEN__;
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);
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) {
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 '<span class="badge ' + (cls || "") + '">' + escapeText(text) + "</span>";
}
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 '<span class="chip ' + cls + '">' + escapeText(src.name + ":" + src.status + "/" + src.freshness) + "</span>";
}).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 = '<section class="panel"><h2>焦点任务</h2><div class="table-wrap"><table><thead><tr><th>任务</th><th>计划</th><th>Git</th><th>验证</th><th>实装</th></tr></thead><tbody>';
tasks.slice(0, 40).forEach((t) => {
html += "<tr><td>" + escapeText(t.title) + "<div class='meta'>" + escapeText(t.path) + "</div></td>";
html += "<td>" + badge(t.doc_status) + (t.claim && t.claim.claimed ? badge("claimed") : badge("unclaimed", "warn")) + "</td>";
html += "<td>" + escapeText((t.git && t.git.branch) || "—") + " " + escapeText((t.git && t.git.cleanliness) || "") + "</td>";
html += "<td>" + escapeText((t.verification && t.verification.result) || "unknown") + "</td>";
const dep = t.deployment || {};
html += "<td>" + escapeText(Object.keys(dep).map((k) => k + ":" + (dep[k].app_readiness || "unknown")).join(" ")) + "</td></tr>";
});
html += "</tbody></table></div></section>";
html += '<section class="panel"><h2>漂移 / 冲突</h2>';
if (!conflicts.length) html += "<p class='meta'>无冲突</p>";
else {
html += "<ul>" + conflicts.map((c) => "<li>" + badge(c.kind, "warn") + " " + escapeText(c.subject + " · " + c.field + " — " + c.summary) + "</li>").join("") + "</ul>";
}
html += "</section>";
el.content.innerHTML = html;
}
function renderModules() {
const rows = (state.snap.modules || []).filter((m) => matchQuery(m.name + (m.mutex || "")));
let html = '<section class="panel"><h2>模块</h2><div class="table-wrap"><table><thead><tr><th>模块</th><th>状态</th><th>mutex</th></tr></thead><tbody>';
rows.forEach((m) => {
html += "<tr><td>" + escapeText(m.name) + "</td><td>" + escapeText(m.status) + "</td><td>" + escapeText(m.mutex) + "</td></tr>";
});
html += "</tbody></table></div></section>";
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 += '<section class="panel"><h2>' + escapeText(t.title) + "</h2>";
html += '<div class="axes">';
html += '<div class="axis"><strong>计划</strong>' + escapeText(t.doc_status) + "<br>" + escapeText((t.claim && t.claim.owner) || "unassigned") + " · " + escapeText((t.claim && t.claim.agent_kind) || "") + " · activity=" + escapeText((t.claim && t.claim.activity) || "unknown") + "</div>";
html += '<div class="axis"><strong>Git 实施</strong>' + escapeText(JSON.stringify(t.git || {})) + "</div>";
html += '<div class="axis"><strong>验证</strong>' + escapeText(JSON.stringify(t.verification || {})) + "</div>";
html += '<div class="axis"><strong>实装</strong>' + escapeText(JSON.stringify(t.deployment || {})) + "</div>";
html += "</div>";
if (t.evidence_chain && t.evidence_chain.length) {
html += "<p><strong>证据链</strong></p><ul>" + t.evidence_chain.map((p) => '<li><button type="button" class="linkish" data-doc="' + escapeText(p) + '">' + escapeText(p) + "</button></li>").join("") + "</ul>";
}
html += "</section>";
});
el.content.innerHTML = html || "<p>无匹配任务</p>";
}
function renderGit() {
const rows = (state.snap.git || []).filter((g) => matchQuery(g.name + (g.worktree || "")));
let html = '<section class="panel"><h2>分支 / worktree</h2><p class="meta">远端状态基于本地 refs,未联网刷新</p><div class="table-wrap"><table><thead><tr><th>分支</th><th>ahead/behind</th><th>worktree</th><th>备注</th></tr></thead><tbody>';
rows.forEach((g) => {
html += "<tr><td>" + escapeText(g.name) + (g.is_current ? " *" : "") + "</td><td>" + escapeText(g.ahead + "/" + g.behind) + "</td><td>" + escapeText(g.worktree || "") + "</td><td>" + escapeText(g.remote_note || "") + "</td></tr>";
});
html += "</tbody></table></div></section>";
el.content.innerHTML = html;
}
function renderDeploy() {
const rows = state.snap.environments || [];
let html = "";
rows.forEach((e) => {
html += '<section class="panel"><h2>环境 ' + escapeText(e.name) + "</h2>";
html += "<p>" + 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" : "") + "</p>";
if (e.drift_notes && e.drift_notes.length) {
html += "<ul>" + e.drift_notes.map((n) => "<li class='badge warn'>" + escapeText(n) + "</li>").join("") + "</ul>";
}
if (e.containers && e.containers.length) {
html += '<div class="table-wrap"><table><thead><tr><th>容器</th><th>状态</th><th>health</th><th>image</th></tr></thead><tbody>';
e.containers.forEach((c) => {
html += "<tr><td>" + escapeText(c.name) + "</td><td>" + escapeText(c.status) + "</td><td>" + escapeText(c.health) + "</td><td>" + escapeText(c.image) + "</td></tr>";
});
html += "</tbody></table></div>";
}
html += "</section>";
});
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 = '<section class="panel"><h2>文档</h2><div class="table-wrap"><table><thead><tr><th>标题</th><th>路径</th><th>状态</th></tr></thead><tbody>';
rows.slice(0, 200).forEach((d) => {
html += '<tr><td><button type="button" class="linkish" data-doc="' + escapeText(d.id) + '">' + escapeText(d.title) + "</button></td><td>" + escapeText(d.path) + "</td><td>" + escapeText(d.status || "") + "</td></tr>";
});
html += "</tbody></table></div></section>";
if (state.selectedDoc) {
const doc = (state.snap.documents || []).find((d) => d.id === state.selectedDoc || d.path === state.selectedDoc);
html += '<section class="panel"><h2>正文(纯文本)</h2>';
if (!doc) html += "<p>未找到文档</p>";
else {
html += "<p class='meta'>" + escapeText(doc.path) + (doc.body_truncated ? " · truncated" : "") + "</p>";
html += '<div class="pre">' + escapeText(doc.body || "") + "</div>";
}
html += "</section>";
}
el.content.innerHTML = html;
}
function renderEvidence() {
const tasks = (state.snap.tasks || []).filter((t) => (t.evidence_chain || []).length && matchQuery(t.title));
let html = '<section class="panel"><h2>证据链导航</h2>';
tasks.forEach((t) => {
html += "<h3>" + escapeText(t.title) + "</h3><ol>" + (t.evidence_chain || []).map((p) => '<li><button type="button" class="linkish" data-doc="' + escapeText(p) + '">' + escapeText(p) + "</button></li>").join("") + "</ol>";
});
html += "</section>";
el.content.innerHTML = html;
}
function renderHardError(msg) {
el.content.innerHTML = '<section class="panel"><h2>错误</h2><p class="badge bad">' + escapeText(msg) + "</p><p>无可用缓存快照。</p></section>";
}
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();
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();
})();
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>个人工程中控台</title>
<link rel="stylesheet" href="/assets/app.css?token=__TOKEN__">
</head>
<body>
<a class="skip" href="#main">跳到主内容</a>
<header class="top">
<div>
<h1>个人工程中控台</h1>
<p class="meta" id="repo-meta">加载中…</p>
</div>
<p class="banner" role="status">只读本地工具 · 不写文档 · 不操作 Git/Docker · 不控制 Herdr</p>
</header>
<div class="layout">
<button type="button" class="nav-toggle" id="nav-toggle" aria-controls="nav" aria-expanded="false">菜单</button>
<nav id="nav" aria-label="主导航">
<ul>
<li><button type="button" data-view="overview" class="nav-btn active">总览</button></li>
<li><button type="button" data-view="modules" class="nav-btn">架构/模块</button></li>
<li><button type="button" data-view="tasks" class="nav-btn">任务</button></li>
<li><button type="button" data-view="git" class="nav-btn">Git</button></li>
<li><button type="button" data-view="deploy" class="nav-btn">部署</button></li>
<li><button type="button" data-view="docs" class="nav-btn">文档</button></li>
<li><button type="button" data-view="evidence" class="nav-btn">证据链</button></li>
</ul>
<label class="search-label">搜索
<input type="search" id="q" placeholder="标题 / 路径 / 正文" autocomplete="off">
</label>
<div id="source-strip" class="source-strip" aria-live="polite"></div>
</nav>
<main id="main" tabindex="-1">
<div id="view-status" class="view-status" role="status"></div>
<div id="content"></div>
</main>
</div>
<script>window.__DASHBOARD_TOKEN__ = "__TOKEN__";</script>
<script src="/assets/app.js?token=__TOKEN__"></script>
</body>
</html>
@@ -0,0 +1,10 @@
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)
}
+257
View File
@@ -0,0 +1,257 @@
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
}
+111
View File
@@ -0,0 +1,111 @@
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)
}
}
+156
View File
@@ -0,0 +1,156 @@
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 </script> sequences
safeJSON := strings.ReplaceAll(string(payload), "<", "\\u003c")
safeJSON = strings.ReplaceAll(safeJSON, ">", "\\u003e")
safeJSON = strings.ReplaceAll(safeJSON, "&", "\\u0026")
safeJSON = strings.ReplaceAll(safeJSON, "</script", "<\\/script")
manJSON, _ := json.MarshalIndent(manifest, "", " ")
manSafe := strings.ReplaceAll(string(manJSON), "<", "\\u003c")
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>工程中控台快照 %s</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'none'; connect-src 'none'; base-uri 'none'">
<style>%s</style>
</head>
<body>
<header class="top">
<div>
<h1>个人工程中控台(离线快照)</h1>
<p class="meta">repo=%s · HEAD=%s · generation=%s · revision=%s · generated_at=%s</p>
</div>
<p class="banner" role="status">敏感快照 · 默认权限 0600 · 不含 assets 二进制 · 交接方自行控制分发</p>
</header>
<main id="main" style="padding:1rem">
<section class="panel">
<h2>覆盖清单</h2>
<pre class="pre" id="manifest"></pre>
</section>
<div id="content"></div>
</main>
<script type="application/json" id="snapshot-data">%s</script>
<script type="application/json" id="manifest-data">%s</script>
<script>
(function(){
const snap = JSON.parse(document.getElementById('snapshot-data').textContent);
const manifest = JSON.parse(document.getElementById('manifest-data').textContent);
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
const root = document.getElementById('content');
function esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
let html = '<section class="panel"><h2>任务四轴</h2>';
(snap.tasks||[]).forEach(t=>{
html += '<article><h3>'+esc(t.title)+'</h3>';
html += '<div class="axes">';
html += '<div class="axis"><strong>计划</strong>'+esc(t.doc_status)+'</div>';
html += '<div class="axis"><strong>Git</strong>'+esc(JSON.stringify(t.git||{}))+'</div>';
html += '<div class="axis"><strong>验证</strong>'+esc(JSON.stringify(t.verification||{}))+'</div>';
html += '<div class="axis"><strong>实装</strong>'+esc(JSON.stringify(t.deployment||{}))+'</div>';
html += '</div></article>';
});
html += '</section>';
html += '<section class="panel"><h2>冲突</h2><ul>'+(snap.conflicts||[]).map(c=>'<li>'+esc(c.kind+': '+c.summary)+'</li>').join('')+'</ul></section>';
html += '<section class="panel"><h2>文档正文</h2>';
(snap.documents||[]).forEach(d=>{
if(!d.body) return;
html += '<h3>'+esc(d.title)+'</h3><p class="meta">'+esc(d.path)+(d.body_truncated?' · truncated':'')+'</p>';
const pre=document.createElement('div');
pre.className='pre';
pre.textContent=d.body;
html += pre.outerHTML;
});
html += '</section>';
root.innerHTML = html;
// Note: textContent assignment above via outerHTML of node with textContent is safe.
})();
</script>
</body>
</html>
`, 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"
@@ -0,0 +1,56 @@
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 </script><script>alert(1)</script> 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, "</script><script>alert") {
t.Fatal("script breakout in data")
}
if !strings.Contains(html, "hello") {
t.Fatal("body missing")
}
if strings.Contains(html, "http://") && strings.Contains(html, "src=\"http") {
t.Fatal("unexpected remote resource")
}
}
+80
View File
@@ -0,0 +1,80 @@
package engineeringdashboard
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestGit_AheadDoesNotClaimRemoteAvailable(t *testing.T) {
now := time.Now().UTC()
fake := &FakeRunner{Map: map[string]FakeResult{
"git rev-parse --abbrev-ref HEAD": {Stdout: "dev\n"},
"git rev-parse HEAD": {Stdout: "abc123\n"},
"git status --porcelain=v2 --branch": {Stdout: "# branch.head dev\n"},
"git for-each-ref --format=%(refname:short)|%(objectname:short)|%(upstream:short) refs/heads": {
Stdout: "dev|abc123|origin/dev\n",
},
"git rev-list --left-right --count origin/dev...dev": {Stdout: "0\t3\n"},
"git worktree list --porcelain": {Stdout: "worktree /repo\nHEAD abc123\nbranch refs/heads/dev\n"},
}}
res := CollectGit(context.Background(), "/repo", fake, now)
if res.Source.Status != SourceOK {
t.Fatalf("git source: %+v", res.Source)
}
if len(res.Branches) != 1 || res.Branches[0].Ahead != 3 {
t.Fatalf("expected ahead=3: %+v", res.Branches)
}
if !strings.Contains(res.Branches[0].RemoteNote, "not network-refreshed") {
t.Fatalf("missing remote disclaimer: %+v", res.Branches[0])
}
fake.AssertNoForbidden(t)
}
func TestGit_WorktreeBranchMismatchSurfaced(t *testing.T) {
now := time.Now().UTC()
fake := &FakeRunner{Map: map[string]FakeResult{
"git rev-parse --abbrev-ref HEAD": {Stdout: "feature/a\n"},
"git rev-parse HEAD": {Stdout: "def\n"},
"git status --porcelain=v2 --branch": {Stdout: "1 .M N... file\n"},
"git for-each-ref --format=%(refname:short)|%(objectname:short)|%(upstream:short) refs/heads": {
Stdout: "feature/a|def|\nfeature/b|ghi|\n",
},
"git worktree list --porcelain": {
Stdout: "worktree /repo\nHEAD def\nbranch refs/heads/feature/a\n\nworktree /repo-wt\nHEAD ghi\nbranch refs/heads/feature/b\n",
},
}}
res := CollectGit(context.Background(), "/repo", fake, now)
var foundB bool
for _, b := range res.Branches {
if b.Name == "feature/b" && b.Worktree == "/repo-wt" {
foundB = true
}
}
if !foundB {
t.Fatalf("worktree mapping missing: %+v", res.Branches)
}
if !res.Repository.Dirty {
t.Fatal("expected dirty")
}
}
func TestGit_TimeoutDegradesSource(t *testing.T) {
fake := &FakeRunner{Map: map[string]FakeResult{
"git rev-parse --abbrev-ref HEAD": {Err: errors.New("command timed out: git")},
}}
res := CollectGit(context.Background(), "/repo", fake, time.Now().UTC())
if res.Source.Status != SourceError {
t.Fatalf("expected error source: %+v", res.Source)
}
}
func TestGit_RunnerDeniesFetch(t *testing.T) {
r := ExecRunner{}
_, _, err := r.Run(context.Background(), "/tmp", "git", "fetch", "origin")
if err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("expected deny, got %v", err)
}
}
+499
View File
@@ -0,0 +1,499 @@
// Package engineeringdashboard implements an independent local read-only
// personal engineering control center. It must not import product business packages.
package engineeringdashboard
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const SchemaVersion = "1"
// Provenance classifies how a fact was obtained.
type Provenance string
const (
ProvenanceDeclared Provenance = "declared"
ProvenanceObserved Provenance = "observed"
ProvenanceVerified Provenance = "verified"
ProvenanceInferred Provenance = "inferred"
)
// Freshness classifies source/data age relative to the current collection.
type Freshness string
const (
FreshnessFresh Freshness = "fresh"
FreshnessStale Freshness = "stale"
FreshnessError Freshness = "error"
FreshnessUnknown Freshness = "unknown"
)
// SourceStatus is the collection outcome for one source.
type SourceStatus string
const (
SourceOK SourceStatus = "ok"
SourceDegraded SourceStatus = "degraded"
SourceError SourceStatus = "error"
SourceUnknown SourceStatus = "unknown"
)
// AgentKind separates Herdr engineering agents from the product PI Agent Runner.
type AgentKind string
const (
AgentKindHerdrEngineering AgentKind = "herdr_engineering"
AgentKindProductPIRunner AgentKind = "product_pi_agent_runner"
AgentKindHuman AgentKind = "human"
AgentKindUnknown AgentKind = "unknown"
)
// DocStatus is the task document declared status (plan axis only).
type DocStatus string
const (
DocStatusPlanned DocStatus = "planned"
DocStatusInProgress DocStatus = "in_progress"
DocStatusBlocked DocStatus = "blocked"
DocStatusDone DocStatus = "done"
DocStatusCancelled DocStatus = "cancelled"
DocStatusUnknown DocStatus = "unknown"
)
// ConflictKind classifies multi-source disagreements.
type ConflictKind string
const (
ConflictDeclaredVsObserved ConflictKind = "declared_vs_observed"
ConflictTaskVsGit ConflictKind = "task_vs_git"
ConflictTestVsSubject ConflictKind = "test_vs_subject"
ConflictReceiptVsRuntime ConflictKind = "receipt_vs_runtime"
ConflictStaleSource ConflictKind = "stale_source"
ConflictMissingEvidence ConflictKind = "missing_evidence"
)
// Snapshot is the immutable delivery model shared by HTML, JSON, and export.
type Snapshot struct {
SchemaVersion string `json:"schema_version"`
GenerationID string `json:"generation_id"`
RevisionID string `json:"revision_id"`
GeneratedAt time.Time `json:"generated_at"`
CollectionDuration time.Duration `json:"-"`
Repository RepositoryIdentity `json:"repository"`
Sources []SourceState `json:"sources"`
Facts []Fact `json:"facts"`
Conflicts []Conflict `json:"conflicts"`
Documents []DocumentNode `json:"documents"`
Modules []ModuleView `json:"modules"`
Tasks []TaskState `json:"tasks"`
Git []GitBranchView `json:"git"`
Environments []EnvironmentState `json:"environments"`
Warnings []string `json:"warnings,omitempty"`
}
// RepositoryIdentity identifies the scanned checkout.
type RepositoryIdentity struct {
Root string `json:"root"`
Branch string `json:"branch"`
Head string `json:"head"`
Dirty bool `json:"dirty"`
}
// SourceState records one collector's outcome.
type SourceState struct {
Name string `json:"name"`
Status SourceStatus `json:"status"`
Freshness Freshness `json:"freshness"`
ObservedAt time.Time `json:"observed_at"`
LastSuccessAt *time.Time `json:"last_success_at,omitempty"`
Error string `json:"error,omitempty"`
DurationMS int64 `json:"duration_ms"`
}
// Fact is an atomic, provenance-tagged observation or declaration.
type Fact struct {
ID string `json:"id"`
Subject string `json:"subject"`
Field string `json:"field"`
Value string `json:"value"`
Provenance Provenance `json:"provenance"`
SourceRef string `json:"source_ref"`
ObservedAt time.Time `json:"observed_at"`
Confidence string `json:"confidence,omitempty"`
}
// Conflict retains all disagreeing fact refs without picking a winner.
type Conflict struct {
Subject string `json:"subject"`
Field string `json:"field"`
Kind ConflictKind `json:"kind"`
FactRefs []string `json:"fact_refs"`
Summary string `json:"summary"`
}
// DocumentNode is an indexed markdown document (body may be truncated/omitted).
type DocumentNode struct {
ID string `json:"id"`
Path string `json:"path"`
Title string `json:"title"`
Taxonomy string `json:"taxonomy,omitempty"`
Status string `json:"status,omitempty"`
FrontMatter map[string]string `json:"front_matter,omitempty"`
Body string `json:"body,omitempty"`
BodyTruncated bool `json:"body_truncated,omitempty"`
LinksOut []string `json:"links_out,omitempty"`
LinksIn []string `json:"links_in,omitempty"`
BrokenLinks []string `json:"broken_links,omitempty"`
Orphan bool `json:"orphan,omitempty"`
Unindexed bool `json:"unindexed,omitempty"`
ParseWarnings []string `json:"parse_warnings,omitempty"`
}
// ModuleView aggregates architecture/module matrix evidence.
type ModuleView struct {
Name string `json:"name"`
Status string `json:"status,omitempty"`
Mutex string `json:"mutex,omitempty"`
Evidence []string `json:"evidence,omitempty"`
Deps []string `json:"deps,omitempty"`
}
// GitFacets are observed implementation facets (never inferred from doc_status).
type GitFacets struct {
Branch string `json:"branch,omitempty"`
Worktree string `json:"worktree,omitempty"`
MergedToDev *bool `json:"merged_to_dev,omitempty"`
Cleanliness string `json:"cleanliness,omitempty"` // clean|dirty|unknown
Ahead int `json:"ahead,omitempty"`
Behind int `json:"behind,omitempty"`
RemoteNote string `json:"remote_note,omitempty"`
}
// VerificationFacets bind test evidence to a subject identity.
type VerificationFacets struct {
Result string `json:"result,omitempty"` // pass|fail|unbound|stale|unknown
Freshness Freshness `json:"freshness,omitempty"`
SubjectID string `json:"subject_id,omitempty"`
ReportPath string `json:"report_path,omitempty"`
}
// DeploymentFacets are per-environment install observations.
type DeploymentFacets struct {
ContainerStatus string `json:"container_status,omitempty"`
AppReadiness string `json:"app_readiness,omitempty"`
ServicesOK string `json:"services_ok,omitempty"` // complete|partial|unknown
ArtifactID string `json:"artifact_id,omitempty"`
IdentityNote string `json:"identity_note,omitempty"`
}
// AgentClaim is declared ownership; activity remains separate.
type AgentClaim struct {
Owner string `json:"owner,omitempty"`
AgentKind AgentKind `json:"agent_kind"`
Claimed bool `json:"claimed"`
Mutex string `json:"mutex,omitempty"`
Branch string `json:"declared_branch,omitempty"`
Worktree string `json:"declared_worktree,omitempty"`
Activity string `json:"activity"` // unknown|observed|...
ActivityAt *time.Time `json:"activity_at,omitempty"`
Evidence string `json:"evidence,omitempty"`
}
// TaskState keeps plan / git / verification / deployment axes independent.
type TaskState struct {
ID string `json:"id"`
Title string `json:"title"`
Path string `json:"path"`
DocStatus DocStatus `json:"doc_status"`
Claim AgentClaim `json:"claim"`
Git GitFacets `json:"git"`
Verification VerificationFacets `json:"verification"`
Deployment map[string]DeploymentFacets `json:"deployment,omitempty"`
EvidenceChain []string `json:"evidence_chain,omitempty"`
}
// GitBranchView is a git observation row.
type GitBranchView struct {
Name string `json:"name"`
IsCurrent bool `json:"is_current,omitempty"`
Head string `json:"head,omitempty"`
Upstream string `json:"upstream,omitempty"`
Ahead int `json:"ahead,omitempty"`
Behind int `json:"behind,omitempty"`
Worktree string `json:"worktree,omitempty"`
Dirty bool `json:"dirty,omitempty"`
RemoteNote string `json:"remote_note,omitempty"`
ObservedAt time.Time `json:"observed_at"`
}
// EnvironmentState is one deploy environment's multi-signal view.
type EnvironmentState struct {
Name string `json:"name"`
Containers []ContainerView `json:"containers,omitempty"`
Readiness string `json:"readiness,omitempty"`
Schema string `json:"schema,omitempty"`
Artifact string `json:"artifact,omitempty"`
Receipt string `json:"receipt,omitempty"`
Facets DeploymentFacets `json:"facets"`
DriftNotes []string `json:"drift_notes,omitempty"`
}
// ContainerView is a whitelist-filtered docker observation.
type ContainerView struct {
Name string `json:"name"`
Image string `json:"image,omitempty"`
Digest string `json:"digest,omitempty"`
Status string `json:"status,omitempty"`
RestartCount int `json:"restart_count,omitempty"`
Health string `json:"health,omitempty"`
Project string `json:"compose_project,omitempty"`
Service string `json:"compose_service,omitempty"`
}
// ValidateProvenance rejects unknown provenance values.
func ValidateProvenance(p Provenance) error {
switch p {
case ProvenanceDeclared, ProvenanceObserved, ProvenanceVerified, ProvenanceInferred:
return nil
default:
return fmt.Errorf("unknown provenance %q", p)
}
}
// ValidateFreshness rejects unknown freshness values.
func ValidateFreshness(f Freshness) error {
switch f {
case FreshnessFresh, FreshnessStale, FreshnessError, FreshnessUnknown:
return nil
default:
return fmt.Errorf("unknown freshness %q", f)
}
}
// ValidateAgentKind rejects unknown agent kinds.
func ValidateAgentKind(k AgentKind) error {
switch k {
case AgentKindHerdrEngineering, AgentKindProductPIRunner, AgentKindHuman, AgentKindUnknown:
return nil
default:
return fmt.Errorf("unknown agent kind %q", k)
}
}
// ComputeRevisionID hashes schema + repo identity + normalized non-volatile payload.
// generated_at, observed_at, and collection duration are excluded.
func ComputeRevisionID(s Snapshot) string {
type revSource struct {
Name string `json:"name"`
Status SourceStatus `json:"status"`
Freshness Freshness `json:"freshness"`
Error string `json:"error,omitempty"`
}
type revFact struct {
ID string `json:"id"`
Subject string `json:"subject"`
Field string `json:"field"`
Value string `json:"value"`
Provenance Provenance `json:"provenance"`
SourceRef string `json:"source_ref"`
Confidence string `json:"confidence,omitempty"`
}
type revPayload struct {
Schema string `json:"schema_version"`
Repo RepositoryIdentity `json:"repository"`
Sources []revSource `json:"sources"`
Facts []revFact `json:"facts"`
Conflicts []Conflict `json:"conflicts"`
Documents []DocumentNode `json:"documents"`
Modules []ModuleView `json:"modules"`
Tasks []TaskState `json:"tasks"`
Git []GitBranchView `json:"git"`
Envs []EnvironmentState `json:"environments"`
Warnings []string `json:"warnings,omitempty"`
}
srcs := make([]revSource, 0, len(s.Sources))
for _, src := range s.Sources {
srcs = append(srcs, revSource{Name: src.Name, Status: src.Status, Freshness: src.Freshness, Error: src.Error})
}
sort.Slice(srcs, func(i, j int) bool { return srcs[i].Name < srcs[j].Name })
facts := make([]revFact, 0, len(s.Facts))
for _, f := range s.Facts {
facts = append(facts, revFact{
ID: f.ID, Subject: f.Subject, Field: f.Field, Value: f.Value,
Provenance: f.Provenance, SourceRef: f.SourceRef, Confidence: f.Confidence,
})
}
sort.Slice(facts, func(i, j int) bool { return facts[i].ID < facts[j].ID })
conflicts := append([]Conflict(nil), s.Conflicts...)
sort.Slice(conflicts, func(i, j int) bool {
a := conflicts[i].Subject + "|" + conflicts[i].Field + "|" + string(conflicts[i].Kind)
b := conflicts[j].Subject + "|" + conflicts[j].Field + "|" + string(conflicts[j].Kind)
return a < b
})
docs := append([]DocumentNode(nil), s.Documents...)
sort.Slice(docs, func(i, j int) bool { return docs[i].ID < docs[j].ID })
mods := append([]ModuleView(nil), s.Modules...)
sort.Slice(mods, func(i, j int) bool { return mods[i].Name < mods[j].Name })
tasks := append([]TaskState(nil), s.Tasks...)
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
git := append([]GitBranchView(nil), s.Git...)
for i := range git {
git[i].ObservedAt = time.Time{}
}
sort.Slice(git, func(i, j int) bool { return git[i].Name < git[j].Name })
envs := append([]EnvironmentState(nil), s.Environments...)
sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name })
warns := append([]string(nil), s.Warnings...)
sort.Strings(warns)
payload := revPayload{
Schema: s.SchemaVersion, Repo: s.Repository, Sources: srcs, Facts: facts,
Conflicts: conflicts, Documents: docs, Modules: mods, Tasks: tasks, Git: git, Envs: envs, Warnings: warns,
}
b, err := json.Marshal(payload)
if err != nil {
sum := sha256.Sum256([]byte(err.Error()))
return "rev_err_" + hex.EncodeToString(sum[:8])
}
sum := sha256.Sum256(b)
return "rev_" + hex.EncodeToString(sum[:16])
}
// MarshalSnapshotJSON encodes a snapshot for API/export.
func MarshalSnapshotJSON(s Snapshot) ([]byte, error) {
type alias Snapshot
type wire struct {
alias
CollectionDurationMS int64 `json:"collection_duration_ms"`
}
w := wire{alias: alias(s), CollectionDurationMS: s.CollectionDuration.Milliseconds()}
return json.MarshalIndent(w, "", " ")
}
// UnmarshalSnapshotJSON decodes a snapshot wire format.
func UnmarshalSnapshotJSON(data []byte) (Snapshot, error) {
type alias Snapshot
type wire struct {
alias
CollectionDurationMS int64 `json:"collection_duration_ms"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return Snapshot{}, err
}
s := Snapshot(w.alias)
s.CollectionDuration = time.Duration(w.CollectionDurationMS) * time.Millisecond
return s, nil
}
// SampleRDevTaskFixture returns the "planned but not implemented" acceptance sample.
func SampleRDevTaskFixture() TaskState {
return TaskState{
ID: "task:rdev-workflow-integration",
Title: "研发工作流 rdev 接入",
Path: ".doc/ops/short-term/2026-09-17-rdev-workflow-integration.md",
DocStatus: DocStatusPlanned,
Claim: AgentClaim{
Claimed: false,
AgentKind: AgentKindUnknown,
Activity: "unknown",
},
Git: GitFacets{
Cleanliness: "unknown",
RemoteNote: "no claimed branch",
},
Verification: VerificationFacets{Result: "unknown", Freshness: FreshnessUnknown},
Deployment: map[string]DeploymentFacets{
"dev": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown", IdentityNote: "not installed"},
"test": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown", IdentityNote: "not installed"},
"main": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown", IdentityNote: "not installed"},
},
EvidenceChain: []string{
".doc/ops/research/2026-09-17-rdev-workflow-reference-analysis.md",
".doc/system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md",
".doc/ops/short-term/2026-09-17-rdev-workflow-integration.md",
".doc/backlog/2026-09-17-rdev-phase2-backlog.md",
},
}
}
// SampleDevAheadGitFixture returns local-ahead-of-origin acceptance sample.
func SampleDevAheadGitFixture() GitBranchView {
return GitBranchView{
Name: "dev",
IsCurrent: true,
Head: "abc1234",
Upstream: "origin/dev",
Ahead: 3,
Behind: 0,
RemoteNote: "based on local refs; not network-refreshed — local present, remote availability unconfirmed",
ObservedAt: time.Date(2026, 9, 17, 6, 0, 0, 0, time.UTC),
}
}
// SampleMainEventsForkFixture returns readiness-ok vs events-restarting sample.
func SampleMainEventsForkFixture() EnvironmentState {
return EnvironmentState{
Name: "main",
Containers: []ContainerView{
{Name: "shuimu-agent-main", Image: "agent:main", Status: "running", Health: "healthy", Project: "agent-main", Service: "app"},
{Name: "shuimu-agent-events-main", Image: "events:main", Status: "restarting", RestartCount: 12, Health: "unhealthy", Project: "agent-main", Service: "events"},
},
Readiness: "ok",
Schema: "47",
Facets: DeploymentFacets{
ContainerStatus: "partial",
AppReadiness: "ok",
ServicesOK: "partial",
IdentityNote: "app ready; events restarting — no aggregate green",
},
DriftNotes: []string{
"CURRENT may declare healthy while events container is restarting",
"app /ready ok coexists with events unhealthy",
},
}
}
// SampleHerdrVsPIClaims returns agent kind isolation sample.
func SampleHerdrVsPIClaims() (AgentClaim, AgentClaim) {
herdr := AgentClaim{
Owner: "herdr_coder",
AgentKind: AgentKindHerdrEngineering,
Claimed: true,
Mutex: "engineering-dashboard",
Activity: "unknown",
Evidence: "CURRENT claim only; no runtime activity source",
}
pi := AgentClaim{
Owner: "product-pi-runner",
AgentKind: AgentKindProductPIRunner,
Claimed: false,
Activity: "unknown",
Evidence: "product runtime identity; must not share herdr type",
}
return herdr, pi
}
// NewConflict builds a conflict retaining all fact refs.
func NewConflict(subject, field string, kind ConflictKind, factRefs []string, summary string) Conflict {
refs := append([]string(nil), factRefs...)
sort.Strings(refs)
return Conflict{Subject: subject, Field: field, Kind: kind, FactRefs: refs, Summary: summary}
}
// FactID builds a stable fact id.
func FactID(subject, field, source string) string {
raw := strings.Join([]string{subject, field, source}, "|")
sum := sha256.Sum256([]byte(raw))
return "fact_" + hex.EncodeToString(sum[:10])
}
+152
View File
@@ -0,0 +1,152 @@
package engineeringdashboard
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestModel_DocDoneDoesNotChangeGitOrDeployFacets(t *testing.T) {
task := SampleRDevTaskFixture()
task.DocStatus = DocStatusDone
if task.Git.Branch != "" || task.Git.Cleanliness != "unknown" {
t.Fatalf("git facets mutated from doc_status: %+v", task.Git)
}
for env, d := range task.Deployment {
if d.AppReadiness != "unknown" || d.ContainerStatus != "unknown" {
t.Fatalf("deployment facet for %s changed from doc_status: %+v", env, d)
}
}
}
func TestModel_ClaimedWithoutRuntimeKeepsActivityUnknown(t *testing.T) {
herdr, pi := SampleHerdrVsPIClaims()
if herdr.Claimed && herdr.Activity != "unknown" {
t.Fatalf("claimed must not imply running: %+v", herdr)
}
if herdr.AgentKind == pi.AgentKind {
t.Fatalf("herdr and product PI runner must not share kind: %s", herdr.AgentKind)
}
if err := ValidateAgentKind(herdr.AgentKind); err != nil {
t.Fatal(err)
}
if err := ValidateAgentKind(pi.AgentKind); err != nil {
t.Fatal(err)
}
}
func TestModel_ConflictRetainsAllFactRefs(t *testing.T) {
f1 := FactID("env:main", "health", "current")
f2 := FactID("env:main", "health", "docker")
c := NewConflict("env:main", "health", ConflictDeclaredVsObserved, []string{f2, f1}, "declared healthy vs events restarting")
if len(c.FactRefs) != 2 {
t.Fatalf("expected 2 fact refs, got %v", c.FactRefs)
}
if c.FactRefs[0] != f1 || c.FactRefs[1] != f2 {
t.Fatalf("refs not sorted/retained: %v", c.FactRefs)
}
}
func TestModel_JSONRoundTripPreservesIdentity(t *testing.T) {
now := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)
s := Snapshot{
SchemaVersion: SchemaVersion,
GenerationID: "gen_1",
GeneratedAt: now,
CollectionDuration: 1500 * time.Millisecond,
Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "abc", Dirty: false},
Sources: []SourceState{{
Name: "docs", Status: SourceOK, Freshness: FreshnessFresh, ObservedAt: now, DurationMS: 10,
}},
Facts: []Fact{{
ID: "fact_a", Subject: "task:rdev", Field: "doc_status", Value: "planned",
Provenance: ProvenanceDeclared, SourceRef: "docs", ObservedAt: now,
}},
Tasks: []TaskState{SampleRDevTaskFixture()},
Git: []GitBranchView{SampleDevAheadGitFixture()},
Environments: []EnvironmentState{SampleMainEventsForkFixture()},
}
s.RevisionID = ComputeRevisionID(s)
raw, err := MarshalSnapshotJSON(s)
if err != nil {
t.Fatal(err)
}
got, err := UnmarshalSnapshotJSON(raw)
if err != nil {
t.Fatal(err)
}
if got.SchemaVersion != SchemaVersion || got.GenerationID != "gen_1" || got.RevisionID != s.RevisionID {
t.Fatalf("identity mismatch: %+v", got)
}
if got.CollectionDuration != 1500*time.Millisecond {
t.Fatalf("duration mismatch: %v", got.CollectionDuration)
}
var wire map[string]any
if err := json.Unmarshal(raw, &wire); err != nil {
t.Fatal(err)
}
if _, ok := wire["collection_duration_ms"]; !ok {
t.Fatal("missing collection_duration_ms in wire JSON")
}
}
func TestModel_RevisionIDIgnoresTimestamps(t *testing.T) {
base := Snapshot{
SchemaVersion: SchemaVersion,
Repository: RepositoryIdentity{Root: "/r", Branch: "dev", Head: "h1"},
Facts: []Fact{{
ID: "f1", Subject: "s", Field: "x", Value: "1",
Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: time.Unix(1, 0),
}},
GeneratedAt: time.Unix(100, 0),
CollectionDuration: time.Second,
}
a := base
a.RevisionID = ComputeRevisionID(a)
b := base
b.GeneratedAt = time.Unix(999, 0)
b.CollectionDuration = 5 * time.Second
b.Facts[0].ObservedAt = time.Unix(50, 0)
b.RevisionID = ComputeRevisionID(b)
if a.RevisionID != b.RevisionID {
t.Fatalf("revision should ignore timestamps: %s vs %s", a.RevisionID, b.RevisionID)
}
c := base
c.Facts[0].Value = "2"
c.RevisionID = ComputeRevisionID(c)
if c.RevisionID == a.RevisionID {
t.Fatal("revision must change when fact value changes")
}
}
func TestModel_UnknownEnumsFail(t *testing.T) {
if err := ValidateProvenance(Provenance("nope")); err == nil || !strings.Contains(err.Error(), "unknown provenance") {
t.Fatalf("expected provenance error, got %v", err)
}
if err := ValidateFreshness(Freshness("nope")); err == nil || !strings.Contains(err.Error(), "unknown freshness") {
t.Fatalf("expected freshness error, got %v", err)
}
if err := ValidateAgentKind(AgentKind("nope")); err == nil || !strings.Contains(err.Error(), "unknown agent kind") {
t.Fatalf("expected agent kind error, got %v", err)
}
}
func TestModel_AcceptanceSamplesAxes(t *testing.T) {
rdev := SampleRDevTaskFixture()
if rdev.DocStatus != DocStatusPlanned || rdev.Claim.Claimed {
t.Fatalf("rdev should be planned/unclaimed: %+v", rdev)
}
git := SampleDevAheadGitFixture()
if git.Ahead <= 0 || !strings.Contains(git.RemoteNote, "unconfirmed") {
t.Fatalf("dev ahead sample invalid: %+v", git)
}
main := SampleMainEventsForkFixture()
if main.Readiness != "ok" || main.Facets.ServicesOK != "partial" {
t.Fatalf("main fork sample invalid: %+v", main)
}
if len(main.DriftNotes) == 0 {
t.Fatal("main sample must retain drift notes, not aggregate green")
}
}
+109
View File
@@ -0,0 +1,109 @@
package engineeringdashboard
import (
"fmt"
"os"
"path/filepath"
"strings"
)
var sensitivePathParts = []string{
"/.git/", "/.git", "/.env", "/.secrets/", "/.secrets", "/data/",
}
// ResolveRepoRoot returns an absolute, symlink-resolved git root.
func ResolveRepoRoot(repo string) (string, error) {
if repo == "" {
wd, err := os.Getwd()
if err != nil {
return "", err
}
repo = wd
}
abs, err := filepath.Abs(repo)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(abs)
if err != nil {
return "", err
}
fi, err := os.Stat(resolved)
if err != nil {
return "", err
}
if !fi.IsDir() {
return "", fmt.Errorf("repo root is not a directory: %s", resolved)
}
return resolved, nil
}
// SafeDocPath validates a repo-relative markdown path under .doc (or allowlisted root md).
func SafeDocPath(repoRoot, rel string) (abs string, err error) {
rel = filepath.ToSlash(rel)
rel = strings.TrimPrefix(rel, "./")
if rel == "" || strings.Contains(rel, "\x00") {
return "", fmt.Errorf("empty path")
}
if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") {
return "", fmt.Errorf("absolute path denied: %s", rel)
}
if strings.Contains(rel, "..") {
return "", fmt.Errorf("path traversal denied: %s", rel)
}
allowRootMD := !strings.Contains(rel, "/") && strings.HasSuffix(strings.ToLower(rel), ".md")
underDoc := strings.HasPrefix(rel, ".doc/")
if !underDoc && !allowRootMD {
return "", fmt.Errorf("path outside allowlist: %s", rel)
}
if strings.Contains(rel, "/assets/") || strings.HasSuffix(rel, "/assets") {
return "", fmt.Errorf("assets body denied: %s", rel)
}
joined := filepath.Join(repoRoot, filepath.FromSlash(rel))
// Reject if the path itself is a symlink (before resolution).
fi, err := os.Lstat(joined)
if err != nil {
return "", err
}
if fi.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("symlink denied: %s", rel)
}
resolved, err := filepath.EvalSymlinks(joined)
if err != nil {
return "", err
}
repoResolved, err := filepath.EvalSymlinks(repoRoot)
if err != nil {
repoResolved = repoRoot
}
if !strings.HasPrefix(resolved, repoResolved+string(os.PathSeparator)) && resolved != repoResolved {
return "", fmt.Errorf("resolved path escapes repo: %s", rel)
}
lower := filepath.ToSlash(strings.ToLower(resolved))
for _, part := range sensitivePathParts {
if strings.Contains(lower, part) || strings.HasSuffix(lower, strings.TrimSuffix(part, "/")) {
// allow .doc paths that happen to mention words; only block real sensitive dirs
if strings.Contains(lower, "/.git") || strings.Contains(lower, "/.env") ||
strings.Contains(lower, "/.secrets") || strings.Contains(lower, "/data/") {
if !strings.Contains(filepath.ToSlash(rel), ".doc/") {
return "", fmt.Errorf("sensitive path denied: %s", rel)
}
// still deny .git/.env/.secrets even under mistaken .doc
if strings.Contains(lower, "/.git") || strings.Contains(lower, "/.secrets") ||
strings.Contains(filepath.Base(lower), ".env") {
return "", fmt.Errorf("sensitive path denied: %s", rel)
}
}
}
}
if !strings.HasSuffix(strings.ToLower(resolved), ".md") {
return "", fmt.Errorf("extension not allowed: %s", rel)
}
return resolved, nil
}
// IsAssetsPath reports whether a relative path is under an assets directory.
func IsAssetsPath(rel string) bool {
rel = filepath.ToSlash(rel)
return strings.Contains(rel, "/assets/") || strings.HasSuffix(rel, "/assets") || strings.HasPrefix(rel, "assets/")
}
@@ -0,0 +1,171 @@
package engineeringdashboard
import (
"strings"
"testing"
"time"
)
func TestReconcile_RDevPlannedUnclaimed(t *testing.T) {
task := SampleRDevTaskFixture()
in := ReconcileInput{
RepoRoot: "/repo",
Now: time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC),
Generation: 1,
Docs: DocsCollectResult{
Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh},
Tasks: []TaskState{task},
Documents: []DocumentNode{{
ID: task.Path, Path: task.Path, Title: task.Title, Status: "planned",
}},
},
Git: GitCollectResult{
Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh},
Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "h1"},
},
Runtime: RuntimeCollectResult{
Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh},
},
}
snap := Reconcile(in)
if len(snap.Tasks) != 1 {
t.Fatal(snap.Tasks)
}
got := snap.Tasks[0]
if got.DocStatus != DocStatusPlanned || got.Claim.Claimed {
t.Fatalf("expected planned/unclaimed: %+v", got)
}
if got.Git.Cleanliness != "unknown" {
t.Fatalf("must not invent git implementation: %+v", got.Git)
}
for _, d := range got.Deployment {
if d.AppReadiness != "unknown" {
t.Fatalf("must not invent deploy: %+v", d)
}
}
}
func TestReconcile_DevAheadSample(t *testing.T) {
g := SampleDevAheadGitFixture()
in := ReconcileInput{
RepoRoot: "/repo",
Now: time.Now().UTC(),
Generation: 2,
Git: GitCollectResult{
Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh},
Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: g.Head},
Branches: []GitBranchView{g},
},
Docs: DocsCollectResult{Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}},
Runtime: RuntimeCollectResult{Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}},
}
snap := Reconcile(in)
if len(snap.Git) != 1 || snap.Git[0].Ahead != 3 {
t.Fatalf("%+v", snap.Git)
}
if !strings.Contains(snap.Git[0].RemoteNote, "unconfirmed") {
t.Fatal(snap.Git[0].RemoteNote)
}
}
func TestReconcile_MainEventsForkConflict(t *testing.T) {
env := SampleMainEventsForkFixture()
in := ReconcileInput{
RepoRoot: "/repo",
Now: time.Now().UTC(),
Generation: 3,
Docs: DocsCollectResult{Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}},
Git: GitCollectResult{Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, Repository: RepositoryIdentity{Root: "/repo"}},
Runtime: RuntimeCollectResult{
Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh},
Environments: []EnvironmentState{env},
Facts: []Fact{
{ID: FactID("env:main", "readiness", "ready"), Subject: "env:main", Field: "readiness", Value: "ok", Provenance: ProvenanceObserved},
{ID: FactID("env:main", "container_status", "docker"), Subject: "env:main", Field: "container_status", Value: "partial", Provenance: ProvenanceObserved},
},
},
}
snap := Reconcile(in)
found := false
for _, c := range snap.Conflicts {
if c.Subject == "env:main" && c.Kind == ConflictDeclaredVsObserved {
found = true
if len(c.FactRefs) < 2 {
t.Fatalf("conflict must retain both refs: %+v", c)
}
}
}
if !found {
t.Fatalf("expected health conflict, got %+v", snap.Conflicts)
}
for _, e := range snap.Environments {
if e.Name == "main" && e.Facets.ServicesOK != "partial" {
t.Fatalf("no aggregate green: %+v", e.Facets)
}
}
}
func TestReconcile_StableAcrossInputOrder(t *testing.T) {
now := time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC)
mk := func(facts []Fact) Snapshot {
return Reconcile(ReconcileInput{
RepoRoot: "/repo", Now: now, Generation: 9,
Docs: DocsCollectResult{
Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh},
Facts: facts,
},
Git: GitCollectResult{Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "x"}},
Runtime: RuntimeCollectResult{Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}},
})
}
f1 := Fact{ID: "a", Subject: "s", Field: "f", Value: "1", Provenance: ProvenanceDeclared, SourceRef: "d", ObservedAt: now}
f2 := Fact{ID: "b", Subject: "s", Field: "f", Value: "2", Provenance: ProvenanceObserved, SourceRef: "g", ObservedAt: now}
a := mk([]Fact{f1, f2})
b := mk([]Fact{f2, f1})
if a.RevisionID != b.RevisionID {
t.Fatalf("revision unstable: %s vs %s", a.RevisionID, b.RevisionID)
}
}
func TestReconcile_DocDoneDoesNotFillDeploy(t *testing.T) {
task := SampleRDevTaskFixture()
task.DocStatus = DocStatusDone
snap := Reconcile(ReconcileInput{
RepoRoot: "/repo", Now: time.Now().UTC(), Generation: 4,
Docs: DocsCollectResult{Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}, Tasks: []TaskState{task}},
Git: GitCollectResult{Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, Repository: RepositoryIdentity{Root: "/repo"}},
Runtime: RuntimeCollectResult{Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}},
})
for _, d := range snap.Tasks[0].Deployment {
if d.AppReadiness != "unknown" {
t.Fatalf("doc done must not imply deploy: %+v", d)
}
}
}
func TestCollector_LastKnownStaleOnFailure(t *testing.T) {
c := NewCollector("/repo", &FakeRunner{Map: map[string]FakeResult{}})
ok := SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}
now := time.Now().UTC()
got := c.applyLastKnown(ok, now)
if got.LastSuccessAt == nil {
t.Fatal("expected last success")
}
fail := SourceState{Name: "git", Status: SourceError, Freshness: FreshnessError, Error: "boom"}
stale := c.applyLastKnown(fail, now.Add(time.Second))
if stale.Freshness != FreshnessStale || stale.Status != SourceDegraded {
t.Fatalf("%+v", stale)
}
if stale.LastSuccessAt == nil || !stale.LastSuccessAt.Equal(now) {
t.Fatalf("must keep prior last-success: %+v", stale)
}
}
func TestCollector_ColdStartFailureNoFakeHistory(t *testing.T) {
c := NewCollector("/repo", &FakeRunner{})
fail := SourceState{Name: "git", Status: SourceError, Error: "missing"}
got := c.applyLastKnown(fail, time.Now().UTC())
if got.Freshness != FreshnessError || got.LastSuccessAt != nil {
t.Fatalf("cold start must not invent last-known: %+v", got)
}
}
+175
View File
@@ -0,0 +1,175 @@
package engineeringdashboard
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
const (
defaultCommandTimeout = 2 * time.Second
defaultOutputLimit = 512 * 1024
)
// CommandRunner executes fixed argv without a shell.
type CommandRunner interface {
Run(ctx context.Context, dir string, name string, args ...string) (stdout string, stderr string, err error)
}
// ExecRunner is the production runner using os/exec.
type ExecRunner struct {
Timeout time.Duration
OutputLimit int
}
func (r ExecRunner) Run(ctx context.Context, dir string, name string, args ...string) (string, string, error) {
if name == "" {
return "", "", errors.New("empty command name")
}
if err := denyDangerousGitArgs(name, args); err != nil {
return "", "", err
}
timeout := r.Timeout
if timeout <= 0 {
timeout = defaultCommandTimeout
}
limit := r.OutputLimit
if limit <= 0 {
limit = defaultOutputLimit
}
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cctx, name, args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &limitedWriter{buf: &stdout, limit: limit}
cmd.Stderr = &limitedWriter{buf: &stderr, limit: limit}
err := cmd.Run()
out := sanitizeOutput(stdout.String())
errOut := sanitizeOutput(stderr.String())
if cctx.Err() == context.DeadlineExceeded {
return out, errOut, fmt.Errorf("command timed out: %s", name)
}
return out, errOut, err
}
type limitedWriter struct {
buf *bytes.Buffer
limit int
n int
}
func (w *limitedWriter) Write(p []byte) (int, error) {
remain := w.limit - w.n
if remain <= 0 {
return len(p), nil
}
if len(p) > remain {
p = p[:remain]
}
n, err := w.buf.Write(p)
w.n += n
return len(p), err
}
func sanitizeOutput(s string) string {
lower := strings.ToLower(s)
for _, needle := range []string{"password=", "secret=", "token=", "access_token=", "api_key="} {
if idx := strings.Index(lower, needle); idx >= 0 {
return s[:idx] + needle + "[redacted]"
}
}
return s
}
func denyDangerousGitArgs(name string, args []string) error {
base := name
if i := strings.LastIndex(name, "/"); i >= 0 {
base = name[i+1:]
}
if base != "git" {
return nil
}
banned := map[string]bool{
"fetch": true, "push": true, "pull": true, "clone": true,
"commit": true, "checkout": true, "merge": true, "rebase": true,
"reset": true, "clean": true, "add": true, "tag": true,
}
for _, a := range args {
if strings.HasPrefix(a, "-") {
continue
}
if banned[a] {
return fmt.Errorf("git write/network command denied: %s", a)
}
break
}
joined := strings.Join(args, " ")
if strings.Contains(joined, "&&") || strings.Contains(joined, "|") || strings.Contains(joined, ";") {
return errors.New("shell metacharacters denied")
}
return nil
}
// FakeRunner records invocations for tests.
type FakeRunner struct {
Calls [][]string
Map map[string]FakeResult // key: name + " " + joined args
}
// FakeResult is a canned command result.
type FakeResult struct {
Stdout string
Stderr string
Err error
}
func (f *FakeRunner) Run(_ context.Context, _ string, name string, args ...string) (string, string, error) {
key := name + " " + strings.Join(args, " ")
f.Calls = append(f.Calls, append([]string{name}, args...))
if f.Map != nil {
if res, ok := f.Map[key]; ok {
return res.Stdout, res.Stderr, res.Err
}
// prefix match for flexibility
for k, res := range f.Map {
if strings.HasPrefix(key, k) {
return res.Stdout, res.Stderr, res.Err
}
}
}
return "", "", fmt.Errorf("no fake result for %q", key)
}
func (f *FakeRunner) AssertNoForbidden(t interface{ Fatalf(string, ...any) }) {
for _, call := range f.Calls {
if len(call) == 0 {
continue
}
name := call[0]
base := name
if i := strings.LastIndex(name, "/"); i >= 0 {
base = name[i+1:]
}
if base == "sh" || base == "bash" || base == "zsh" {
t.Fatalf("forbidden shell observed: %v", call)
}
joined := strings.Join(call, " ")
if base == "git" {
for _, a := range call[1:] {
if strings.HasPrefix(a, "-") {
continue
}
switch a {
case "fetch", "push", "pull", "clone", "commit", "checkout", "merge", "rebase", "reset", "clean", "add", "tag":
t.Fatalf("forbidden git command observed: %s", joined)
}
break
}
}
}
}
@@ -0,0 +1,117 @@
package engineeringdashboard
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestRuntime_ReadyOKAndEventsRestarting(t *testing.T) {
now := time.Now().UTC()
dockerLines := []map[string]any{
{"Names": "shuimu-agent-main", "Image": "agent:main", "Status": "Up (healthy)", "Labels": "com.docker.compose.project=agent-main,com.docker.compose.service=app"},
{"Names": "shuimu-agent-events-main", "Image": "events:main", "Status": "Restarting (1) 2 seconds ago", "Labels": "com.docker.compose.project=agent-main,com.docker.compose.service=events"},
}
var b strings.Builder
for _, row := range dockerLines {
raw, _ := json.Marshal(row)
b.Write(raw)
b.WriteByte('\n')
}
fake := &FakeRunner{Map: map[string]FakeResult{
"docker ps -a --format {{json .}}": {Stdout: b.String()},
}}
// Override ready endpoints via temporary server on allowlisted pattern — use real client against loopback.
// CollectRuntime uses fixed ports; for unit test we only validate docker facet path by calling parse helpers.
cv, env, ok := parseDockerLine(string(mustJSON(dockerLines[1])))
if !ok || env != "main" || cv.Status == "" {
t.Fatalf("parse failed: %+v %s", cv, env)
}
envState := EnvironmentState{
Name: "main",
Containers: []ContainerView{
mustParseCV(dockerLines[0]),
mustParseCV(dockerLines[1]),
},
Readiness: "ok",
}
envState.Facets.ContainerStatus = summarizeContainers(envState.Containers)
envState.Facets.AppReadiness = "ok"
envState.Facets.ServicesOK = summarizeServices(envState.Containers, "ok")
if envState.Facets.ContainerStatus != "partial" || envState.Facets.ServicesOK != "partial" {
t.Fatalf("expected partial facets: %+v", envState.Facets)
}
_ = fake
_ = now
}
func TestRuntime_RedirectOffAllowlistDenied(t *testing.T) {
if err := assertLoopbackReadyURL("http://example.com/ready"); err == nil {
t.Fatal("expected deny")
}
if err := assertLoopbackReadyURL("http://127.0.0.1:9999/ready"); err == nil {
t.Fatal("expected port deny")
}
if err := assertLoopbackReadyURL("http://127.0.0.1:8080/ready"); err != nil {
t.Fatal(err)
}
}
func TestRuntime_DockerUnavailableStillReturnsEnvs(t *testing.T) {
fake := &FakeRunner{Map: map[string]FakeResult{
"docker ps -a --format {{json .}}": {Err: errorsNew("docker missing")},
}}
// Avoid hitting real ready ports: still OK if degraded.
res := CollectRuntime(context.Background(), t.TempDir(), fake, time.Now().UTC())
if len(res.Environments) != 4 {
t.Fatalf("expected 4 envs: %+v", res.Environments)
}
if res.Source.Status != SourceDegraded && res.Source.Status != SourceOK {
t.Fatalf("unexpected source: %+v", res.Source)
}
}
func TestRuntime_NoEnvInOutput(t *testing.T) {
line := `{"Names":"x","Image":"i","Status":"Up","Labels":"com.docker.compose.project=agent-dev,com.docker.compose.service=app","Env":["SECRET=1"]}`
cv, _, ok := parseDockerLine(line)
if !ok {
t.Fatal("parse failed")
}
raw, _ := json.Marshal(cv)
if strings.Contains(string(raw), "SECRET") || strings.Contains(string(raw), "Env") {
t.Fatalf("env leaked: %s", raw)
}
}
func TestReadyHTTP_UsesLoopbackServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"schema_version":48}`))
}))
defer srv.Close()
// server URL is 127.0.0.1:random — not allowlisted port, assertLoopbackReadyURL should fail
if err := assertLoopbackReadyURL(srv.URL + "/ready"); err == nil {
t.Fatal("random port must be denied by allowlist")
}
}
func mustJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
}
func mustParseCV(row map[string]any) ContainerView {
cv, _, _ := parseDockerLine(string(mustJSON(row)))
return cv
}
func errorsNew(s string) error { return &simpleErr{s} }
type simpleErr struct{ s string }
func (e *simpleErr) Error() string { return e.s }
+218
View File
@@ -0,0 +1,218 @@
package engineeringdashboard
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Server is the local read-only HTTP surface.
type Server struct {
Collector *Collector
Token string
mu sync.Mutex
}
// NewServer creates a server with a random startup token.
func NewServer(c *Collector) (*Server, error) {
tok, err := randomToken(16)
if err != nil {
return nil, err
}
return &Server{Collector: c, Token: tok}, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// Handler returns the HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/api/v1/snapshot", s.handleSnapshot)
mux.HandleFunc("/api/v1/doc/", s.handleDoc)
mux.HandleFunc("/", s.handleStatic)
return s.secure(mux)
}
func (s *Server) secure(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "..") || strings.Contains(r.URL.RawPath, "..") || strings.Contains(r.URL.RawQuery, "..") {
http.Error(w, "path traversal denied", http.StatusForbidden)
return
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if host != "127.0.0.1" && host != "localhost" && host != "::1" {
http.Error(w, "host not allowed", http.StatusForbidden)
return
}
origin := r.Header.Get("Origin")
if origin != "" && !isLocalOrigin(origin) {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
site := r.Header.Get("Sec-Fetch-Site")
if site == "cross-site" {
http.Error(w, "cross-site fetch denied", http.StatusForbidden)
return
}
// token required for API and page (query or header)
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/assets") {
tok := r.URL.Query().Get("token")
if tok == "" {
tok = r.Header.Get("X-Dashboard-Token")
}
if tok != s.Token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; img-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func isLocalOrigin(origin string) bool {
return strings.HasPrefix(origin, "http://127.0.0.1") ||
strings.HasPrefix(origin, "http://localhost") ||
strings.HasPrefix(origin, "http://[::1]")
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
snap := s.Collector.Current()
if snap == nil || r.URL.Query().Get("refresh") == "1" {
var err error
snap, err = s.Collector.Refresh(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
} else {
// soft refresh if TTL expired
if time.Since(snap.GeneratedAt) > s.Collector.TTL {
go func() { _, _ = s.Collector.Refresh(ctx) }()
}
}
etag := `"` + snap.RevisionID + `"`
w.Header().Set("ETag", etag)
if match := r.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
raw, err := MarshalSnapshotJSON(*snap)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(raw)
}
func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/v1/doc/")
id = strings.TrimPrefix(id, "/")
if id == "" {
http.Error(w, "missing doc id", http.StatusBadRequest)
return
}
if _, err := SafeDocPath(s.Collector.RepoRoot, id); err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
snap := s.Collector.Current()
if snap == nil {
var err error
snap, err = s.Collector.Refresh(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
}
for _, d := range snap.Documents {
if d.ID == id || d.Path == id {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(d)
return
}
}
http.Error(w, "not found", http.StatusNotFound)
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch path {
case "/", "/index.html":
b, err := readAsset("assets/index.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
html := strings.ReplaceAll(string(b), "__TOKEN__", s.Token)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
case "/assets/app.js":
b, err := readAsset("assets/app.js")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write(b)
case "/assets/app.css":
b, err := readAsset("assets/app.css")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write(b)
default:
http.NotFound(w, r)
}
}
// ListenAndServe binds loopback-only.
func ListenAndServe(addr string, h http.Handler) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return fmt.Errorf("refusing non-loopback bind address: %s", addr)
}
ln, err := net.Listen("tcp", net.JoinHostPort(host, port))
if err != nil {
return err
}
return http.Serve(ln, h)
}
@@ -0,0 +1,130 @@
package engineeringdashboard
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestServer_RejectsNonGETAndBadHost(t *testing.T) {
c := NewCollector(t.TempDir(), &FakeRunner{Map: map[string]FakeResult{}})
c.mu.Lock()
c.current = &Snapshot{
SchemaVersion: SchemaVersion, GenerationID: "gen_1", RevisionID: "rev_1",
GeneratedAt: time.Now().UTC(), Repository: RepositoryIdentity{Root: "/r"},
}
c.mu.Unlock()
srv, err := NewServer(c)
if err != nil {
t.Fatal(err)
}
h := srv.Handler()
req := httptest.NewRequest(http.MethodPost, "/api/v1/snapshot?token="+srv.Token, nil)
req.Host = "127.0.0.1:8787"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST: %d", rr.Code)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
req.Host = "evil.example"
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("bad host: %d", rr.Code)
}
}
func TestServer_TokenAndSnapshotIdentity(t *testing.T) {
c := NewCollector(t.TempDir(), &FakeRunner{Map: map[string]FakeResult{}})
snap := Snapshot{
SchemaVersion: SchemaVersion, GenerationID: "gen_9", RevisionID: "rev_abc",
GeneratedAt: time.Now().UTC(), Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "h"},
Tasks: []TaskState{SampleRDevTaskFixture()},
}
c.mu.Lock()
c.current = &snap
c.mu.Unlock()
srv, err := NewServer(c)
if err != nil {
t.Fatal(err)
}
h := srv.Handler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot", nil)
req.Host = "127.0.0.1:8787"
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("no token: %d", rr.Code)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
req.Host = "127.0.0.1:8787"
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("snapshot: %d %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `"generation_id": "gen_9"`) || !strings.Contains(body, `"revision_id": "rev_abc"`) {
t.Fatalf("identity missing: %s", body)
}
if rr.Header().Get("ETag") != `"rev_abc"` {
t.Fatalf("etag %q", rr.Header().Get("ETag"))
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
req.Host = "127.0.0.1:8787"
req.Header.Set("If-None-Match", `"rev_abc"`)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusNotModified {
t.Fatalf("etag: %d", rr.Code)
}
}
func TestServer_DocPathTraversalDenied(t *testing.T) {
root := t.TempDir()
writeDoc(t, root, ".doc/ops/ok.md", "# ok\n")
c := NewCollector(root, &FakeRunner{Map: map[string]FakeResult{}})
srv, err := NewServer(c)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/doc/not-a-real.md?token="+srv.Token, nil)
req.Host = "127.0.0.1:8787"
req.URL.Path = "/api/v1/doc/../../.env"
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("got %d body=%s", rr.Code, rr.Body.String())
}
}
func TestListenAndServe_RejectsNonLoopback(t *testing.T) {
err := ListenAndServe("0.0.0.0:8787", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
if err == nil || !strings.Contains(err.Error(), "loopback") {
t.Fatalf("expected loopback reject, got %v", err)
}
}
func TestServer_CrossOriginDenied(t *testing.T) {
c := NewCollector(t.TempDir(), &FakeRunner{})
c.mu.Lock()
c.current = &Snapshot{RevisionID: "r", GenerationID: "g", GeneratedAt: time.Now().UTC()}
c.mu.Unlock()
srv, _ := NewServer(c)
req := httptest.NewRequest(http.MethodGet, "/api/v1/snapshot?token="+srv.Token, nil)
req.Host = "127.0.0.1:8787"
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("got %d", rr.Code)
}
}
@@ -0,0 +1,367 @@
package engineeringdashboard
import (
"bufio"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
maxDocBodyBytes = 256 * 1024
maxTotalBodyBytes = 8 * 1024 * 1024
defaultDocsSource = "docs"
)
var mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`)
// DocsCollectResult is the docs collector output.
type DocsCollectResult struct {
Source SourceState
Documents []DocumentNode
Facts []Fact
Modules []ModuleView
Tasks []TaskState
Warnings []string
}
// CollectDocs indexes allowlisted markdown under repoRoot.
func CollectDocs(repoRoot string, now time.Time) DocsCollectResult {
start := time.Now()
res := DocsCollectResult{}
totalBody := 0
var docs []DocumentNode
byID := map[string]*DocumentNode{}
err := filepath.WalkDir(filepath.Join(repoRoot, ".doc"), func(path string, d os.DirEntry, err error) error {
if err != nil {
res.Warnings = append(res.Warnings, err.Error())
return nil
}
rel, err := filepath.Rel(repoRoot, path)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if d.IsDir() {
if d.Name() == "assets" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(strings.ToLower(rel), ".md") {
return nil
}
if IsAssetsPath(rel) {
return nil
}
abs, err := SafeDocPath(repoRoot, rel)
if err != nil {
res.Warnings = append(res.Warnings, err.Error())
return nil
}
raw, err := os.ReadFile(abs)
if err != nil {
res.Warnings = append(res.Warnings, err.Error())
return nil
}
node := parseMarkdownDoc(rel, string(raw), &totalBody)
docs = append(docs, node)
byID[node.ID] = &docs[len(docs)-1]
return nil
})
if err != nil {
res.Source = SourceState{
Name: defaultDocsSource, Status: SourceError, Freshness: FreshnessError,
ObservedAt: now, Error: err.Error(), DurationMS: time.Since(start).Milliseconds(),
}
return res
}
// reverse links + broken
idSet := map[string]bool{}
for i := range docs {
idSet[docs[i].ID] = true
}
for i := range docs {
var broken []string
for _, out := range docs[i].LinksOut {
target := normalizeLinkTarget(docs[i].Path, out)
if target == "" {
continue
}
if !idSet[target] {
broken = append(broken, out)
continue
}
if t := byID[target]; t != nil {
t.LinksIn = append(t.LinksIn, docs[i].ID)
}
}
docs[i].BrokenLinks = broken
}
for i := range docs {
if len(docs[i].LinksIn) == 0 && docs[i].Taxonomy != "ops" {
// orphan heuristic: no inbound and not CURRENT-like
if !strings.Contains(docs[i].Path, "CURRENT.md") && !strings.Contains(docs[i].Path, "README.md") {
docs[i].Orphan = len(docs[i].LinksOut) == 0
}
}
}
res.Documents = docs
res.Modules = parseFeatureMatrixModules(docs)
res.Tasks = parseTaskDocs(docs, now)
res.Facts = append(res.Facts, factsFromTasks(res.Tasks, now)...)
res.Source = SourceState{
Name: defaultDocsSource, Status: SourceOK, Freshness: FreshnessFresh,
ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(),
}
return res
}
func parseMarkdownDoc(rel, raw string, totalBody *int) DocumentNode {
node := DocumentNode{
ID: filepath.ToSlash(rel),
Path: filepath.ToSlash(rel),
}
body := raw
fm, rest, warn := splitFrontMatter(raw)
if warn != "" {
node.ParseWarnings = append(node.ParseWarnings, warn)
}
if fm != nil {
node.FrontMatter = fm
if s := fm["status"]; s != "" {
node.Status = s
}
if t := fm["title"]; t != "" {
node.Title = t
}
body = rest
}
if node.Title == "" {
node.Title = firstHeading(body)
}
if node.Title == "" {
node.Title = filepath.Base(rel)
}
node.Taxonomy = taxonomyOf(rel)
for _, m := range mdLinkRe.FindAllStringSubmatch(body, -1) {
href := strings.TrimSpace(m[2])
if href == "" || strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "mailto:") {
continue
}
if strings.HasPrefix(href, "#") {
continue
}
node.LinksOut = append(node.LinksOut, href)
}
remain := maxTotalBodyBytes - *totalBody
if remain < 0 {
remain = 0
}
limit := maxDocBodyBytes
if remain < limit {
limit = remain
}
if len(body) > limit {
node.Body = body[:limit]
node.BodyTruncated = true
} else {
node.Body = body
}
*totalBody += len(node.Body)
return node
}
func splitFrontMatter(raw string) (map[string]string, string, string) {
if !strings.HasPrefix(raw, "---\n") && !strings.HasPrefix(raw, "---\r\n") {
return nil, raw, ""
}
rest := raw[3:]
if strings.HasPrefix(rest, "\r\n") {
rest = rest[2:]
} else if strings.HasPrefix(rest, "\n") {
rest = rest[1:]
}
end := strings.Index(rest, "\n---")
if end < 0 {
return nil, raw, "front matter not closed"
}
block := rest[:end]
body := rest[end+4:]
body = strings.TrimPrefix(body, "\r")
body = strings.TrimPrefix(body, "\n")
var y map[string]any
if err := yaml.Unmarshal([]byte(block), &y); err != nil {
return nil, raw, "front matter yaml: " + err.Error()
}
out := map[string]string{}
for k, v := range y {
out[k] = stringifyYAML(v)
}
return out, body, ""
}
func stringifyYAML(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
if t {
return "true"
}
return "false"
default:
b, _ := yaml.Marshal(v)
return strings.TrimSpace(string(b))
}
}
func firstHeading(body string) string {
sc := bufio.NewScanner(strings.NewReader(body))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
}
return ""
}
func taxonomyOf(rel string) string {
rel = filepath.ToSlash(rel)
parts := strings.Split(rel, "/")
if len(parts) >= 2 && parts[0] == ".doc" {
return parts[1]
}
return ""
}
func normalizeLinkTarget(fromPath, href string) string {
href = strings.Split(href, "#")[0]
href = strings.TrimSpace(href)
if href == "" {
return ""
}
dir := filepath.ToSlash(filepath.Dir(fromPath))
joined := filepath.ToSlash(filepath.Clean(filepath.Join(dir, href)))
joined = strings.TrimPrefix(joined, "/")
if !strings.HasSuffix(strings.ToLower(joined), ".md") {
return ""
}
return joined
}
func parseFeatureMatrixModules(docs []DocumentNode) []ModuleView {
var out []ModuleView
for _, d := range docs {
if !strings.HasSuffix(d.Path, "FEATURE-MATRIX.md") {
continue
}
sc := bufio.NewScanner(strings.NewReader(d.Body))
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "|") || strings.Contains(line, "---") {
continue
}
cols := splitTableRow(line)
if len(cols) < 7 {
continue
}
name := strings.TrimSpace(cols[0])
if name == "" || name == "模块" || strings.HasPrefix(name, "**") {
continue
}
out = append(out, ModuleView{
Name: name,
Status: strings.TrimSpace(cols[2]),
Mutex: strings.Trim(strings.TrimSpace(cols[6]), "`"),
Evidence: []string{d.Path},
})
}
}
return out
}
func splitTableRow(line string) []string {
line = strings.Trim(line, "|")
parts := strings.Split(line, "|")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
func parseTaskDocs(docs []DocumentNode, now time.Time) []TaskState {
var out []TaskState
for _, d := range docs {
if !strings.Contains(d.Path, "/short-term/") && !strings.Contains(d.Path, "/long-term/") && !strings.Contains(d.Path, "/backlog/") {
continue
}
st := DocStatusUnknown
if d.Status != "" {
st = DocStatus(d.Status)
}
claimed := false
owner := d.FrontMatter["owner"]
branch := d.FrontMatter["branch"]
wt := d.FrontMatter["worktree"]
mutex := d.FrontMatter["mutex"]
if owner != "" && owner != "unassigned" {
claimed = true
}
kind := AgentKindUnknown
if strings.Contains(strings.ToLower(owner), "herdr") {
kind = AgentKindHerdrEngineering
} else if owner != "" && owner != "unassigned" {
kind = AgentKindHuman
}
task := TaskState{
ID: "task:" + d.ID,
Title: d.Title,
Path: d.Path,
DocStatus: st,
Claim: AgentClaim{
Owner: owner, AgentKind: kind, Claimed: claimed,
Mutex: mutex, Branch: branch, Worktree: wt,
Activity: "unknown",
Evidence: "declared in task front matter / CURRENT; activity unknown without runtime source",
},
Git: GitFacets{Branch: branch, Worktree: wt, Cleanliness: "unknown", RemoteNote: "git facets filled by reconcile"},
Verification: VerificationFacets{Result: "unknown", Freshness: FreshnessUnknown},
Deployment: map[string]DeploymentFacets{
"dev": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown"},
"test": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown"},
"main": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown"},
},
EvidenceChain: append([]string{d.Path}, d.LinksOut...),
}
_ = now
out = append(out, task)
}
return out
}
func factsFromTasks(tasks []TaskState, now time.Time) []Fact {
var facts []Fact
for _, t := range tasks {
id := FactID(t.ID, "doc_status", "docs")
facts = append(facts, Fact{
ID: id, Subject: t.ID, Field: "doc_status", Value: string(t.DocStatus),
Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: now,
})
if t.Claim.Claimed {
facts = append(facts, Fact{
ID: FactID(t.ID, "claim", "docs"), Subject: t.ID, Field: "claim",
Value: t.Claim.Owner, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: now,
})
}
}
return facts
}
+160
View File
@@ -0,0 +1,160 @@
package engineeringdashboard
import (
"context"
"fmt"
"strconv"
"strings"
"time"
)
// GitCollectResult is the git collector output.
type GitCollectResult struct {
Source SourceState
Repository RepositoryIdentity
Branches []GitBranchView
Facts []Fact
Warnings []string
}
// CollectGit gathers read-only git observations via runner.
func CollectGit(ctx context.Context, repoRoot string, runner CommandRunner, now time.Time) GitCollectResult {
start := time.Now()
res := GitCollectResult{
Repository: RepositoryIdentity{Root: repoRoot},
}
if runner == nil {
runner = ExecRunner{}
}
branch, err := gitOut(ctx, runner, repoRoot, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return gitFail(res, start, now, err)
}
head, err := gitOut(ctx, runner, repoRoot, "rev-parse", "HEAD")
if err != nil {
return gitFail(res, start, now, err)
}
res.Repository.Branch = strings.TrimSpace(branch)
res.Repository.Head = strings.TrimSpace(head)
status, err := gitOut(ctx, runner, repoRoot, "status", "--porcelain=v2", "--branch")
if err != nil {
return gitFail(res, start, now, err)
}
dirty := false
for _, line := range strings.Split(status, "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
dirty = true
break
}
res.Repository.Dirty = dirty
refs, err := gitOut(ctx, runner, repoRoot, "for-each-ref", "--format=%(refname:short)|%(objectname:short)|%(upstream:short)", "refs/heads")
if err != nil {
res.Warnings = append(res.Warnings, err.Error())
}
worktrees, _ := gitOut(ctx, runner, repoRoot, "worktree", "list", "--porcelain")
wtByBranch := parseWorktrees(worktrees)
remoteNote := "based on local refs; not network-refreshed"
for _, line := range strings.Split(refs, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "|")
name := parts[0]
sha := ""
up := ""
if len(parts) > 1 {
sha = parts[1]
}
if len(parts) > 2 {
up = parts[2]
}
ahead, behind := 0, 0
if up != "" {
ab, err := gitOut(ctx, runner, repoRoot, "rev-list", "--left-right", "--count", up+"..."+name)
if err == nil {
fields := strings.Fields(strings.TrimSpace(ab))
if len(fields) == 2 {
behind, _ = strconv.Atoi(fields[0])
ahead, _ = strconv.Atoi(fields[1])
}
}
}
view := GitBranchView{
Name: name,
IsCurrent: name == res.Repository.Branch,
Head: sha,
Upstream: up,
Ahead: ahead,
Behind: behind,
Worktree: wtByBranch[name],
Dirty: name == res.Repository.Branch && dirty,
RemoteNote: remoteNote,
ObservedAt: now,
}
res.Branches = append(res.Branches, view)
res.Facts = append(res.Facts, Fact{
ID: FactID("git:"+name, "ahead_behind", "git"), Subject: "git:" + name, Field: "ahead_behind",
Value: fmt.Sprintf("ahead=%d behind=%d", ahead, behind),
Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: now,
})
}
res.Source = SourceState{
Name: "git", Status: SourceOK, Freshness: FreshnessFresh,
ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(),
}
return res
}
func gitOut(ctx context.Context, runner CommandRunner, dir string, args ...string) (string, error) {
stdout, stderr, err := runner.Run(ctx, dir, "git", args...)
if err != nil {
msg := strings.TrimSpace(stderr)
if msg == "" {
msg = err.Error()
}
return stdout, fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
}
return stdout, nil
}
func gitFail(res GitCollectResult, start time.Time, now time.Time, err error) GitCollectResult {
res.Source = SourceState{
Name: "git", Status: SourceError, Freshness: FreshnessError,
ObservedAt: now, Error: err.Error(), DurationMS: time.Since(start).Milliseconds(),
}
return res
}
func parseWorktrees(porcelain string) map[string]string {
out := map[string]string{}
var path, branch string
flush := func() {
if path != "" && branch != "" {
out[branch] = path
}
path, branch = "", ""
}
for _, line := range strings.Split(porcelain, "\n") {
if line == "" {
flush()
continue
}
if strings.HasPrefix(line, "worktree ") {
path = strings.TrimPrefix(line, "worktree ")
}
if strings.HasPrefix(line, "branch ") {
branch = strings.TrimPrefix(line, "branch refs/heads/")
}
}
flush()
return out
}
@@ -0,0 +1,351 @@
package engineeringdashboard
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// ReadyEndpoint maps an environment name to a loopback readiness URL.
type ReadyEndpoint struct {
Env string
URL string
}
var defaultReadyEndpoints = []ReadyEndpoint{
{Env: "dev", URL: "http://127.0.0.1:8080/ready"},
{Env: "test", URL: "http://127.0.0.1:8081/ready"},
{Env: "main", URL: "http://127.0.0.1:8000/ready"},
}
var allowedComposeProjects = map[string]string{
"agent-dev": "dev",
"agent-test": "test",
"agent-main": "main",
"agent-infra": "shared",
}
// RuntimeCollectResult is the runtime collector output.
type RuntimeCollectResult struct {
Source SourceState
Environments []EnvironmentState
Facts []Fact
Warnings []string
}
// CollectRuntime gathers docker + readiness + receipt observations.
func CollectRuntime(ctx context.Context, repoRoot string, runner CommandRunner, now time.Time) RuntimeCollectResult {
start := time.Now()
res := RuntimeCollectResult{}
if runner == nil {
runner = ExecRunner{}
}
envs := map[string]*EnvironmentState{
"shared": {Name: "shared"},
"dev": {Name: "dev"},
"test": {Name: "test"},
"main": {Name: "main"},
}
psOut, stderr, err := runner.Run(ctx, repoRoot, "docker", "ps", "-a", "--format", "{{json .}}")
if err != nil {
res.Warnings = append(res.Warnings, "docker unavailable: "+strings.TrimSpace(stderr+" "+err.Error()))
for _, e := range envs {
e.Facets = DeploymentFacets{
ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown",
IdentityNote: "docker unavailable",
}
}
} else {
for _, line := range strings.Split(psOut, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
cv, envName, ok := parseDockerLine(line)
if !ok {
continue
}
e := envs[envName]
if e == nil {
continue
}
e.Containers = append(e.Containers, cv)
}
}
client := &http.Client{
Timeout: 2 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if err := assertLoopbackReadyURL(req.URL.String()); err != nil {
return err
}
if len(via) >= 3 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
for _, ep := range defaultReadyEndpoints {
if err := assertLoopbackReadyURL(ep.URL); err != nil {
res.Warnings = append(res.Warnings, err.Error())
continue
}
status, body, err := getReady(ctx, client, ep.URL)
e := envs[ep.Env]
if e == nil {
continue
}
if err != nil {
e.Readiness = "error"
e.Facets.AppReadiness = "error"
res.Warnings = append(res.Warnings, ep.Env+" ready: "+err.Error())
continue
}
if status >= 200 && status < 300 {
e.Readiness = "ok"
e.Facets.AppReadiness = "ok"
} else {
e.Readiness = fmt.Sprintf("http_%d", status)
e.Facets.AppReadiness = e.Readiness
}
if strings.Contains(body, "schema_version") {
e.Schema = extractJSONField(body, "schema_version")
}
res.Facts = append(res.Facts, Fact{
ID: FactID("env:"+ep.Env, "readiness", "ready"), Subject: "env:" + ep.Env, Field: "readiness",
Value: e.Readiness, Provenance: ProvenanceObserved, SourceRef: ep.URL, ObservedAt: now,
})
}
for name, e := range envs {
e.Facets.ContainerStatus = summarizeContainers(e.Containers)
e.Facets.ServicesOK = summarizeServices(e.Containers, e.Facets.AppReadiness)
if e.Facets.AppReadiness == "" {
e.Facets.AppReadiness = "unknown"
}
receipt := findLatestReceipt(repoRoot)
if receipt != "" {
e.Receipt = receipt
e.Facets.ArtifactID = filepath.Base(receipt)
}
if e.Facets.ContainerStatus == "partial" && e.Facets.AppReadiness == "ok" {
e.DriftNotes = append(e.DriftNotes, "app readiness ok coexists with partial container health — no aggregate green")
e.Facets.IdentityNote = "running visible; service completeness partial"
}
if e.Facets.ContainerStatus != "unknown" && e.Facets.ArtifactID == "" {
e.Facets.IdentityNote = "running visible, version unverified"
}
res.Facts = append(res.Facts, Fact{
ID: FactID("env:"+name, "container_status", "docker"), Subject: "env:" + name, Field: "container_status",
Value: e.Facets.ContainerStatus, Provenance: ProvenanceObserved, SourceRef: "docker", ObservedAt: now,
})
}
for _, name := range []string{"shared", "dev", "test", "main"} {
res.Environments = append(res.Environments, *envs[name])
}
status := SourceOK
fresh := FreshnessFresh
if len(res.Warnings) > 0 {
status = SourceDegraded
}
res.Source = SourceState{
Name: "runtime", Status: status, Freshness: fresh,
ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(),
}
return res
}
func parseDockerLine(line string) (ContainerView, string, bool) {
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
return ContainerView{}, "", false
}
// docker --format {{json .}} fields vary; support both Names and names
name := strField(raw, "Names", "names", "Name")
image := strField(raw, "Image", "image")
status := strField(raw, "Status", "status", "State")
labels := strField(raw, "Labels", "labels")
project, service := parseComposeLabels(labels)
envName := allowedComposeProjects[project]
if envName == "" {
// try infer from name
lname := strings.ToLower(name)
switch {
case strings.Contains(lname, "-dev"), strings.Contains(lname, "_dev"):
envName = "dev"
case strings.Contains(lname, "-test"), strings.Contains(lname, "_test"):
envName = "test"
case strings.Contains(lname, "-main"), strings.Contains(lname, "_main"):
envName = "main"
default:
return ContainerView{}, "", false
}
}
health := ""
lowerStatus := strings.ToLower(status)
switch {
case strings.Contains(lowerStatus, "unhealthy"):
health = "unhealthy"
case strings.Contains(lowerStatus, "healthy"):
health = "healthy"
case strings.Contains(lowerStatus, "restarting"):
health = "unhealthy"
}
restarts := 0
if strings.Contains(lowerStatus, "restarting") {
restarts = 1
}
cv := ContainerView{
Name: name, Image: image, Status: status, Health: health,
RestartCount: restarts, Project: project, Service: service,
}
return cv, envName, true
}
func strField(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
switch t := v.(type) {
case string:
return t
default:
return fmt.Sprint(t)
}
}
}
return ""
}
func parseComposeLabels(labels string) (project, service string) {
for _, part := range strings.Split(labels, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "com.docker.compose.project":
project = kv[1]
case "com.docker.compose.service":
service = kv[1]
}
}
return project, service
}
func summarizeContainers(cs []ContainerView) string {
if len(cs) == 0 {
return "unknown"
}
bad := 0
for _, c := range cs {
ls := strings.ToLower(c.Status + " " + c.Health)
if strings.Contains(ls, "restart") || strings.Contains(ls, "unhealthy") || strings.Contains(ls, "exited") {
bad++
}
}
if bad == 0 {
return "ok"
}
if bad < len(cs) {
return "partial"
}
return "unhealthy"
}
func summarizeServices(cs []ContainerView, readiness string) string {
cstat := summarizeContainers(cs)
if cstat == "ok" && readiness == "ok" {
return "complete"
}
if cstat == "unknown" && (readiness == "" || readiness == "unknown") {
return "unknown"
}
if cstat == "partial" || readiness == "ok" {
return "partial"
}
return "partial"
}
func assertLoopbackReadyURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("ready scheme denied: %s", u.Scheme)
}
host := u.Hostname()
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return fmt.Errorf("ready host must be loopback: %s", host)
}
port := u.Port()
switch port {
case "8080", "8081", "8000", "":
// ok (empty only if default — still require explicit in our list)
default:
return fmt.Errorf("ready port not allowlisted: %s", port)
}
return nil
}
func getReady(ctx context.Context, client *http.Client, rawURL string) (int, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return 0, "", err
}
resp, err := client.Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return resp.StatusCode, string(body), nil
}
func extractJSONField(body, key string) string {
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
return ""
}
if v, ok := m[key]; ok {
return fmt.Sprint(v)
}
return ""
}
func findLatestReceipt(repoRoot string) string {
dir := filepath.Join(repoRoot, ".doc", "test-reports", "assets", "promotion-receipts")
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
var latest string
var latestMod time.Time
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().After(latestMod) {
latestMod = info.ModTime()
latest = filepath.ToSlash(filepath.Join(".doc/test-reports/assets/promotion-receipts", e.Name()))
}
}
return latest
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Thin launcher for the local read-only engineering dashboard.
# No install, deploy, git, or docker side effects.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
exec go run ./cmd/engineering-dashboard "$@"