diff --git a/internal/store/evidence_log.go b/internal/store/evidence_log.go new file mode 100644 index 0000000..94fe971 --- /dev/null +++ b/internal/store/evidence_log.go @@ -0,0 +1,96 @@ +package store + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" +) + +// FocusEvent is one raw, disposable observation in a session's firehose log. +type FocusEvent struct { + AtUnixMillis int64 `json:"at_unix_millis"` + Class string `json:"class"` + Title string `json:"title"` // full, unscrubbed + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` +} + +func sessionFile(dir, sessionID string) string { + return filepath.Join(dir, sessionID+".jsonl") +} + +// AppendFocus appends one event to dir/.jsonl, creating dir if +// needed. It is best-effort detail, not the state of truth. +func AppendFocus(dir, sessionID string, e FocusEvent) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + line, err := json.Marshal(e) + if err != nil { + return err + } + f, err := os.OpenFile(sessionFile(dir, sessionID), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(append(line, '\n')) + return err +} + +// ReplaySession reads all events for a session in append order. A missing file +// yields an empty slice and no error. +func ReplaySession(dir, sessionID string) ([]FocusEvent, error) { + f, err := os.Open(sessionFile(dir, sessionID)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + var out []FocusEvent + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var e FocusEvent + if err := json.Unmarshal([]byte(line), &e); err != nil { + return nil, err + } + out = append(out, e) + } + return out, sc.Err() +} + +// PruneOlderThan deletes session files whose modification time precedes +// now-age. A missing dir is a no-op. now is injected for testability. +func PruneOlderThan(dir string, age time.Duration, now time.Time) error { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + cutoff := now.Add(-age) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } + return nil +} diff --git a/internal/store/evidence_log_test.go b/internal/store/evidence_log_test.go new file mode 100644 index 0000000..278680b --- /dev/null +++ b/internal/store/evidence_log_test.go @@ -0,0 +1,73 @@ +package store + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAppendFocusReplayRoundTrips(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sessions") + id := "session-x" + events := []FocusEvent{ + {AtUnixMillis: 1000, Class: "code", Title: "a", Available: true}, + {AtUnixMillis: 2000, Class: "firefox", Title: "b", Available: true}, + {AtUnixMillis: 3000, Class: "", Title: "", Available: false, Reason: "no active window"}, + } + for _, e := range events { + if err := AppendFocus(dir, id, e); err != nil { + t.Fatalf("append: %v", err) + } + } + got, err := ReplaySession(dir, id) + if err != nil { + t.Fatalf("replay: %v", err) + } + if len(got) != len(events) { + t.Fatalf("got %d events, want %d", len(got), len(events)) + } + for i := range events { + if got[i] != events[i] { + t.Errorf("event %d: got %+v want %+v", i, got[i], events[i]) + } + } +} + +func TestReplayMissingSessionIsEmpty(t *testing.T) { + got, err := ReplaySession(filepath.Join(t.TempDir(), "sessions"), "nope") + if err != nil { + t.Fatalf("missing session should not error: %v", err) + } + if len(got) != 0 { + t.Fatalf("missing session should be empty, got %d", len(got)) + } +} + +func TestPruneOlderThan(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sessions") + _ = AppendFocus(dir, "old", FocusEvent{AtUnixMillis: 1}) + _ = AppendFocus(dir, "new", FocusEvent{AtUnixMillis: 1}) + + now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) + oldTime := now.Add(-40 * 24 * time.Hour) + if err := os.Chtimes(filepath.Join(dir, "old.jsonl"), oldTime, oldTime); err != nil { + t.Fatalf("chtimes: %v", err) + } + + if err := PruneOlderThan(dir, 30*24*time.Hour, now); err != nil { + t.Fatalf("prune: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "old.jsonl")); !os.IsNotExist(err) { + t.Errorf("old session should be pruned") + } + if _, err := os.Stat(filepath.Join(dir, "new.jsonl")); err != nil { + t.Errorf("new session should survive: %v", err) + } +} + +func TestPruneMissingDirIsNoop(t *testing.T) { + if err := PruneOlderThan(filepath.Join(t.TempDir(), "nope"), time.Hour, time.Now()); err != nil { + t.Fatalf("missing dir should be a no-op, got %v", err) + } +}