feat: complete v0.3.1 agent workflow
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"agentu/internal/safeio"
|
||||
)
|
||||
|
||||
const sessionIndexFile = "index.json"
|
||||
|
||||
type sessionIndex struct {
|
||||
Sessions []SessionMeta `json:"sessions"`
|
||||
}
|
||||
|
||||
func metaFromSession(sess *Session) SessionMeta {
|
||||
return SessionMeta{
|
||||
ID: sess.ID,
|
||||
Name: sess.Name,
|
||||
CreatedAt: sess.CreatedAt,
|
||||
UpdatedAt: sess.UpdatedAt,
|
||||
Provider: sess.Provider,
|
||||
Model: sess.Model,
|
||||
MessageCount: len(sess.Messages),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) saveIndexFromSessions(metas []SessionMeta) error {
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
|
||||
})
|
||||
idx := sessionIndex{Sessions: metas}
|
||||
data, err := json.MarshalIndent(idx, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal session index: %w", err)
|
||||
}
|
||||
path := filepath.Join(s.dir, sessionIndexFile)
|
||||
return safeio.AtomicWriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) loadIndex() ([]SessionMeta, error) {
|
||||
path := filepath.Join(s.dir, sessionIndexFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var idx sessionIndex
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(idx.Sessions, func(i, j int) bool {
|
||||
return idx.Sessions[i].UpdatedAt.After(idx.Sessions[j].UpdatedAt)
|
||||
})
|
||||
return idx.Sessions, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agentu/internal/safeio"
|
||||
"agentu/pkg/llm"
|
||||
)
|
||||
|
||||
@@ -54,8 +55,11 @@ func NewStore(dir string) (*Store, error) {
|
||||
|
||||
// Save writes a session to disk.
|
||||
func (s *Store) Save(sess *Session) error {
|
||||
if sess.ID == "" {
|
||||
return fmt.Errorf("session ID is required")
|
||||
if sess == nil {
|
||||
return fmt.Errorf("session is required")
|
||||
}
|
||||
if err := validateSessionID(sess.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
sess.UpdatedAt = time.Now().UTC()
|
||||
if sess.CreatedAt.IsZero() {
|
||||
@@ -66,14 +70,17 @@ func (s *Store) Save(sess *Session) error {
|
||||
return fmt.Errorf("marshal session: %w", err)
|
||||
}
|
||||
path := s.filePath(sess.ID)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
if err := safeio.AtomicWriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write session: %w", err)
|
||||
}
|
||||
return nil
|
||||
return s.updateIndex(metaFromSession(sess))
|
||||
}
|
||||
|
||||
// Load reads a session by ID.
|
||||
func (s *Store) Load(id string) (*Session, error) {
|
||||
if err := validateSessionID(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := s.filePath(id)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -91,13 +98,27 @@ func (s *Store) Load(id string) (*Session, error) {
|
||||
|
||||
// List returns metadata for all sessions, sorted by updated_at descending.
|
||||
func (s *Store) List() ([]SessionMeta, error) {
|
||||
if metas, err := s.loadIndex(); err == nil {
|
||||
return metas, nil
|
||||
}
|
||||
metas, err := s.scanSessions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.saveIndexFromSessions(metas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
func (s *Store) scanSessions() ([]SessionMeta, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read session dir: %w", err)
|
||||
}
|
||||
var metas []SessionMeta
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
if entry.IsDir() || entry.Name() == sessionIndexFile || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.dir, entry.Name())
|
||||
@@ -127,6 +148,9 @@ func (s *Store) List() ([]SessionMeta, error) {
|
||||
|
||||
// Delete removes a session file.
|
||||
func (s *Store) Delete(id string) error {
|
||||
if err := validateSessionID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
path := s.filePath(id)
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -134,13 +158,64 @@ func (s *Store) Delete(id string) error {
|
||||
}
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
metas, err := s.loadIndex()
|
||||
if err != nil {
|
||||
metas, err = s.scanSessions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
filtered := metas[:0]
|
||||
for _, meta := range metas {
|
||||
if meta.ID != id {
|
||||
filtered = append(filtered, meta)
|
||||
}
|
||||
}
|
||||
return s.saveIndexFromSessions(filtered)
|
||||
}
|
||||
|
||||
func (s *Store) filePath(id string) string {
|
||||
return filepath.Join(s.dir, id+".json")
|
||||
}
|
||||
|
||||
func validateSessionID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("session ID is required")
|
||||
}
|
||||
if id == "." || id == ".." {
|
||||
return fmt.Errorf("invalid session ID %q", id)
|
||||
}
|
||||
for _, r := range id {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("invalid session ID %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) updateIndex(meta SessionMeta) error {
|
||||
metas, err := s.loadIndex()
|
||||
if err != nil {
|
||||
metas, err = s.scanSessions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for i := range metas {
|
||||
if metas[i].ID == meta.ID {
|
||||
metas[i] = meta
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
return s.saveIndexFromSessions(metas)
|
||||
}
|
||||
|
||||
// GenerateID creates a random 8-character hex session ID.
|
||||
func GenerateID() string {
|
||||
b := make([]byte, 4)
|
||||
|
||||
@@ -119,6 +119,9 @@ func TestDeleteNotFound(t *testing.T) {
|
||||
|
||||
func TestSaveRequiresID(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
if err := store.Save(nil); err == nil {
|
||||
t.Fatal("expected error for nil session")
|
||||
}
|
||||
err := store.Save(&Session{Name: "no id"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty ID")
|
||||
@@ -172,3 +175,40 @@ func TestNewStoreCreatesDir(t *testing.T) {
|
||||
t.Error("expected directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMaintainsSessionIndex(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
if err := store.Save(&Session{ID: "indexed", Name: "Indexed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(store.dir, sessionIndexFile)); err != nil {
|
||||
t.Fatalf("index not created: %v", err)
|
||||
}
|
||||
metas, err := store.loadIndex()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 1 || metas[0].ID != "indexed" {
|
||||
t.Fatalf("index metadata = %+v", metas)
|
||||
}
|
||||
if err := store.Delete("indexed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metas, err = store.loadIndex()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 0 {
|
||||
t.Fatalf("index after delete = %+v", metas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsUnsafeSessionID(t *testing.T) {
|
||||
store := tempStore(t)
|
||||
if _, err := store.Load("../outside"); err == nil {
|
||||
t.Fatal("expected unsafe ID rejection")
|
||||
}
|
||||
if err := store.Save(&Session{ID: "a/b"}); err == nil {
|
||||
t.Fatal("expected unsafe ID rejection")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user