wip
This commit is contained in:
185
internal/persist/persist.go
Normal file
185
internal/persist/persist.go
Normal 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
|
||||
}
|
||||
94
internal/persist/persist_test.go
Normal file
94
internal/persist/persist_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user