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 }