feat: Phase F-2 — socket.io inbound + wakeup gate + token refresh

End-to-end Fabric inbound→Plexum→Fabric outbound now works against a
live Fabric stack:

  alice posts in bt2-clean (Fabric REST)
    → guild emits message.created over socket.io
    → plugin's wakeup gate decides dispatch
    → notifications/plexum/channel/inbound to host
    → Plexum agent runs (echo provider)
    → outbound `send` tool posts via Fabric REST
    → fabrictester reply visible in channel

internal/socketio/ (~280 LOC + 2 tests):
- Minimal Engine.IO v4 + Socket.IO v5 client over websocket
- WebSocket-only transport (skip polling upgrade dance)
- AuthFunc callback re-evaluated on every (re)connect — fixes the
  stale-JWT-on-reconnect bug openclaw plugin documented for the JS
  client's single-shot auth, which the available Go socket.io
  library (zishang520) doesn't address either
- PING/PONG per server-supplied interval
- Caller-driven reconnect: Connect returns on close, supervisor
  re-dials with fresh token

internal/tokens/ (~95 LOC + 9 tests):
- Per-agent session cache with 8min TTL (matches openclaw's
  TOKEN_TTL_MS); guild tokens are ~15min so 8min keeps a margin
- Invalidate forces re-login (used by inbound when CONNECT auth fires)
- GuildToken helper picks the per-guild JWT from the cached session;
  if the guild is missing from the cache, invalidate + retry once

internal/inbound/ (~290 LOC):
- Supervisor: one socket.io conn per (agent, guild); reconnect with
  fresh token on drop; ChannelSyncInterval (60s) polling + push
  channel.joined/channel.left handlers
- Wakeup gate: dm channels deliver any non-self message; other
  x_types require wakeup=true (record-only for non-wake non-dm
  deferred — Plexum has no history-injection equivalent in v1)
- Self-author filter on selfUserId from cached session
- Per-(agent,msgId) dedup bounded to 5000 entries
- Per-channel serial queue with 5s idle drain so concurrent inbounds
  on the same channel run one-at-a-time (matches openclaw plugin)
- Emits notifications/plexum/channel/inbound with session_id =
  "s_fab_<fabric_channel_id>" for stable per-channel session continuity

cmd/plexum-fabric-channel-plugin:
- Wires inbound supervisor at Init; runs in a background goroutine
  for the plugin's lifetime
- Replaces F-1's sessions map with tokens.Cache (same warm-sessions
  behavior, now backed by TTL)
- hostLogHandler: bridges slog records from inbound supervisor to
  HostAPI.Log notifications

F-2 deferred to F-3+:
- record-only history injection (Plexum v1 has no equivalent)
- tools.ts port (15 MCP tools — channel/canvas/sub-discussion family)
- presence-sync, command-sync, attachments, coalesce parity

Tests: 22 (5 identity + 6 config + 9 tokens + 2 socketio).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-31 15:29:01 +01:00
parent f8d43ae70e
commit 0efcdfd342
8 changed files with 1162 additions and 36 deletions

View File

@@ -0,0 +1,130 @@
package tokens
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"git.hangman-lab.top/hzhang/Plexum-fabric-channel-plugin/internal/fabric"
)
func fakeSession(guildNodes ...string) *fabric.Session {
s := &fabric.Session{User: fabric.SessionUser{ID: "u1", Email: "u@x"}}
for _, g := range guildNodes {
s.Guilds = append(s.Guilds, fabric.GuildInfo{NodeID: g, Endpoint: "http://" + g})
s.GuildAccessTokens = append(s.GuildAccessTokens, fabric.GuildAccessToken{
GuildNodeID: g, Token: "tok-" + g,
})
}
return s
}
func TestGetFirstLogsIn(t *testing.T) {
var calls atomic.Int32
c := New(0, func(context.Context, string) (*fabric.Session, error) {
calls.Add(1)
return fakeSession("g1"), nil
})
s, err := c.Get(context.Background(), "alice")
if err != nil || s.User.ID != "u1" {
t.Fatalf("get err=%v", err)
}
if calls.Load() != 1 {
t.Errorf("calls = %d", calls.Load())
}
}
func TestGetWithinTTLReusesCached(t *testing.T) {
var calls atomic.Int32
c := New(time.Minute, func(context.Context, string) (*fabric.Session, error) {
calls.Add(1)
return fakeSession("g1"), nil
})
c.Get(context.Background(), "alice")
c.Get(context.Background(), "alice")
c.Get(context.Background(), "alice")
if calls.Load() != 1 {
t.Errorf("calls = %d (TTL fresh)", calls.Load())
}
}
func TestGetAfterTTLReLogs(t *testing.T) {
var calls atomic.Int32
c := New(10*time.Millisecond, func(context.Context, string) (*fabric.Session, error) {
calls.Add(1)
return fakeSession("g1"), nil
})
c.Get(context.Background(), "alice")
time.Sleep(20 * time.Millisecond)
c.Get(context.Background(), "alice")
if calls.Load() != 2 {
t.Errorf("calls = %d, want 2 after TTL expiry", calls.Load())
}
}
func TestInvalidateForcesReLogin(t *testing.T) {
var calls atomic.Int32
c := New(time.Minute, func(context.Context, string) (*fabric.Session, error) {
calls.Add(1)
return fakeSession("g1"), nil
})
c.Get(context.Background(), "alice")
c.Invalidate("alice")
c.Get(context.Background(), "alice")
if calls.Load() != 2 {
t.Errorf("calls = %d, want 2 after Invalidate", calls.Load())
}
}
func TestLoginErrorBubbles(t *testing.T) {
sentinel := errors.New("boom")
c := New(0, func(context.Context, string) (*fabric.Session, error) {
return nil, sentinel
})
_, err := c.Get(context.Background(), "alice")
if !errors.Is(err, sentinel) {
t.Errorf("err = %v", err)
}
}
func TestGuildTokenHappy(t *testing.T) {
c := New(time.Minute, func(context.Context, string) (*fabric.Session, error) {
return fakeSession("g1", "g2"), nil
})
tok, err := c.GuildToken(context.Background(), "alice", "g2")
if err != nil || tok != "tok-g2" {
t.Errorf("token=%q err=%v", tok, err)
}
}
func TestGuildTokenMissingGuildRetriesThenErrors(t *testing.T) {
var calls atomic.Int32
c := New(time.Minute, func(context.Context, string) (*fabric.Session, error) {
calls.Add(1)
return fakeSession("g1"), nil
})
_, err := c.GuildToken(context.Background(), "alice", "missing")
if err == nil {
t.Fatal("expected error")
}
// First Get + post-invalidate Get = 2 logins
if calls.Load() != 2 {
t.Errorf("calls = %d, want 2 (initial + retry)", calls.Load())
}
}
func TestPeekDoesNotLogin(t *testing.T) {
var calls atomic.Int32
c := New(time.Minute, func(context.Context, string) (*fabric.Session, error) {
calls.Add(1)
return fakeSession("g1"), nil
})
if c.Peek("alice") != nil {
t.Errorf("Peek on empty should be nil")
}
if calls.Load() != 0 {
t.Errorf("Peek should not call Login")
}
}