diff --git a/pkg/store/batch.go b/pkg/store/batch.go index 8976aa7..9d9b58b 100644 --- a/pkg/store/batch.go +++ b/pkg/store/batch.go @@ -50,6 +50,27 @@ func (job *batchJob) appendLog(data []byte) { job.mu.Unlock() } +func (job *batchJob) RunnableID() string { return job.id } + +func (job *batchJob) Cancel() { + job.mu.RLock() + cancel := job.cancel + job.mu.RUnlock() + if cancel != nil { + cancel() + } +} + +func (job *batchJob) Interrupt() { job.Cancel() } + +func (job *batchJob) AppendLog(data []byte) { job.appendLog(data) } + +func (job *batchJob) LogInfo() (int, uint32) { + job.mu.RLock() + defer job.mu.RUnlock() + return len(job.log), job.logVers +} + // batchSpec is the parsed result of a b/new write. type batchSpec struct { name string @@ -70,19 +91,44 @@ type BatchStoreConfig struct { } // BatchStore implements Store for batch job management. -// Entries are job IDs (directories) plus the synthetic files "new" and "idx". -// Put("new", data) creates jobs; Delete(id) cancels and removes one. type BatchStore struct { + *storeConfig cfg BatchStoreConfig mu sync.RWMutex jobs map[string]*batchJob } func NewBatchStore(cfg BatchStoreConfig) *BatchStore { - return &BatchStore{cfg: cfg, jobs: make(map[string]*batchJob)} + bs := &BatchStore{cfg: cfg, jobs: make(map[string]*batchJob)} + bs.storeConfig = &storeConfig{ + StatFn: bs.stat, + ListFn: bs.list, + GetFn: bs.get, + PutFn: bs.put, + DeleteFn: bs.del, + CreateFn: func(string) error { return fmt.Errorf("create not supported for batch jobs") }, + RenameFn: func(string, string) error { return fmt.Errorf("rename not supported for batch jobs") }, + } + return bs } -func (s *BatchStore) List() ([]os.DirEntry, error) { +// AddJob inserts a completed job into the store. This allows callers to +// populate the store without going through the full agent execution path. +func (s *BatchStore) AddJob(id, state, result, spec string) { + done := make(chan struct{}) + close(done) + s.mu.Lock() + s.jobs[id] = &batchJob{ + id: id, + spec: spec, + state: state, + result: result, + done: done, + } + s.mu.Unlock() +} + +func (s *BatchStore) list() ([]os.DirEntry, error) { entries := []os.DirEntry{ FileEntry("new", 0666), FileEntry("idx", 0444), @@ -95,7 +141,7 @@ func (s *BatchStore) List() ([]os.DirEntry, error) { return entries, nil } -func (s *BatchStore) Stat(name string) (os.FileInfo, error) { +func (s *BatchStore) stat(name string) (os.FileInfo, error) { switch name { case "new": return &SyntheticFileInfo{Name_: "new", Mode_: 0666, Size_: int64(len(batchNewTemplate))}, nil @@ -111,7 +157,7 @@ func (s *BatchStore) Stat(name string) (os.FileInfo, error) { return nil, fmt.Errorf("%s: not found", name) } -func (s *BatchStore) Get(name string) ([]byte, error) { +func (s *BatchStore) get(name string) ([]byte, error) { switch name { case "new": return []byte(batchNewTemplate), nil @@ -121,14 +167,14 @@ func (s *BatchStore) Get(name string) ([]byte, error) { return nil, fmt.Errorf("%s: not a readable file", name) } -func (s *BatchStore) Put(name string, data []byte) error { +func (s *BatchStore) put(name string, data []byte) error { if name != "new" { return fmt.Errorf("%s: not writable", name) } return s.handleNewBatch(strings.TrimSpace(string(data))) } -func (s *BatchStore) Delete(name string) error { +func (s *BatchStore) del(name string) error { s.mu.Lock() job, ok := s.jobs[name] if ok { @@ -138,24 +184,11 @@ func (s *BatchStore) Delete(name string) error { if !ok { return fmt.Errorf("batch job not found: %s", name) } - job.mu.RLock() - cancel := job.cancel - job.mu.RUnlock() - if cancel != nil { - cancel() - } + job.Cancel() s.cfg.Log.Info("removed batch job %s", name) return nil } -func (s *BatchStore) Create(name string) error { - return fmt.Errorf("create not supported for batch jobs") -} - -func (s *BatchStore) Rename(oldName, newName string) error { - return fmt.Errorf("rename not supported for batch jobs") -} - // Shutdown cancels all running jobs. func (s *BatchStore) Shutdown() { s.mu.Lock() @@ -169,6 +202,15 @@ func (s *BatchStore) Shutdown() { } } +// InterruptAll cancels all running jobs without removing them. +func (s *BatchStore) InterruptAll() { + s.mu.RLock() + defer s.mu.RUnlock() + for _, job := range s.jobs { + job.Interrupt() + } +} + // Job looks up a job by ID (nil if not found). func (s *BatchStore) Job(id string) *batchJob { s.mu.RLock() @@ -290,7 +332,7 @@ func (s *BatchStore) executeJob(ctx context.Context, job *batchJob) (string, err be.SetModel(modelName) } - cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName) + cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil) newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{ "execute": execute.Decl(cwd), @@ -521,9 +563,12 @@ func (js *BatchJobStore) LogInfo() (length int, vers uint32) { // LoadAgentConfig resolves and loads the config for a named agent. // Returns nil (not an error) if the config file does not exist; -// BuildAgentEnv handles nil configs. -func LoadAgentConfig(agentsDir, name string) *config.Config { - f, err := os.Open(agentsDir + "/" + name + ".json") +// BuildAgentEnv handles nil configs. open defaults to os.Open if nil. +func LoadAgentConfig(agentsDir, name string, open func(string) (*os.File, error)) *config.Config { + if open == nil { + open = os.Open + } + f, err := open(agentsDir + "/" + name + ".json") if err != nil { return nil } diff --git a/pkg/store/session.go b/pkg/store/session.go index 501762d..2339b34 100644 --- a/pkg/store/session.go +++ b/pkg/store/session.go @@ -18,31 +18,45 @@ import ( // Session holds all state for one agent session. type Session struct { mu sync.RWMutex - ID string + id string Core agent.Core Ctx context.Context - Cancel context.CancelFunc - ChatLog []byte - ChatVers uint32 + cancel context.CancelFunc + log []byte + logVers uint32 ChatOffset int } -// AppendChat appends data to the session's chat log and bumps the version. -func (sess *Session) AppendChat(data []byte) { +func NewSession(id string, core agent.Core, ctx context.Context, cancel context.CancelFunc) *Session { + return &Session{id: id, Core: core, Ctx: ctx, cancel: cancel} +} + +func (sess *Session) RunnableID() string { return sess.id } + +func (sess *Session) Cancel() { + sess.cancel() +} + +func (sess *Session) Interrupt() { + sess.Core.Interrupt(agent.ErrInterrupted) +} + +// AppendLog appends data to the session's log and bumps the version. +func (sess *Session) AppendLog(data []byte) { if len(data) == 0 { return } sess.mu.Lock() - sess.ChatLog = append(sess.ChatLog, data...) - sess.ChatVers++ + sess.log = append(sess.log, data...) + sess.logVers++ sess.mu.Unlock() } -// ChatInfo returns the current chat log length and version atomically. -func (sess *Session) ChatInfo() (length int, vers uint32) { +// LogInfo returns the current log length and version atomically. +func (sess *Session) LogInfo() (length int, vers uint32) { sess.mu.RLock() defer sess.mu.RUnlock() - return len(sess.ChatLog), sess.ChatVers + return len(sess.log), sess.logVers } // sessionStoreFiles maps fixed file entries in s/ to their permissions. @@ -69,6 +83,8 @@ type SessionStoreConfig struct { SessionsDir string Log *olog.Logger Sink *olog.Sink + ReadFile func(string) ([]byte, error) + MkdirAll func(string, os.FileMode) error // OnRename is called after a session is renamed in the map, // for protocol-level fixups (e.g. fid path rewriting). OnRename func(oldID, newID string) @@ -76,16 +92,40 @@ type SessionStoreConfig struct { // SessionStore implements Store for session management. type SessionStore struct { + *storeConfig cfg SessionStoreConfig mu sync.RWMutex sessions map[string]*Session } func NewSessionStore(cfg SessionStoreConfig) *SessionStore { - return &SessionStore{cfg: cfg, sessions: make(map[string]*Session)} + if cfg.ReadFile == nil { + cfg.ReadFile = os.ReadFile + } + if cfg.MkdirAll == nil { + cfg.MkdirAll = os.MkdirAll + } + ss := &SessionStore{cfg: cfg, sessions: make(map[string]*Session)} + ss.storeConfig = &storeConfig{ + StatFn: ss.stat, + ListFn: ss.list, + GetFn: ss.get, + PutFn: ss.put, + DeleteFn: ss.del, + CreateFn: func(string) error { return fmt.Errorf("create not supported for sessions") }, + RenameFn: ss.renameSession, + } + return ss } -func (s *SessionStore) List() ([]os.DirEntry, error) { +// AddSession inserts a pre-built session into the store. +func (s *SessionStore) AddSession(sess *Session) { + s.mu.Lock() + s.sessions[sess.RunnableID()] = sess + s.mu.Unlock() +} + +func (s *SessionStore) list() ([]os.DirEntry, error) { entries := make([]os.DirEntry, 0, len(sessionStoreOrder)) for _, name := range sessionStoreOrder { entries = append(entries, FileEntry(name, sessionStoreFiles[name])) @@ -98,7 +138,7 @@ func (s *SessionStore) List() ([]os.DirEntry, error) { return entries, nil } -func (s *SessionStore) Stat(name string) (os.FileInfo, error) { +func (s *SessionStore) stat(name string) (os.FileInfo, error) { if mode, ok := sessionStoreFiles[name]; ok { return &SyntheticFileInfo{Name_: name, Mode_: mode}, nil } @@ -111,7 +151,7 @@ func (s *SessionStore) Stat(name string) (os.FileInfo, error) { return nil, fmt.Errorf("%s: not found", name) } -func (s *SessionStore) Get(name string) ([]byte, error) { +func (s *SessionStore) get(name string) ([]byte, error) { switch name { case "new": return []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\n"), nil @@ -119,13 +159,13 @@ func (s *SessionStore) Get(name string) ([]byte, error) { return s.index(), nil default: if _, ok := sessionStoreFiles[name]; ok { - return os.ReadFile(paths.CfgDir() + "/scripts/s/" + name) + return s.cfg.ReadFile(paths.CfgDir() + "/scripts/s/" + name) } } return nil, fmt.Errorf("%s: not a readable file", name) } -func (s *SessionStore) Put(name string, data []byte) error { +func (s *SessionStore) put(name string, data []byte) error { if name != "new" { return fmt.Errorf("%s: not writable", name) } @@ -133,7 +173,7 @@ func (s *SessionStore) Put(name string, data []byte) error { return s.createSession(args) } -func (s *SessionStore) Delete(name string) error { +func (s *SessionStore) del(name string) error { s.mu.RLock() _, ok := s.sessions[name] s.mu.RUnlock() @@ -144,14 +184,6 @@ func (s *SessionStore) Delete(name string) error { return nil } -func (s *SessionStore) Create(name string) error { - return fmt.Errorf("create not supported for sessions") -} - -func (s *SessionStore) Rename(oldName, newName string) error { - return s.renameSession(oldName, newName) -} - // Session returns the session for the given ID, or nil. func (s *SessionStore) Session(id string) *Session { s.mu.RLock() @@ -263,11 +295,11 @@ func (s *SessionStore) createSession(args []string) error { be.SetModel(modelOverride) } - if err := os.MkdirAll(s.cfg.SessionsDir, 0700); err != nil { + if err := s.cfg.MkdirAll(s.cfg.SessionsDir, 0700); err != nil { return fmt.Errorf("sessions dir: %w", err) } - cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName) + cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil) newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{ "execute": execute.Decl(cwd), @@ -299,12 +331,7 @@ func (s *SessionStore) createSession(args []string) error { NewDispatcher: newDisp, Log: s.cfg.Sink.NewLogger("core"), }) - sess := &Session{ - ID: sessID, - Core: core, - Ctx: ctx, - Cancel: cancel, - } + sess := NewSession(sessID, core, ctx, cancel) s.mu.Lock() s.sessions[sessID] = sess @@ -334,7 +361,7 @@ func (s *SessionStore) renameSession(oldID, newID string) error { return err } - sess.ID = newID + sess.id = newID s.sessions[newID] = sess delete(s.sessions, oldID) @@ -342,7 +369,7 @@ func (s *SessionStore) renameSession(oldID, newID string) error { s.cfg.OnRename(oldID, newID) } - sess.AppendChat([]byte(fmt.Sprintf("(session renamed: %s -> %s)\n", oldID, newID))) + sess.AppendLog([]byte(fmt.Sprintf("(session renamed: %s -> %s)\n", oldID, newID))) s.cfg.Log.Info("renamed session %s -> %s", oldID, newID) return nil } diff --git a/pkg/store/sessionfile.go b/pkg/store/sessionfile.go index ab96e38..921c203 100644 --- a/pkg/store/sessionfile.go +++ b/pkg/store/sessionfile.go @@ -68,7 +68,7 @@ func (s *SessionFileStore) Stat(name string) (os.FileInfo, error) { switch name { case "chat": s.sess.mu.RLock() - size = int64(len(s.sess.ChatLog)) + size = int64(len(s.sess.log)) s.sess.mu.RUnlock() case "statewait", "usagewait", "ctxszwait", "cwdwait": // Blocking reads; size is unknown until resolved. @@ -85,8 +85,8 @@ func (s *SessionFileStore) Get(name string) ([]byte, error) { switch name { case "chat": s.sess.mu.RLock() - data := make([]byte, len(s.sess.ChatLog)) - copy(data, s.sess.ChatLog) + data := make([]byte, len(s.sess.log)) + copy(data, s.sess.log) s.sess.mu.RUnlock() return data, nil case "offset": @@ -314,14 +314,14 @@ func (s *SessionFileStore) makePublish() func(agent.Event) { assistantStarted = false case "assistant": if !assistantStarted { - s.sess.AppendChat([]byte("assistant: ")) + s.sess.AppendLog([]byte("assistant: ")) assistantStarted = true } } - s.sess.AppendChat(FormatEvent(ev)) + s.sess.AppendLog(FormatEvent(ev)) if ev.Role == "user" { s.sess.mu.Lock() - s.sess.ChatOffset = len(s.sess.ChatLog) + s.sess.ChatOffset = len(s.sess.log) s.sess.mu.Unlock() } } @@ -345,8 +345,8 @@ func (s *SessionFileStore) handleCtl(input string) error { } case "save": s.sess.mu.RLock() - data := make([]byte, len(s.sess.ChatLog)) - copy(data, s.sess.ChatLog) + data := make([]byte, len(s.sess.log)) + copy(data, s.sess.log) s.sess.mu.RUnlock() return s.saveTranscript(data) case "compact", "clear", "backend", "model", "models", diff --git a/pkg/store/skill.go b/pkg/store/skill.go index 05ef821..020303c 100644 --- a/pkg/store/skill.go +++ b/pkg/store/skill.go @@ -11,18 +11,82 @@ import ( "ollie/pkg/skills" ) -// SkillStore is a Store backed by the skills package. -// Skills are stored as directories containing a SKILL.md file, but are -// exposed as flat "{name}.md" entries. The synthetic "idx" entry -// lists all skills with their descriptions. -type SkillStore struct{} - -func NewSkillStore() *SkillStore { - return &SkillStore{} +// SkillStoreConfig holds optional dependencies for NewSkillStore. +// Nil functions default to their os package equivalents. +type SkillStoreConfig struct { + Dirs []string + ReadDir func(string) ([]os.DirEntry, error) + Open func(string) (*os.File, error) + ReadFile func(string) ([]byte, error) + WriteFile func(string, []byte, os.FileMode) error + MkdirAll func(string, os.FileMode) error + RemoveAll func(string) error + Rename func(string, string) error +} + +type skillState struct { + dirs []string + readDir func(string) ([]os.DirEntry, error) + open func(string) (*os.File, error) + readFile func(string) ([]byte, error) + writeFile func(string, []byte, os.FileMode) error + mkdirAll func(string, os.FileMode) error + removeAll func(string) error + rename func(string, string) error +} + +func NewSkillStore() Store { + return NewSkillStoreWith(SkillStoreConfig{}) +} + +func NewSkillStoreWith(cfg SkillStoreConfig) Store { + if cfg.Dirs == nil { + cfg.Dirs = skillDirs() + } + if cfg.ReadDir == nil { + cfg.ReadDir = os.ReadDir + } + if cfg.Open == nil { + cfg.Open = os.Open + } + if cfg.ReadFile == nil { + cfg.ReadFile = os.ReadFile + } + if cfg.WriteFile == nil { + cfg.WriteFile = os.WriteFile + } + if cfg.MkdirAll == nil { + cfg.MkdirAll = os.MkdirAll + } + if cfg.RemoveAll == nil { + cfg.RemoveAll = os.RemoveAll + } + if cfg.Rename == nil { + cfg.Rename = os.Rename + } + + ss := &skillState{ + dirs: cfg.Dirs, + readDir: cfg.ReadDir, + open: cfg.Open, + readFile: cfg.ReadFile, + writeFile: cfg.WriteFile, + mkdirAll: cfg.MkdirAll, + removeAll: cfg.RemoveAll, + rename: cfg.Rename, + } + + return &storeConfig{ + StatFn: ss.stat, + ListFn: ss.list, + GetFn: ss.get, + PutFn: ss.put, + DeleteFn: ss.del, + CreateFn: ss.create, + RenameFn: ss.ren, + } } -// skillDirs returns the skill directories from OLLIE_SKILLS_PATH, -// falling back to the default skills directory. func skillDirs() []string { if env := os.Getenv("OLLIE_SKILLS_PATH"); env != "" { return strings.Split(env, ":") @@ -30,13 +94,11 @@ func skillDirs() []string { return []string{paths.CfgDir() + "/skills"} } -// listSkills scans all skill directories and returns deduplicated metadata. -// First occurrence by directory order wins on name collision. -func listSkills() []skills.Meta { +func (ss *skillState) listSkills() []skills.Meta { seen := make(map[string]bool) var result []skills.Meta - for _, dir := range skillDirs() { - entries, err := os.ReadDir(dir) + for _, dir := range ss.dirs { + entries, err := ss.readDir(dir) if err != nil { continue } @@ -45,7 +107,7 @@ func listSkills() []skills.Meta { continue } skillDir := filepath.Join(dir, e.Name()) - f, err := os.Open(filepath.Join(skillDir, "SKILL.md")) + f, err := ss.open(filepath.Join(skillDir, "SKILL.md")) if err != nil { continue } @@ -62,95 +124,94 @@ func listSkills() []skills.Meta { return result } -// readSkill returns the SKILL.md content for the named skill. -func readSkill(name string) ([]byte, error) { - for _, m := range listSkills() { +func (ss *skillState) readSkill(name string) ([]byte, error) { + for _, m := range ss.listSkills() { if m.Name == name { - return os.ReadFile(filepath.Join(m.Dir, "SKILL.md")) + return ss.readFile(filepath.Join(m.Dir, "SKILL.md")) } } return nil, os.ErrNotExist } -func (s *SkillStore) Stat(name string) (os.FileInfo, error) { +func (ss *skillState) stat(name string) (os.FileInfo, error) { if name == "idx" { return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil } skillName := strings.TrimSuffix(name, ".md") - if _, err := readSkill(skillName); err != nil { + if _, err := ss.readSkill(skillName); err != nil { return nil, fmt.Errorf("%s: not found", name) } return &SyntheticFileInfo{Name_: name, Mode_: 0666}, nil } -func (s *SkillStore) List() ([]os.DirEntry, error) { +func (ss *skillState) list() ([]os.DirEntry, error) { result := []os.DirEntry{FileEntry("idx", 0444)} - for _, m := range listSkills() { + for _, m := range ss.listSkills() { result = append(result, FileEntry(m.Name+".md", 0666)) } return result, nil } -func (s *SkillStore) Get(name string) ([]byte, error) { +func (ss *skillState) get(name string) ([]byte, error) { if name == "idx" { - return s.index() + return ss.index() } skillName := strings.TrimSuffix(name, ".md") - return readSkill(skillName) + return ss.readSkill(skillName) } -func (s *SkillStore) Put(name string, data []byte) error { +func (ss *skillState) put(name string, data []byte) error { skillName := strings.TrimSuffix(name, ".md") dir := "" - for _, m := range listSkills() { + for _, m := range ss.listSkills() { if m.Name == skillName { dir = m.Dir break } } if dir == "" { - dir = filepath.Join(skillDirs()[0], skillName) + dir = filepath.Join(ss.dirs[0], skillName) } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ss.mkdirAll(dir, 0755); err != nil { return err } - return os.WriteFile(filepath.Join(dir, "SKILL.md"), data, 0644) + return ss.writeFile(filepath.Join(dir, "SKILL.md"), data, 0644) } -func (s *SkillStore) Delete(name string) error { +func (ss *skillState) del(name string) error { skillName := strings.TrimSuffix(name, ".md") - for _, m := range listSkills() { + for _, m := range ss.listSkills() { if m.Name == skillName { - return os.RemoveAll(m.Dir) + return ss.removeAll(m.Dir) } } return fmt.Errorf("skill not found: %s", skillName) } -func (s *SkillStore) Rename(oldName, newName string) error { +func (ss *skillState) ren(oldName, newName string) error { oldSkill := strings.TrimSuffix(oldName, ".md") newSkill := strings.TrimSuffix(newName, ".md") - for _, m := range listSkills() { + for _, m := range ss.listSkills() { if m.Name == oldSkill { newDir := filepath.Join(filepath.Dir(m.Dir), newSkill) - return os.Rename(m.Dir, newDir) + return ss.rename(m.Dir, newDir) } } return fmt.Errorf("skill not found: %s", oldSkill) } -func (s *SkillStore) Create(name string) error { +func (ss *skillState) create(name string) error { skillName := strings.TrimSuffix(name, ".md") - dir := filepath.Join(skillDirs()[0], skillName) - if err := os.MkdirAll(dir, 0755); err != nil { + dir := filepath.Join(ss.dirs[0], skillName) + if err := ss.mkdirAll(dir, 0755); err != nil { return err } - return os.WriteFile(filepath.Join(dir, "SKILL.md"), nil, 0644) + return ss.writeFile(filepath.Join(dir, "SKILL.md"), nil, 0644) } -func (s *SkillStore) index() ([]byte, error) { +func (ss *skillState) index() ([]byte, error) { var sb strings.Builder - for _, m := range listSkills() { + for _, m := range ss.listSkills() { fmt.Fprintf(&sb, "## %s\n", m.Name) fmt.Fprintf(&sb, "description: %s\n", m.Description) sb.WriteString("\n") diff --git a/pkg/store/store.go b/pkg/store/store.go index 1cb9191..ad05152 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -15,6 +15,15 @@ type ReadableStore interface { Get(name string) ([]byte, error) } +// Runnable is a running agent that can be observed and controlled. +type Runnable interface { + RunnableID() string + Cancel() + Interrupt() + AppendLog([]byte) + LogInfo() (length int, vers uint32) +} + // WritableStore supports writing blobs by name. type WritableStore interface { Put(name string, data []byte) error @@ -39,6 +48,51 @@ type Store interface { Rename(oldName, newName string) error } +// storeConfig implements the store interfaces via function pointers. +type storeConfig struct { + StatFn func(string) (os.FileInfo, error) + ListFn func() ([]os.DirEntry, error) + GetFn func(string) ([]byte, error) + PutFn func(string, []byte) error + DeleteFn func(string) error + CreateFn func(string) error + RenameFn func(string, string) error +} + +func (s *storeConfig) Stat(name string) (os.FileInfo, error) { return s.StatFn(name) } +func (s *storeConfig) List() ([]os.DirEntry, error) { return s.ListFn() } +func (s *storeConfig) Get(name string) ([]byte, error) { return s.GetFn(name) } +func (s *storeConfig) Put(name string, data []byte) error { return s.PutFn(name, data) } +func (s *storeConfig) Delete(name string) error { return s.DeleteFn(name) } +func (s *storeConfig) Create(name string) error { return s.CreateFn(name) } +func (s *storeConfig) Rename(old, new string) error { return s.RenameFn(old, new) } + +// NewFlatDir returns a Store backed by a directory on the local filesystem. +func NewFlatDir(dir string, perm os.FileMode) Store { + join := func(name string) string { return filepath.Join(dir, name) } + ensureDir := func() error { return os.MkdirAll(dir, 0755) } + + return &storeConfig{ + StatFn: func(name string) (os.FileInfo, error) { return os.Stat(join(name)) }, + ListFn: func() ([]os.DirEntry, error) { return os.ReadDir(dir) }, + GetFn: func(name string) ([]byte, error) { return os.ReadFile(join(name)) }, + PutFn: func(name string, data []byte) error { + if err := ensureDir(); err != nil { + return err + } + return os.WriteFile(join(name), data, perm) + }, + DeleteFn: func(name string) error { return os.Remove(join(name)) }, + CreateFn: func(name string) error { + if err := ensureDir(); err != nil { + return err + } + return os.WriteFile(join(name), nil, perm) + }, + RenameFn: func(old, new string) error { return os.Rename(join(old), join(new)) }, + } +} + // SyntheticFileInfo implements os.FileInfo for entries with no backing file. type SyntheticFileInfo struct { Name_ string @@ -82,49 +136,3 @@ func FileEntry(name string, mode os.FileMode) os.DirEntry { func DirEntry(name string, mode os.FileMode) os.DirEntry { return &SyntheticEntry{Name_: name, Mode_: mode, IsDir_: true} } - -// FlatDir implements Store backed by a directory on the local filesystem. -type FlatDir struct { - dir string - perm os.FileMode -} - -// NewFlatDir returns a FlatDir rooted at dir. -// perm is applied to newly created or written files. -func NewFlatDir(dir string, perm os.FileMode) *FlatDir { - return &FlatDir{dir: dir, perm: perm} -} - -func (s *FlatDir) Stat(name string) (os.FileInfo, error) { - return os.Stat(filepath.Join(s.dir, name)) -} - -func (s *FlatDir) List() ([]os.DirEntry, error) { - return os.ReadDir(s.dir) -} - -func (s *FlatDir) Get(name string) ([]byte, error) { - return os.ReadFile(filepath.Join(s.dir, name)) -} - -func (s *FlatDir) Put(name string, data []byte) error { - if err := os.MkdirAll(s.dir, 0755); err != nil { - return err - } - return os.WriteFile(filepath.Join(s.dir, name), data, s.perm) -} - -func (s *FlatDir) Delete(name string) error { - return os.Remove(filepath.Join(s.dir, name)) -} - -func (s *FlatDir) Create(name string) error { - if err := os.MkdirAll(s.dir, 0755); err != nil { - return err - } - return os.WriteFile(filepath.Join(s.dir, name), nil, s.perm) -} - -func (s *FlatDir) Rename(oldName, newName string) error { - return os.Rename(filepath.Join(s.dir, oldName), filepath.Join(s.dir, newName)) -} diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go new file mode 100644 index 0000000..1ba2c83 --- /dev/null +++ b/pkg/store/store_test.go @@ -0,0 +1,940 @@ +package store_test + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "ollie/pkg/agent" + "ollie/pkg/backend" + olog "ollie/pkg/log" + "ollie/pkg/store" +) + +// --- stub agent.Core --- + +type stubCore struct { + state string + running bool + backend string + model string + agentName string + cwd string + usage string + ctxsz string + models string + sysprompt string + reply string + params backend.GenerationParams + closed bool +} + +func (c *stubCore) Submit(context.Context, string, agent.EventHandler) {} +func (c *stubCore) Interrupt(error) bool { return c.running } +func (c *stubCore) Inject(string) {} +func (c *stubCore) Queue(string) {} +func (c *stubCore) PopQueue() (string, bool) { return "", false } +func (c *stubCore) IsRunning() bool { return c.running } +func (c *stubCore) State() string { return c.state } +func (c *stubCore) Reply() string { return c.reply } +func (c *stubCore) AgentName() string { return c.agentName } +func (c *stubCore) BackendName() string { return c.backend } +func (c *stubCore) ModelName() string { return c.model } +func (c *stubCore) CtxSz() string { return c.ctxsz } +func (c *stubCore) Usage() string { return c.usage } +func (c *stubCore) ListModels() string { return c.models } +func (c *stubCore) CWD() string { return c.cwd } +func (c *stubCore) SetCWD(dir string) error { c.cwd = dir; return nil } +func (c *stubCore) SetSessionID(string) error { return nil } +func (c *stubCore) SystemPrompt() string { return c.sysprompt } +func (c *stubCore) GenerationParams() backend.GenerationParams { return c.params } +func (c *stubCore) SetGenerationParams(p backend.GenerationParams) error { c.params = p; return nil } +func (c *stubCore) SetEnv(string, string) {} +func (c *stubCore) WaitChange(ctx context.Context, _, _ string) (string, bool) { <-ctx.Done(); return "", false } +func (c *stubCore) Close() { c.closed = true } + +// --- helpers --- + +func testSink() *olog.Sink { + return olog.NewSink(io.Discard, io.Discard, olog.LevelError) +} + +func testSession(id string) *store.Session { + ctx, cancel := context.WithCancel(context.Background()) + return store.NewSession(id, &stubCore{state: "idle", backend: "stub", model: "m", agentName: "default", cwd: "/tmp"}, ctx, cancel) +} + +func seedSkill(t *testing.T, base, name string) { + t.Helper() + d := filepath.Join(base, name) + os.MkdirAll(d, 0755) + os.WriteFile(filepath.Join(d, "SKILL.md"), []byte("---\ndescription: test\n---\n"), 0644) +} + +func newSessionStore(t *testing.T) *store.SessionStore { + t.Helper() + sink := testSink() + return store.NewSessionStore(store.SessionStoreConfig{ + Log: sink.NewLogger("test"), + Sink: sink, + ReadFile: func(string) ([]byte, error) { return []byte("#!/bin/sh\n"), nil }, + MkdirAll: func(string, os.FileMode) error { return nil }, + }) +} + +func newBatchStore(t *testing.T) *store.BatchStore { + t.Helper() + sink := testSink() + return store.NewBatchStore(store.BatchStoreConfig{ + Log: sink.NewLogger("test"), + Sink: sink, + }) +} + +// --- contract checks --- + +func checkReadableContract(t *testing.T, s store.ReadableStore, name string) { + t.Helper() + fi, err := s.Stat(name) + if err != nil { + t.Fatalf("Stat(%q): %v", name, err) + } + if fi.Name() != name { + t.Errorf("Stat(%q).Name() = %q", name, fi.Name()) + } + if _, err := s.Stat("__nonexistent__"); err == nil { + t.Error("Stat(nonexistent) should error") + } + entries, err := s.List() + if err != nil { + t.Fatalf("List: %v", err) + } + found := false + for _, e := range entries { + if e.Name() == name { + found = true + } + } + if !found { + t.Errorf("List() missing %q", name) + } + if _, err := s.Get(name); err != nil { + t.Fatalf("Get(%q): %v", name, err) + } + if _, err := s.Get("__nonexistent__"); err == nil { + t.Error("Get(nonexistent) should error") + } +} + +func checkReadWriteContract(t *testing.T, s store.ReadWriteStore, name string) { + t.Helper() + want := []byte("hello") + if err := s.Put(name, want); err != nil { + t.Fatalf("Put: %v", err) + } + got, err := s.Get(name) + if err != nil { + t.Fatalf("Get after Put: %v", err) + } + if string(got) != string(want) { + t.Errorf("Get = %q; want %q", got, want) + } + want2 := []byte("world") + if err := s.Put(name, want2); err != nil { + t.Fatalf("Put overwrite: %v", err) + } + got2, err := s.Get(name) + if err != nil { + t.Fatalf("Get after overwrite: %v", err) + } + if string(got2) != string(want2) { + t.Errorf("Get after overwrite = %q; want %q", got2, want2) + } + checkReadableContract(t, s, name) +} + +func checkBlobStoreContract(t *testing.T, s store.BlobStore, name string) { + t.Helper() + if err := s.Create(name); err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := s.Stat(name); err != nil { + t.Fatalf("Stat after Create: %v", err) + } + if err := s.Delete(name); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := s.Stat(name); err == nil { + t.Error("Stat after Delete should error") + } + if err := s.Delete("__nonexistent__"); err == nil { + t.Error("Delete(nonexistent) should error") + } + checkReadWriteContract(t, s, name) +} + +func checkStoreContract(t *testing.T, s store.Store, name string) { + t.Helper() + renamed := name + "-renamed" + if err := s.Create(name); err != nil { + t.Fatalf("Create: %v", err) + } + if err := s.Rename(name, renamed); err != nil { + t.Fatalf("Rename: %v", err) + } + if _, err := s.Stat(name); err == nil { + t.Error("Stat(old) should error after Rename") + } + if _, err := s.Stat(renamed); err != nil { + t.Errorf("Stat(new) after Rename: %v", err) + } + s.Delete(renamed) //nolint:errcheck + checkBlobStoreContract(t, s, name) +} + +// ===== FlatDir ===== + +func TestFlatDirContract(t *testing.T) { + checkStoreContract(t, store.NewFlatDir(t.TempDir(), 0644), "test-file") +} + +func TestFlatDirCreateMkdirError(t *testing.T) { + fd := store.NewFlatDir("/nonexistent/path", 0644) + if err := fd.Create("f"); err == nil { + t.Error("Create should fail when dir doesn't exist") + } +} + +func TestFlatDirPutMkdirError(t *testing.T) { + fd := store.NewFlatDir("/nonexistent/path", 0644) + if err := fd.Put("f", []byte("x")); err == nil { + t.Error("Put should fail when dir doesn't exist") + } +} + +// ===== SkillStore ===== + +func TestSkillStoreContract(t *testing.T) { + dir := t.TempDir() + t.Setenv("OLLIE_SKILLS_PATH", dir) + seedSkill(t, dir, "test-skill") + + s := store.NewSkillStore() + + // Rename (needs seeded skill with valid front matter) + seedSkill(t, dir, "rename-src") + if err := s.Rename("rename-src.md", "rename-dst.md"); err != nil { + t.Fatalf("Rename: %v", err) + } + if _, err := s.Stat("rename-src.md"); err == nil { + t.Error("Stat(old) should error after Rename") + } + if _, err := s.Stat("rename-dst.md"); err != nil { + t.Errorf("Stat(new) after Rename: %v", err) + } + s.Delete("rename-dst.md") //nolint:errcheck + + // Delete + seedSkill(t, dir, "del-test") + if err := s.Delete("del-test.md"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := s.Stat("del-test.md"); err == nil { + t.Error("Stat after Delete should error") + } + if err := s.Delete("__nonexistent__.md"); err == nil { + t.Error("Delete(nonexistent) should error") + } + + // Put/Get round-trip (needs valid front matter) + content := []byte("---\ndescription: rw test\n---\nbody\n") + if err := s.Put("rw-skill.md", content); err != nil { + t.Fatalf("Put: %v", err) + } + got, err := s.Get("rw-skill.md") + if err != nil { + t.Fatalf("Get after Put: %v", err) + } + if string(got) != string(content) { + t.Errorf("Get = %q; want %q", got, content) + } + + // Readable + checkReadableContract(t, s, "test-skill.md") + + // idx + idx, err := s.Get("idx") + if err != nil { + t.Fatalf("Get(idx): %v", err) + } + if len(idx) == 0 { + t.Error("idx should be non-empty with seeded skills") + } + + // Rename nonexistent + if err := s.Rename("__nope__.md", "x.md"); err == nil { + t.Error("Rename(nonexistent) should error") + } + + // Create calls through injected mkdirAll + writeFile + var createdDir, createdFile string + s2 := store.NewSkillStoreWith(store.SkillStoreConfig{ + Dirs: []string{dir}, + MkdirAll: func(p string, _ os.FileMode) error { createdDir = p; return nil }, + WriteFile: func(p string, _ []byte, _ os.FileMode) error { createdFile = p; return nil }, + }) + if err := s2.Create("newskill.md"); err != nil { + t.Fatalf("Create: %v", err) + } + if !strings.HasSuffix(createdDir, "newskill") { + t.Errorf("mkdirAll path = %q; want suffix newskill", createdDir) + } + if !strings.HasSuffix(createdFile, "SKILL.md") { + t.Errorf("writeFile path = %q; want suffix SKILL.md", createdFile) + } + + // Create mkdirAll error + s3 := store.NewSkillStoreWith(store.SkillStoreConfig{ + Dirs: []string{dir}, + MkdirAll: func(string, os.FileMode) error { return fmt.Errorf("denied") }, + }) + if err := s3.Create("x.md"); err == nil { + t.Error("Create should fail when mkdirAll errors") + } +} + +// ===== Session ===== + +func TestSessionAppendLog(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + + sess.AppendLog([]byte("hello")) + sess.AppendLog(nil) // no-op + sess.AppendLog([]byte(" world")) + + l, v := sess.LogInfo() + if l != 11 { + t.Errorf("ChatInfo length = %d; want 11", l) + } + if v != 2 { + t.Errorf("ChatInfo vers = %d; want 2", v) + } +} + +func TestSessionStoreFileMode(t *testing.T) { + if m, ok := store.SessionStoreFileMode("new"); !ok || m != 0666 { + t.Errorf("SessionStoreFileMode(new) = %o, %v", m, ok) + } + if _, ok := store.SessionStoreFileMode("bogus"); ok { + t.Error("SessionStoreFileMode(bogus) should be false") + } +} + +// ===== SessionStore ===== + +func TestSessionStoreReadableContract(t *testing.T) { + checkReadableContract(t, newSessionStore(t), "new") +} + +func TestSessionStoreGetIdx(t *testing.T) { + s := newSessionStore(t) + sess := testSession("abc") + defer sess.Cancel() + s.AddSession(sess) + + data, err := s.Get("idx") + if err != nil { + t.Fatalf("Get(idx): %v", err) + } + if !strings.Contains(string(data), "abc") { + t.Errorf("idx = %q; want to contain abc", data) + } +} + +func TestSessionStoreGetScript(t *testing.T) { + s := newSessionStore(t) + data, err := s.Get("ls") + if err != nil { + t.Fatalf("Get(ls): %v", err) + } + if string(data) != "#!/bin/sh\n" { + t.Errorf("Get(ls) = %q", data) + } +} + +func TestSessionStoreStatSession(t *testing.T) { + s := newSessionStore(t) + sess := testSession("s1") + defer sess.Cancel() + s.AddSession(sess) + + fi, err := s.Stat("s1") + if err != nil { + t.Fatalf("Stat(s1): %v", err) + } + if !fi.IsDir() { + t.Error("session stat should be dir") + } +} + +func TestSessionStoreListIncludesSessions(t *testing.T) { + s := newSessionStore(t) + sess := testSession("s1") + defer sess.Cancel() + s.AddSession(sess) + + entries, _ := s.List() + found := false + for _, e := range entries { + if e.Name() == "s1" { + found = true + } + } + if !found { + t.Error("List() missing session s1") + } +} + +func TestSessionStorePutNotWritable(t *testing.T) { + s := newSessionStore(t) + if err := s.Put("idx", nil); err == nil { + t.Error("Put(idx) should error") + } +} + +func TestSessionStoreCreateErrors(t *testing.T) { + s := newSessionStore(t) + if err := s.Create("x"); err == nil { + t.Error("Create should always error") + } +} + +func TestSessionStoreDeleteAndKill(t *testing.T) { + s := newSessionStore(t) + sess := testSession("s1") + s.AddSession(sess) + + if err := s.Delete("s1"); err != nil { + t.Fatalf("Delete: %v", err) + } + if s.Session("s1") != nil { + t.Error("session should be gone after Delete") + } + core := sess.Core.(*stubCore) + if !core.closed { + t.Error("core should be closed after Delete") + } + if err := s.Delete("nope"); err == nil { + t.Error("Delete(nonexistent) should error") + } +} + +func TestSessionStoreSession(t *testing.T) { + s := newSessionStore(t) + if s.Session("nope") != nil { + t.Error("Session(nonexistent) should be nil") + } + sess := testSession("s1") + defer sess.Cancel() + s.AddSession(sess) + if s.Session("s1") == nil { + t.Error("Session(s1) should not be nil") + } +} + +func TestSessionStoreInterruptAll(t *testing.T) { + s := newSessionStore(t) + sess := testSession("s1") + defer sess.Cancel() + sess.Core.(*stubCore).running = true + s.AddSession(sess) + + s.InterruptAll() // should not panic +} + +func TestSessionStoreShutdown(t *testing.T) { + s := newSessionStore(t) + sess := testSession("s1") + s.AddSession(sess) + + s.Shutdown() + if s.Session("s1") != nil { + t.Error("session should be gone after Shutdown") + } +} + +func TestSessionStoreRename(t *testing.T) { + s := newSessionStore(t) + sess := testSession("old") + defer sess.Cancel() + s.AddSession(sess) + + if err := s.Rename("old", "new"); err != nil { + t.Fatalf("Rename: %v", err) + } + if s.Session("old") != nil { + t.Error("old session should be gone") + } + if s.Session("new") == nil { + t.Error("new session should exist") + } +} + +func TestSessionStoreRenameErrors(t *testing.T) { + s := newSessionStore(t) + // nonexistent + if err := s.Rename("nope", "x"); err == nil { + t.Error("Rename(nonexistent) should error") + } + // duplicate + s.AddSession(testSession("a")) + s.AddSession(testSession("b")) + if err := s.Rename("a", "b"); err == nil { + t.Error("Rename to existing should error") + } + // running + sess := testSession("r") + sess.Core.(*stubCore).running = true + s.AddSession(sess) + if err := s.Rename("r", "r2"); err == nil { + t.Error("Rename while running should error") + } +} + +// ===== SessionFileStore ===== + +func TestSessionFileStoreReadableContract(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + checkReadableContract(t, sf, "state") +} + +func TestSessionFileStoreList(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + + entries, err := sf.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != len(store.SessionFileList) { + t.Errorf("List() returned %d entries; want %d", len(entries), len(store.SessionFileList)) + } +} + +func TestSessionFileStoreStatChat(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sess.AppendLog([]byte("hello")) + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + + fi, err := sf.Stat("chat") + if err != nil { + t.Fatalf("Stat(chat): %v", err) + } + if fi.Size() != 5 { + t.Errorf("Stat(chat).Size() = %d; want 5", fi.Size()) + } +} + +func TestSessionFileStoreGetChat(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sess.AppendLog([]byte("hello")) + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + + data, err := sf.Get("chat") + if err != nil { + t.Fatalf("Get(chat): %v", err) + } + if string(data) != "hello" { + t.Errorf("Get(chat) = %q; want hello", data) + } +} + +func TestSessionFileStoreGetContent(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + + for _, name := range []string{"backend", "agent", "model", "state", "cwd", "offset", "params"} { + if _, err := sf.Get(name); err != nil { + t.Errorf("Get(%q): %v", name, err) + } + } +} + +func TestSessionFileStorePutCwd(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + + if err := sf.Put("cwd", []byte("/new/path")); err != nil { + t.Fatalf("Put(cwd): %v", err) + } + core := sess.Core.(*stubCore) + if core.cwd != "/new/path" { + t.Errorf("cwd = %q; want /new/path", core.cwd) + } +} + +func TestSessionFileStorePutEmpty(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + + // Empty write is a no-op + if err := sf.Put("cwd", []byte("")); err != nil { + t.Fatalf("Put(cwd, empty): %v", err) + } +} + +func TestSessionFileStoreCurrentWaitValue(t *testing.T) { + sess := testSession("s1") + defer sess.Cancel() + sink := testSink() + sf := store.NewSessionFileStore(sess, sink.NewLogger("test"), + func() {}, func(string) error { return nil }, func([]byte) error { return nil }) + + if v := sf.CurrentWaitValue("statewait"); v != "idle" { + t.Errorf("CurrentWaitValue(statewait) = %q; want idle", v) + } + if v := sf.CurrentWaitValue("cwdwait"); v != "/tmp" { + t.Errorf("CurrentWaitValue(cwdwait) = %q; want /tmp", v) + } +} + +// ===== FormatParams / ParseParams ===== + +func TestFormatParseParamsRoundTrip(t *testing.T) { + temp := 0.7 + p := backend.GenerationParams{MaxTokens: 1024, Temperature: &temp} + s := store.FormatParams(p) + got, err := store.ParseParams(s, backend.GenerationParams{}) + if err != nil { + t.Fatalf("ParseParams: %v", err) + } + if got.MaxTokens != 1024 { + t.Errorf("MaxTokens = %d; want 1024", got.MaxTokens) + } + if got.Temperature == nil || *got.Temperature != 0.7 { + t.Errorf("Temperature = %v; want 0.7", got.Temperature) + } +} + +func TestParseParamsErrors(t *testing.T) { + if _, err := store.ParseParams("maxTokens=abc", backend.GenerationParams{}); err == nil { + t.Error("expected error for invalid maxTokens") + } + if _, err := store.ParseParams("temperature=abc", backend.GenerationParams{}); err == nil { + t.Error("expected error for invalid temperature") + } + if _, err := store.ParseParams("frequencyPenalty=abc", backend.GenerationParams{}); err == nil { + t.Error("expected error for invalid frequencyPenalty") + } + if _, err := store.ParseParams("presencePenalty=abc", backend.GenerationParams{}); err == nil { + t.Error("expected error for invalid presencePenalty") + } +} + +func TestParseParamsClearOptional(t *testing.T) { + temp := 0.5 + p := backend.GenerationParams{Temperature: &temp} + got, err := store.ParseParams("temperature=\nmaxTokens=", p) + if err != nil { + t.Fatalf("ParseParams: %v", err) + } + if got.Temperature != nil { + t.Error("Temperature should be nil after clearing") + } +} + +// ===== BatchStore ===== + +func TestBatchStoreReadableContract(t *testing.T) { + checkReadableContract(t, newBatchStore(t), "new") +} + +func TestBatchStoreStatJob(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "result", "spec") + + fi, err := s.Stat("j1") + if err != nil { + t.Fatalf("Stat(j1): %v", err) + } + if !fi.IsDir() { + t.Error("job stat should be dir") + } +} + +func TestBatchStoreListIncludesJobs(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "result", "spec") + + entries, _ := s.List() + found := false + for _, e := range entries { + if e.Name() == "j1" { + found = true + } + } + if !found { + t.Error("List() missing job j1") + } +} + +func TestBatchStoreGetIdx(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "result", "spec") + + data, err := s.Get("idx") + if err != nil { + t.Fatalf("Get(idx): %v", err) + } + if !strings.Contains(string(data), "j1") { + t.Errorf("idx = %q; want to contain j1", data) + } +} + +func TestBatchStorePutNotWritable(t *testing.T) { + s := newBatchStore(t) + if err := s.Put("idx", nil); err == nil { + t.Error("Put(idx) should error") + } +} + +func TestBatchStoreCreateErrors(t *testing.T) { + s := newBatchStore(t) + if err := s.Create("x"); err == nil { + t.Error("Create should always error") + } +} + +func TestBatchStoreRenameErrors(t *testing.T) { + s := newBatchStore(t) + if err := s.Rename("a", "b"); err == nil { + t.Error("Rename should always error") + } +} + +func TestBatchStoreDelete(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "result", "spec") + + if err := s.Delete("j1"); err != nil { + t.Fatalf("Delete: %v", err) + } + if s.Job("j1") != nil { + t.Error("job should be gone after Delete") + } + if err := s.Delete("nope"); err == nil { + t.Error("Delete(nonexistent) should error") + } +} + +func TestBatchStoreJob(t *testing.T) { + s := newBatchStore(t) + if s.Job("nope") != nil { + t.Error("Job(nonexistent) should be nil") + } + s.AddJob("j1", "done", "result", "spec") + if s.Job("j1") == nil { + t.Error("Job(j1) should not be nil") + } +} + +func TestBatchStoreShutdown(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "result", "spec") + s.Shutdown() + if s.Job("j1") != nil { + t.Error("job should be gone after Shutdown") + } +} + +// ===== BatchJobStore ===== + +func TestBatchJobStoreReadableContract(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "the result", "the spec") + js, ok := s.JobStore("j1") + if !ok { + t.Fatal("JobStore(j1) not found") + } + checkReadableContract(t, js, "state") +} + +func TestBatchJobStoreContent(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "the result", "the spec") + js, _ := s.JobStore("j1") + + for _, tc := range []struct { + name, want string + }{ + {"spec", "the spec"}, + {"state", "done\n"}, + {"result", "the result"}, + } { + data, err := js.Get(tc.name) + if err != nil { + t.Errorf("Get(%q): %v", tc.name, err) + continue + } + if string(data) != tc.want { + t.Errorf("Get(%q) = %q; want %q", tc.name, data, tc.want) + } + } +} + +func TestBatchJobStoreWait(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "", "") + js, _ := s.JobStore("j1") + + // Job is already done, so Wait returns immediately + data, err := js.Wait(context.Background(), "statewait", "") + if err != nil { + t.Fatalf("Wait: %v", err) + } + if !strings.Contains(string(data), "done") { + t.Errorf("Wait = %q; want to contain done", data) + } + + // Non-wait file + if _, err := js.Wait(context.Background(), "spec", ""); err == nil { + t.Error("Wait(spec) should error") + } +} + +func TestBatchJobStoreLogInfo(t *testing.T) { + s := newBatchStore(t) + s.AddJob("j1", "done", "", "") + js, _ := s.JobStore("j1") + + l, v := js.LogInfo() + if l != 0 || v != 0 { + t.Errorf("LogInfo = (%d, %d); want (0, 0)", l, v) + } +} + +func TestBatchJobStoreNotFound(t *testing.T) { + s := newBatchStore(t) + if _, ok := s.JobStore("nope"); ok { + t.Error("JobStore(nonexistent) should be false") + } +} + +// ===== ParseBatchSpec ===== + +func TestParseBatchSpec(t *testing.T) { + input := "name=test\ncwd=/tmp\nagent=default\n---\ndo something" + if _, err := store.ParseBatchSpec(input); err != nil { + t.Fatalf("ParseBatchSpec: %v", err) + } +} + +func TestParseBatchSpecErrors(t *testing.T) { + // missing delimiter + if _, err := store.ParseBatchSpec("no delimiter"); err == nil { + t.Error("expected error for missing ---") + } + // missing cwd + if _, err := store.ParseBatchSpec("name=x\n---\nprompt"); err == nil { + t.Error("expected error for missing cwd") + } + // missing prompt + if _, err := store.ParseBatchSpec("cwd=/tmp\n---\n"); err == nil { + t.Error("expected error for missing prompt") + } +} + +// ===== LoadAgentConfig ===== + +func TestLoadAgentConfig(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "test.json"), []byte(`{}`), 0644) + + cfg := store.LoadAgentConfig(dir, "test", nil) + if cfg == nil { + t.Error("LoadAgentConfig should return non-nil for existing file") + } + + cfg = store.LoadAgentConfig(dir, "nonexistent", nil) + if cfg != nil { + t.Error("LoadAgentConfig should return nil for missing file") + } +} + +// ===== FormatEvent ===== + +func TestFormatEvent(t *testing.T) { + for _, tc := range []struct { + ev agent.Event + want string + }{ + {agent.Event{Role: "user", Content: "hi"}, "user: hi\n"}, + {agent.Event{Role: "assistant", Content: "hello"}, "hello"}, + {agent.Event{Role: "error", Content: "oops"}, "error: oops\n"}, + {agent.Event{Role: "call", Name: "fn", Content: "args"}, "-> fn(args)\n"}, + {agent.Event{Role: "tool", Content: "result\n"}, "result\n"}, + {agent.Event{Role: "retry", Content: "5"}, "retrying in 5s...\n"}, + {agent.Event{Role: "stalled"}, "agent stalled\n"}, + {agent.Event{Role: "unknown"}, ""}, + } { + got := string(store.FormatEvent(tc.ev)) + if got != tc.want { + t.Errorf("FormatEvent(%q) = %q; want %q", tc.ev.Role, got, tc.want) + } + } +} + +// ===== SyntheticFileInfo / SyntheticEntry / FileEntry / DirEntry ===== + +func TestSyntheticFileInfo(t *testing.T) { + fi := &store.SyntheticFileInfo{Name_: "f", Mode_: 0644, Size_: 42, IsDir_: false} + if fi.Name() != "f" || fi.Size() != 42 || fi.Mode() != 0644 || fi.IsDir() || fi.Sys() != nil { + t.Error("SyntheticFileInfo field mismatch") + } + if !fi.ModTime().IsZero() { + t.Error("ModTime should be zero") + } +} + +func TestFileEntryDirEntry(t *testing.T) { + fe := store.FileEntry("f", 0644) + if fe.Name() != "f" || fe.IsDir() || fe.Type() != 0 { + t.Error("FileEntry mismatch") + } + de := store.DirEntry("d", 0755) + if de.Name() != "d" || !de.IsDir() || de.Type() != os.ModeDir { + t.Error("DirEntry mismatch") + } + // Info() + info, err := de.Info() + if err != nil || info.Name() != "d" || !info.IsDir() { + t.Error("DirEntry.Info() mismatch") + } +}