This commit is contained in:
2026-05-15 00:28:06 +01:00
parent 2f969fa215
commit 0d578d54f1
31 changed files with 3209 additions and 164 deletions

185
internal/persist/persist.go Normal file
View File

@@ -0,0 +1,185 @@
// 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
}

View File

@@ -0,0 +1,94 @@
package persist
import (
"os"
"reflect"
"testing"
)
func TestSaveAndReloadEntry(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s1, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if got := s1.List(); len(got) != 0 {
t.Fatalf("fresh store should be empty, got %v", got)
}
want := Entry{
ID: "p_abc123",
Name: "bun-dev",
Argv: []string{"sh", "-lc", "bun run dev"},
WorkDir: "/tmp/proj",
PresetRef: "shell",
AutoRestart: true,
}
if err := s1.Save(want); err != nil {
t.Fatalf("save: %v", err)
}
s2, err := Open("projkey")
if err != nil {
t.Fatalf("reopen: %v", err)
}
got := s2.List()
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
t.Fatalf("reload mismatch: got %v want [%v]", got, want)
}
if _, err := os.Stat(s2.Path()); err != nil {
t.Fatalf("stat processes.json: %v", err)
}
}
func TestRemoveEntry(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if err := s.Save(Entry{ID: "a", Name: "a", Argv: []string{"a"}}); err != nil {
t.Fatalf("save a: %v", err)
}
if err := s.Save(Entry{ID: "b", Name: "b", Argv: []string{"b"}}); err != nil {
t.Fatalf("save b: %v", err)
}
if err := s.Remove("a"); err != nil {
t.Fatalf("remove a: %v", err)
}
got := s.List()
if len(got) != 1 || got[0].ID != "b" {
t.Fatalf("after remove a, got %v", got)
}
// Removing a non-existent entry is a no-op.
if err := s.Remove("missing"); err != nil {
t.Fatalf("remove missing: %v", err)
}
}
func TestSaveUpdatesExistingEntry(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if err := s.Save(Entry{ID: "a", Name: "old"}); err != nil {
t.Fatalf("save: %v", err)
}
if err := s.Save(Entry{ID: "a", Name: "new", AutoRestart: true}); err != nil {
t.Fatalf("update: %v", err)
}
got := s.List()
if len(got) != 1 || got[0].Name != "new" || !got[0].AutoRestart {
t.Fatalf("update mismatch: %v", got)
}
}
func TestOpenRequiresProjectKey(t *testing.T) {
if _, err := Open(""); err == nil {
t.Fatalf("open with empty project key should fail")
}
}