M1: raw per-session focus log with retention prune
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<sessionID>.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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user