store: refactor to storeConfig architecture

- Replace FlatDir type with storeConfig (unexported) + NewFlatDir returning Store
- Refactor SkillStore to skillState + storeConfig, NewSkillStore returns Store
- SessionStore/BatchStore embed *storeConfig for Store interface methods
- Add Runnable interface (RunnableID, Cancel, Interrupt, AppendLog, LogInfo)
- Session and batchJob satisfy Runnable
- Add Terminator-style Shutdown/InterruptAll on SessionStore and BatchStore
- NewSession constructor replaces exported field access
- Rename AppendChat->AppendLog, ChatInfo->LogInfo on Session
This commit is contained in:
Levi Neely 2026-04-21 18:23:18 +02:00
parent 500f7fe96b
commit 24e4209a95
6 changed files with 1241 additions and 160 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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",

View File

@ -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")

View File

@ -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))
}

940
pkg/store/store_test.go Normal file
View File

@ -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")
}
}