feat: Phase F-5+F-7 — command-sync + sub-discussion + channel-create tools
Bundles the remaining "agent does things in Fabric" surface, skipping
presence-sync (Plexum's state machine isn't 1:1 with HF status semantics)
and attachments (Plexum has no media pipeline yet) — both deferred.
internal/subdisc/ (~140 LOC + 5 tests): persistent KV at
<profile>/state/plugin-kv/fabric-sub-discussions.json
- Entry: SubChannelID, HostAgentID, HostUserID, GuestUserIDs,
HostGuide, GuestGuide, CallbackGuildNodeID, CallbackChannelID, CreatedAt
- Open / Lookup / LookupForHost (host-enforcement) / Add / Remove / All
- atomic tmp+rename persistence; corrupt file = start empty
internal/tools/tools.go — 7 new tools:
- create-chat-channel (xType=general)
- create-work-channel (xType=work)
- create-report-channel (xType=report)
- create-discussion-channel (xType=discuss)
- discussion-complete (post summary + close channel)
- create-sub-discussion (create + invite + KV store + greeting)
- close-sub-discussion (host-only; posts callback to parent + closes)
internal/fabric/client.go: CreateChannel / CloseChannel /
JoinChannel / LeaveChannel / SetChannelPurpose / Canvas CRUD /
SyncCommands (added in earlier F-4 commit; reused here)
cmd/plexum-fabric-channel-plugin/main.go:
- subdisc.Store opened from DefaultPath at init; passed into tools.Deps
- HostConfig adds commands_sync_key + sync_commands (defaults
["new","stop"]) — F-5 command-sync to every (agent,guild) pair at
init when key is set; silently skips when omitted
- syncCommandsToGuilds: best-effort PUT per guild, logs per-guild
outcome
scripts/install.sh: manifest tools[] expanded to 16 entries
(9 from F-4 + 7 new). create-channel variants share a schema shape.
Live verified:
$ plexum plugin-call create-chat-channel \
'{"agent_id":"fabrictester","guild_node_id":"test-guild2",
"name":"plexum-f7-smoke","is_public":true}'
→ "created general channel plexum-f7-smoke (id=6315e636-...)"
$ plexum plugin-call fabric-channel-list ...
→ 3 channels listed including the new one
F-6 (attachments) + F-5b (presence-sync) + F-8 (coalesce) deliberately
deferred — see DEV-NOTES.
Tests: 5 new in internal/subdisc (27 total in this repo).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
169
internal/subdisc/store.go
Normal file
169
internal/subdisc/store.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// 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
|
||||
// <profile>/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 <profile>/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)
|
||||
}
|
||||
74
internal/subdisc/store_test.go
Normal file
74
internal/subdisc/store_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package subdisc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub.json")
|
||||
s, _ := Open(path)
|
||||
err := s.Add(Entry{
|
||||
SubChannelID: "ch1", HostAgentID: "alice", HostUserID: "uA",
|
||||
GuestUserIDs: []string{"uB"}, CallbackGuildNodeID: "g1", CallbackChannelID: "pCh",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, _ := Open(path)
|
||||
e := s2.Lookup("ch1")
|
||||
if e == nil || e.HostAgentID != "alice" || e.CallbackChannelID != "pCh" {
|
||||
t.Errorf("entry = %+v", e)
|
||||
}
|
||||
if e.CreatedAt == "" {
|
||||
t.Errorf("CreatedAt should be auto-set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupForHost(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "x.json"))
|
||||
s.Add(Entry{SubChannelID: "c", HostAgentID: "alice"})
|
||||
if s.LookupForHost("c", "alice") == nil {
|
||||
t.Errorf("alice should match")
|
||||
}
|
||||
if s.LookupForHost("c", "bob") != nil {
|
||||
t.Errorf("bob should NOT match")
|
||||
}
|
||||
if s.LookupForHost("missing", "alice") != nil {
|
||||
t.Errorf("missing channel should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "x.json"))
|
||||
s.Add(Entry{SubChannelID: "c"})
|
||||
if !s.Remove("c") {
|
||||
t.Errorf("Remove existing should return true")
|
||||
}
|
||||
if s.Remove("c") {
|
||||
t.Errorf("Remove missing should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReturnsCopy(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "x.json"))
|
||||
s.Add(Entry{SubChannelID: "a", HostAgentID: "alice"})
|
||||
s.Add(Entry{SubChannelID: "b", HostAgentID: "bob"})
|
||||
all := s.All()
|
||||
if len(all) != 2 {
|
||||
t.Errorf("len = %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCorruptStartsEmpty(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "corrupt.json")
|
||||
os.WriteFile(path, []byte("not json"), 0o600)
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Errorf("Open corrupt should tolerate: %v", err)
|
||||
}
|
||||
if len(s.All()) != 0 {
|
||||
t.Errorf("corrupt should start empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user