feat: SessionStore — save/load/list/prune session snapshots to .gnoma/sessions/
This commit is contained in:
26
internal/session/snapshot.go
Normal file
26
internal/session/snapshot.go
Normal file
@@ -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"`
|
||||
}
|
||||
146
internal/session/store.go
Normal file
146
internal/session/store.go
Normal file
@@ -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 <projectRoot>/.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
|
||||
}
|
||||
130
internal/session/store_test.go
Normal file
130
internal/session/store_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user