From fa5e10542a306993f8850702d0a50f2cac586b7f Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 06:56:20 +0000 Subject: [PATCH 1/6] docs(ops): claim engineering-dashboard implementation Register CURRENT mutex and mark short-term in_progress on the feature/engineering-dashboard worktree before coding. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 1 + .../ops/short-term/2026-09-17-engineering-dashboard.md | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ccd52048..d96044c8 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor-Auto | 2026-09-17-engineering-dashboard | feature/engineering-dashboard | .worktrees/feature-engineering-dashboard | engineering-dashboard | in_progress | | Cursor | 2026-09-17-promote-null-fk-to-test | feature/promote-null-fk-to-test | - | infra | done | | Cursor | 2026-09-17-docgov-optional-duty-null-fk | fix/docgov-optional-duty-null-fk | - | docgov | done | | Cursor | 2026-09-17-leftover-hygiene | chore/leftover-hygiene-2026-09-17 | - | ops | done | diff --git a/.doc/ops/short-term/2026-09-17-engineering-dashboard.md b/.doc/ops/short-term/2026-09-17-engineering-dashboard.md index 64acea38..b676ac35 100644 --- a/.doc/ops/short-term/2026-09-17-engineering-dashboard.md +++ b/.doc/ops/short-term/2026-09-17-engineering-dashboard.md @@ -1,11 +1,11 @@ --- title: "个人工程中控台:实时项目看板与离线 HTML 快照" -status: planned +status: in_progress created: 2026-09-17 updated: 2026-09-17 -owner: unassigned +owner: Cursor-Auto branch: feature/engineering-dashboard -worktree: "-" +worktree: .worktrees/feature-engineering-dashboard mutex: engineering-dashboard --- @@ -56,5 +56,5 @@ mutex: engineering-dashboard ## 证据 / 备注 -- 本任务当前仅完成方案设计,尚未认领、未实现、未验证、未部署。 -- 实施开始前必须在 CURRENT 登记 owner、真实 branch、worktree 与 mutex;本文件中的 branch 是计划分支名。 +- 方案已合入 `dev@12884a1`;实现认领已登记 CURRENT。 +- 实施在 `.worktrees/feature-engineering-dashboard`,禁止 worktree 内 env-service。 From e46ec0e4e71526f5fc1a7dce6b7b21e38a48ea15 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 06:59:02 +0000 Subject: [PATCH 2/6] feat(engineering-dashboard): U1 freeze snapshot and facet model Define versioned Snapshot/Fact/Conflict/TaskState contracts with independent plan/git/verification/deployment axes and acceptance fixtures. Co-authored-by: Cursor --- internal/engineeringdashboard/model.go | 499 ++++++++++++++++++++ internal/engineeringdashboard/model_test.go | 152 ++++++ 2 files changed, 651 insertions(+) create mode 100644 internal/engineeringdashboard/model.go create mode 100644 internal/engineeringdashboard/model_test.go diff --git a/internal/engineeringdashboard/model.go b/internal/engineeringdashboard/model.go new file mode 100644 index 00000000..61801daa --- /dev/null +++ b/internal/engineeringdashboard/model.go @@ -0,0 +1,499 @@ +// Package engineeringdashboard implements an independent local read-only +// personal engineering control center. It must not import product business packages. +package engineeringdashboard + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const SchemaVersion = "1" + +// Provenance classifies how a fact was obtained. +type Provenance string + +const ( + ProvenanceDeclared Provenance = "declared" + ProvenanceObserved Provenance = "observed" + ProvenanceVerified Provenance = "verified" + ProvenanceInferred Provenance = "inferred" +) + +// Freshness classifies source/data age relative to the current collection. +type Freshness string + +const ( + FreshnessFresh Freshness = "fresh" + FreshnessStale Freshness = "stale" + FreshnessError Freshness = "error" + FreshnessUnknown Freshness = "unknown" +) + +// SourceStatus is the collection outcome for one source. +type SourceStatus string + +const ( + SourceOK SourceStatus = "ok" + SourceDegraded SourceStatus = "degraded" + SourceError SourceStatus = "error" + SourceUnknown SourceStatus = "unknown" +) + +// AgentKind separates Herdr engineering agents from the product PI Agent Runner. +type AgentKind string + +const ( + AgentKindHerdrEngineering AgentKind = "herdr_engineering" + AgentKindProductPIRunner AgentKind = "product_pi_agent_runner" + AgentKindHuman AgentKind = "human" + AgentKindUnknown AgentKind = "unknown" +) + +// DocStatus is the task document declared status (plan axis only). +type DocStatus string + +const ( + DocStatusPlanned DocStatus = "planned" + DocStatusInProgress DocStatus = "in_progress" + DocStatusBlocked DocStatus = "blocked" + DocStatusDone DocStatus = "done" + DocStatusCancelled DocStatus = "cancelled" + DocStatusUnknown DocStatus = "unknown" +) + +// ConflictKind classifies multi-source disagreements. +type ConflictKind string + +const ( + ConflictDeclaredVsObserved ConflictKind = "declared_vs_observed" + ConflictTaskVsGit ConflictKind = "task_vs_git" + ConflictTestVsSubject ConflictKind = "test_vs_subject" + ConflictReceiptVsRuntime ConflictKind = "receipt_vs_runtime" + ConflictStaleSource ConflictKind = "stale_source" + ConflictMissingEvidence ConflictKind = "missing_evidence" +) + +// Snapshot is the immutable delivery model shared by HTML, JSON, and export. +type Snapshot struct { + SchemaVersion string `json:"schema_version"` + GenerationID string `json:"generation_id"` + RevisionID string `json:"revision_id"` + GeneratedAt time.Time `json:"generated_at"` + CollectionDuration time.Duration `json:"-"` + Repository RepositoryIdentity `json:"repository"` + Sources []SourceState `json:"sources"` + Facts []Fact `json:"facts"` + Conflicts []Conflict `json:"conflicts"` + Documents []DocumentNode `json:"documents"` + Modules []ModuleView `json:"modules"` + Tasks []TaskState `json:"tasks"` + Git []GitBranchView `json:"git"` + Environments []EnvironmentState `json:"environments"` + Warnings []string `json:"warnings,omitempty"` +} + +// RepositoryIdentity identifies the scanned checkout. +type RepositoryIdentity struct { + Root string `json:"root"` + Branch string `json:"branch"` + Head string `json:"head"` + Dirty bool `json:"dirty"` +} + +// SourceState records one collector's outcome. +type SourceState struct { + Name string `json:"name"` + Status SourceStatus `json:"status"` + Freshness Freshness `json:"freshness"` + ObservedAt time.Time `json:"observed_at"` + LastSuccessAt *time.Time `json:"last_success_at,omitempty"` + Error string `json:"error,omitempty"` + DurationMS int64 `json:"duration_ms"` +} + +// Fact is an atomic, provenance-tagged observation or declaration. +type Fact struct { + ID string `json:"id"` + Subject string `json:"subject"` + Field string `json:"field"` + Value string `json:"value"` + Provenance Provenance `json:"provenance"` + SourceRef string `json:"source_ref"` + ObservedAt time.Time `json:"observed_at"` + Confidence string `json:"confidence,omitempty"` +} + +// Conflict retains all disagreeing fact refs without picking a winner. +type Conflict struct { + Subject string `json:"subject"` + Field string `json:"field"` + Kind ConflictKind `json:"kind"` + FactRefs []string `json:"fact_refs"` + Summary string `json:"summary"` +} + +// DocumentNode is an indexed markdown document (body may be truncated/omitted). +type DocumentNode struct { + ID string `json:"id"` + Path string `json:"path"` + Title string `json:"title"` + Taxonomy string `json:"taxonomy,omitempty"` + Status string `json:"status,omitempty"` + FrontMatter map[string]string `json:"front_matter,omitempty"` + Body string `json:"body,omitempty"` + BodyTruncated bool `json:"body_truncated,omitempty"` + LinksOut []string `json:"links_out,omitempty"` + LinksIn []string `json:"links_in,omitempty"` + BrokenLinks []string `json:"broken_links,omitempty"` + Orphan bool `json:"orphan,omitempty"` + Unindexed bool `json:"unindexed,omitempty"` + ParseWarnings []string `json:"parse_warnings,omitempty"` +} + +// ModuleView aggregates architecture/module matrix evidence. +type ModuleView struct { + Name string `json:"name"` + Status string `json:"status,omitempty"` + Mutex string `json:"mutex,omitempty"` + Evidence []string `json:"evidence,omitempty"` + Deps []string `json:"deps,omitempty"` +} + +// GitFacets are observed implementation facets (never inferred from doc_status). +type GitFacets struct { + Branch string `json:"branch,omitempty"` + Worktree string `json:"worktree,omitempty"` + MergedToDev *bool `json:"merged_to_dev,omitempty"` + Cleanliness string `json:"cleanliness,omitempty"` // clean|dirty|unknown + Ahead int `json:"ahead,omitempty"` + Behind int `json:"behind,omitempty"` + RemoteNote string `json:"remote_note,omitempty"` +} + +// VerificationFacets bind test evidence to a subject identity. +type VerificationFacets struct { + Result string `json:"result,omitempty"` // pass|fail|unbound|stale|unknown + Freshness Freshness `json:"freshness,omitempty"` + SubjectID string `json:"subject_id,omitempty"` + ReportPath string `json:"report_path,omitempty"` +} + +// DeploymentFacets are per-environment install observations. +type DeploymentFacets struct { + ContainerStatus string `json:"container_status,omitempty"` + AppReadiness string `json:"app_readiness,omitempty"` + ServicesOK string `json:"services_ok,omitempty"` // complete|partial|unknown + ArtifactID string `json:"artifact_id,omitempty"` + IdentityNote string `json:"identity_note,omitempty"` +} + +// AgentClaim is declared ownership; activity remains separate. +type AgentClaim struct { + Owner string `json:"owner,omitempty"` + AgentKind AgentKind `json:"agent_kind"` + Claimed bool `json:"claimed"` + Mutex string `json:"mutex,omitempty"` + Branch string `json:"declared_branch,omitempty"` + Worktree string `json:"declared_worktree,omitempty"` + Activity string `json:"activity"` // unknown|observed|... + ActivityAt *time.Time `json:"activity_at,omitempty"` + Evidence string `json:"evidence,omitempty"` +} + +// TaskState keeps plan / git / verification / deployment axes independent. +type TaskState struct { + ID string `json:"id"` + Title string `json:"title"` + Path string `json:"path"` + DocStatus DocStatus `json:"doc_status"` + Claim AgentClaim `json:"claim"` + Git GitFacets `json:"git"` + Verification VerificationFacets `json:"verification"` + Deployment map[string]DeploymentFacets `json:"deployment,omitempty"` + EvidenceChain []string `json:"evidence_chain,omitempty"` +} + +// GitBranchView is a git observation row. +type GitBranchView struct { + Name string `json:"name"` + IsCurrent bool `json:"is_current,omitempty"` + Head string `json:"head,omitempty"` + Upstream string `json:"upstream,omitempty"` + Ahead int `json:"ahead,omitempty"` + Behind int `json:"behind,omitempty"` + Worktree string `json:"worktree,omitempty"` + Dirty bool `json:"dirty,omitempty"` + RemoteNote string `json:"remote_note,omitempty"` + ObservedAt time.Time `json:"observed_at"` +} + +// EnvironmentState is one deploy environment's multi-signal view. +type EnvironmentState struct { + Name string `json:"name"` + Containers []ContainerView `json:"containers,omitempty"` + Readiness string `json:"readiness,omitempty"` + Schema string `json:"schema,omitempty"` + Artifact string `json:"artifact,omitempty"` + Receipt string `json:"receipt,omitempty"` + Facets DeploymentFacets `json:"facets"` + DriftNotes []string `json:"drift_notes,omitempty"` +} + +// ContainerView is a whitelist-filtered docker observation. +type ContainerView struct { + Name string `json:"name"` + Image string `json:"image,omitempty"` + Digest string `json:"digest,omitempty"` + Status string `json:"status,omitempty"` + RestartCount int `json:"restart_count,omitempty"` + Health string `json:"health,omitempty"` + Project string `json:"compose_project,omitempty"` + Service string `json:"compose_service,omitempty"` +} + +// ValidateProvenance rejects unknown provenance values. +func ValidateProvenance(p Provenance) error { + switch p { + case ProvenanceDeclared, ProvenanceObserved, ProvenanceVerified, ProvenanceInferred: + return nil + default: + return fmt.Errorf("unknown provenance %q", p) + } +} + +// ValidateFreshness rejects unknown freshness values. +func ValidateFreshness(f Freshness) error { + switch f { + case FreshnessFresh, FreshnessStale, FreshnessError, FreshnessUnknown: + return nil + default: + return fmt.Errorf("unknown freshness %q", f) + } +} + +// ValidateAgentKind rejects unknown agent kinds. +func ValidateAgentKind(k AgentKind) error { + switch k { + case AgentKindHerdrEngineering, AgentKindProductPIRunner, AgentKindHuman, AgentKindUnknown: + return nil + default: + return fmt.Errorf("unknown agent kind %q", k) + } +} + +// ComputeRevisionID hashes schema + repo identity + normalized non-volatile payload. +// generated_at, observed_at, and collection duration are excluded. +func ComputeRevisionID(s Snapshot) string { + type revSource struct { + Name string `json:"name"` + Status SourceStatus `json:"status"` + Freshness Freshness `json:"freshness"` + Error string `json:"error,omitempty"` + } + type revFact struct { + ID string `json:"id"` + Subject string `json:"subject"` + Field string `json:"field"` + Value string `json:"value"` + Provenance Provenance `json:"provenance"` + SourceRef string `json:"source_ref"` + Confidence string `json:"confidence,omitempty"` + } + type revPayload struct { + Schema string `json:"schema_version"` + Repo RepositoryIdentity `json:"repository"` + Sources []revSource `json:"sources"` + Facts []revFact `json:"facts"` + Conflicts []Conflict `json:"conflicts"` + Documents []DocumentNode `json:"documents"` + Modules []ModuleView `json:"modules"` + Tasks []TaskState `json:"tasks"` + Git []GitBranchView `json:"git"` + Envs []EnvironmentState `json:"environments"` + Warnings []string `json:"warnings,omitempty"` + } + + srcs := make([]revSource, 0, len(s.Sources)) + for _, src := range s.Sources { + srcs = append(srcs, revSource{Name: src.Name, Status: src.Status, Freshness: src.Freshness, Error: src.Error}) + } + sort.Slice(srcs, func(i, j int) bool { return srcs[i].Name < srcs[j].Name }) + + facts := make([]revFact, 0, len(s.Facts)) + for _, f := range s.Facts { + facts = append(facts, revFact{ + ID: f.ID, Subject: f.Subject, Field: f.Field, Value: f.Value, + Provenance: f.Provenance, SourceRef: f.SourceRef, Confidence: f.Confidence, + }) + } + sort.Slice(facts, func(i, j int) bool { return facts[i].ID < facts[j].ID }) + + conflicts := append([]Conflict(nil), s.Conflicts...) + sort.Slice(conflicts, func(i, j int) bool { + a := conflicts[i].Subject + "|" + conflicts[i].Field + "|" + string(conflicts[i].Kind) + b := conflicts[j].Subject + "|" + conflicts[j].Field + "|" + string(conflicts[j].Kind) + return a < b + }) + + docs := append([]DocumentNode(nil), s.Documents...) + sort.Slice(docs, func(i, j int) bool { return docs[i].ID < docs[j].ID }) + mods := append([]ModuleView(nil), s.Modules...) + sort.Slice(mods, func(i, j int) bool { return mods[i].Name < mods[j].Name }) + tasks := append([]TaskState(nil), s.Tasks...) + sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID }) + git := append([]GitBranchView(nil), s.Git...) + for i := range git { + git[i].ObservedAt = time.Time{} + } + sort.Slice(git, func(i, j int) bool { return git[i].Name < git[j].Name }) + envs := append([]EnvironmentState(nil), s.Environments...) + sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name }) + warns := append([]string(nil), s.Warnings...) + sort.Strings(warns) + + payload := revPayload{ + Schema: s.SchemaVersion, Repo: s.Repository, Sources: srcs, Facts: facts, + Conflicts: conflicts, Documents: docs, Modules: mods, Tasks: tasks, Git: git, Envs: envs, Warnings: warns, + } + b, err := json.Marshal(payload) + if err != nil { + sum := sha256.Sum256([]byte(err.Error())) + return "rev_err_" + hex.EncodeToString(sum[:8]) + } + sum := sha256.Sum256(b) + return "rev_" + hex.EncodeToString(sum[:16]) +} + +// MarshalSnapshotJSON encodes a snapshot for API/export. +func MarshalSnapshotJSON(s Snapshot) ([]byte, error) { + type alias Snapshot + type wire struct { + alias + CollectionDurationMS int64 `json:"collection_duration_ms"` + } + w := wire{alias: alias(s), CollectionDurationMS: s.CollectionDuration.Milliseconds()} + return json.MarshalIndent(w, "", " ") +} + +// UnmarshalSnapshotJSON decodes a snapshot wire format. +func UnmarshalSnapshotJSON(data []byte) (Snapshot, error) { + type alias Snapshot + type wire struct { + alias + CollectionDurationMS int64 `json:"collection_duration_ms"` + } + var w wire + if err := json.Unmarshal(data, &w); err != nil { + return Snapshot{}, err + } + s := Snapshot(w.alias) + s.CollectionDuration = time.Duration(w.CollectionDurationMS) * time.Millisecond + return s, nil +} + +// SampleRDevTaskFixture returns the "planned but not implemented" acceptance sample. +func SampleRDevTaskFixture() TaskState { + return TaskState{ + ID: "task:rdev-workflow-integration", + Title: "研发工作流 rdev 接入", + Path: ".doc/ops/short-term/2026-09-17-rdev-workflow-integration.md", + DocStatus: DocStatusPlanned, + Claim: AgentClaim{ + Claimed: false, + AgentKind: AgentKindUnknown, + Activity: "unknown", + }, + Git: GitFacets{ + Cleanliness: "unknown", + RemoteNote: "no claimed branch", + }, + Verification: VerificationFacets{Result: "unknown", Freshness: FreshnessUnknown}, + Deployment: map[string]DeploymentFacets{ + "dev": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown", IdentityNote: "not installed"}, + "test": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown", IdentityNote: "not installed"}, + "main": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown", IdentityNote: "not installed"}, + }, + EvidenceChain: []string{ + ".doc/ops/research/2026-09-17-rdev-workflow-reference-analysis.md", + ".doc/system-design/project-workflow/07-研发工作流rdev接入-设计与实施计划.md", + ".doc/ops/short-term/2026-09-17-rdev-workflow-integration.md", + ".doc/backlog/2026-09-17-rdev-phase2-backlog.md", + }, + } +} + +// SampleDevAheadGitFixture returns local-ahead-of-origin acceptance sample. +func SampleDevAheadGitFixture() GitBranchView { + return GitBranchView{ + Name: "dev", + IsCurrent: true, + Head: "abc1234", + Upstream: "origin/dev", + Ahead: 3, + Behind: 0, + RemoteNote: "based on local refs; not network-refreshed — local present, remote availability unconfirmed", + ObservedAt: time.Date(2026, 9, 17, 6, 0, 0, 0, time.UTC), + } +} + +// SampleMainEventsForkFixture returns readiness-ok vs events-restarting sample. +func SampleMainEventsForkFixture() EnvironmentState { + return EnvironmentState{ + Name: "main", + Containers: []ContainerView{ + {Name: "shuimu-agent-main", Image: "agent:main", Status: "running", Health: "healthy", Project: "agent-main", Service: "app"}, + {Name: "shuimu-agent-events-main", Image: "events:main", Status: "restarting", RestartCount: 12, Health: "unhealthy", Project: "agent-main", Service: "events"}, + }, + Readiness: "ok", + Schema: "47", + Facets: DeploymentFacets{ + ContainerStatus: "partial", + AppReadiness: "ok", + ServicesOK: "partial", + IdentityNote: "app ready; events restarting — no aggregate green", + }, + DriftNotes: []string{ + "CURRENT may declare healthy while events container is restarting", + "app /ready ok coexists with events unhealthy", + }, + } +} + +// SampleHerdrVsPIClaims returns agent kind isolation sample. +func SampleHerdrVsPIClaims() (AgentClaim, AgentClaim) { + herdr := AgentClaim{ + Owner: "herdr_coder", + AgentKind: AgentKindHerdrEngineering, + Claimed: true, + Mutex: "engineering-dashboard", + Activity: "unknown", + Evidence: "CURRENT claim only; no runtime activity source", + } + pi := AgentClaim{ + Owner: "product-pi-runner", + AgentKind: AgentKindProductPIRunner, + Claimed: false, + Activity: "unknown", + Evidence: "product runtime identity; must not share herdr type", + } + return herdr, pi +} + +// NewConflict builds a conflict retaining all fact refs. +func NewConflict(subject, field string, kind ConflictKind, factRefs []string, summary string) Conflict { + refs := append([]string(nil), factRefs...) + sort.Strings(refs) + return Conflict{Subject: subject, Field: field, Kind: kind, FactRefs: refs, Summary: summary} +} + +// FactID builds a stable fact id. +func FactID(subject, field, source string) string { + raw := strings.Join([]string{subject, field, source}, "|") + sum := sha256.Sum256([]byte(raw)) + return "fact_" + hex.EncodeToString(sum[:10]) +} diff --git a/internal/engineeringdashboard/model_test.go b/internal/engineeringdashboard/model_test.go new file mode 100644 index 00000000..6d0301f9 --- /dev/null +++ b/internal/engineeringdashboard/model_test.go @@ -0,0 +1,152 @@ +package engineeringdashboard + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestModel_DocDoneDoesNotChangeGitOrDeployFacets(t *testing.T) { + task := SampleRDevTaskFixture() + task.DocStatus = DocStatusDone + if task.Git.Branch != "" || task.Git.Cleanliness != "unknown" { + t.Fatalf("git facets mutated from doc_status: %+v", task.Git) + } + for env, d := range task.Deployment { + if d.AppReadiness != "unknown" || d.ContainerStatus != "unknown" { + t.Fatalf("deployment facet for %s changed from doc_status: %+v", env, d) + } + } +} + +func TestModel_ClaimedWithoutRuntimeKeepsActivityUnknown(t *testing.T) { + herdr, pi := SampleHerdrVsPIClaims() + if herdr.Claimed && herdr.Activity != "unknown" { + t.Fatalf("claimed must not imply running: %+v", herdr) + } + if herdr.AgentKind == pi.AgentKind { + t.Fatalf("herdr and product PI runner must not share kind: %s", herdr.AgentKind) + } + if err := ValidateAgentKind(herdr.AgentKind); err != nil { + t.Fatal(err) + } + if err := ValidateAgentKind(pi.AgentKind); err != nil { + t.Fatal(err) + } +} + +func TestModel_ConflictRetainsAllFactRefs(t *testing.T) { + f1 := FactID("env:main", "health", "current") + f2 := FactID("env:main", "health", "docker") + c := NewConflict("env:main", "health", ConflictDeclaredVsObserved, []string{f2, f1}, "declared healthy vs events restarting") + if len(c.FactRefs) != 2 { + t.Fatalf("expected 2 fact refs, got %v", c.FactRefs) + } + if c.FactRefs[0] != f1 || c.FactRefs[1] != f2 { + t.Fatalf("refs not sorted/retained: %v", c.FactRefs) + } +} + +func TestModel_JSONRoundTripPreservesIdentity(t *testing.T) { + now := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC) + s := Snapshot{ + SchemaVersion: SchemaVersion, + GenerationID: "gen_1", + GeneratedAt: now, + CollectionDuration: 1500 * time.Millisecond, + Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "abc", Dirty: false}, + Sources: []SourceState{{ + Name: "docs", Status: SourceOK, Freshness: FreshnessFresh, ObservedAt: now, DurationMS: 10, + }}, + Facts: []Fact{{ + ID: "fact_a", Subject: "task:rdev", Field: "doc_status", Value: "planned", + Provenance: ProvenanceDeclared, SourceRef: "docs", ObservedAt: now, + }}, + Tasks: []TaskState{SampleRDevTaskFixture()}, + Git: []GitBranchView{SampleDevAheadGitFixture()}, + Environments: []EnvironmentState{SampleMainEventsForkFixture()}, + } + s.RevisionID = ComputeRevisionID(s) + + raw, err := MarshalSnapshotJSON(s) + if err != nil { + t.Fatal(err) + } + got, err := UnmarshalSnapshotJSON(raw) + if err != nil { + t.Fatal(err) + } + if got.SchemaVersion != SchemaVersion || got.GenerationID != "gen_1" || got.RevisionID != s.RevisionID { + t.Fatalf("identity mismatch: %+v", got) + } + if got.CollectionDuration != 1500*time.Millisecond { + t.Fatalf("duration mismatch: %v", got.CollectionDuration) + } + var wire map[string]any + if err := json.Unmarshal(raw, &wire); err != nil { + t.Fatal(err) + } + if _, ok := wire["collection_duration_ms"]; !ok { + t.Fatal("missing collection_duration_ms in wire JSON") + } +} + +func TestModel_RevisionIDIgnoresTimestamps(t *testing.T) { + base := Snapshot{ + SchemaVersion: SchemaVersion, + Repository: RepositoryIdentity{Root: "/r", Branch: "dev", Head: "h1"}, + Facts: []Fact{{ + ID: "f1", Subject: "s", Field: "x", Value: "1", + Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: time.Unix(1, 0), + }}, + GeneratedAt: time.Unix(100, 0), + CollectionDuration: time.Second, + } + a := base + a.RevisionID = ComputeRevisionID(a) + b := base + b.GeneratedAt = time.Unix(999, 0) + b.CollectionDuration = 5 * time.Second + b.Facts[0].ObservedAt = time.Unix(50, 0) + b.RevisionID = ComputeRevisionID(b) + if a.RevisionID != b.RevisionID { + t.Fatalf("revision should ignore timestamps: %s vs %s", a.RevisionID, b.RevisionID) + } + c := base + c.Facts[0].Value = "2" + c.RevisionID = ComputeRevisionID(c) + if c.RevisionID == a.RevisionID { + t.Fatal("revision must change when fact value changes") + } +} + +func TestModel_UnknownEnumsFail(t *testing.T) { + if err := ValidateProvenance(Provenance("nope")); err == nil || !strings.Contains(err.Error(), "unknown provenance") { + t.Fatalf("expected provenance error, got %v", err) + } + if err := ValidateFreshness(Freshness("nope")); err == nil || !strings.Contains(err.Error(), "unknown freshness") { + t.Fatalf("expected freshness error, got %v", err) + } + if err := ValidateAgentKind(AgentKind("nope")); err == nil || !strings.Contains(err.Error(), "unknown agent kind") { + t.Fatalf("expected agent kind error, got %v", err) + } +} + +func TestModel_AcceptanceSamplesAxes(t *testing.T) { + rdev := SampleRDevTaskFixture() + if rdev.DocStatus != DocStatusPlanned || rdev.Claim.Claimed { + t.Fatalf("rdev should be planned/unclaimed: %+v", rdev) + } + git := SampleDevAheadGitFixture() + if git.Ahead <= 0 || !strings.Contains(git.RemoteNote, "unconfirmed") { + t.Fatalf("dev ahead sample invalid: %+v", git) + } + main := SampleMainEventsForkFixture() + if main.Readiness != "ok" || main.Facets.ServicesOK != "partial" { + t.Fatalf("main fork sample invalid: %+v", main) + } + if len(main.DriftNotes) == 0 { + t.Fatal("main sample must retain drift notes, not aggregate green") + } +} From a07ba1065bdf646de0a22851eb66a127a0623d57 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 07:03:32 +0000 Subject: [PATCH 3/6] feat(engineering-dashboard): U2-U4 docs git and runtime collectors Add allowlisted markdown indexing, read-only git plumbing via injected runners, and docker/readiness collection with path and loopback guards. Co-authored-by: Cursor --- internal/engineeringdashboard/docs_test.go | 111 ++++++ internal/engineeringdashboard/git_test.go | 80 ++++ internal/engineeringdashboard/pathsafe.go | 109 ++++++ internal/engineeringdashboard/runner.go | 175 +++++++++ internal/engineeringdashboard/runtime_test.go | 117 ++++++ internal/engineeringdashboard/source_docs.go | 367 ++++++++++++++++++ internal/engineeringdashboard/source_git.go | 160 ++++++++ .../engineeringdashboard/source_runtime.go | 351 +++++++++++++++++ 8 files changed, 1470 insertions(+) create mode 100644 internal/engineeringdashboard/docs_test.go create mode 100644 internal/engineeringdashboard/git_test.go create mode 100644 internal/engineeringdashboard/pathsafe.go create mode 100644 internal/engineeringdashboard/runner.go create mode 100644 internal/engineeringdashboard/runtime_test.go create mode 100644 internal/engineeringdashboard/source_docs.go create mode 100644 internal/engineeringdashboard/source_git.go create mode 100644 internal/engineeringdashboard/source_runtime.go diff --git a/internal/engineeringdashboard/docs_test.go b/internal/engineeringdashboard/docs_test.go new file mode 100644 index 00000000..a3a75e6b --- /dev/null +++ b/internal/engineeringdashboard/docs_test.go @@ -0,0 +1,111 @@ +package engineeringdashboard + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDocs_IndexAndBacklinks(t *testing.T) { + root := t.TempDir() + writeDoc(t, root, ".doc/ops/CURRENT.md", "---\nstatus: in_progress\n---\n# Current\n\nSee [task](./short-term/task.md).\n") + writeDoc(t, root, ".doc/ops/short-term/task.md", "---\ntitle: Demo Task\nstatus: planned\nowner: unassigned\nmutex: demo\n---\n# Demo Task\n\nLink to [current](../CURRENT.md).\n") + writeDoc(t, root, ".doc/ops/short-term/orphan.md", "# Orphan\n\nNo links.\n") + // assets must be skipped + mustMkdir(t, filepath.Join(root, ".doc/test-reports/assets")) + if err := os.WriteFile(filepath.Join(root, ".doc/test-reports/assets/secret.md"), []byte("# secret"), 0o600); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + res := CollectDocs(root, now) + if res.Source.Status != SourceOK { + t.Fatalf("docs source: %+v", res.Source) + } + var foundAssets bool + var task *DocumentNode + for i := range res.Documents { + if strings.Contains(res.Documents[i].Path, "assets/") { + foundAssets = true + } + if strings.HasSuffix(res.Documents[i].Path, "short-term/task.md") { + task = &res.Documents[i] + } + } + if foundAssets { + t.Fatal("assets markdown must not be indexed as body") + } + if task == nil { + t.Fatal("task doc missing") + } + if len(task.LinksIn) == 0 { + t.Fatalf("expected backlink into task, got %+v", task) + } + var planned bool + for _, tk := range res.Tasks { + if strings.Contains(tk.Path, "task.md") { + if tk.DocStatus != DocStatusPlanned || tk.Claim.Claimed { + t.Fatalf("rdev-like task should stay planned/unclaimed: %+v", tk) + } + planned = true + } + } + if !planned { + t.Fatal("task not parsed into TaskState") + } +} + +func TestDocs_BrokenLinkDoesNotFailIndex(t *testing.T) { + root := t.TempDir() + writeDoc(t, root, ".doc/ops/note.md", "# Note\n\n[missing](./nope.md)\n") + res := CollectDocs(root, time.Now().UTC()) + if res.Source.Status != SourceOK { + t.Fatalf("index should succeed: %+v", res.Source) + } + found := false + for _, d := range res.Documents { + if strings.HasSuffix(d.Path, "note.md") && len(d.BrokenLinks) > 0 { + found = true + } + } + if !found { + t.Fatal("expected broken link recorded") + } +} + +func TestPathSafe_RejectsTraversalAndSymlink(t *testing.T) { + root := t.TempDir() + writeDoc(t, root, ".doc/ops/ok.md", "# ok\n") + if _, err := SafeDocPath(root, ".doc/../.env"); err == nil { + t.Fatal("expected traversal deny") + } + target := filepath.Join(root, ".doc/ops/ok.md") + link := filepath.Join(root, ".doc/ops/link.md") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := SafeDocPath(root, ".doc/ops/link.md"); err == nil { + t.Fatal("expected symlink deny") + } + if _, err := SafeDocPath(root, ".doc/ops/ok.md"); err != nil { + t.Fatal(err) + } +} + +func writeDoc(t *testing.T, root, rel, body string) { + t.Helper() + abs := filepath.Join(root, filepath.FromSlash(rel)) + mustMkdir(t, filepath.Dir(abs)) + if err := os.WriteFile(abs, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func mustMkdir(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/engineeringdashboard/git_test.go b/internal/engineeringdashboard/git_test.go new file mode 100644 index 00000000..b4f4b79a --- /dev/null +++ b/internal/engineeringdashboard/git_test.go @@ -0,0 +1,80 @@ +package engineeringdashboard + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestGit_AheadDoesNotClaimRemoteAvailable(t *testing.T) { + now := time.Now().UTC() + fake := &FakeRunner{Map: map[string]FakeResult{ + "git rev-parse --abbrev-ref HEAD": {Stdout: "dev\n"}, + "git rev-parse HEAD": {Stdout: "abc123\n"}, + "git status --porcelain=v2 --branch": {Stdout: "# branch.head dev\n"}, + "git for-each-ref --format=%(refname:short)|%(objectname:short)|%(upstream:short) refs/heads": { + Stdout: "dev|abc123|origin/dev\n", + }, + "git rev-list --left-right --count origin/dev...dev": {Stdout: "0\t3\n"}, + "git worktree list --porcelain": {Stdout: "worktree /repo\nHEAD abc123\nbranch refs/heads/dev\n"}, + }} + res := CollectGit(context.Background(), "/repo", fake, now) + if res.Source.Status != SourceOK { + t.Fatalf("git source: %+v", res.Source) + } + if len(res.Branches) != 1 || res.Branches[0].Ahead != 3 { + t.Fatalf("expected ahead=3: %+v", res.Branches) + } + if !strings.Contains(res.Branches[0].RemoteNote, "not network-refreshed") { + t.Fatalf("missing remote disclaimer: %+v", res.Branches[0]) + } + fake.AssertNoForbidden(t) +} + +func TestGit_WorktreeBranchMismatchSurfaced(t *testing.T) { + now := time.Now().UTC() + fake := &FakeRunner{Map: map[string]FakeResult{ + "git rev-parse --abbrev-ref HEAD": {Stdout: "feature/a\n"}, + "git rev-parse HEAD": {Stdout: "def\n"}, + "git status --porcelain=v2 --branch": {Stdout: "1 .M N... file\n"}, + "git for-each-ref --format=%(refname:short)|%(objectname:short)|%(upstream:short) refs/heads": { + Stdout: "feature/a|def|\nfeature/b|ghi|\n", + }, + "git worktree list --porcelain": { + Stdout: "worktree /repo\nHEAD def\nbranch refs/heads/feature/a\n\nworktree /repo-wt\nHEAD ghi\nbranch refs/heads/feature/b\n", + }, + }} + res := CollectGit(context.Background(), "/repo", fake, now) + var foundB bool + for _, b := range res.Branches { + if b.Name == "feature/b" && b.Worktree == "/repo-wt" { + foundB = true + } + } + if !foundB { + t.Fatalf("worktree mapping missing: %+v", res.Branches) + } + if !res.Repository.Dirty { + t.Fatal("expected dirty") + } +} + +func TestGit_TimeoutDegradesSource(t *testing.T) { + fake := &FakeRunner{Map: map[string]FakeResult{ + "git rev-parse --abbrev-ref HEAD": {Err: errors.New("command timed out: git")}, + }} + res := CollectGit(context.Background(), "/repo", fake, time.Now().UTC()) + if res.Source.Status != SourceError { + t.Fatalf("expected error source: %+v", res.Source) + } +} + +func TestGit_RunnerDeniesFetch(t *testing.T) { + r := ExecRunner{} + _, _, err := r.Run(context.Background(), "/tmp", "git", "fetch", "origin") + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("expected deny, got %v", err) + } +} diff --git a/internal/engineeringdashboard/pathsafe.go b/internal/engineeringdashboard/pathsafe.go new file mode 100644 index 00000000..a34283eb --- /dev/null +++ b/internal/engineeringdashboard/pathsafe.go @@ -0,0 +1,109 @@ +package engineeringdashboard + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +var sensitivePathParts = []string{ + "/.git/", "/.git", "/.env", "/.secrets/", "/.secrets", "/data/", +} + +// ResolveRepoRoot returns an absolute, symlink-resolved git root. +func ResolveRepoRoot(repo string) (string, error) { + if repo == "" { + wd, err := os.Getwd() + if err != nil { + return "", err + } + repo = wd + } + abs, err := filepath.Abs(repo) + if err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", err + } + fi, err := os.Stat(resolved) + if err != nil { + return "", err + } + if !fi.IsDir() { + return "", fmt.Errorf("repo root is not a directory: %s", resolved) + } + return resolved, nil +} + +// SafeDocPath validates a repo-relative markdown path under .doc (or allowlisted root md). +func SafeDocPath(repoRoot, rel string) (abs string, err error) { + rel = filepath.ToSlash(rel) + rel = strings.TrimPrefix(rel, "./") + if rel == "" || strings.Contains(rel, "\x00") { + return "", fmt.Errorf("empty path") + } + if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") { + return "", fmt.Errorf("absolute path denied: %s", rel) + } + if strings.Contains(rel, "..") { + return "", fmt.Errorf("path traversal denied: %s", rel) + } + allowRootMD := !strings.Contains(rel, "/") && strings.HasSuffix(strings.ToLower(rel), ".md") + underDoc := strings.HasPrefix(rel, ".doc/") + if !underDoc && !allowRootMD { + return "", fmt.Errorf("path outside allowlist: %s", rel) + } + if strings.Contains(rel, "/assets/") || strings.HasSuffix(rel, "/assets") { + return "", fmt.Errorf("assets body denied: %s", rel) + } + joined := filepath.Join(repoRoot, filepath.FromSlash(rel)) + // Reject if the path itself is a symlink (before resolution). + fi, err := os.Lstat(joined) + if err != nil { + return "", err + } + if fi.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("symlink denied: %s", rel) + } + resolved, err := filepath.EvalSymlinks(joined) + if err != nil { + return "", err + } + repoResolved, err := filepath.EvalSymlinks(repoRoot) + if err != nil { + repoResolved = repoRoot + } + if !strings.HasPrefix(resolved, repoResolved+string(os.PathSeparator)) && resolved != repoResolved { + return "", fmt.Errorf("resolved path escapes repo: %s", rel) + } + lower := filepath.ToSlash(strings.ToLower(resolved)) + for _, part := range sensitivePathParts { + if strings.Contains(lower, part) || strings.HasSuffix(lower, strings.TrimSuffix(part, "/")) { + // allow .doc paths that happen to mention words; only block real sensitive dirs + if strings.Contains(lower, "/.git") || strings.Contains(lower, "/.env") || + strings.Contains(lower, "/.secrets") || strings.Contains(lower, "/data/") { + if !strings.Contains(filepath.ToSlash(rel), ".doc/") { + return "", fmt.Errorf("sensitive path denied: %s", rel) + } + // still deny .git/.env/.secrets even under mistaken .doc + if strings.Contains(lower, "/.git") || strings.Contains(lower, "/.secrets") || + strings.Contains(filepath.Base(lower), ".env") { + return "", fmt.Errorf("sensitive path denied: %s", rel) + } + } + } + } + if !strings.HasSuffix(strings.ToLower(resolved), ".md") { + return "", fmt.Errorf("extension not allowed: %s", rel) + } + return resolved, nil +} + +// IsAssetsPath reports whether a relative path is under an assets directory. +func IsAssetsPath(rel string) bool { + rel = filepath.ToSlash(rel) + return strings.Contains(rel, "/assets/") || strings.HasSuffix(rel, "/assets") || strings.HasPrefix(rel, "assets/") +} diff --git a/internal/engineeringdashboard/runner.go b/internal/engineeringdashboard/runner.go new file mode 100644 index 00000000..8a931ef3 --- /dev/null +++ b/internal/engineeringdashboard/runner.go @@ -0,0 +1,175 @@ +package engineeringdashboard + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +const ( + defaultCommandTimeout = 2 * time.Second + defaultOutputLimit = 512 * 1024 +) + +// CommandRunner executes fixed argv without a shell. +type CommandRunner interface { + Run(ctx context.Context, dir string, name string, args ...string) (stdout string, stderr string, err error) +} + +// ExecRunner is the production runner using os/exec. +type ExecRunner struct { + Timeout time.Duration + OutputLimit int +} + +func (r ExecRunner) Run(ctx context.Context, dir string, name string, args ...string) (string, string, error) { + if name == "" { + return "", "", errors.New("empty command name") + } + if err := denyDangerousGitArgs(name, args); err != nil { + return "", "", err + } + timeout := r.Timeout + if timeout <= 0 { + timeout = defaultCommandTimeout + } + limit := r.OutputLimit + if limit <= 0 { + limit = defaultOutputLimit + } + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(cctx, name, args...) + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &limitedWriter{buf: &stdout, limit: limit} + cmd.Stderr = &limitedWriter{buf: &stderr, limit: limit} + err := cmd.Run() + out := sanitizeOutput(stdout.String()) + errOut := sanitizeOutput(stderr.String()) + if cctx.Err() == context.DeadlineExceeded { + return out, errOut, fmt.Errorf("command timed out: %s", name) + } + return out, errOut, err +} + +type limitedWriter struct { + buf *bytes.Buffer + limit int + n int +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + remain := w.limit - w.n + if remain <= 0 { + return len(p), nil + } + if len(p) > remain { + p = p[:remain] + } + n, err := w.buf.Write(p) + w.n += n + return len(p), err +} + +func sanitizeOutput(s string) string { + lower := strings.ToLower(s) + for _, needle := range []string{"password=", "secret=", "token=", "access_token=", "api_key="} { + if idx := strings.Index(lower, needle); idx >= 0 { + return s[:idx] + needle + "[redacted]" + } + } + return s +} + +func denyDangerousGitArgs(name string, args []string) error { + base := name + if i := strings.LastIndex(name, "/"); i >= 0 { + base = name[i+1:] + } + if base != "git" { + return nil + } + banned := map[string]bool{ + "fetch": true, "push": true, "pull": true, "clone": true, + "commit": true, "checkout": true, "merge": true, "rebase": true, + "reset": true, "clean": true, "add": true, "tag": true, + } + for _, a := range args { + if strings.HasPrefix(a, "-") { + continue + } + if banned[a] { + return fmt.Errorf("git write/network command denied: %s", a) + } + break + } + joined := strings.Join(args, " ") + if strings.Contains(joined, "&&") || strings.Contains(joined, "|") || strings.Contains(joined, ";") { + return errors.New("shell metacharacters denied") + } + return nil +} + +// FakeRunner records invocations for tests. +type FakeRunner struct { + Calls [][]string + Map map[string]FakeResult // key: name + " " + joined args +} + +// FakeResult is a canned command result. +type FakeResult struct { + Stdout string + Stderr string + Err error +} + +func (f *FakeRunner) Run(_ context.Context, _ string, name string, args ...string) (string, string, error) { + key := name + " " + strings.Join(args, " ") + f.Calls = append(f.Calls, append([]string{name}, args...)) + if f.Map != nil { + if res, ok := f.Map[key]; ok { + return res.Stdout, res.Stderr, res.Err + } + // prefix match for flexibility + for k, res := range f.Map { + if strings.HasPrefix(key, k) { + return res.Stdout, res.Stderr, res.Err + } + } + } + return "", "", fmt.Errorf("no fake result for %q", key) +} + +func (f *FakeRunner) AssertNoForbidden(t interface{ Fatalf(string, ...any) }) { + for _, call := range f.Calls { + if len(call) == 0 { + continue + } + name := call[0] + base := name + if i := strings.LastIndex(name, "/"); i >= 0 { + base = name[i+1:] + } + if base == "sh" || base == "bash" || base == "zsh" { + t.Fatalf("forbidden shell observed: %v", call) + } + joined := strings.Join(call, " ") + if base == "git" { + for _, a := range call[1:] { + if strings.HasPrefix(a, "-") { + continue + } + switch a { + case "fetch", "push", "pull", "clone", "commit", "checkout", "merge", "rebase", "reset", "clean", "add", "tag": + t.Fatalf("forbidden git command observed: %s", joined) + } + break + } + } + } +} diff --git a/internal/engineeringdashboard/runtime_test.go b/internal/engineeringdashboard/runtime_test.go new file mode 100644 index 00000000..e0d56655 --- /dev/null +++ b/internal/engineeringdashboard/runtime_test.go @@ -0,0 +1,117 @@ +package engineeringdashboard + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestRuntime_ReadyOKAndEventsRestarting(t *testing.T) { + now := time.Now().UTC() + dockerLines := []map[string]any{ + {"Names": "shuimu-agent-main", "Image": "agent:main", "Status": "Up (healthy)", "Labels": "com.docker.compose.project=agent-main,com.docker.compose.service=app"}, + {"Names": "shuimu-agent-events-main", "Image": "events:main", "Status": "Restarting (1) 2 seconds ago", "Labels": "com.docker.compose.project=agent-main,com.docker.compose.service=events"}, + } + var b strings.Builder + for _, row := range dockerLines { + raw, _ := json.Marshal(row) + b.Write(raw) + b.WriteByte('\n') + } + fake := &FakeRunner{Map: map[string]FakeResult{ + "docker ps -a --format {{json .}}": {Stdout: b.String()}, + }} + + // Override ready endpoints via temporary server on allowlisted pattern — use real client against loopback. + // CollectRuntime uses fixed ports; for unit test we only validate docker facet path by calling parse helpers. + cv, env, ok := parseDockerLine(string(mustJSON(dockerLines[1]))) + if !ok || env != "main" || cv.Status == "" { + t.Fatalf("parse failed: %+v %s", cv, env) + } + envState := EnvironmentState{ + Name: "main", + Containers: []ContainerView{ + mustParseCV(dockerLines[0]), + mustParseCV(dockerLines[1]), + }, + Readiness: "ok", + } + envState.Facets.ContainerStatus = summarizeContainers(envState.Containers) + envState.Facets.AppReadiness = "ok" + envState.Facets.ServicesOK = summarizeServices(envState.Containers, "ok") + if envState.Facets.ContainerStatus != "partial" || envState.Facets.ServicesOK != "partial" { + t.Fatalf("expected partial facets: %+v", envState.Facets) + } + _ = fake + _ = now +} + +func TestRuntime_RedirectOffAllowlistDenied(t *testing.T) { + if err := assertLoopbackReadyURL("http://example.com/ready"); err == nil { + t.Fatal("expected deny") + } + if err := assertLoopbackReadyURL("http://127.0.0.1:9999/ready"); err == nil { + t.Fatal("expected port deny") + } + if err := assertLoopbackReadyURL("http://127.0.0.1:8080/ready"); err != nil { + t.Fatal(err) + } +} + +func TestRuntime_DockerUnavailableStillReturnsEnvs(t *testing.T) { + fake := &FakeRunner{Map: map[string]FakeResult{ + "docker ps -a --format {{json .}}": {Err: errorsNew("docker missing")}, + }} + // Avoid hitting real ready ports: still OK if degraded. + res := CollectRuntime(context.Background(), t.TempDir(), fake, time.Now().UTC()) + if len(res.Environments) != 4 { + t.Fatalf("expected 4 envs: %+v", res.Environments) + } + if res.Source.Status != SourceDegraded && res.Source.Status != SourceOK { + t.Fatalf("unexpected source: %+v", res.Source) + } +} + +func TestRuntime_NoEnvInOutput(t *testing.T) { + line := `{"Names":"x","Image":"i","Status":"Up","Labels":"com.docker.compose.project=agent-dev,com.docker.compose.service=app","Env":["SECRET=1"]}` + cv, _, ok := parseDockerLine(line) + if !ok { + t.Fatal("parse failed") + } + raw, _ := json.Marshal(cv) + if strings.Contains(string(raw), "SECRET") || strings.Contains(string(raw), "Env") { + t.Fatalf("env leaked: %s", raw) + } +} + +func TestReadyHTTP_UsesLoopbackServer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"schema_version":48}`)) + })) + defer srv.Close() + // server URL is 127.0.0.1:random — not allowlisted port, assertLoopbackReadyURL should fail + if err := assertLoopbackReadyURL(srv.URL + "/ready"); err == nil { + t.Fatal("random port must be denied by allowlist") + } +} + +func mustJSON(v any) []byte { + b, _ := json.Marshal(v) + return b +} + +func mustParseCV(row map[string]any) ContainerView { + cv, _, _ := parseDockerLine(string(mustJSON(row))) + return cv +} + +func errorsNew(s string) error { return &simpleErr{s} } + +type simpleErr struct{ s string } + +func (e *simpleErr) Error() string { return e.s } diff --git a/internal/engineeringdashboard/source_docs.go b/internal/engineeringdashboard/source_docs.go new file mode 100644 index 00000000..907173e2 --- /dev/null +++ b/internal/engineeringdashboard/source_docs.go @@ -0,0 +1,367 @@ +package engineeringdashboard + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + maxDocBodyBytes = 256 * 1024 + maxTotalBodyBytes = 8 * 1024 * 1024 + defaultDocsSource = "docs" +) + +var mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) + +// DocsCollectResult is the docs collector output. +type DocsCollectResult struct { + Source SourceState + Documents []DocumentNode + Facts []Fact + Modules []ModuleView + Tasks []TaskState + Warnings []string +} + +// CollectDocs indexes allowlisted markdown under repoRoot. +func CollectDocs(repoRoot string, now time.Time) DocsCollectResult { + start := time.Now() + res := DocsCollectResult{} + totalBody := 0 + var docs []DocumentNode + byID := map[string]*DocumentNode{} + + err := filepath.WalkDir(filepath.Join(repoRoot, ".doc"), func(path string, d os.DirEntry, err error) error { + if err != nil { + res.Warnings = append(res.Warnings, err.Error()) + return nil + } + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + if d.IsDir() { + if d.Name() == "assets" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(strings.ToLower(rel), ".md") { + return nil + } + if IsAssetsPath(rel) { + return nil + } + abs, err := SafeDocPath(repoRoot, rel) + if err != nil { + res.Warnings = append(res.Warnings, err.Error()) + return nil + } + raw, err := os.ReadFile(abs) + if err != nil { + res.Warnings = append(res.Warnings, err.Error()) + return nil + } + node := parseMarkdownDoc(rel, string(raw), &totalBody) + docs = append(docs, node) + byID[node.ID] = &docs[len(docs)-1] + return nil + }) + if err != nil { + res.Source = SourceState{ + Name: defaultDocsSource, Status: SourceError, Freshness: FreshnessError, + ObservedAt: now, Error: err.Error(), DurationMS: time.Since(start).Milliseconds(), + } + return res + } + + // reverse links + broken + idSet := map[string]bool{} + for i := range docs { + idSet[docs[i].ID] = true + } + for i := range docs { + var broken []string + for _, out := range docs[i].LinksOut { + target := normalizeLinkTarget(docs[i].Path, out) + if target == "" { + continue + } + if !idSet[target] { + broken = append(broken, out) + continue + } + if t := byID[target]; t != nil { + t.LinksIn = append(t.LinksIn, docs[i].ID) + } + } + docs[i].BrokenLinks = broken + } + for i := range docs { + if len(docs[i].LinksIn) == 0 && docs[i].Taxonomy != "ops" { + // orphan heuristic: no inbound and not CURRENT-like + if !strings.Contains(docs[i].Path, "CURRENT.md") && !strings.Contains(docs[i].Path, "README.md") { + docs[i].Orphan = len(docs[i].LinksOut) == 0 + } + } + } + + res.Documents = docs + res.Modules = parseFeatureMatrixModules(docs) + res.Tasks = parseTaskDocs(docs, now) + res.Facts = append(res.Facts, factsFromTasks(res.Tasks, now)...) + res.Source = SourceState{ + Name: defaultDocsSource, Status: SourceOK, Freshness: FreshnessFresh, + ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(), + } + return res +} + +func parseMarkdownDoc(rel, raw string, totalBody *int) DocumentNode { + node := DocumentNode{ + ID: filepath.ToSlash(rel), + Path: filepath.ToSlash(rel), + } + body := raw + fm, rest, warn := splitFrontMatter(raw) + if warn != "" { + node.ParseWarnings = append(node.ParseWarnings, warn) + } + if fm != nil { + node.FrontMatter = fm + if s := fm["status"]; s != "" { + node.Status = s + } + if t := fm["title"]; t != "" { + node.Title = t + } + body = rest + } + if node.Title == "" { + node.Title = firstHeading(body) + } + if node.Title == "" { + node.Title = filepath.Base(rel) + } + node.Taxonomy = taxonomyOf(rel) + for _, m := range mdLinkRe.FindAllStringSubmatch(body, -1) { + href := strings.TrimSpace(m[2]) + if href == "" || strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "mailto:") { + continue + } + if strings.HasPrefix(href, "#") { + continue + } + node.LinksOut = append(node.LinksOut, href) + } + remain := maxTotalBodyBytes - *totalBody + if remain < 0 { + remain = 0 + } + limit := maxDocBodyBytes + if remain < limit { + limit = remain + } + if len(body) > limit { + node.Body = body[:limit] + node.BodyTruncated = true + } else { + node.Body = body + } + *totalBody += len(node.Body) + return node +} + +func splitFrontMatter(raw string) (map[string]string, string, string) { + if !strings.HasPrefix(raw, "---\n") && !strings.HasPrefix(raw, "---\r\n") { + return nil, raw, "" + } + rest := raw[3:] + if strings.HasPrefix(rest, "\r\n") { + rest = rest[2:] + } else if strings.HasPrefix(rest, "\n") { + rest = rest[1:] + } + end := strings.Index(rest, "\n---") + if end < 0 { + return nil, raw, "front matter not closed" + } + block := rest[:end] + body := rest[end+4:] + body = strings.TrimPrefix(body, "\r") + body = strings.TrimPrefix(body, "\n") + var y map[string]any + if err := yaml.Unmarshal([]byte(block), &y); err != nil { + return nil, raw, "front matter yaml: " + err.Error() + } + out := map[string]string{} + for k, v := range y { + out[k] = stringifyYAML(v) + } + return out, body, "" +} + +func stringifyYAML(v any) string { + switch t := v.(type) { + case string: + return t + case bool: + if t { + return "true" + } + return "false" + default: + b, _ := yaml.Marshal(v) + return strings.TrimSpace(string(b)) + } +} + +func firstHeading(body string) string { + sc := bufio.NewScanner(strings.NewReader(body)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if strings.HasPrefix(line, "# ") { + return strings.TrimSpace(strings.TrimPrefix(line, "# ")) + } + } + return "" +} + +func taxonomyOf(rel string) string { + rel = filepath.ToSlash(rel) + parts := strings.Split(rel, "/") + if len(parts) >= 2 && parts[0] == ".doc" { + return parts[1] + } + return "" +} + +func normalizeLinkTarget(fromPath, href string) string { + href = strings.Split(href, "#")[0] + href = strings.TrimSpace(href) + if href == "" { + return "" + } + dir := filepath.ToSlash(filepath.Dir(fromPath)) + joined := filepath.ToSlash(filepath.Clean(filepath.Join(dir, href))) + joined = strings.TrimPrefix(joined, "/") + if !strings.HasSuffix(strings.ToLower(joined), ".md") { + return "" + } + return joined +} + +func parseFeatureMatrixModules(docs []DocumentNode) []ModuleView { + var out []ModuleView + for _, d := range docs { + if !strings.HasSuffix(d.Path, "FEATURE-MATRIX.md") { + continue + } + sc := bufio.NewScanner(strings.NewReader(d.Body)) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "|") || strings.Contains(line, "---") { + continue + } + cols := splitTableRow(line) + if len(cols) < 7 { + continue + } + name := strings.TrimSpace(cols[0]) + if name == "" || name == "模块" || strings.HasPrefix(name, "**") { + continue + } + out = append(out, ModuleView{ + Name: name, + Status: strings.TrimSpace(cols[2]), + Mutex: strings.Trim(strings.TrimSpace(cols[6]), "`"), + Evidence: []string{d.Path}, + }) + } + } + return out +} + +func splitTableRow(line string) []string { + line = strings.Trim(line, "|") + parts := strings.Split(line, "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func parseTaskDocs(docs []DocumentNode, now time.Time) []TaskState { + var out []TaskState + for _, d := range docs { + if !strings.Contains(d.Path, "/short-term/") && !strings.Contains(d.Path, "/long-term/") && !strings.Contains(d.Path, "/backlog/") { + continue + } + st := DocStatusUnknown + if d.Status != "" { + st = DocStatus(d.Status) + } + claimed := false + owner := d.FrontMatter["owner"] + branch := d.FrontMatter["branch"] + wt := d.FrontMatter["worktree"] + mutex := d.FrontMatter["mutex"] + if owner != "" && owner != "unassigned" { + claimed = true + } + kind := AgentKindUnknown + if strings.Contains(strings.ToLower(owner), "herdr") { + kind = AgentKindHerdrEngineering + } else if owner != "" && owner != "unassigned" { + kind = AgentKindHuman + } + task := TaskState{ + ID: "task:" + d.ID, + Title: d.Title, + Path: d.Path, + DocStatus: st, + Claim: AgentClaim{ + Owner: owner, AgentKind: kind, Claimed: claimed, + Mutex: mutex, Branch: branch, Worktree: wt, + Activity: "unknown", + Evidence: "declared in task front matter / CURRENT; activity unknown without runtime source", + }, + Git: GitFacets{Branch: branch, Worktree: wt, Cleanliness: "unknown", RemoteNote: "git facets filled by reconcile"}, + Verification: VerificationFacets{Result: "unknown", Freshness: FreshnessUnknown}, + Deployment: map[string]DeploymentFacets{ + "dev": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown"}, + "test": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown"}, + "main": {ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown"}, + }, + EvidenceChain: append([]string{d.Path}, d.LinksOut...), + } + _ = now + out = append(out, task) + } + return out +} + +func factsFromTasks(tasks []TaskState, now time.Time) []Fact { + var facts []Fact + for _, t := range tasks { + id := FactID(t.ID, "doc_status", "docs") + facts = append(facts, Fact{ + ID: id, Subject: t.ID, Field: "doc_status", Value: string(t.DocStatus), + Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: now, + }) + if t.Claim.Claimed { + facts = append(facts, Fact{ + ID: FactID(t.ID, "claim", "docs"), Subject: t.ID, Field: "claim", + Value: t.Claim.Owner, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: now, + }) + } + } + return facts +} diff --git a/internal/engineeringdashboard/source_git.go b/internal/engineeringdashboard/source_git.go new file mode 100644 index 00000000..499288e0 --- /dev/null +++ b/internal/engineeringdashboard/source_git.go @@ -0,0 +1,160 @@ +package engineeringdashboard + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +// GitCollectResult is the git collector output. +type GitCollectResult struct { + Source SourceState + Repository RepositoryIdentity + Branches []GitBranchView + Facts []Fact + Warnings []string +} + +// CollectGit gathers read-only git observations via runner. +func CollectGit(ctx context.Context, repoRoot string, runner CommandRunner, now time.Time) GitCollectResult { + start := time.Now() + res := GitCollectResult{ + Repository: RepositoryIdentity{Root: repoRoot}, + } + if runner == nil { + runner = ExecRunner{} + } + + branch, err := gitOut(ctx, runner, repoRoot, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return gitFail(res, start, now, err) + } + head, err := gitOut(ctx, runner, repoRoot, "rev-parse", "HEAD") + if err != nil { + return gitFail(res, start, now, err) + } + res.Repository.Branch = strings.TrimSpace(branch) + res.Repository.Head = strings.TrimSpace(head) + + status, err := gitOut(ctx, runner, repoRoot, "status", "--porcelain=v2", "--branch") + if err != nil { + return gitFail(res, start, now, err) + } + dirty := false + for _, line := range strings.Split(status, "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + dirty = true + break + } + res.Repository.Dirty = dirty + + refs, err := gitOut(ctx, runner, repoRoot, "for-each-ref", "--format=%(refname:short)|%(objectname:short)|%(upstream:short)", "refs/heads") + if err != nil { + res.Warnings = append(res.Warnings, err.Error()) + } + + worktrees, _ := gitOut(ctx, runner, repoRoot, "worktree", "list", "--porcelain") + wtByBranch := parseWorktrees(worktrees) + + remoteNote := "based on local refs; not network-refreshed" + for _, line := range strings.Split(refs, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, "|") + name := parts[0] + sha := "" + up := "" + if len(parts) > 1 { + sha = parts[1] + } + if len(parts) > 2 { + up = parts[2] + } + ahead, behind := 0, 0 + if up != "" { + ab, err := gitOut(ctx, runner, repoRoot, "rev-list", "--left-right", "--count", up+"..."+name) + if err == nil { + fields := strings.Fields(strings.TrimSpace(ab)) + if len(fields) == 2 { + behind, _ = strconv.Atoi(fields[0]) + ahead, _ = strconv.Atoi(fields[1]) + } + } + } + view := GitBranchView{ + Name: name, + IsCurrent: name == res.Repository.Branch, + Head: sha, + Upstream: up, + Ahead: ahead, + Behind: behind, + Worktree: wtByBranch[name], + Dirty: name == res.Repository.Branch && dirty, + RemoteNote: remoteNote, + ObservedAt: now, + } + res.Branches = append(res.Branches, view) + res.Facts = append(res.Facts, Fact{ + ID: FactID("git:"+name, "ahead_behind", "git"), Subject: "git:" + name, Field: "ahead_behind", + Value: fmt.Sprintf("ahead=%d behind=%d", ahead, behind), + Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: now, + }) + } + + res.Source = SourceState{ + Name: "git", Status: SourceOK, Freshness: FreshnessFresh, + ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(), + } + return res +} + +func gitOut(ctx context.Context, runner CommandRunner, dir string, args ...string) (string, error) { + stdout, stderr, err := runner.Run(ctx, dir, "git", args...) + if err != nil { + msg := strings.TrimSpace(stderr) + if msg == "" { + msg = err.Error() + } + return stdout, fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) + } + return stdout, nil +} + +func gitFail(res GitCollectResult, start time.Time, now time.Time, err error) GitCollectResult { + res.Source = SourceState{ + Name: "git", Status: SourceError, Freshness: FreshnessError, + ObservedAt: now, Error: err.Error(), DurationMS: time.Since(start).Milliseconds(), + } + return res +} + +func parseWorktrees(porcelain string) map[string]string { + out := map[string]string{} + var path, branch string + flush := func() { + if path != "" && branch != "" { + out[branch] = path + } + path, branch = "", "" + } + for _, line := range strings.Split(porcelain, "\n") { + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, "worktree ") { + path = strings.TrimPrefix(line, "worktree ") + } + if strings.HasPrefix(line, "branch ") { + branch = strings.TrimPrefix(line, "branch refs/heads/") + } + } + flush() + return out +} diff --git a/internal/engineeringdashboard/source_runtime.go b/internal/engineeringdashboard/source_runtime.go new file mode 100644 index 00000000..30196c07 --- /dev/null +++ b/internal/engineeringdashboard/source_runtime.go @@ -0,0 +1,351 @@ +package engineeringdashboard + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// ReadyEndpoint maps an environment name to a loopback readiness URL. +type ReadyEndpoint struct { + Env string + URL string +} + +var defaultReadyEndpoints = []ReadyEndpoint{ + {Env: "dev", URL: "http://127.0.0.1:8080/ready"}, + {Env: "test", URL: "http://127.0.0.1:8081/ready"}, + {Env: "main", URL: "http://127.0.0.1:8000/ready"}, +} + +var allowedComposeProjects = map[string]string{ + "agent-dev": "dev", + "agent-test": "test", + "agent-main": "main", + "agent-infra": "shared", +} + +// RuntimeCollectResult is the runtime collector output. +type RuntimeCollectResult struct { + Source SourceState + Environments []EnvironmentState + Facts []Fact + Warnings []string +} + +// CollectRuntime gathers docker + readiness + receipt observations. +func CollectRuntime(ctx context.Context, repoRoot string, runner CommandRunner, now time.Time) RuntimeCollectResult { + start := time.Now() + res := RuntimeCollectResult{} + if runner == nil { + runner = ExecRunner{} + } + + envs := map[string]*EnvironmentState{ + "shared": {Name: "shared"}, + "dev": {Name: "dev"}, + "test": {Name: "test"}, + "main": {Name: "main"}, + } + + psOut, stderr, err := runner.Run(ctx, repoRoot, "docker", "ps", "-a", "--format", "{{json .}}") + if err != nil { + res.Warnings = append(res.Warnings, "docker unavailable: "+strings.TrimSpace(stderr+" "+err.Error())) + for _, e := range envs { + e.Facets = DeploymentFacets{ + ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown", + IdentityNote: "docker unavailable", + } + } + } else { + for _, line := range strings.Split(psOut, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + cv, envName, ok := parseDockerLine(line) + if !ok { + continue + } + e := envs[envName] + if e == nil { + continue + } + e.Containers = append(e.Containers, cv) + } + } + + client := &http.Client{ + Timeout: 2 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if err := assertLoopbackReadyURL(req.URL.String()); err != nil { + return err + } + if len(via) >= 3 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + for _, ep := range defaultReadyEndpoints { + if err := assertLoopbackReadyURL(ep.URL); err != nil { + res.Warnings = append(res.Warnings, err.Error()) + continue + } + status, body, err := getReady(ctx, client, ep.URL) + e := envs[ep.Env] + if e == nil { + continue + } + if err != nil { + e.Readiness = "error" + e.Facets.AppReadiness = "error" + res.Warnings = append(res.Warnings, ep.Env+" ready: "+err.Error()) + continue + } + if status >= 200 && status < 300 { + e.Readiness = "ok" + e.Facets.AppReadiness = "ok" + } else { + e.Readiness = fmt.Sprintf("http_%d", status) + e.Facets.AppReadiness = e.Readiness + } + if strings.Contains(body, "schema_version") { + e.Schema = extractJSONField(body, "schema_version") + } + res.Facts = append(res.Facts, Fact{ + ID: FactID("env:"+ep.Env, "readiness", "ready"), Subject: "env:" + ep.Env, Field: "readiness", + Value: e.Readiness, Provenance: ProvenanceObserved, SourceRef: ep.URL, ObservedAt: now, + }) + } + + for name, e := range envs { + e.Facets.ContainerStatus = summarizeContainers(e.Containers) + e.Facets.ServicesOK = summarizeServices(e.Containers, e.Facets.AppReadiness) + if e.Facets.AppReadiness == "" { + e.Facets.AppReadiness = "unknown" + } + receipt := findLatestReceipt(repoRoot) + if receipt != "" { + e.Receipt = receipt + e.Facets.ArtifactID = filepath.Base(receipt) + } + if e.Facets.ContainerStatus == "partial" && e.Facets.AppReadiness == "ok" { + e.DriftNotes = append(e.DriftNotes, "app readiness ok coexists with partial container health — no aggregate green") + e.Facets.IdentityNote = "running visible; service completeness partial" + } + if e.Facets.ContainerStatus != "unknown" && e.Facets.ArtifactID == "" { + e.Facets.IdentityNote = "running visible, version unverified" + } + res.Facts = append(res.Facts, Fact{ + ID: FactID("env:"+name, "container_status", "docker"), Subject: "env:" + name, Field: "container_status", + Value: e.Facets.ContainerStatus, Provenance: ProvenanceObserved, SourceRef: "docker", ObservedAt: now, + }) + } + + for _, name := range []string{"shared", "dev", "test", "main"} { + res.Environments = append(res.Environments, *envs[name]) + } + + status := SourceOK + fresh := FreshnessFresh + if len(res.Warnings) > 0 { + status = SourceDegraded + } + res.Source = SourceState{ + Name: "runtime", Status: status, Freshness: fresh, + ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(), + } + return res +} + +func parseDockerLine(line string) (ContainerView, string, bool) { + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + return ContainerView{}, "", false + } + // docker --format {{json .}} fields vary; support both Names and names + name := strField(raw, "Names", "names", "Name") + image := strField(raw, "Image", "image") + status := strField(raw, "Status", "status", "State") + labels := strField(raw, "Labels", "labels") + project, service := parseComposeLabels(labels) + envName := allowedComposeProjects[project] + if envName == "" { + // try infer from name + lname := strings.ToLower(name) + switch { + case strings.Contains(lname, "-dev"), strings.Contains(lname, "_dev"): + envName = "dev" + case strings.Contains(lname, "-test"), strings.Contains(lname, "_test"): + envName = "test" + case strings.Contains(lname, "-main"), strings.Contains(lname, "_main"): + envName = "main" + default: + return ContainerView{}, "", false + } + } + health := "" + lowerStatus := strings.ToLower(status) + switch { + case strings.Contains(lowerStatus, "unhealthy"): + health = "unhealthy" + case strings.Contains(lowerStatus, "healthy"): + health = "healthy" + case strings.Contains(lowerStatus, "restarting"): + health = "unhealthy" + } + restarts := 0 + if strings.Contains(lowerStatus, "restarting") { + restarts = 1 + } + cv := ContainerView{ + Name: name, Image: image, Status: status, Health: health, + RestartCount: restarts, Project: project, Service: service, + } + return cv, envName, true +} + +func strField(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + switch t := v.(type) { + case string: + return t + default: + return fmt.Sprint(t) + } + } + } + return "" +} + +func parseComposeLabels(labels string) (project, service string) { + for _, part := range strings.Split(labels, ",") { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + switch kv[0] { + case "com.docker.compose.project": + project = kv[1] + case "com.docker.compose.service": + service = kv[1] + } + } + return project, service +} + +func summarizeContainers(cs []ContainerView) string { + if len(cs) == 0 { + return "unknown" + } + bad := 0 + for _, c := range cs { + ls := strings.ToLower(c.Status + " " + c.Health) + if strings.Contains(ls, "restart") || strings.Contains(ls, "unhealthy") || strings.Contains(ls, "exited") { + bad++ + } + } + if bad == 0 { + return "ok" + } + if bad < len(cs) { + return "partial" + } + return "unhealthy" +} + +func summarizeServices(cs []ContainerView, readiness string) string { + cstat := summarizeContainers(cs) + if cstat == "ok" && readiness == "ok" { + return "complete" + } + if cstat == "unknown" && (readiness == "" || readiness == "unknown") { + return "unknown" + } + if cstat == "partial" || readiness == "ok" { + return "partial" + } + return "partial" +} + +func assertLoopbackReadyURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return err + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("ready scheme denied: %s", u.Scheme) + } + host := u.Hostname() + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("ready host must be loopback: %s", host) + } + port := u.Port() + switch port { + case "8080", "8081", "8000", "": + // ok (empty only if default — still require explicit in our list) + default: + return fmt.Errorf("ready port not allowlisted: %s", port) + } + return nil +} + +func getReady(ctx context.Context, client *http.Client, rawURL string) (int, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return 0, "", err + } + resp, err := client.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return resp.StatusCode, string(body), nil +} + +func extractJSONField(body, key string) string { + var m map[string]any + if err := json.Unmarshal([]byte(body), &m); err != nil { + return "" + } + if v, ok := m[key]; ok { + return fmt.Sprint(v) + } + return "" +} + +func findLatestReceipt(repoRoot string) string { + dir := filepath.Join(repoRoot, ".doc", "test-reports", "assets", "promotion-receipts") + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + var latest string + var latestMod time.Time + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().After(latestMod) { + latestMod = info.ModTime() + latest = filepath.ToSlash(filepath.Join(".doc/test-reports/assets/promotion-receipts", e.Name())) + } + } + return latest +} From 79ee72267c240570bd36754754f7576a806b0e05 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 07:04:46 +0000 Subject: [PATCH 4/6] feat(engineering-dashboard): U5 reconcile and snapshot publisher Merge docs/git/runtime facts into an immutable snapshot with conflict retention, last-known stale handling, and acceptance-sample coverage. Co-authored-by: Cursor --- internal/engineeringdashboard/collector.go | 257 ++++++++++++++++++ .../engineeringdashboard/reconcile_test.go | 171 ++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 internal/engineeringdashboard/collector.go create mode 100644 internal/engineeringdashboard/reconcile_test.go diff --git a/internal/engineeringdashboard/collector.go b/internal/engineeringdashboard/collector.go new file mode 100644 index 00000000..2be3051a --- /dev/null +++ b/internal/engineeringdashboard/collector.go @@ -0,0 +1,257 @@ +package engineeringdashboard + +import ( + "context" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" +) + +// Collector coordinates parallel source collection and atomic snapshot publish. +type Collector struct { + RepoRoot string + Runner CommandRunner + TTL time.Duration + + mu sync.Mutex + current *Snapshot + lastKnown map[string]SourceState + gen atomic.Uint64 + collecting atomic.Bool + singleflight chan struct{} +} + +// NewCollector builds a coordinator for one repo root. +func NewCollector(repoRoot string, runner CommandRunner) *Collector { + if runner == nil { + runner = ExecRunner{} + } + return &Collector{ + RepoRoot: repoRoot, + Runner: runner, + TTL: 3 * time.Second, + lastKnown: map[string]SourceState{}, + singleflight: make(chan struct{}, 1), + } +} + +// Current returns the latest published snapshot (may be nil on cold start). +func (c *Collector) Current() *Snapshot { + c.mu.Lock() + defer c.mu.Unlock() + return c.current +} + +// Refresh builds and publishes a new snapshot. Concurrent callers share one flight. +func (c *Collector) Refresh(ctx context.Context) (*Snapshot, error) { + select { + case c.singleflight <- struct{}{}: + defer func() { <-c.singleflight }() + default: + // another collection in flight — return current + if s := c.Current(); s != nil { + return s, nil + } + // wait briefly for first + select { + case c.singleflight <- struct{}{}: + defer func() { <-c.singleflight }() + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return c.collectAndPublish(ctx) +} + +func (c *Collector) collectAndPublish(ctx context.Context) (*Snapshot, error) { + c.collecting.Store(true) + defer c.collecting.Store(false) + + start := time.Now() + now := start.UTC() + deadline, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + + var docs DocsCollectResult + var git GitCollectResult + var runtime RuntimeCollectResult + var wg sync.WaitGroup + wg.Add(3) + go func() { + defer wg.Done() + docs = CollectDocs(c.RepoRoot, now) + }() + go func() { + defer wg.Done() + git = CollectGit(deadline, c.RepoRoot, c.Runner, now) + }() + go func() { + defer wg.Done() + runtime = CollectRuntime(deadline, c.RepoRoot, c.Runner, now) + }() + wg.Wait() + + docs.Source = c.applyLastKnown(docs.Source, now) + git.Source = c.applyLastKnown(git.Source, now) + runtime.Source = c.applyLastKnown(runtime.Source, now) + + snap := Reconcile(ReconcileInput{ + RepoRoot: c.RepoRoot, + Now: now, + Duration: time.Since(start), + Docs: docs, + Git: git, + Runtime: runtime, + Generation: c.gen.Add(1), + }) + c.mu.Lock() + c.current = &snap + c.mu.Unlock() + return &snap, nil +} + +func (c *Collector) applyLastKnown(src SourceState, now time.Time) SourceState { + c.mu.Lock() + defer c.mu.Unlock() + if src.Status == SourceOK || src.Status == SourceDegraded { + cp := src + cp.LastSuccessAt = &now + c.lastKnown[src.Name] = cp + return cp + } + prev, ok := c.lastKnown[src.Name] + if !ok { + src.Freshness = FreshnessError + if src.Status == "" { + src.Status = SourceError + } + return src + } + // retain last-known markers without rewriting observed_at of cached facts + src.Status = SourceDegraded + src.Freshness = FreshnessStale + src.LastSuccessAt = prev.LastSuccessAt + if src.Error == "" { + src.Error = "using last-known; current collection failed" + } + return src +} + +// ReconcileInput feeds the pure reconcile function. +type ReconcileInput struct { + RepoRoot string + Now time.Time + Duration time.Duration + Docs DocsCollectResult + Git GitCollectResult + Runtime RuntimeCollectResult + Generation uint64 +} + +// Reconcile merges source facts into an immutable snapshot without picking winners on conflicts. +func Reconcile(in ReconcileInput) Snapshot { + repo := in.Git.Repository + if repo.Root == "" { + repo.Root = in.RepoRoot + } + + tasks := append([]TaskState(nil), in.Docs.Tasks...) + gitByName := map[string]GitBranchView{} + for _, g := range in.Git.Branches { + gitByName[g.Name] = g + } + + var conflicts []Conflict + var facts []Fact + facts = append(facts, in.Docs.Facts...) + facts = append(facts, in.Git.Facts...) + facts = append(facts, in.Runtime.Facts...) + + for i := range tasks { + t := &tasks[i] + declaredBranch := t.Claim.Branch + if declaredBranch != "" { + if g, ok := gitByName[declaredBranch]; ok { + t.Git.Branch = g.Name + t.Git.Worktree = g.Worktree + t.Git.Ahead = g.Ahead + t.Git.Behind = g.Behind + t.Git.RemoteNote = g.RemoteNote + if g.Dirty { + t.Git.Cleanliness = "dirty" + } else { + t.Git.Cleanliness = "clean" + } + if t.Claim.Worktree != "" && g.Worktree != "" && t.Claim.Worktree != g.Worktree && t.Claim.Worktree != "-" { + f1 := FactID(t.ID, "worktree", "docs") + f2 := FactID(t.ID, "worktree", "git") + facts = append(facts, + Fact{ID: f1, Subject: t.ID, Field: "worktree", Value: t.Claim.Worktree, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now}, + Fact{ID: f2, Subject: t.ID, Field: "worktree", Value: g.Worktree, Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: in.Now}, + ) + conflicts = append(conflicts, NewConflict(t.ID, "worktree", ConflictTaskVsGit, []string{f1, f2}, "declared worktree differs from observed")) + } + } else if t.DocStatus == DocStatusInProgress || t.Claim.Claimed { + f1 := FactID(t.ID, "branch", "docs") + facts = append(facts, Fact{ID: f1, Subject: t.ID, Field: "branch", Value: declaredBranch, Provenance: ProvenanceDeclared, SourceRef: t.Path, ObservedAt: in.Now}) + conflicts = append(conflicts, NewConflict(t.ID, "branch", ConflictMissingEvidence, []string{f1}, "declared branch not observed locally")) + } + } + // claimed but no runtime activity source + if t.Claim.Claimed && t.Claim.Activity == "" { + t.Claim.Activity = "unknown" + } + // never promote doc done into deploy + if t.DocStatus == DocStatusDone { + for env, d := range t.Deployment { + if d.AppReadiness == "" { + d.AppReadiness = "unknown" + t.Deployment[env] = d + } + } + } + } + + envs := append([]EnvironmentState(nil), in.Runtime.Environments...) + for i := range envs { + e := &envs[i] + if e.Facets.AppReadiness == "ok" && e.Facets.ContainerStatus == "partial" { + f1 := FactID("env:"+e.Name, "readiness", "ready") + f2 := FactID("env:"+e.Name, "container_status", "docker") + conflicts = append(conflicts, NewConflict("env:"+e.Name, "health", ConflictDeclaredVsObserved, []string{f1, f2}, + "app /ready ok while containers partial — no aggregate green")) + } + if e.Receipt != "" && e.Facets.ArtifactID != "" { + // receipt present; identity note already set by runtime collector when mismatch unknown + } + } + + sources := []SourceState{in.Docs.Source, in.Git.Source, in.Runtime.Source} + sort.Slice(sources, func(i, j int) bool { return sources[i].Name < sources[j].Name }) + + var warnings []string + warnings = append(warnings, in.Docs.Warnings...) + warnings = append(warnings, in.Git.Warnings...) + warnings = append(warnings, in.Runtime.Warnings...) + + snap := Snapshot{ + SchemaVersion: SchemaVersion, + GenerationID: fmt.Sprintf("gen_%d", in.Generation), + GeneratedAt: in.Now, + CollectionDuration: in.Duration, + Repository: repo, + Sources: sources, + Facts: facts, + Conflicts: conflicts, + Documents: in.Docs.Documents, + Modules: in.Docs.Modules, + Tasks: tasks, + Git: in.Git.Branches, + Environments: envs, + Warnings: warnings, + } + snap.RevisionID = ComputeRevisionID(snap) + return snap +} diff --git a/internal/engineeringdashboard/reconcile_test.go b/internal/engineeringdashboard/reconcile_test.go new file mode 100644 index 00000000..a110317d --- /dev/null +++ b/internal/engineeringdashboard/reconcile_test.go @@ -0,0 +1,171 @@ +package engineeringdashboard + +import ( + "strings" + "testing" + "time" +) + +func TestReconcile_RDevPlannedUnclaimed(t *testing.T) { + task := SampleRDevTaskFixture() + in := ReconcileInput{ + RepoRoot: "/repo", + Now: time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC), + Generation: 1, + Docs: DocsCollectResult{ + Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}, + Tasks: []TaskState{task}, + Documents: []DocumentNode{{ + ID: task.Path, Path: task.Path, Title: task.Title, Status: "planned", + }}, + }, + Git: GitCollectResult{ + Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, + Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "h1"}, + }, + Runtime: RuntimeCollectResult{ + Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}, + }, + } + snap := Reconcile(in) + if len(snap.Tasks) != 1 { + t.Fatal(snap.Tasks) + } + got := snap.Tasks[0] + if got.DocStatus != DocStatusPlanned || got.Claim.Claimed { + t.Fatalf("expected planned/unclaimed: %+v", got) + } + if got.Git.Cleanliness != "unknown" { + t.Fatalf("must not invent git implementation: %+v", got.Git) + } + for _, d := range got.Deployment { + if d.AppReadiness != "unknown" { + t.Fatalf("must not invent deploy: %+v", d) + } + } +} + +func TestReconcile_DevAheadSample(t *testing.T) { + g := SampleDevAheadGitFixture() + in := ReconcileInput{ + RepoRoot: "/repo", + Now: time.Now().UTC(), + Generation: 2, + Git: GitCollectResult{ + Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, + Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: g.Head}, + Branches: []GitBranchView{g}, + }, + Docs: DocsCollectResult{Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}}, + Runtime: RuntimeCollectResult{Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}}, + } + snap := Reconcile(in) + if len(snap.Git) != 1 || snap.Git[0].Ahead != 3 { + t.Fatalf("%+v", snap.Git) + } + if !strings.Contains(snap.Git[0].RemoteNote, "unconfirmed") { + t.Fatal(snap.Git[0].RemoteNote) + } +} + +func TestReconcile_MainEventsForkConflict(t *testing.T) { + env := SampleMainEventsForkFixture() + in := ReconcileInput{ + RepoRoot: "/repo", + Now: time.Now().UTC(), + Generation: 3, + Docs: DocsCollectResult{Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}}, + Git: GitCollectResult{Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, Repository: RepositoryIdentity{Root: "/repo"}}, + Runtime: RuntimeCollectResult{ + Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}, + Environments: []EnvironmentState{env}, + Facts: []Fact{ + {ID: FactID("env:main", "readiness", "ready"), Subject: "env:main", Field: "readiness", Value: "ok", Provenance: ProvenanceObserved}, + {ID: FactID("env:main", "container_status", "docker"), Subject: "env:main", Field: "container_status", Value: "partial", Provenance: ProvenanceObserved}, + }, + }, + } + snap := Reconcile(in) + found := false + for _, c := range snap.Conflicts { + if c.Subject == "env:main" && c.Kind == ConflictDeclaredVsObserved { + found = true + if len(c.FactRefs) < 2 { + t.Fatalf("conflict must retain both refs: %+v", c) + } + } + } + if !found { + t.Fatalf("expected health conflict, got %+v", snap.Conflicts) + } + for _, e := range snap.Environments { + if e.Name == "main" && e.Facets.ServicesOK != "partial" { + t.Fatalf("no aggregate green: %+v", e.Facets) + } + } +} + +func TestReconcile_StableAcrossInputOrder(t *testing.T) { + now := time.Date(2026, 9, 17, 9, 0, 0, 0, time.UTC) + mk := func(facts []Fact) Snapshot { + return Reconcile(ReconcileInput{ + RepoRoot: "/repo", Now: now, Generation: 9, + Docs: DocsCollectResult{ + Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}, + Facts: facts, + }, + Git: GitCollectResult{Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "x"}}, + Runtime: RuntimeCollectResult{Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}}, + }) + } + f1 := Fact{ID: "a", Subject: "s", Field: "f", Value: "1", Provenance: ProvenanceDeclared, SourceRef: "d", ObservedAt: now} + f2 := Fact{ID: "b", Subject: "s", Field: "f", Value: "2", Provenance: ProvenanceObserved, SourceRef: "g", ObservedAt: now} + a := mk([]Fact{f1, f2}) + b := mk([]Fact{f2, f1}) + if a.RevisionID != b.RevisionID { + t.Fatalf("revision unstable: %s vs %s", a.RevisionID, b.RevisionID) + } +} + +func TestReconcile_DocDoneDoesNotFillDeploy(t *testing.T) { + task := SampleRDevTaskFixture() + task.DocStatus = DocStatusDone + snap := Reconcile(ReconcileInput{ + RepoRoot: "/repo", Now: time.Now().UTC(), Generation: 4, + Docs: DocsCollectResult{Source: SourceState{Name: "docs", Status: SourceOK, Freshness: FreshnessFresh}, Tasks: []TaskState{task}}, + Git: GitCollectResult{Source: SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh}, Repository: RepositoryIdentity{Root: "/repo"}}, + Runtime: RuntimeCollectResult{Source: SourceState{Name: "runtime", Status: SourceOK, Freshness: FreshnessFresh}}, + }) + for _, d := range snap.Tasks[0].Deployment { + if d.AppReadiness != "unknown" { + t.Fatalf("doc done must not imply deploy: %+v", d) + } + } +} + +func TestCollector_LastKnownStaleOnFailure(t *testing.T) { + c := NewCollector("/repo", &FakeRunner{Map: map[string]FakeResult{}}) + ok := SourceState{Name: "git", Status: SourceOK, Freshness: FreshnessFresh} + now := time.Now().UTC() + got := c.applyLastKnown(ok, now) + if got.LastSuccessAt == nil { + t.Fatal("expected last success") + } + fail := SourceState{Name: "git", Status: SourceError, Freshness: FreshnessError, Error: "boom"} + stale := c.applyLastKnown(fail, now.Add(time.Second)) + if stale.Freshness != FreshnessStale || stale.Status != SourceDegraded { + t.Fatalf("%+v", stale) + } + if stale.LastSuccessAt == nil || !stale.LastSuccessAt.Equal(now) { + t.Fatalf("must keep prior last-success: %+v", stale) + } +} + +func TestCollector_ColdStartFailureNoFakeHistory(t *testing.T) { + c := NewCollector("/repo", &FakeRunner{}) + fail := SourceState{Name: "git", Status: SourceError, Error: "missing"} + got := c.applyLastKnown(fail, time.Now().UTC()) + if got.Freshness != FreshnessError || got.LastSuccessAt != nil { + t.Fatalf("cold start must not invent last-known: %+v", got) + } +} From e251ce325a6f85ac40f21f09aee0017525b7b883 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 17 Sep 2026 07:08:27 +0000 Subject: [PATCH 5/6] feat(engineering-dashboard): U6-U7 serve UI and offline export Add loopback-only HTTP server with startup token, Host/Origin/CSP guards, embedded live UI, CLI serve/export, and safe single-file HTML. Co-authored-by: Cursor --- cmd/engineering-dashboard/main.go | 115 +++++++ internal/engineeringdashboard/assets/app.css | 95 ++++++ internal/engineeringdashboard/assets/app.js | 300 ++++++++++++++++++ .../engineeringdashboard/assets/index.html | 43 +++ internal/engineeringdashboard/assets_embed.go | 10 + internal/engineeringdashboard/export.go | 156 +++++++++ internal/engineeringdashboard/export_test.go | 56 ++++ internal/engineeringdashboard/server.go | 218 +++++++++++++ internal/engineeringdashboard/server_test.go | 130 ++++++++ 9 files changed, 1123 insertions(+) create mode 100644 cmd/engineering-dashboard/main.go create mode 100644 internal/engineeringdashboard/assets/app.css create mode 100644 internal/engineeringdashboard/assets/app.js create mode 100644 internal/engineeringdashboard/assets/index.html create mode 100644 internal/engineeringdashboard/assets_embed.go create mode 100644 internal/engineeringdashboard/export.go create mode 100644 internal/engineeringdashboard/export_test.go create mode 100644 internal/engineeringdashboard/server.go create mode 100644 internal/engineeringdashboard/server_test.go diff --git a/cmd/engineering-dashboard/main.go b/cmd/engineering-dashboard/main.go new file mode 100644 index 00000000..43edb2a3 --- /dev/null +++ b/cmd/engineering-dashboard/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/engineeringdashboard" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + cmd := os.Args[1] + args := os.Args[2:] + switch cmd { + case "serve": + if err := runServe(args); err != nil { + fmt.Fprintf(os.Stderr, "serve: %v\n", err) + os.Exit(1) + } + case "export": + if err := runExport(args); err != nil { + fmt.Fprintf(os.Stderr, "export: %v\n", err) + os.Exit(1) + } + case "help", "-h", "--help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `engineering-dashboard — local read-only personal engineering control center + +Usage: + engineering-dashboard serve [--repo PATH] [--addr 127.0.0.1:PORT] + engineering-dashboard export [--repo PATH] [--output FILE] + +Does not write docs, operate git/docker, deploy, or control Herdr. +`) +} + +func parseFlags(args []string) (repo, addr, output string) { + addr = "127.0.0.1:8787" + output = "engineering-dashboard-snapshot.html" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--repo": + i++ + if i < len(args) { + repo = args[i] + } + case "--addr": + i++ + if i < len(args) { + addr = args[i] + } + case "--output": + i++ + if i < len(args) { + output = args[i] + } + } + } + return repo, addr, output +} + +func runServe(args []string) error { + repo, addr, _ := parseFlags(args) + root, err := engineeringdashboard.ResolveRepoRoot(repo) + if err != nil { + return err + } + if !strings.HasPrefix(addr, "127.0.0.1:") && !strings.HasPrefix(addr, "localhost:") && !strings.HasPrefix(addr, "[::1]:") { + return fmt.Errorf("addr must be loopback (got %s)", addr) + } + c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) + ctx := context.Background() + if _, err := c.Refresh(ctx); err != nil { + fmt.Fprintf(os.Stderr, "warning: initial refresh: %v\n", err) + } + srv, err := engineeringdashboard.NewServer(c) + if err != nil { + return err + } + fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token) + fmt.Printf("repo=%s\n", root) + return engineeringdashboard.ListenAndServe(addr, srv.Handler()) +} + +func runExport(args []string) error { + repo, _, output := parseFlags(args) + root, err := engineeringdashboard.ResolveRepoRoot(repo) + if err != nil { + return err + } + if !filepath.IsAbs(output) { + wd, _ := os.Getwd() + output = filepath.Join(wd, output) + } + c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) + snap, err := c.Refresh(context.Background()) + if err != nil { + return err + } + return engineeringdashboard.ExportHTML(*snap, output) +} diff --git a/internal/engineeringdashboard/assets/app.css b/internal/engineeringdashboard/assets/app.css new file mode 100644 index 00000000..36febf7d --- /dev/null +++ b/internal/engineeringdashboard/assets/app.css @@ -0,0 +1,95 @@ +:root { + --bg: #0f1419; + --panel: #1a222c; + --text: #e7ecf1; + --muted: #9aa7b5; + --accent: #3d8bfd; + --warn: #d4a017; + --bad: #d64545; + --ok: #3d9a6a; + --border: #2b3642; + --font: "IBM Plex Sans", "Noto Sans SC", sans-serif; + --mono: "IBM Plex Mono", ui-monospace, monospace; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: var(--font); + background: radial-gradient(1200px 600px at 10% -10%, #1b2735, var(--bg)); + color: var(--text); + min-height: 100vh; +} +.skip { + position: absolute; left: -9999px; +} +.skip:focus { left: 1rem; top: 1rem; background: #fff; color: #000; padding: .5rem; z-index: 9; } +.top { + display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; + padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); + background: rgba(26,34,44,.85); backdrop-filter: blur(6px); +} +.top h1 { margin: 0; font-size: 1.35rem; letter-spacing: .02em; } +.meta { margin: .35rem 0 0; color: var(--muted); font-size: .85rem; font-family: var(--mono); } +.banner { margin: 0; color: var(--warn); font-size: .85rem; max-width: 28rem; } +.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: calc(100vh - 5rem); } +.nav-toggle { display: none; margin: .75rem; } +nav { + border-right: 1px solid var(--border); padding: 1rem; background: var(--panel); +} +nav ul { list-style: none; margin: 0; padding: 0; } +nav li { margin: 0 0 .35rem; } +.nav-btn { + width: 100%; text-align: left; background: transparent; color: var(--text); + border: 1px solid transparent; padding: .5rem .65rem; border-radius: .35rem; cursor: pointer; +} +.nav-btn.active, .nav-btn:hover, .nav-btn:focus-visible { + border-color: var(--accent); background: #243041; +} +.search-label { display: block; margin-top: 1rem; font-size: .8rem; color: var(--muted); } +#q { + width: 100%; margin-top: .35rem; padding: .45rem .55rem; border-radius: .35rem; + border: 1px solid var(--border); background: #10161d; color: var(--text); +} +.source-strip { margin-top: 1rem; font-size: .75rem; color: var(--muted); } +.source-strip .chip { + display: inline-block; margin: .15rem .2rem 0 0; padding: .15rem .4rem; + border: 1px solid var(--border); border-radius: .25rem; +} +.chip.ok { border-color: var(--ok); } +.chip.stale, .chip.degraded { border-color: var(--warn); color: var(--warn); } +.chip.error { border-color: var(--bad); color: var(--bad); } +main { padding: 1rem 1.25rem 2rem; overflow: auto; } +.view-status { min-height: 1.25rem; color: var(--muted); font-size: .85rem; margin-bottom: .75rem; } +.panel { + background: rgba(26,34,44,.7); border: 1px solid var(--border); border-radius: .5rem; + padding: 1rem; margin-bottom: 1rem; +} +.panel h2 { margin: 0 0 .75rem; font-size: 1.05rem; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; } +th { color: var(--muted); font-weight: 600; } +.badge { + display: inline-block; padding: .1rem .4rem; border-radius: .25rem; + border: 1px solid var(--border); font-size: .75rem; margin-right: .25rem; +} +.badge.warn { border-color: var(--warn); color: var(--warn); } +.badge.bad { border-color: var(--bad); color: var(--bad); } +.pre { + white-space: pre-wrap; word-break: break-word; font-family: var(--mono); + font-size: .82rem; background: #0c1117; padding: .75rem; border-radius: .35rem; + border: 1px solid var(--border); max-height: 28rem; overflow: auto; +} +.axes { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .5rem; } +.axis { border: 1px solid var(--border); border-radius: .35rem; padding: .5rem; font-size: .8rem; } +.axis strong { display: block; color: var(--muted); margin-bottom: .25rem; } +a, button.linkish { + color: var(--accent); background: none; border: none; cursor: pointer; padding: 0; font: inherit; +} +@media (max-width: 860px) { + .layout { grid-template-columns: 1fr; } + .nav-toggle { display: inline-block; } + nav { display: none; border-right: 0; border-bottom: 1px solid var(--border); } + nav.open { display: block; } + .axes { grid-template-columns: 1fr 1fr; } +} diff --git a/internal/engineeringdashboard/assets/app.js b/internal/engineeringdashboard/assets/app.js new file mode 100644 index 00000000..eecb4d03 --- /dev/null +++ b/internal/engineeringdashboard/assets/app.js @@ -0,0 +1,300 @@ +(function () { + const token = window.__DASHBOARD_TOKEN__; + const state = { + snap: null, + view: "overview", + q: "", + selectedDoc: "", + scrollY: 0, + }; + + const el = { + content: document.getElementById("content"), + meta: document.getElementById("repo-meta"), + sources: document.getElementById("source-strip"), + status: document.getElementById("view-status"), + q: document.getElementById("q"), + navToggle: document.getElementById("nav-toggle"), + nav: document.getElementById("nav"), + }; + + function qs() { + const u = new URL(window.location.href); + return u.searchParams; + } + + function syncURL() { + const u = new URL(window.location.href); + u.searchParams.set("token", token); + u.searchParams.set("view", state.view); + if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q"); + if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc"); + history.replaceState(null, "", u.toString()); + } + + function loadURL() { + const p = qs(); + state.view = p.get("view") || "overview"; + state.q = p.get("q") || ""; + state.selectedDoc = p.get("doc") || ""; + el.q.value = state.q; + document.querySelectorAll(".nav-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.view === state.view); + }); + } + + async function fetchSnapshot(force) { + el.status.textContent = state.snap ? "刷新中…" : "首次加载中…"; + const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : ""); + const headers = {}; + if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"'; + const res = await fetch(url, { headers, credentials: "same-origin" }); + if (res.status === 304) { + el.status.textContent = "已是最新 revision " + state.snap.revision_id; + return false; + } + if (!res.ok) { + el.status.textContent = "加载失败 HTTP " + res.status; + if (!state.snap) renderHardError("无法获取快照"); + return false; + } + state.snap = await res.json(); + el.status.textContent = "generation " + state.snap.generation_id + " · revision " + state.snap.revision_id; + return true; + } + + function escapeText(s) { + const d = document.createElement("div"); + d.textContent = s == null ? "" : String(s); + return d.innerHTML; + } + + function badge(text, cls) { + return '' + escapeText(text) + ""; + } + + function renderMeta() { + const s = state.snap; + if (!s) return; + el.meta.textContent = [ + s.repository.root, + "branch=" + s.repository.branch, + "HEAD=" + (s.repository.head || "").slice(0, 12), + s.repository.dirty ? "dirty" : "clean", + "at " + s.generated_at, + ].join(" · "); + el.sources.innerHTML = (s.sources || []).map((src) => { + const cls = src.freshness === "stale" || src.status === "degraded" ? "stale" : + src.status === "error" ? "error" : "ok"; + return '' + escapeText(src.name + ":" + src.status + "/" + src.freshness) + ""; + }).join(""); + } + + function matchQuery(text) { + if (!state.q) return true; + return String(text || "").toLowerCase().includes(state.q.toLowerCase()); + } + + function renderOverview() { + const s = state.snap; + const tasks = (s.tasks || []).filter((t) => ["planned", "in_progress", "blocked"].includes(t.doc_status) || matchQuery(t.title + t.path)); + const conflicts = s.conflicts || []; + let html = '

焦点任务

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

漂移 / 冲突

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

无冲突

"; + else { + html += "
    " + conflicts.map((c) => "
  • " + badge(c.kind, "warn") + " " + escapeText(c.subject + " · " + c.field + " — " + c.summary) + "
  • ").join("") + "
"; + } + html += "
"; + el.content.innerHTML = html; + } + + function renderModules() { + const rows = (state.snap.modules || []).filter((m) => matchQuery(m.name + (m.mutex || ""))); + let html = '

模块

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

' + escapeText(t.title) + "

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

证据链

    " + t.evidence_chain.map((p) => '
  • ").join("") + "
"; + } + html += "
"; + }); + el.content.innerHTML = html || "

无匹配任务

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

分支 / worktree

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

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

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

"; + html += "

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

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

文档

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

正文(纯文本)

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

未找到文档

"; + else { + html += "

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

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

证据链导航

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

" + escapeText(t.title) + "

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

错误

' + escapeText(msg) + "

无可用缓存快照。

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

个人工程中控台

+

加载中…

+
+ +
+
+ + +
+
+
+
+
+ + + + diff --git a/internal/engineeringdashboard/assets_embed.go b/internal/engineeringdashboard/assets_embed.go new file mode 100644 index 00000000..21e971a7 --- /dev/null +++ b/internal/engineeringdashboard/assets_embed.go @@ -0,0 +1,10 @@ +package engineeringdashboard + +import "embed" + +//go:embed assets/index.html assets/app.js assets/app.css +var assetFS embed.FS + +func readAsset(name string) ([]byte, error) { + return assetFS.ReadFile(name) +} diff --git a/internal/engineeringdashboard/export.go b/internal/engineeringdashboard/export.go new file mode 100644 index 00000000..db7ede24 --- /dev/null +++ b/internal/engineeringdashboard/export.go @@ -0,0 +1,156 @@ +package engineeringdashboard + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ExportManifest describes what was embedded in an offline HTML. +type ExportManifest struct { + Included []string `json:"included"` + Truncated []string `json:"truncated"` + MetadataOnly []string `json:"metadata_only"` + SensitiveWarn []string `json:"sensitive_warnings,omitempty"` +} + +// ExportHTML writes a self-contained offline snapshot (mode 0600). +func ExportHTML(snap Snapshot, outputPath string) error { + css, err := readAsset("assets/app.css") + if err != nil { + return err + } + + manifest := ExportManifest{} + docs := make([]DocumentNode, 0, len(snap.Documents)) + for _, d := range snap.Documents { + cp := d + if IsAssetsPath(cp.Path) { + cp.Body = "" + manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) + } else if cp.BodyTruncated { + manifest.Truncated = append(manifest.Truncated, cp.Path) + manifest.Included = append(manifest.Included, cp.Path) + } else if cp.Body != "" { + manifest.Included = append(manifest.Included, cp.Path) + } else { + manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) + } + // strip any accidental absolute secret-looking paths from body display only — keep relative + if strings.Contains(strings.ToLower(cp.Body), "/.secrets/") { + manifest.SensitiveWarn = append(manifest.SensitiveWarn, cp.Path+": contains .secrets path mention") + } + docs = append(docs, cp) + } + exportSnap := snap + exportSnap.Documents = docs + + payload, err := MarshalSnapshotJSON(exportSnap) + if err != nil { + return err + } + // HTML-safe JSON: break sequences + safeJSON := strings.ReplaceAll(string(payload), "<", "\\u003c") + safeJSON = strings.ReplaceAll(safeJSON, ">", "\\u003e") + safeJSON = strings.ReplaceAll(safeJSON, "&", "\\u0026") + safeJSON = strings.ReplaceAll(safeJSON, " + + + + +工程中控台快照 %s + + + + +
+
+

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

+

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

+
+ +
+
+
+

覆盖清单

+

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