diff --git a/internal/session/snapshot.go b/internal/session/snapshot.go new file mode 100644 index 0000000..e19f064 --- /dev/null +++ b/internal/session/snapshot.go @@ -0,0 +1,26 @@ +package session + +import ( + "time" + + "somegit.dev/Owlibou/gnoma/internal/message" +) + +// Metadata holds session summary information persisted alongside messages. +type Metadata struct { + ID string `json:"id"` + Provider string `json:"provider"` + Model string `json:"model"` + TurnCount int `json:"turn_count"` + Usage message.Usage `json:"usage"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MessageCount int `json:"message_count"` +} + +// Snapshot is a complete serialisable representation of a session at a point in time. +type Snapshot struct { + ID string `json:"id"` + Metadata Metadata `json:"metadata"` + Messages []message.Message `json:"messages"` +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..333c83f --- /dev/null +++ b/internal/session/store.go @@ -0,0 +1,146 @@ +package session + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + + "somegit.dev/Owlibou/gnoma/internal/message" +) + +// SessionStore manages session persistence to .gnoma/sessions/. +type SessionStore struct { + dir string + maxKeep int + logger *slog.Logger +} + +// NewSessionStore creates a store rooted at /.gnoma/sessions/. +func NewSessionStore(projectRoot string, maxKeep int, logger *slog.Logger) *SessionStore { + return &SessionStore{ + dir: filepath.Join(projectRoot, ".gnoma", "sessions"), + maxKeep: maxKeep, + logger: logger, + } +} + +func (s *SessionStore) Save(snap Snapshot) error { + dir := filepath.Join(s.dir, snap.ID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("session %q: create dir: %w", snap.ID, err) + } + + if err := atomicWrite(filepath.Join(dir, "metadata.json"), snap.Metadata); err != nil { + return fmt.Errorf("session %q: write metadata: %w", snap.ID, err) + } + + if err := atomicWrite(filepath.Join(dir, "messages.json"), snap.Messages); err != nil { + return fmt.Errorf("session %q: write messages: %w", snap.ID, err) + } + + return s.Prune() +} + +func (s *SessionStore) Load(id string) (Snapshot, error) { + dir := filepath.Join(s.dir, id) + + metaBytes, err := os.ReadFile(filepath.Join(dir, "metadata.json")) + if err != nil { + return Snapshot{}, fmt.Errorf("session %q not found: %w", id, err) + } + + var meta Metadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + return Snapshot{}, fmt.Errorf("session %q: corrupt metadata: %w", id, err) + } + + msgBytes, err := os.ReadFile(filepath.Join(dir, "messages.json")) + if err != nil { + return Snapshot{}, fmt.Errorf("session %q: read messages: %w", id, err) + } + + var msgs []message.Message + if err := json.Unmarshal(msgBytes, &msgs); err != nil { + return Snapshot{}, fmt.Errorf("session %q: corrupt messages: %w", id, err) + } + + return Snapshot{ + ID: id, + Metadata: meta, + Messages: msgs, + }, nil +} + +func (s *SessionStore) List() ([]Metadata, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("list sessions: %w", err) + } + + var result []Metadata + for _, entry := range entries { + if !entry.IsDir() { + continue + } + id := entry.Name() + metaBytes, err := os.ReadFile(filepath.Join(s.dir, id, "metadata.json")) + if err != nil { + s.logger.Warn("session: skip unreadable metadata", "id", id, "err", err) + continue + } + var meta Metadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + s.logger.Warn("session: skip corrupt metadata", "id", id, "err", err) + continue + } + meta.ID = id + result = append(result, meta) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].UpdatedAt.After(result[j].UpdatedAt) + }) + + return result, nil +} + +func (s *SessionStore) Prune() error { + list, err := s.List() + if err != nil { + return fmt.Errorf("prune: list sessions: %w", err) + } + + if len(list) <= s.maxKeep { + return nil + } + + for _, meta := range list[s.maxKeep:] { + dir := filepath.Join(s.dir, meta.ID) + if err := os.RemoveAll(dir); err != nil { + s.logger.Warn("session: prune failed", "id", meta.ID, "err", err) + } + } + + return nil +} + +func atomicWrite(path string, v any) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return fmt.Errorf("write tmp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("rename: %w", err) + } + return nil +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go new file mode 100644 index 0000000..801b4ae --- /dev/null +++ b/internal/session/store_test.go @@ -0,0 +1,130 @@ +package session_test + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "somegit.dev/Owlibou/gnoma/internal/message" + "somegit.dev/Owlibou/gnoma/internal/session" +) + +func makeSnap(id string, updated time.Time) session.Snapshot { + return session.Snapshot{ + ID: id, + Metadata: session.Metadata{ + ID: id, + Provider: "anthropic", + Model: "claude", + TurnCount: 1, + UpdatedAt: updated, + CreatedAt: updated, + MessageCount: 2, + }, + Messages: []message.Message{ + message.NewUserText("hello"), + message.NewAssistantText("hi"), + }, + } +} + +func makeStore(t *testing.T) *session.SessionStore { + t.Helper() + root := t.TempDir() + return session.NewSessionStore(root, 3, slog.Default()) +} + +func TestSessionStore_SaveLoad(t *testing.T) { + store := makeStore(t) + snap := makeSnap("sess-001", time.Now().UTC()) + + if err := store.Save(snap); err != nil { + t.Fatal(err) + } + + got, err := store.Load("sess-001") + if err != nil { + t.Fatal(err) + } + if got.ID != "sess-001" { + t.Errorf("ID mismatch: %q", got.ID) + } + if len(got.Messages) != 2 { + t.Errorf("messages: %d", len(got.Messages)) + } + if got.Metadata.Provider != "anthropic" { + t.Errorf("provider: %q", got.Metadata.Provider) + } +} + +func TestSessionStore_Load_Missing(t *testing.T) { + store := makeStore(t) + _, err := store.Load("nonexistent") + if err == nil { + t.Error("expected error for missing session") + } +} + +func TestSessionStore_Load_CorruptMetadata(t *testing.T) { + root := t.TempDir() + store := session.NewSessionStore(root, 3, slog.Default()) + + dir := filepath.Join(root, ".gnoma", "sessions", "corrupt-sess") + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "metadata.json"), []byte("not json"), 0o644) + os.WriteFile(filepath.Join(dir, "messages.json"), []byte("[]"), 0o644) + + _, err := store.Load("corrupt-sess") + if err == nil { + t.Error("expected error for corrupt metadata") + } +} + +func TestSessionStore_List_SortedByUpdatedAt(t *testing.T) { + store := makeStore(t) + now := time.Now().UTC() + + store.Save(makeSnap("sess-old", now.Add(-2*time.Hour))) + store.Save(makeSnap("sess-new", now)) + store.Save(makeSnap("sess-mid", now.Add(-1*time.Hour))) + + list, err := store.List() + if err != nil { + t.Fatal(err) + } + if len(list) != 3 { + t.Fatalf("expected 3 sessions, got %d", len(list)) + } + if list[0].ID != "sess-new" { + t.Errorf("first should be newest: %q", list[0].ID) + } + if list[2].ID != "sess-old" { + t.Errorf("last should be oldest: %q", list[2].ID) + } +} + +func TestSessionStore_Prune_RemovesOldest(t *testing.T) { + store := makeStore(t) // maxKeep = 3 + now := time.Now().UTC() + + for i := 0; i < 5; i++ { + id := fmt.Sprintf("sess-%03d", i) + store.Save(makeSnap(id, now.Add(time.Duration(i)*time.Minute))) + } + + list, err := store.List() + if err != nil { + t.Fatal(err) + } + if len(list) != 3 { + t.Errorf("expected 3 sessions after prune, got %d", len(list)) + } + for _, m := range list { + if m.ID == "sess-000" || m.ID == "sess-001" { + t.Errorf("oldest session %q should have been pruned", m.ID) + } + } +}