package controllogic import ( "encoding/json" "fmt" "os" "sort" "strconv" "strings" "time" ) // VersionMeta describes a single persisted revision of a control-logic graph, // mirroring storage.VersionMeta so the frontend can treat all versioned // document types uniformly. type VersionMeta struct { Version int `json:"version"` Name string `json:"name"` Tag string `json:"tag,omitempty"` Current bool `json:"current"` SavedAt time.Time `json:"savedAt"` } // Versions returns metadata for every persisted revision of the graph, newest // first. The live revision (held in controllogic.json) is flagged Current. func (s *Store) Versions(id string) ([]VersionMeta, error) { s.mu.RLock() defer s.mu.RUnlock() cur, ok := s.items[id] if !ok { return nil, ErrNotFound } curV := cur.Version if curV < 1 { curV = 1 } var savedAt time.Time if info, err := os.Stat(s.path); err == nil { savedAt = info.ModTime() } out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}} entries, err := os.ReadDir(s.versionsDir) if err != nil && !os.IsNotExist(err) { return nil, err } prefix := id + ".v" for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".json") { continue } vStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".json") v, err := strconv.Atoi(vStr) if err != nil { continue } g, err := s.readVersion(id, v) if err != nil { continue } info, err := e.Info() if err != nil { continue } out = append(out, VersionMeta{Version: v, Name: g.Name, Tag: g.Tag, SavedAt: info.ModTime()}) } sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version }) return out, nil } // GetVersion returns a specific revision of the graph. The current revision is // served from the live store; older revisions come from their backup file. func (s *Store) GetVersion(id string, version int) (Graph, error) { s.mu.RLock() defer s.mu.RUnlock() cur, ok := s.items[id] if !ok { return Graph{}, ErrNotFound } curV := cur.Version if curV < 1 { curV = 1 } if version == curV { return cur, nil } return s.readVersion(id, version) } // readVersion loads a backup revision file. Caller must hold s.mu. func (s *Store) readVersion(id string, version int) (Graph, error) { data, err := os.ReadFile(s.versionPath(id, version)) if os.IsNotExist(err) { return Graph{}, ErrNotFound } if err != nil { return Graph{}, err } var g Graph if err := json.Unmarshal(data, &g); err != nil { return Graph{}, fmt.Errorf("parse revision: %w", err) } return g, nil } // Promote makes a past revision current by re-saving it on top of history. The // existing current revision is preserved as a backup, so promotion is // non-destructive. Returns the resulting (new current) graph. func (s *Store) Promote(id string, version int) (Graph, error) { g, err := s.GetVersion(id, version) if err != nil { return Graph{}, err } g.Tag = fmt.Sprintf("restored from v%d", version) if err := s.Save(g); err != nil { return Graph{}, err } return s.Get(id) } // Fork creates a brand-new graph from a specific revision, assigning a fresh id // and resetting its version to 1. Returns the new graph. func (s *Store) Fork(id string, version int) (Graph, error) { g, err := s.GetVersion(id, version) if err != nil { return Graph{}, err } g.ID = fmt.Sprintf("%s-fork-%d", id, time.Now().UnixMilli()) g.Version = 1 g.Tag = "" if g.Name != "" { g.Name = g.Name + " (fork)" } if err := s.Save(g); err != nil { return Graph{}, err } return g, nil }