refactor(control-center): remove legacy engineering-dashboard, superseded by control-center
- delete cmd/engineering-dashboard, internal/engineeringdashboard, scripts/engineering-dashboard.sh - archive old design doc to .doc/archive/design/ with deprecation banner - FEATURE-MATRIX: mark old row removed, add removed to status contract - system-design README: section 8 now points to Control Center SSOT
This commit is contained in:
+2
@@ -7,6 +7,8 @@ product_contract_source: ce-plan-bootstrap
|
||||
execution: code
|
||||
---
|
||||
|
||||
> **已废弃(2026-09-17)**:旧版只读工程中控台(`cmd/engineering-dashboard` / `internal/engineeringdashboard`)代码已移除,由独立的可写中控台 Control Center 承接(见 `02-control-center-design.md`)。本文仅作历史证据保留。
|
||||
|
||||
# 个人工程中控台:实时项目看板与离线 HTML 快照
|
||||
|
||||
## Goal Capsule
|
||||
@@ -27,7 +27,7 @@ mutex: controlcenter
|
||||
|
||||
- 不接产品 sysadmin、不动 internal/server 路由
|
||||
- 不做多仓库、不做远端 CI 集成
|
||||
- 不删旧 `internal/engineeringdashboard`(共存,对齐后另行弃用)
|
||||
- 旧 `internal/engineeringdashboard`:用户已授权移除(2026-09-17 执行:代码删除,设计文档归档 `.doc/archive/design/`,验收报告保留为历史证据)
|
||||
- 不做 agent 自动推进阶段(任何推进必须人工批准)
|
||||
|
||||
## 步骤
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| **声明范围** | 本行冻结的能力边界;`done` 只对该边界负责 |
|
||||
| **状态** | `done` \| `partial` \| `planned` \| `deferred` \| `blocked` |
|
||||
| **状态** | `done` \| `partial` \| `planned` \| `deferred` \| `blocked` \| `removed`(已下线,仅留历史证据) |
|
||||
| **发布关系** | 与当前首发/已签门禁的关系:`non_blocker` / `补证` / `加固` / `deferred_enhancement` |
|
||||
| **关闭条件** | 何时可将本行升为 `done`(或关闭 `partial`);stub/SKIP/fallback 不得冒充 |
|
||||
| **证据** | 可追溯到报告/commit/环境/schema;代码或 artifact 变化后须重验 |
|
||||
@@ -68,7 +68,7 @@
|
||||
| 可靠事件核心 | PG dispatcher、notify/advance/projection live、生产闭包 hook 退出、Phase2 选择性解耦已合入 | done | non_blocker | [E0A–E2](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md) 等;[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[shared live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[phase2 closeout](../test-reports/2026-09-15-phase2-closeout-test-report.md) | **before**:与 FAV off 混成 partial → **after**:FAV 独立行。选择性后续解耦按收益触发 | `architecture-docs` / `events` |
|
||||
| Final Artifact Vault(FAV) | 不可变终态制品库;`FAV_DEGRADED_READ=false` | partial | deferred_enhancement | [Track E 复盘](../ops/research/2026-09-15-track-e-retrospective.md);[U7 报告](../test-reports/2026-09-16-fav-u7-incremental-test-report.md);[W6 shadow](../test-reports/2026-09-17-w6-fav-subscriber-shadow-test-report.md);[short-term](../ops/short-term/2026-09-17-w6-fav-subscriber-shadow.md) | **after W6**:subscriber `ObserveShadow` 只计算 intended outcome,不写副作用。仍**禁止**整支 merge;`FAV_DEGRADED_READ` 默认 false;**live 未放量**(staging→artifact→publication 各 ≥7 天观察窗) | `events` |
|
||||
| 系统管理台 | 全屏系统级管理视图(总览/KB/特性开关/系统设置/工作流/角色/用户/文档 8 分组);`/api/v1/admin/system/*`;`system_settings` 白名单可写;吸收下线 AdminSettingsModal | done | non_blocker | [short-term](../ops/short-term/2026-09-17-system-admin-console.md);[测试报告](../test-reports/2026-09-17-system-admin-console-test-report.md);commit `f1404d5`/`0eaca61`、merge `dc7c31e`(dev);dev 冒烟 schema 49 + 四端点 + 写读回环 PASS | 二期:Playwright 实跑、细粒度 `system:*` 权限、设置审计日志、KB 运维动作、白名单项接消费方 | `web` + `sysadmin` |
|
||||
| 个人工程中控台 | 独立本地只读 CLI/`serve`/`export`;四轴状态;`.doc`/Git/Docker/`/ready` 聚合;不接产品后台 | done | deferred_enhancement(工具,非产品门禁) | [设计](../system-design/project-control-center/01-personal-engineering-control-center-plan.md);[报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 一期已交付。跨快照历史、远端 CI、富渲染另立后续 | `engineering-dashboard` |
|
||||
| 个人工程中控台(旧,已移除) | 原独立本地只读 CLI/`serve`/`export`;四轴状态;`.doc`/Git/Docker/`/ready` 聚合 | removed | 已被 Control Center 取代 | [设计(已归档)](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);[报告](../test-reports/2026-09-17-engineering-dashboard-test-report.md) | 2026-09-17 代码移除(`cmd/engineering-dashboard`、`internal/engineeringdashboard`、`scripts/engineering-dashboard.sh`);能力由下一行 Control Center 承接 | `engineering-dashboard`(已删) |
|
||||
| 个人中控台 Control Center | 独立 Go 服务(loopback,独立 pgvector 库):8 阶段流水线 + agent 协作 API(禁自动推进)+ 决策上报 + 租约/SSE + `.doc` 向量检索 + 导入导出 + 每日审查 + React SPA | partial | deferred_enhancement(个人工具,非产品门禁) | [设计](../system-design/project-control-center/02-control-center-design.md);[agent 接入规范](../system-design/project-control-center/agent-integration.md);[任务](../ops/short-term/2026-09-17-control-center-server.md);[报告](../test-reports/2026-09-17-control-center-test-report.md);commit `2be43ef`→`07a8e12`(P0–P5) | 后端 P0–P5 + 构建/单测/curl 冒烟已签。**未签**:前端人工走查(SPA 实机操作)。关闭:走查 PASS 后升 done;多仓库/远端 CI 另立 | `controlcenter` |
|
||||
|
||||
---
|
||||
|
||||
@@ -138,15 +138,17 @@
|
||||
|
||||
---
|
||||
|
||||
### 8. 个人工程中控台
|
||||
### 8. 个人中控台(Control Center)
|
||||
|
||||
| 项 | 链接 |
|
||||
|-----|------|
|
||||
| 设计与实施方案 | [project-control-center/01-personal-engineering-control-center-plan.md](project-control-center/01-personal-engineering-control-center-plan.md) |
|
||||
| 任务入口 | [../ops/short-term/2026-09-17-engineering-dashboard.md](../ops/short-term/2026-09-17-engineering-dashboard.md) |
|
||||
| 一句话总结 | 独立于产品后台的本地只读中控台,以同一不可变快照驱动实时项目看板、Agent JSON 和离线 HTML |
|
||||
| 设计与架构 SSOT | [project-control-center/02-control-center-design.md](project-control-center/02-control-center-design.md) |
|
||||
| Agent 接入规范 | [project-control-center/agent-integration.md](project-control-center/agent-integration.md) |
|
||||
| 任务入口 | [../ops/short-term/2026-09-17-control-center-server.md](../ops/short-term/2026-09-17-control-center-server.md) |
|
||||
| 一句话总结 | 独立于产品系统的个人项目管理中控台:8 阶段流水线(agent 禁自动推进)+ 决策上报 + 向量文档库 + 每日审查 + React SPA |
|
||||
| 旧版(已移除) | 只读工程中控台设计已归档:[../archive/design/2026-09-17-personal-engineering-control-center-plan.md](../archive/design/2026-09-17-personal-engineering-control-center-plan.md);任务:[../archive/ops/short-term/2026-09-17-engineering-dashboard.md](../archive/ops/short-term/2026-09-17-engineering-dashboard.md) |
|
||||
|
||||
**核心边界**:计划、Git 实施、验证、环境实装四轴独立;冲突保留来源证据;不写文档、不操作 Git/Docker/部署/Herdr,不读取凭证。
|
||||
**核心边界**:个人工具、不接产品后台 sysadmin;agent 只能上报与 advance-request,阶段推进与模糊决策必须人工。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -275,7 +275,6 @@ imports:
|
||||
- controlcenter/tasks
|
||||
- controlcenter/webui
|
||||
controlcenter/webui: []
|
||||
engineeringdashboard: []
|
||||
|
||||
table_owners:
|
||||
document_records:
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"chat-agent-system/internal/engineeringdashboard"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
cmd := os.Args[1]
|
||||
args := os.Args[2:]
|
||||
switch cmd {
|
||||
case "serve":
|
||||
if err := runServe(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "export":
|
||||
if err := runExport(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "export: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd)
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `engineering-dashboard — local read-only personal engineering control center
|
||||
|
||||
Usage:
|
||||
engineering-dashboard serve [--repo PATH] [--addr 127.0.0.1:PORT]
|
||||
engineering-dashboard export [--repo PATH] [--output FILE]
|
||||
|
||||
Does not write docs, operate git/docker, deploy, or control Herdr.
|
||||
`)
|
||||
}
|
||||
|
||||
func parseFlags(args []string) (repo, addr, output string) {
|
||||
addr = "127.0.0.1:8787"
|
||||
output = "engineering-dashboard-snapshot.html"
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--repo":
|
||||
i++
|
||||
if i < len(args) {
|
||||
repo = args[i]
|
||||
}
|
||||
case "--addr":
|
||||
i++
|
||||
if i < len(args) {
|
||||
addr = args[i]
|
||||
}
|
||||
case "--output":
|
||||
i++
|
||||
if i < len(args) {
|
||||
output = args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return repo, addr, output
|
||||
}
|
||||
|
||||
func runServe(args []string) error {
|
||||
repo, addr, _ := parseFlags(args)
|
||||
root, err := engineeringdashboard.ResolveRepoRoot(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(addr, "127.0.0.1:") && !strings.HasPrefix(addr, "localhost:") && !strings.HasPrefix(addr, "[::1]:") {
|
||||
return fmt.Errorf("addr must be loopback (got %s)", addr)
|
||||
}
|
||||
c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{})
|
||||
ctx := context.Background()
|
||||
if _, err := c.Refresh(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: initial refresh: %v\n", err)
|
||||
}
|
||||
srv, err := engineeringdashboard.NewServer(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token)
|
||||
fmt.Printf("repo=%s\n", root)
|
||||
return engineeringdashboard.ListenAndServe(addr, srv.Handler())
|
||||
}
|
||||
|
||||
func runExport(args []string) error {
|
||||
repo, _, output := parseFlags(args)
|
||||
root, err := engineeringdashboard.ResolveRepoRoot(repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !filepath.IsAbs(output) {
|
||||
wd, _ := os.Getwd()
|
||||
output = filepath.Join(wd, output)
|
||||
}
|
||||
c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{})
|
||||
snap, err := c.Refresh(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return engineeringdashboard.ExportHTML(*snap, output)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--panel: #1a222c;
|
||||
--text: #e7ecf1;
|
||||
--muted: #9aa7b5;
|
||||
--accent: #3d8bfd;
|
||||
--warn: #d4a017;
|
||||
--bad: #d64545;
|
||||
--ok: #3d9a6a;
|
||||
--border: #2b3642;
|
||||
--font: "IBM Plex Sans", "Noto Sans SC", sans-serif;
|
||||
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
background: radial-gradient(1200px 600px at 10% -10%, #1b2735, var(--bg));
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.skip {
|
||||
position: absolute; left: -9999px;
|
||||
}
|
||||
.skip:focus { left: 1rem; top: 1rem; background: #fff; color: #000; padding: .5rem; z-index: 9; }
|
||||
.top {
|
||||
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem;
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
|
||||
background: rgba(26,34,44,.85); backdrop-filter: blur(6px);
|
||||
}
|
||||
.top h1 { margin: 0; font-size: 1.35rem; letter-spacing: .02em; }
|
||||
.meta { margin: .35rem 0 0; color: var(--muted); font-size: .85rem; font-family: var(--mono); }
|
||||
.banner { margin: 0; color: var(--warn); font-size: .85rem; max-width: 28rem; }
|
||||
.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: calc(100vh - 5rem); }
|
||||
.nav-toggle { display: none; margin: .75rem; }
|
||||
nav {
|
||||
border-right: 1px solid var(--border); padding: 1rem; background: var(--panel);
|
||||
}
|
||||
nav ul { list-style: none; margin: 0; padding: 0; }
|
||||
nav li { margin: 0 0 .35rem; }
|
||||
.nav-btn {
|
||||
width: 100%; text-align: left; background: transparent; color: var(--text);
|
||||
border: 1px solid transparent; padding: .5rem .65rem; border-radius: .35rem; cursor: pointer;
|
||||
}
|
||||
.nav-btn.active, .nav-btn:hover, .nav-btn:focus-visible {
|
||||
border-color: var(--accent); background: #243041;
|
||||
}
|
||||
.search-label { display: block; margin-top: 1rem; font-size: .8rem; color: var(--muted); }
|
||||
#q {
|
||||
width: 100%; margin-top: .35rem; padding: .45rem .55rem; border-radius: .35rem;
|
||||
border: 1px solid var(--border); background: #10161d; color: var(--text);
|
||||
}
|
||||
.source-strip { margin-top: 1rem; font-size: .75rem; color: var(--muted); }
|
||||
.source-strip .chip {
|
||||
display: inline-block; margin: .15rem .2rem 0 0; padding: .15rem .4rem;
|
||||
border: 1px solid var(--border); border-radius: .25rem;
|
||||
}
|
||||
.chip.ok { border-color: var(--ok); }
|
||||
.chip.stale, .chip.degraded { border-color: var(--warn); color: var(--warn); }
|
||||
.chip.error { border-color: var(--bad); color: var(--bad); }
|
||||
main { padding: 1rem 1.25rem 2rem; overflow: auto; }
|
||||
.view-status { min-height: 1.25rem; color: var(--muted); font-size: .85rem; margin-bottom: .75rem; }
|
||||
.panel {
|
||||
background: rgba(26,34,44,.7); border: 1px solid var(--border); border-radius: .5rem;
|
||||
padding: 1rem; margin-bottom: 1rem;
|
||||
}
|
||||
.panel h2 { margin: 0 0 .75rem; font-size: 1.05rem; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||
th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
.badge {
|
||||
display: inline-block; padding: .1rem .4rem; border-radius: .25rem;
|
||||
border: 1px solid var(--border); font-size: .75rem; margin-right: .25rem;
|
||||
}
|
||||
.badge.warn { border-color: var(--warn); color: var(--warn); }
|
||||
.badge.bad { border-color: var(--bad); color: var(--bad); }
|
||||
.pre {
|
||||
white-space: pre-wrap; word-break: break-word; font-family: var(--mono);
|
||||
font-size: .82rem; background: #0c1117; padding: .75rem; border-radius: .35rem;
|
||||
border: 1px solid var(--border); max-height: 28rem; overflow: auto;
|
||||
}
|
||||
.axes { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .5rem; }
|
||||
.axis { border: 1px solid var(--border); border-radius: .35rem; padding: .5rem; font-size: .8rem; }
|
||||
.axis strong { display: block; color: var(--muted); margin-bottom: .25rem; }
|
||||
a, button.linkish {
|
||||
color: var(--accent); background: none; border: none; cursor: pointer; padding: 0; font: inherit;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.nav-toggle { display: inline-block; }
|
||||
nav { display: none; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
nav.open { display: block; }
|
||||
.axes { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
(function () {
|
||||
function resolveToken() {
|
||||
const fromURL = new URL(window.location.href).searchParams.get("token");
|
||||
if (fromURL) return fromURL;
|
||||
const meta = document.querySelector('meta[name="engineering-dashboard-token"]');
|
||||
const fromMeta = meta && meta.getAttribute("content");
|
||||
if (fromMeta && fromMeta !== "__TOKEN__") return fromMeta;
|
||||
return "";
|
||||
}
|
||||
const token = resolveToken();
|
||||
const state = {
|
||||
snap: null,
|
||||
view: "overview",
|
||||
q: "",
|
||||
selectedDoc: "",
|
||||
scrollY: 0,
|
||||
};
|
||||
|
||||
const el = {
|
||||
content: document.getElementById("content"),
|
||||
meta: document.getElementById("repo-meta"),
|
||||
sources: document.getElementById("source-strip"),
|
||||
status: document.getElementById("view-status"),
|
||||
q: document.getElementById("q"),
|
||||
navToggle: document.getElementById("nav-toggle"),
|
||||
nav: document.getElementById("nav"),
|
||||
};
|
||||
|
||||
function qs() {
|
||||
const u = new URL(window.location.href);
|
||||
return u.searchParams;
|
||||
}
|
||||
|
||||
function syncURL() {
|
||||
const u = new URL(window.location.href);
|
||||
if (token) u.searchParams.set("token", token);
|
||||
u.searchParams.set("view", state.view);
|
||||
if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q");
|
||||
if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc");
|
||||
history.replaceState(null, "", u.toString());
|
||||
}
|
||||
|
||||
function loadURL() {
|
||||
const p = qs();
|
||||
state.view = p.get("view") || "overview";
|
||||
state.q = p.get("q") || "";
|
||||
state.selectedDoc = p.get("doc") || "";
|
||||
el.q.value = state.q;
|
||||
document.querySelectorAll(".nav-btn").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.view === state.view);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSnapshot(force) {
|
||||
if (!token) {
|
||||
el.status.textContent = "缺少访问令牌";
|
||||
renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面");
|
||||
return false;
|
||||
}
|
||||
el.status.textContent = state.snap ? "刷新中…" : "首次加载中…";
|
||||
const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : "");
|
||||
const headers = {};
|
||||
if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"';
|
||||
const res = await fetch(url, { headers, credentials: "same-origin" });
|
||||
if (res.status === 304) {
|
||||
el.status.textContent = "已是最新 revision " + state.snap.revision_id;
|
||||
return false;
|
||||
}
|
||||
if (!res.ok) {
|
||||
el.status.textContent = "加载失败 HTTP " + res.status;
|
||||
if (!state.snap) renderHardError("无法获取快照");
|
||||
return false;
|
||||
}
|
||||
state.snap = await res.json();
|
||||
el.status.textContent = "generation " + state.snap.generation_id + " · revision " + state.snap.revision_id;
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeText(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s == null ? "" : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function badge(text, cls) {
|
||||
return '<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();
|
||||
if (!token) {
|
||||
renderHardError("缺少 token:请使用服务启动时打印的完整 URL(含 ?token=)打开页面");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const changed = await fetchSnapshot(true);
|
||||
if (changed || state.snap) render();
|
||||
} catch (e) {
|
||||
el.status.textContent = "网络错误";
|
||||
if (!state.snap) renderHardError(String(e));
|
||||
}
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const changed = await fetchSnapshot(false);
|
||||
if (changed) render();
|
||||
} catch (_) { /* keep last snapshot */ }
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
loop();
|
||||
})();
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="engineering-dashboard-token" content="__TOKEN__">
|
||||
<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 src="/assets/app.js?token=__TOKEN__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed assets/index.html assets/app.js assets/app.css
|
||||
var assetFS embed.FS
|
||||
|
||||
func readAsset(name string) ([]byte, error) {
|
||||
return assetFS.ReadFile(name)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Collector coordinates parallel source collection and atomic snapshot publish.
|
||||
type Collector struct {
|
||||
RepoRoot string
|
||||
Runner CommandRunner
|
||||
TTL time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
current *Snapshot
|
||||
lastKnown map[string]SourceState
|
||||
gen atomic.Uint64
|
||||
collecting atomic.Bool
|
||||
singleflight chan struct{}
|
||||
}
|
||||
|
||||
// NewCollector builds a coordinator for one repo root.
|
||||
func NewCollector(repoRoot string, runner CommandRunner) *Collector {
|
||||
if runner == nil {
|
||||
runner = ExecRunner{}
|
||||
}
|
||||
return &Collector{
|
||||
RepoRoot: repoRoot,
|
||||
Runner: runner,
|
||||
TTL: 3 * time.Second,
|
||||
lastKnown: map[string]SourceState{},
|
||||
singleflight: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Current returns the latest published snapshot (may be nil on cold start).
|
||||
func (c *Collector) Current() *Snapshot {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.current
|
||||
}
|
||||
|
||||
// Refresh builds and publishes a new snapshot. Concurrent callers share one flight.
|
||||
func (c *Collector) Refresh(ctx context.Context) (*Snapshot, error) {
|
||||
select {
|
||||
case c.singleflight <- struct{}{}:
|
||||
defer func() { <-c.singleflight }()
|
||||
default:
|
||||
// another collection in flight — return current
|
||||
if s := c.Current(); s != nil {
|
||||
return s, nil
|
||||
}
|
||||
// wait briefly for first
|
||||
select {
|
||||
case c.singleflight <- struct{}{}:
|
||||
defer func() { <-c.singleflight }()
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return c.collectAndPublish(ctx)
|
||||
}
|
||||
|
||||
func (c *Collector) collectAndPublish(ctx context.Context) (*Snapshot, error) {
|
||||
c.collecting.Store(true)
|
||||
defer c.collecting.Store(false)
|
||||
|
||||
start := time.Now()
|
||||
now := start.UTC()
|
||||
deadline, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var docs DocsCollectResult
|
||||
var git GitCollectResult
|
||||
var runtime RuntimeCollectResult
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
docs = CollectDocs(c.RepoRoot, now)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
git = CollectGit(deadline, c.RepoRoot, c.Runner, now)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runtime = CollectRuntime(deadline, c.RepoRoot, c.Runner, now)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
docs.Source = c.applyLastKnown(docs.Source, now)
|
||||
git.Source = c.applyLastKnown(git.Source, now)
|
||||
runtime.Source = c.applyLastKnown(runtime.Source, now)
|
||||
|
||||
snap := Reconcile(ReconcileInput{
|
||||
RepoRoot: c.RepoRoot,
|
||||
Now: now,
|
||||
Duration: time.Since(start),
|
||||
Docs: docs,
|
||||
Git: git,
|
||||
Runtime: runtime,
|
||||
Generation: c.gen.Add(1),
|
||||
})
|
||||
c.mu.Lock()
|
||||
c.current = &snap
|
||||
c.mu.Unlock()
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
func (c *Collector) applyLastKnown(src SourceState, now time.Time) SourceState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if src.Status == SourceOK || src.Status == SourceDegraded {
|
||||
cp := src
|
||||
cp.LastSuccessAt = &now
|
||||
c.lastKnown[src.Name] = cp
|
||||
return cp
|
||||
}
|
||||
prev, ok := c.lastKnown[src.Name]
|
||||
if !ok {
|
||||
src.Freshness = FreshnessError
|
||||
if src.Status == "" {
|
||||
src.Status = SourceError
|
||||
}
|
||||
return src
|
||||
}
|
||||
// retain last-known markers without rewriting observed_at of cached facts
|
||||
src.Status = SourceDegraded
|
||||
src.Freshness = FreshnessStale
|
||||
src.LastSuccessAt = prev.LastSuccessAt
|
||||
if src.Error == "" {
|
||||
src.Error = "using last-known; current collection failed"
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// ReconcileInput feeds the pure reconcile function.
|
||||
type ReconcileInput struct {
|
||||
RepoRoot string
|
||||
Now time.Time
|
||||
Duration time.Duration
|
||||
Docs DocsCollectResult
|
||||
Git GitCollectResult
|
||||
Runtime RuntimeCollectResult
|
||||
Generation uint64
|
||||
}
|
||||
|
||||
// Reconcile merges source facts into an immutable snapshot without picking winners on conflicts.
|
||||
func Reconcile(in ReconcileInput) Snapshot {
|
||||
repo := in.Git.Repository
|
||||
if repo.Root == "" {
|
||||
repo.Root = in.RepoRoot
|
||||
}
|
||||
|
||||
tasks := append([]TaskState(nil), in.Docs.Tasks...)
|
||||
gitByName := map[string]GitBranchView{}
|
||||
for _, g := range in.Git.Branches {
|
||||
gitByName[g.Name] = g
|
||||
}
|
||||
|
||||
var conflicts []Conflict
|
||||
var facts []Fact
|
||||
facts = append(facts, in.Docs.Facts...)
|
||||
facts = append(facts, in.Git.Facts...)
|
||||
facts = append(facts, in.Runtime.Facts...)
|
||||
|
||||
for i := range tasks {
|
||||
t := &tasks[i]
|
||||
declaredBranch := t.Claim.Branch
|
||||
if declaredBranch != "" {
|
||||
if g, ok := gitByName[declaredBranch]; ok {
|
||||
t.Git.Branch = g.Name
|
||||
t.Git.Worktree = g.Worktree
|
||||
t.Git.Ahead = g.Ahead
|
||||
t.Git.Behind = g.Behind
|
||||
t.Git.RemoteNote = g.RemoteNote
|
||||
if g.Dirty {
|
||||
t.Git.Cleanliness = "dirty"
|
||||
} else {
|
||||
t.Git.Cleanliness = "clean"
|
||||
}
|
||||
if t.Claim.Worktree != "" && g.Worktree != "" && t.Claim.Worktree != g.Worktree && t.Claim.Worktree != "-" {
|
||||
f1 := FactID(t.ID, "worktree", "docs")
|
||||
f2 := FactID(t.ID, "worktree", "git")
|
||||
facts = append(facts,
|
||||
Fact{ID: f1, Subject: t.ID, Field: "worktree", Value: t.Claim.Worktree, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now},
|
||||
Fact{ID: f2, Subject: t.ID, Field: "worktree", Value: g.Worktree, Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: in.Now},
|
||||
)
|
||||
conflicts = append(conflicts, NewConflict(t.ID, "worktree", ConflictTaskVsGit, []string{f1, f2}, "declared worktree differs from observed"))
|
||||
}
|
||||
} else if t.DocStatus == DocStatusInProgress || t.Claim.Claimed {
|
||||
f1 := FactID(t.ID, "branch", "docs")
|
||||
facts = append(facts, Fact{ID: f1, Subject: t.ID, Field: "branch", Value: declaredBranch, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now})
|
||||
conflicts = append(conflicts, NewConflict(t.ID, "branch", ConflictMissingEvidence, []string{f1}, "declared branch not observed locally"))
|
||||
}
|
||||
}
|
||||
// claimed but no runtime activity source
|
||||
if t.Claim.Claimed && t.Claim.Activity == "" {
|
||||
t.Claim.Activity = "unknown"
|
||||
}
|
||||
// never promote doc done into deploy
|
||||
if t.DocStatus == DocStatusDone {
|
||||
for env, d := range t.Deployment {
|
||||
if d.AppReadiness == "" {
|
||||
d.AppReadiness = "unknown"
|
||||
t.Deployment[env] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
envs := append([]EnvironmentState(nil), in.Runtime.Environments...)
|
||||
for i := range envs {
|
||||
e := &envs[i]
|
||||
if e.Facets.AppReadiness == "ok" && e.Facets.ContainerStatus == "partial" {
|
||||
f1 := FactID("env:"+e.Name, "readiness", "ready")
|
||||
f2 := FactID("env:"+e.Name, "container_status", "docker")
|
||||
conflicts = append(conflicts, NewConflict("env:"+e.Name, "health", ConflictDeclaredVsObserved, []string{f1, f2},
|
||||
"app /ready ok while containers partial — no aggregate green"))
|
||||
}
|
||||
if e.Receipt != "" && e.Facets.ArtifactID != "" {
|
||||
// receipt present; identity note already set by runtime collector when mismatch unknown
|
||||
}
|
||||
}
|
||||
|
||||
sources := []SourceState{in.Docs.Source, in.Git.Source, in.Runtime.Source}
|
||||
sort.Slice(sources, func(i, j int) bool { return sources[i].Name < sources[j].Name })
|
||||
|
||||
var warnings []string
|
||||
warnings = append(warnings, in.Docs.Warnings...)
|
||||
warnings = append(warnings, in.Git.Warnings...)
|
||||
warnings = append(warnings, in.Runtime.Warnings...)
|
||||
|
||||
snap := Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
GenerationID: fmt.Sprintf("gen_%d", in.Generation),
|
||||
GeneratedAt: in.Now,
|
||||
CollectionDuration: in.Duration,
|
||||
Repository: repo,
|
||||
Sources: sources,
|
||||
Facts: facts,
|
||||
Conflicts: conflicts,
|
||||
Documents: in.Docs.Documents,
|
||||
Modules: in.Docs.Modules,
|
||||
Tasks: tasks,
|
||||
Git: in.Git.Branches,
|
||||
Environments: envs,
|
||||
Warnings: warnings,
|
||||
}
|
||||
snap.RevisionID = ComputeRevisionID(snap)
|
||||
return snap
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDocs_IndexAndBacklinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDoc(t, root, ".doc/ops/CURRENT.md", "---\nstatus: in_progress\n---\n# Current\n\nSee [task](./short-term/task.md).\n")
|
||||
writeDoc(t, root, ".doc/ops/short-term/task.md", "---\ntitle: Demo Task\nstatus: planned\nowner: unassigned\nmutex: demo\n---\n# Demo Task\n\nLink to [current](../CURRENT.md).\n")
|
||||
writeDoc(t, root, ".doc/ops/short-term/orphan.md", "# Orphan\n\nNo links.\n")
|
||||
// assets must be skipped
|
||||
mustMkdir(t, filepath.Join(root, ".doc/test-reports/assets"))
|
||||
if err := os.WriteFile(filepath.Join(root, ".doc/test-reports/assets/secret.md"), []byte("# secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
res := CollectDocs(root, now)
|
||||
if res.Source.Status != SourceOK {
|
||||
t.Fatalf("docs source: %+v", res.Source)
|
||||
}
|
||||
var foundAssets bool
|
||||
var task *DocumentNode
|
||||
for i := range res.Documents {
|
||||
if strings.Contains(res.Documents[i].Path, "assets/") {
|
||||
foundAssets = true
|
||||
}
|
||||
if strings.HasSuffix(res.Documents[i].Path, "short-term/task.md") {
|
||||
task = &res.Documents[i]
|
||||
}
|
||||
}
|
||||
if foundAssets {
|
||||
t.Fatal("assets markdown must not be indexed as body")
|
||||
}
|
||||
if task == nil {
|
||||
t.Fatal("task doc missing")
|
||||
}
|
||||
if len(task.LinksIn) == 0 {
|
||||
t.Fatalf("expected backlink into task, got %+v", task)
|
||||
}
|
||||
var planned bool
|
||||
for _, tk := range res.Tasks {
|
||||
if strings.Contains(tk.Path, "task.md") {
|
||||
if tk.DocStatus != DocStatusPlanned || tk.Claim.Claimed {
|
||||
t.Fatalf("rdev-like task should stay planned/unclaimed: %+v", tk)
|
||||
}
|
||||
planned = true
|
||||
}
|
||||
}
|
||||
if !planned {
|
||||
t.Fatal("task not parsed into TaskState")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocs_BrokenLinkDoesNotFailIndex(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDoc(t, root, ".doc/ops/note.md", "# Note\n\n[missing](./nope.md)\n")
|
||||
res := CollectDocs(root, time.Now().UTC())
|
||||
if res.Source.Status != SourceOK {
|
||||
t.Fatalf("index should succeed: %+v", res.Source)
|
||||
}
|
||||
found := false
|
||||
for _, d := range res.Documents {
|
||||
if strings.HasSuffix(d.Path, "note.md") && len(d.BrokenLinks) > 0 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected broken link recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathSafe_RejectsTraversalAndSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeDoc(t, root, ".doc/ops/ok.md", "# ok\n")
|
||||
if _, err := SafeDocPath(root, ".doc/../.env"); err == nil {
|
||||
t.Fatal("expected traversal deny")
|
||||
}
|
||||
target := filepath.Join(root, ".doc/ops/ok.md")
|
||||
link := filepath.Join(root, ".doc/ops/link.md")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SafeDocPath(root, ".doc/ops/link.md"); err == nil {
|
||||
t.Fatal("expected symlink deny")
|
||||
}
|
||||
if _, err := SafeDocPath(root, ".doc/ops/ok.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDoc(t *testing.T, root, rel, body string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(root, filepath.FromSlash(rel))
|
||||
mustMkdir(t, filepath.Dir(abs))
|
||||
if err := os.WriteFile(abs, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExportManifest describes what was embedded in an offline HTML.
|
||||
type ExportManifest struct {
|
||||
Included []string `json:"included"`
|
||||
Truncated []string `json:"truncated"`
|
||||
MetadataOnly []string `json:"metadata_only"`
|
||||
SensitiveWarn []string `json:"sensitive_warnings,omitempty"`
|
||||
}
|
||||
|
||||
// ExportHTML writes a self-contained offline snapshot (mode 0600).
|
||||
func ExportHTML(snap Snapshot, outputPath string) error {
|
||||
css, err := readAsset("assets/app.css")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manifest := ExportManifest{}
|
||||
docs := make([]DocumentNode, 0, len(snap.Documents))
|
||||
for _, d := range snap.Documents {
|
||||
cp := d
|
||||
if IsAssetsPath(cp.Path) {
|
||||
cp.Body = ""
|
||||
manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path)
|
||||
} else if cp.BodyTruncated {
|
||||
manifest.Truncated = append(manifest.Truncated, cp.Path)
|
||||
manifest.Included = append(manifest.Included, cp.Path)
|
||||
} else if cp.Body != "" {
|
||||
manifest.Included = append(manifest.Included, cp.Path)
|
||||
} else {
|
||||
manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path)
|
||||
}
|
||||
// strip any accidental absolute secret-looking paths from body display only — keep relative
|
||||
if strings.Contains(strings.ToLower(cp.Body), "/.secrets/") {
|
||||
manifest.SensitiveWarn = append(manifest.SensitiveWarn, cp.Path+": contains .secrets path mention")
|
||||
}
|
||||
docs = append(docs, cp)
|
||||
}
|
||||
exportSnap := snap
|
||||
exportSnap.Documents = docs
|
||||
|
||||
payload, err := MarshalSnapshotJSON(exportSnap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// HTML-safe JSON: break </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"
|
||||
@@ -1,56 +0,0 @@
|
||||
package engineeringdashboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExport_OfflineReadableAndSafe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "snap.html")
|
||||
snap := Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
GenerationID: "gen_export",
|
||||
RevisionID: "rev_export",
|
||||
GeneratedAt: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC),
|
||||
Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "deadbeef"},
|
||||
Tasks: []TaskState{SampleRDevTaskFixture()},
|
||||
Conflicts: []Conflict{NewConflict("env:main", "health", ConflictDeclaredVsObserved, []string{"a", "b"}, "no aggregate green")},
|
||||
Documents: []DocumentNode{{
|
||||
ID: ".doc/ops/note.md", Path: ".doc/ops/note.md", Title: "Note",
|
||||
Body: "hello </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")
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
// 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])
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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/")
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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 }
|
||||
@@ -1,218 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_IndexUsesMetaTokenNoInlineScript(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, err := NewServer(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/?token="+srv.Token, nil)
|
||||
req.Host = "127.0.0.1:8787"
|
||||
rr := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("index: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "window.__DASHBOARD_TOKEN__") {
|
||||
t.Fatal("index must not use inline token script (CSP script-src 'self')")
|
||||
}
|
||||
meta := `name="engineering-dashboard-token" content="` + srv.Token + `"`
|
||||
if !strings.Contains(body, meta) {
|
||||
t.Fatalf("missing token meta: %s", body[:min(400, len(body))])
|
||||
}
|
||||
if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "script-src 'self'") {
|
||||
t.Fatalf("unexpected CSP: %s", rr.Header().Get("Content-Security-Policy"))
|
||||
}
|
||||
// app.js must not reference the removed global
|
||||
js, err := readAsset("assets/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(js), "__DASHBOARD_TOKEN__") {
|
||||
t.Fatal("app.js still references __DASHBOARD_TOKEN__")
|
||||
}
|
||||
if !strings.Contains(string(js), "engineering-dashboard-token") {
|
||||
t.Fatal("app.js should read meta engineering-dashboard-token")
|
||||
}
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user