Files
compaction-mcp/persist.go
T
lukaszraczylo dded4ec04c Add release infrastructure and complete implementation
- Dockerfile: distroless container for MCP server
- GoReleaser: multi-platform binary and Docker builds with cosign signing
- GitHub Actions: release workflow using shared actions
- Semver config for automatic version calculation
- Persistence layer, content indexing, and improved tool handlers
2026-03-07 18:31:00 +00:00

203 lines
4.4 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
const stateVersion = 1
type persistedState struct {
SavedAt time.Time `json:"saved_at"`
Items []persistedItem `json:"items"`
Version int `json:"version"`
Budget int `json:"budget"`
}
type persistedItem struct {
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Content string `json:"content"`
Summary string `json:"summary,omitempty"`
Tags []string `json:"tags,omitempty"`
Importance int `json:"importance"`
AccessCount int `json:"access_count"`
Tokens int `json:"tokens"`
ContentType ContentType `json:"content_type,omitempty"`
Pinned bool `json:"pinned,omitempty"`
}
// Persister handles file-backed persistence for a Store.
type Persister struct {
store *Store
stopCh chan struct{}
dir string
mu sync.Mutex
dirty bool
}
// snapshot returns a copy of all items and the token budget from the store.
func (s *Store) snapshot() ([]persistedItem, int) {
s.mu.Lock()
defer s.mu.Unlock()
items := make([]persistedItem, 0, len(s.items))
for _, item := range s.items {
items = append(items, persistedItem{
ID: item.ID,
Content: item.Content,
Summary: item.Summary,
Tags: item.Tags,
Importance: item.Importance,
ContentType: item.ContentType,
Pinned: item.Pinned,
CreatedAt: item.CreatedAt,
AccessCount: item.AccessCount,
Tokens: item.Tokens,
})
}
return items, s.tokenBudget
}
// restore loads persisted items back into the store.
func (s *Store) restore(items []persistedItem) {
s.mu.Lock()
defer s.mu.Unlock()
for _, pi := range items {
item := &Item{
ID: pi.ID,
Content: pi.Content,
Summary: pi.Summary,
Tags: pi.Tags,
Importance: pi.Importance,
ContentType: pi.ContentType,
Pinned: pi.Pinned,
CreatedAt: pi.CreatedAt,
AccessCount: pi.AccessCount,
Tokens: pi.Tokens,
AccessedAt: time.Now(),
}
s.items[item.ID] = item
s.usedTokens += item.Tokens
s.index.Add(item.ID, item.Content, item.Tags)
}
}
// NewPersister creates a Persister that saves store state to the given directory.
func NewPersister(dir string, store *Store) (*Persister, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
return &Persister{
dir: dir,
store: store,
}, nil
}
func (p *Persister) stateFile() string {
return filepath.Join(p.dir, "state.json")
}
func (p *Persister) tmpFile() string {
return filepath.Join(p.dir, "state.json.tmp")
}
// Save snapshots the store and writes it atomically to state.json.
func (p *Persister) Save() error {
items, budget := p.store.snapshot()
state := persistedState{
Version: stateVersion,
Budget: budget,
Items: items,
SavedAt: time.Now(),
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
tmp := p.tmpFile()
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, p.stateFile())
}
// Load reads state.json and restores items into the store.
// Returns nil if the file does not exist (fresh start).
// Returns an error if the file exists but cannot be parsed.
func (p *Persister) Load() error {
data, err := os.ReadFile(p.stateFile())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var state persistedState
if err := json.Unmarshal(data, &state); err != nil {
return err
}
p.store.restore(state.Items)
return nil
}
// Start launches a background goroutine that periodically saves if dirty.
func (p *Persister) Start(interval time.Duration) {
p.mu.Lock()
p.stopCh = make(chan struct{})
p.mu.Unlock()
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
p.mu.Lock()
shouldSave := p.dirty
p.dirty = false
p.mu.Unlock()
if shouldSave {
_ = p.Save()
}
case <-p.stopCh:
return
}
}
}()
}
// MarkDirty flags the store state as needing a save.
func (p *Persister) MarkDirty() {
p.mu.Lock()
p.dirty = true
p.mu.Unlock()
}
// Stop signals the background goroutine to exit and performs a final save if dirty.
func (p *Persister) Stop() {
p.mu.Lock()
if p.stopCh != nil {
close(p.stopCh)
}
shouldSave := p.dirty
p.dirty = false
p.mu.Unlock()
if shouldSave {
_ = p.Save()
}
}