Go 182 lines
package memory
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/ajhahnde/eeco/internal/queue"
)
// GCAction summarises what GC did with a single fact. Action is one of
// "archived", "queued", or "kept". Reason is the human-readable trigger
// description for archive/queue, or empty for kept facts.
type GCAction struct {
Name string
Type FactType
Action string
Reason string
}
// GCResult is the aggregate result of a GC pass.
type GCResult struct {
Actions []GCAction
Archived int
Queued int
Kept int
}
const (
gcLogFilename = "gc.log"
)
// GC walks every fact in the store, applies the PLAN.md GC table, and
// performs the prescribed action on each. Pinned facts are always
// kept. Reference and finding facts are archived to the attic.
// Project, feedback, and user facts are queued for review (never
// silently dropped). The MEMORY.md index is regenerated from whatever
// remains. Errors short-circuit; a failure mid-pass leaves the store
// in a consistent on-disk state up to that point.
func (s *Store) GC() (GCResult, error) {
var res GCResult
facts, err := s.LoadAll()
if err != nil {
return res, fmt.Errorf("gc: %w", err)
}
now := s.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
project := filepath.Base(s.RepoRoot)
if project == "." || project == "/" {
project = "repo"
}
for _, f := range facts {
if f.Pin {
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "pinned"})
continue
}
if f.Disabled {
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "disabled"})
continue
}
reason := s.triggerFor(f, today)
if reason == "" {
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept"})
continue
}
switch f.Type {
case TypeReference, TypeFinding:
if err := s.archive(f, reason, now); err != nil {
return res, fmt.Errorf("gc archive %s: %w", f.Name, err)
}
res.Archived++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "archived", Reason: reason})
case TypeProject, TypeFeedback, TypeUser:
if err := s.queueReview(f, reason, project, today, now); err != nil {
return res, fmt.Errorf("gc queue %s: %w", f.Name, err)
}
res.Queued++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "queued", Reason: reason})
default:
// Should be unreachable; ValidType is enforced at parse.
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "unknown type"})
}
}
remaining, err := s.LoadAll()
if err != nil {
return res, fmt.Errorf("gc: reload after pass: %w", err)
}
if err := s.WriteIndex(remaining); err != nil {
return res, fmt.Errorf("gc: write index: %w", err)
}
return res, nil
}
// triggerFor evaluates the GC table rows in spec order and returns the
// first matching reason, or "" if the fact is to be kept. Today is the
// UTC date to compare against.
func (s *Store) triggerFor(f *Fact, today time.Time) string {
if f.Ref != "" {
full := filepath.Join(s.RepoRoot, f.Ref)
if _, err := os.Stat(full); err != nil {
if errors.Is(err, os.ErrNotExist) {
return "ref missing: " + f.Ref
}
// Other stat errors (perm, etc.) — surface as a trigger so
// the user is alerted rather than silently dropped.
return "ref unreadable: " + f.Ref
}
}
if f.Expires != nil && f.Expires.Before(today) {
return "expired " + f.Expires.UTC().Format(DateLayout)
}
if f.Type == TypeFinding && f.Status == "resolved" {
return "finding resolved"
}
if f.Type == TypeReference {
age := today.Sub(f.LastUsed.UTC())
threshold := time.Duration(s.StaleDays) * 24 * time.Hour
if age > threshold {
return fmt.Sprintf("stale: last_used %s (> %d days)", f.LastUsed.UTC().Format(DateLayout), s.StaleDays)
}
}
return ""
}
// archive moves the fact file from MemoryDir to AtticDir and appends a
// log entry. A name collision in the attic is renamed by suffix to
// avoid clobbering an earlier archive.
func (s *Store) archive(f *Fact, reason string, now time.Time) error {
if err := os.MkdirAll(s.AtticDir, 0o755); err != nil {
return err
}
dst := filepath.Join(s.AtticDir, f.Name+".md")
if _, err := os.Stat(dst); err == nil {
dst = filepath.Join(s.AtticDir, fmt.Sprintf("%s.%d.md", f.Name, now.Unix()))
}
if err := os.Rename(f.Path, dst); err != nil {
return err
}
return s.logGC(now, "archived", f.Name, reason)
}
// queueReview appends a review item to the queue. The fact file is
// left in place: load-bearing user/project/feedback facts are never
// silently moved. today is the calendar date stamped on the queue row;
// now is the wall-clock timestamp recorded in gc.log.
func (s *Store) queueReview(f *Fact, reason, project string, today, now time.Time) error {
item := queue.Item{
Kind: "gc-review",
Title: fmt.Sprintf("memory '%s' looks stale: %s", f.Name, reason),
Project: project,
Detail: fmt.Sprintf("type=%s description=%q", f.Type, f.Description),
Date: today,
}
if _, err := queue.AppendUnique(s.StateDir, item); err != nil {
return err
}
return s.logGC(now, "queued", f.Name, reason)
}
func (s *Store) logGC(now time.Time, action, name, reason string) error {
if err := os.MkdirAll(s.StateDir, 0o755); err != nil {
return err
}
logPath := filepath.Join(s.StateDir, gcLogFilename)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s %s %s reason=%q\n", now.UTC().Format(time.RFC3339), action, name, reason)
return err
}