// Package subdisc persists the sub-discussion entries managed by the // create-sub-discussion / close-sub-discussion tools (port of openclaw // plugin's sub-discussion-store.ts). State lives under // /state/plugin-kv/fabric-sub-discussions.json — the // Plexum-host's standard plugin KV dir. // // One entry = one sub-discussion channel. Lifetime: from // create-sub-discussion until close-sub-discussion (or the operator // nukes the JSON file). package subdisc import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "sync" "time" ) // FileName under /state/plugin-kv/. const FileName = "fabric-sub-discussions.json" // Entry is the on-disk shape of one sub-discussion. type Entry struct { SubChannelID string `json:"sub_channel_id"` HostAgentID string `json:"host_agent_id"` HostUserID string `json:"host_user_id"` GuestUserIDs []string `json:"guest_user_ids"` HostGuide string `json:"host_guide,omitempty"` GuestGuide string `json:"guest_guide,omitempty"` CallbackGuildNodeID string `json:"callback_guild_node_id"` CallbackChannelID string `json:"callback_channel_id"` CreatedAt string `json:"created_at"` } // Store wraps the on-disk file. Thread-safe. type Store struct { mu sync.Mutex path string entries map[string]*Entry // by SubChannelID } // Open loads (or creates empty) the store at path. func Open(path string) (*Store, error) { s := &Store{path: path, entries: map[string]*Entry{}} raw, err := os.ReadFile(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return s, nil } return nil, fmt.Errorf("subdisc: read %s: %w", path, err) } if len(raw) == 0 { return s, nil } var wire struct { Entries []*Entry `json:"entries"` } if err := json.Unmarshal(raw, &wire); err != nil { // Tolerate corruption — start empty; first write will overwrite. return s, nil } for _, e := range wire.Entries { if e != nil && e.SubChannelID != "" { s.entries[e.SubChannelID] = e } } return s, nil } // Lookup returns the entry for subChannelID or nil. func (s *Store) Lookup(subChannelID string) *Entry { s.mu.Lock() defer s.mu.Unlock() if e, ok := s.entries[subChannelID]; ok { copyE := *e return ©E } return nil } // LookupForHost returns the entry IF subChannelID exists AND the entry // was created by hostAgentID. Used by close-sub-discussion to enforce // "only the host can close". func (s *Store) LookupForHost(subChannelID, hostAgentID string) *Entry { e := s.Lookup(subChannelID) if e == nil || e.HostAgentID != hostAgentID { return nil } return e } // Add inserts a new entry; CreatedAt is auto-set if empty. func (s *Store) Add(e Entry) error { if e.SubChannelID == "" { return errors.New("subdisc: SubChannelID required") } if e.CreatedAt == "" { e.CreatedAt = time.Now().UTC().Format(time.RFC3339) } s.mu.Lock() s.entries[e.SubChannelID] = &e s.mu.Unlock() return s.persist() } // Remove drops the entry; returns true iff it was present. func (s *Store) Remove(subChannelID string) bool { s.mu.Lock() _, ok := s.entries[subChannelID] if ok { delete(s.entries, subChannelID) } s.mu.Unlock() if !ok { return false } _ = s.persist() // best-effort return true } // All returns a snapshot copy of every entry (for diagnostic tools). func (s *Store) All() []Entry { s.mu.Lock() defer s.mu.Unlock() out := make([]Entry, 0, len(s.entries)) for _, e := range s.entries { out = append(out, *e) } return out } func (s *Store) persist() error { s.mu.Lock() entries := make([]*Entry, 0, len(s.entries)) for _, e := range s.entries { entries = append(entries, e) } s.mu.Unlock() if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } data, err := json.MarshalIndent(struct { Entries []*Entry `json:"entries"` }{entries}, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } return os.Rename(tmp, s.path) } // DefaultPath returns the canonical path under // $PLEXUM_PROFILE_ROOT/state/plugin-kv (or ~/.plexum/...). func DefaultPath() string { root := os.Getenv("PLEXUM_PROFILE_ROOT") if root == "" { home, _ := os.UserHomeDir() root = filepath.Join(home, ".plexum") } return filepath.Join(root, "state", "plugin-kv", FileName) }