Skip to content

Commit cd82df6

Browse files
committed
feat(engine): write graph-wide receipt to ~/.enola/receipt.json
Add a global receipt describing the CURRENT multi-repo "graph of graphs": which repositories compose it, each repo's git commit/ref/dirty state, when each entered the graph and how long it has been a member, and what the graph consists of right now (fact/insight counts, cross-repo services and edges, coverage). Complements the existing per-repo <repo>/.enola/receipt.json. - New GraphReceipt/GraphRepoEntry types (internal/facts/model.go) - Engine.WriteGlobalReceipt(): per-repo git capture across every repo in the graph, merge-forward of added_at (preserved across regenerations; a moved commit keeps added_at and stamps commit_changed_at), atomic temp-file+rename write, graceful degradation when $HOME is unavailable (internal/engine/global_receipt.go) - service_count / cross_repo_edge_count describe the graph-of-graphs topology (edges counted from service-node depends_on relations, not total dep facts) - Store.CountByRepo() for O(1) per-repo fact counts (internal/facts/store.go) - Wire into CLI --generate and the MCP generate_snapshot handler (non-fatal) - Unit tests: merge-forward, commit-change, departed-repo, single-repo fallback, atomic write/read, cross-repo edge count
1 parent 51e47ec commit cd82df6

7 files changed

Lines changed: 527 additions & 0 deletions

File tree

cmd/enola/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ func main() {
7979
log.Fatalf("failed to write artifacts: %v", err)
8080
}
8181

82+
// Refresh the graph-wide receipt at ~/.enola/receipt.json. Non-fatal: a
83+
// failure here must not abort an otherwise-successful snapshot.
84+
if err := eng.WriteGlobalReceipt(); err != nil {
85+
log.Printf("warning: failed to write global receipt: %v", err)
86+
}
87+
8288
fmt.Fprintf(os.Stderr, "\nSnapshot complete:\n")
8389
fmt.Fprintf(os.Stderr, " Repository: %s\n", snapshot.Meta.RepoPath)
8490
fmt.Fprintf(os.Stderr, " Facts: %d\n", snapshot.Meta.FactCount)

internal/engine/global_receipt.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package engine
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"time"
11+
12+
"github.com/enola-labs/enola/internal/facts"
13+
"github.com/enola-labs/enola/internal/version"
14+
)
15+
16+
// globalReceiptDirName / globalReceiptFileName locate the graph-wide receipt under
17+
// the user's home directory (~/.enola/receipt.json).
18+
const (
19+
globalReceiptDirName = ".enola"
20+
globalReceiptFileName = "receipt.json"
21+
)
22+
23+
// globalReceiptPath resolves ~/.enola/receipt.json. It returns an error when the
24+
// home directory is unavailable (e.g. a sandboxed run with no $HOME) so callers
25+
// can degrade gracefully instead of failing the snapshot.
26+
func globalReceiptPath() (string, error) {
27+
home, err := os.UserHomeDir()
28+
if err != nil {
29+
return "", fmt.Errorf("resolving home dir: %w", err)
30+
}
31+
return filepath.Join(home, globalReceiptDirName, globalReceiptFileName), nil
32+
}
33+
34+
// repoEntries returns one GraphRepoEntry per repository currently in the graph.
35+
// In multi-repo (append) mode it iterates RepoPaths(); in single-repo mode it
36+
// falls back to the sole primary repo from the snapshot meta. Git state is captured
37+
// per repo via gitInfo (nil for non-git dirs) and fact counts come from the store's
38+
// byRepo index. AddedAt/CommitChangedAt are left to the merge step; InGraphFor is
39+
// derived at write time. Entries are sorted by Label for stable output.
40+
func (e *Engine) repoEntries() []facts.GraphRepoEntry {
41+
// label -> absolute path for every repo in the graph.
42+
repos := e.RepoPaths()
43+
if len(repos) == 0 {
44+
// Single-repo graph: RepoPaths() is nil until a repo is appended.
45+
if e.snapshot == nil || e.snapshot.Meta.RepoPath == "" {
46+
return nil
47+
}
48+
abs := e.snapshot.Meta.RepoPath
49+
repos = map[string]string{filepath.Base(abs): abs}
50+
}
51+
52+
entries := make([]facts.GraphRepoEntry, 0, len(repos))
53+
for label, abs := range repos {
54+
entries = append(entries, facts.GraphRepoEntry{
55+
Label: label,
56+
Path: abs,
57+
Git: gitInfo(abs),
58+
FactCount: e.store.CountByRepo(label),
59+
})
60+
}
61+
sort.Slice(entries, func(i, j int) bool { return entries[i].Label < entries[j].Label })
62+
return entries
63+
}
64+
65+
// crossRepoEdgeCount counts the consumer->provider edges in the cross-repo "graph
66+
// of graphs". Those edges are materialized as depends_on relations on the synthetic
67+
// KindService nodes (one per repo), so summing them reads the true cross-repo edge
68+
// count directly — and cheaply (there are only as many service nodes as repos). It
69+
// deliberately does NOT use ByKind(KindDependency): that kind also covers every
70+
// ordinary import/dependency fact, which would over-count by orders of magnitude.
71+
func crossRepoEdgeCount(store *facts.Store) int {
72+
n := 0
73+
for _, svc := range store.ByKind(facts.KindService) {
74+
for _, r := range svc.Relations {
75+
if r.Kind == facts.RelDependsOn {
76+
n++
77+
}
78+
}
79+
}
80+
return n
81+
}
82+
83+
// assembleGraphReceipt builds a GraphReceipt describing the current graph state.
84+
// Membership timestamps (AddedAt/CommitChangedAt) are set to their first-write
85+
// defaults here; WriteGlobalReceipt merges forward from any prior receipt.
86+
func (e *Engine) assembleGraphReceipt(now time.Time) facts.GraphReceipt {
87+
nowStr := now.UTC().Format(time.RFC3339)
88+
89+
entries := e.repoEntries()
90+
for i := range entries {
91+
entries[i].AddedAt = nowStr
92+
entries[i].InGraphFor = "0s"
93+
}
94+
95+
gr := facts.GraphReceipt{
96+
GeneratedAt: nowStr,
97+
EnolaVersion: version.Version,
98+
ServiceCount: len(e.store.ByKind(facts.KindService)),
99+
CrossRepoEdgeCount: crossRepoEdgeCount(e.store),
100+
Coverage: coverageSummary(e.store),
101+
Repos: entries,
102+
}
103+
if e.snapshot != nil {
104+
gr.SnapshotID = e.snapshot.Meta.SnapshotID
105+
gr.FactCount = e.snapshot.Meta.FactCount
106+
gr.InsightCount = e.snapshot.Meta.InsightCount
107+
}
108+
return gr
109+
}
110+
111+
// WriteGlobalReceipt writes ~/.enola/receipt.json for the current graph. It reads
112+
// any existing receipt to merge forward per-repo membership timestamps (so a repo's
113+
// added_at is preserved across regenerations and a moved commit does not reset it),
114+
// then atomically replaces the file. It never aborts a snapshot: a missing home dir
115+
// is logged and skipped, and a corrupt prior receipt is treated as no prior state.
116+
func (e *Engine) WriteGlobalReceipt() error {
117+
if e.snapshot == nil {
118+
return fmt.Errorf("no snapshot generated")
119+
}
120+
121+
path, err := globalReceiptPath()
122+
if err != nil {
123+
log.Printf("[engine] global receipt skipped: %v", err)
124+
return nil
125+
}
126+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
127+
return fmt.Errorf("creating global receipt dir: %w", err)
128+
}
129+
130+
now := time.Now().UTC()
131+
gr := e.assembleGraphReceipt(now)
132+
133+
// Merge forward membership timestamps from the prior receipt, if any. Repos
134+
// present in the prior receipt but absent now are simply omitted: the receipt
135+
// is rebuilt only from current entries, so departed repos drop out.
136+
prevByLabel := readPriorGraphReceipt(path)
137+
gr.Repos = mergeRepoEntries(gr.Repos, prevByLabel, now)
138+
139+
data, err := json.MarshalIndent(gr, "", " ")
140+
if err != nil {
141+
return fmt.Errorf("marshaling global receipt: %w", err)
142+
}
143+
if err := writeFileAtomic(path, data, 0o644); err != nil {
144+
return fmt.Errorf("writing global receipt: %w", err)
145+
}
146+
log.Printf("[engine] wrote %s (%d repos)", path, len(gr.Repos))
147+
return nil
148+
}
149+
150+
// mergeRepoEntries carries per-repo membership state forward from a prior receipt
151+
// (keyed by label) onto the freshly-assembled current entries, and stamps each
152+
// entry's derived InGraphFor. For a repo already present, AddedAt is preserved (a
153+
// regeneration is not a re-entry) and a moved commit records CommitChangedAt=now
154+
// WITHOUT resetting AddedAt; an unchanged commit carries the prior CommitChangedAt
155+
// forward. A repo absent from prev keeps its default AddedAt=now. cur already
156+
// excludes departed repos, so they drop out.
157+
func mergeRepoEntries(cur []facts.GraphRepoEntry, prevByLabel map[string]facts.GraphRepoEntry, now time.Time) []facts.GraphRepoEntry {
158+
nowStr := now.UTC().Format(time.RFC3339)
159+
for i := range cur {
160+
c := &cur[i]
161+
if prev, ok := prevByLabel[c.Label]; ok {
162+
c.AddedAt = prev.AddedAt
163+
if prev.Git != nil && c.Git != nil && prev.Git.Commit != c.Git.Commit {
164+
c.CommitChangedAt = nowStr
165+
} else {
166+
c.CommitChangedAt = prev.CommitChangedAt
167+
}
168+
}
169+
c.InGraphFor = inGraphFor(c.AddedAt, now)
170+
}
171+
return cur
172+
}
173+
174+
// readPriorGraphReceipt loads the existing global receipt keyed by repo label. A
175+
// missing or corrupt file yields an empty map (the corrupt case is logged), so a
176+
// hand-edited or truncated receipt self-heals rather than failing the write.
177+
func readPriorGraphReceipt(path string) map[string]facts.GraphRepoEntry {
178+
data, err := os.ReadFile(path)
179+
if err != nil {
180+
return nil // missing (or unreadable) => no prior state
181+
}
182+
var prev facts.GraphReceipt
183+
if err := json.Unmarshal(data, &prev); err != nil {
184+
log.Printf("[engine] warning: ignoring corrupt global receipt %s: %v", path, err)
185+
return nil
186+
}
187+
byLabel := make(map[string]facts.GraphRepoEntry, len(prev.Repos))
188+
for _, r := range prev.Repos {
189+
byLabel[r.Label] = r
190+
}
191+
return byLabel
192+
}
193+
194+
// inGraphFor returns a human-readable duration since addedAt (RFC3339), rounded to
195+
// the second. It falls back to "0s" when addedAt cannot be parsed.
196+
func inGraphFor(addedAt string, now time.Time) string {
197+
t, err := time.Parse(time.RFC3339, addedAt)
198+
if err != nil {
199+
return "0s"
200+
}
201+
d := now.Sub(t)
202+
if d < 0 {
203+
d = 0
204+
}
205+
return d.Round(time.Second).String()
206+
}
207+
208+
// writeFileAtomic writes data to a temp file in the destination directory and
209+
// renames it over path, so a concurrent reader never sees a torn/partial receipt.
210+
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
211+
dir := filepath.Dir(path)
212+
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
213+
if err != nil {
214+
return err
215+
}
216+
tmpName := tmp.Name()
217+
defer os.Remove(tmpName) // no-op after a successful rename
218+
if _, err := tmp.Write(data); err != nil {
219+
tmp.Close()
220+
return err
221+
}
222+
if err := tmp.Close(); err != nil {
223+
return err
224+
}
225+
if err := os.Chmod(tmpName, perm); err != nil {
226+
return err
227+
}
228+
return os.Rename(tmpName, path)
229+
}

0 commit comments

Comments
 (0)