● Zion Boggan
repos/Agent Time Ledger/internal/state/state.go
zionboggan.com ↗
220 lines · go
History for this file →
1
package state
2
 
3
import (
4
	"bufio"
5
	"encoding/json"
6
	"errors"
7
	"fmt"
8
	"os"
9
	"path/filepath"
10
	"time"
11
)
12
 
13
const (
14
	StateFile  = "state.json"
15
	EventsFile = "events.jsonl"
16
	ConfigFile = "config.toml"
17
)
18
 
19
type State struct {
20
	ActiveSessionID string             `json:"active_session_id,omitempty"`
21
	Sessions        map[string]Session `json:"sessions"`
22
	Marks           map[string]Mark    `json:"marks"`
23
}
24
 
25
type Session struct {
26
	ID        string     `json:"id"`
27
	Name      string     `json:"name"`
28
	StartedAt time.Time  `json:"started_at"`
29
	EndedAt   *time.Time `json:"ended_at,omitempty"`
30
}
31
 
32
type Mark struct {
33
	Name      string    `json:"name"`
34
	StartedAt time.Time `json:"started_at"`
35
}
36
 
37
type Event struct {
38
	Timestamp time.Time      `json:"timestamp"`
39
	Type      string         `json:"type"`
40
	Fields    map[string]any `json:"fields,omitempty"`
41
}
42
 
43
type Store struct {
44
	Dir string
45
}
46
 
47
func DefaultDir() (string, error) {
48
	if dir := os.Getenv("ATL_HOME"); dir != "" {
49
		return dir, nil
50
	}
51
	home, err := os.UserHomeDir()
52
	if err != nil {
53
		return "", err
54
	}
55
	return filepath.Join(home, ".agent-time-ledger"), nil
56
}
57
 
58
func NewStore(dir string) *Store {
59
	return &Store{Dir: dir}
60
}
61
 
62
func DefaultStore() (*Store, error) {
63
	dir, err := DefaultDir()
64
	if err != nil {
65
		return nil, err
66
	}
67
	return NewStore(dir), nil
68
}
69
 
70
func NewState() State {
71
	return State{
72
		Sessions: map[string]Session{},
73
		Marks:    map[string]Mark{},
74
	}
75
}
76
 
77
func (s *Store) Ensure() error {
78
	if s.Dir == "" {
79
		return errors.New("store directory is empty")
80
	}
81
	if err := os.MkdirAll(s.Dir, 0o700); err != nil {
82
		return err
83
	}
84
	if _, err := os.Stat(s.statePath()); errors.Is(err, os.ErrNotExist) {
85
		if err := s.Save(NewState()); err != nil {
86
			return err
87
		}
88
	} else if err != nil {
89
		return err
90
	}
91
	if _, err := os.Stat(s.eventsPath()); errors.Is(err, os.ErrNotExist) {
92
		file, err := os.OpenFile(s.eventsPath(), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
93
		if err != nil {
94
			return err
95
		}
96
		if err := file.Close(); err != nil {
97
			return err
98
		}
99
	} else if err != nil {
100
		return err
101
	}
102
	if _, err := os.Stat(s.configPath()); errors.Is(err, os.ErrNotExist) {
103
		if err := os.WriteFile(s.configPath(), []byte("# agent-time-ledger local config\n"), 0o600); err != nil {
104
			return err
105
		}
106
	} else if err != nil {
107
		return err
108
	}
109
	return nil
110
}
111
 
112
func (s *Store) Load() (State, error) {
113
	if err := s.Ensure(); err != nil {
114
		return State{}, err
115
	}
116
	data, err := os.ReadFile(s.statePath())
117
	if err != nil {
118
		return State{}, err
119
	}
120
	if len(data) == 0 {
121
		return NewState(), nil
122
	}
123
 
124
	var st State
125
	if err := json.Unmarshal(data, &st); err != nil {
126
		return State{}, err
127
	}
128
	if st.Sessions == nil {
129
		st.Sessions = map[string]Session{}
130
	}
131
	if st.Marks == nil {
132
		st.Marks = map[string]Mark{}
133
	}
134
	return st, nil
135
}
136
 
137
func (s *Store) Save(st State) error {
138
	if st.Sessions == nil {
139
		st.Sessions = map[string]Session{}
140
	}
141
	if st.Marks == nil {
142
		st.Marks = map[string]Mark{}
143
	}
144
	if err := os.MkdirAll(s.Dir, 0o700); err != nil {
145
		return err
146
	}
147
	data, err := json.MarshalIndent(st, "", "  ")
148
	if err != nil {
149
		return err
150
	}
151
	data = append(data, '\n')
152
	tmp := s.statePath() + ".tmp"
153
	if err := os.WriteFile(tmp, data, 0o600); err != nil {
154
		return err
155
	}
156
	return os.Rename(tmp, s.statePath())
157
}
158
 
159
func (s *Store) AppendEvent(event Event) error {
160
	if err := s.Ensure(); err != nil {
161
		return err
162
	}
163
	file, err := os.OpenFile(s.eventsPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
164
	if err != nil {
165
		return err
166
	}
167
	defer file.Close()
168
 
169
	line, err := json.Marshal(event)
170
	if err != nil {
171
		return err
172
	}
173
	if _, err := file.Write(append(line, '\n')); err != nil {
174
		return err
175
	}
176
	return nil
177
}
178
 
179
func (s *Store) ReadEvents() ([]Event, error) {
180
	if err := s.Ensure(); err != nil {
181
		return nil, err
182
	}
183
	file, err := os.Open(s.eventsPath())
184
	if err != nil {
185
		return nil, err
186
	}
187
	defer file.Close()
188
 
189
	var events []Event
190
	scanner := bufio.NewScanner(file)
191
	lineNumber := 0
192
	for scanner.Scan() {
193
		lineNumber++
194
		line := scanner.Bytes()
195
		if len(line) == 0 {
196
			continue
197
		}
198
		var event Event
199
		if err := json.Unmarshal(line, &event); err != nil {
200
			return nil, fmt.Errorf("%s:%d: %w", EventsFile, lineNumber, err)
201
		}
202
		events = append(events, event)
203
	}
204
	if err := scanner.Err(); err != nil {
205
		return nil, err
206
	}
207
	return events, nil
208
}
209
 
210
func (s *Store) statePath() string {
211
	return filepath.Join(s.Dir, StateFile)
212
}
213
 
214
func (s *Store) eventsPath() string {
215
	return filepath.Join(s.Dir, EventsFile)
216
}
217
 
218
func (s *Store) configPath() string {
219
	return filepath.Join(s.Dir, ConfigFile)
220
}