186 lines
4.7 KiB
Go
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
|
|
}
|