Files
patterm/internal/persist/persist.go
2026-05-15 00:28:06 +01:00

186 lines
4.7 KiB
Go

// Package persist stores the set of user-created top-level command
// processes for a project so they can be re-spawned after patterm
// restarts. SPEC §2 keeps everything ephemeral within one run; this
// state file is the exception — it survives the process tear-down so a
// user who fires up `bun run dev` and `tail -F log` doesn't have to
// re-spawn them every time patterm relaunches.
//
// Only top-level command entries (ParentID == "") are recorded.
// Agents, terminals, and orchestrator-spawned commands stay ephemeral.
// The file lives at
// $XDG_DATA_HOME/patterm/projects/<projectKey>/processes.json — the
// same parent directory the trust store uses.
package persist
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
// Entry is one persisted top-level command process. ID matches the
// session-minted process id; on restore Session.Spawn mints a fresh
// id, so ID is treated as opaque (used only to key Save/Remove).
type Entry struct {
ID string `json:"id"`
Name string `json:"name"`
Argv []string `json:"argv"`
WorkDir string `json:"working_dir,omitempty"`
PresetRef string `json:"preset_ref,omitempty"`
AutoRestart bool `json:"auto_restart,omitempty"`
}
// Store is one project's persisted-process file. Safe for concurrent
// use.
type Store struct {
path string
mu sync.Mutex
entries map[string]Entry
order []string
}
// Open loads (or creates) the processes file for projectKey. Missing
// file is not an error — it simply means nothing has been spawned
// yet.
func Open(projectKey string) (*Store, error) {
if projectKey == "" {
return nil, errors.New("persist.Open: empty project key")
}
base, err := dataDir()
if err != nil {
return nil, err
}
dir := filepath.Join(base, "projects", projectKey)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("persist: mkdir %s: %w", dir, err)
}
path := filepath.Join(dir, "processes.json")
s := &Store{path: path, entries: make(map[string]Entry)}
if err := s.loadLocked(); err != nil {
return nil, err
}
return s, nil
}
func dataDir() (string, error) {
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
return filepath.Join(h, "patterm"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", "patterm"), nil
}
// Path returns the on-disk file path. Used by tests / diagnostics.
func (s *Store) Path() string { return s.path }
// Save inserts or updates an entry, keyed by Entry.ID. Empty ID is an
// error.
func (s *Store) Save(e Entry) error {
if e.ID == "" {
return errors.New("persist.Save: empty entry id")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.entries[e.ID]; !exists {
s.order = append(s.order, e.ID)
}
s.entries[e.ID] = e
return s.saveLocked()
}
// Remove drops an entry by ID. No-op if the entry doesn't exist.
func (s *Store) Remove(id string) error {
if id == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.entries[id]; !exists {
return nil
}
delete(s.entries, id)
for i, oid := range s.order {
if oid == id {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
return s.saveLocked()
}
// List returns entries in the order they were first saved.
func (s *Store) List() []Entry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Entry, 0, len(s.order))
for _, id := range s.order {
if e, ok := s.entries[id]; ok {
out = append(out, e)
}
}
return out
}
type fileShape struct {
Processes []Entry `json:"processes"`
}
func (s *Store) loadLocked() error {
b, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("persist: read %s: %w", s.path, err)
}
if len(b) == 0 {
return nil
}
var f fileShape
if err := json.Unmarshal(b, &f); err != nil {
return fmt.Errorf("persist: parse %s: %w", s.path, err)
}
for _, e := range f.Processes {
if e.ID == "" {
continue
}
if _, exists := s.entries[e.ID]; !exists {
s.order = append(s.order, e.ID)
}
s.entries[e.ID] = e
}
// Stable serialization order across re-saves.
sort.SliceStable(s.order, func(i, j int) bool { return s.order[i] < s.order[j] })
return nil
}
func (s *Store) saveLocked() error {
out := make([]Entry, 0, len(s.entries))
for _, id := range s.order {
if e, ok := s.entries[id]; ok {
out = append(out, e)
}
}
body, err := json.MarshalIndent(fileShape{Processes: out}, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return fmt.Errorf("persist: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("persist: rename %s: %w", s.path, err)
}
return nil
}