// 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//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 }