feat(gateway): admin 列表端点接真实数据——persona/session/memory 补 List() 并注入 gateway.Deps
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因:gateway.Server 定义 SessionStore/PersonaStore/MemoryStore 接口但生产从未满足:
- 底层 manager 无 List() 方法;
- wireFrontend 的 gateway.Deps{} 未传 Session/Personas/Memories。
→ /api/v1/sessions|personas 与 /api/v1/admin/{sessions,personas,memories} 恒返回 []。

改动:
- persona/registry:Registry.List() []Character(并发安全、值拷贝)。
- channel/session:SessionManager.List(ctx)(查 channel_sessions 表+按 actor_role 重建)。
- runtime/memory:Store.List(ctx)(全表,复用 scanItems)。
- gateway 三 Store 接口改为返回域类型(Character/MemoryItem),映射统一放 collect* 侧。
- cmd/cyrene/wire.go:Deps 补传 Session/Personas/Memories。
- .gitignore:cyrene→/cyrene(锚定根,不再误伤 cmd/cyrene 目录)。

实机验证:/api/v1/admin/personas 返回 {id:cyrene,name:昔涟,isDefault:true}。
This commit is contained in:
Aska committed 2026-09-06 14:05:34 +08:00
1 parent 5dae2b9741
commit 04bfa2c7a7
11 files changed
+312 -18

No files matched your search

+57 -1
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -40,6 +41,18 @@ VALUES (?, ?, ?, ?, ?, ?)`
const selectSessionSQL = `
SELECT session_id FROM channel_sessions WHERE platform = ? AND channel_id = ?`
// listSessionsSQL selects every persisted channel mapping, newest first. Group
// sessions key on a channel_id carrying groupChannelPrefix; direct sessions use
// their channel id.
const listSessionSQL = `
SELECT platform, channel_id, session_id, actor_role FROM channel_sessions
ORDER BY created_at DESC`
// groupChannelPrefix marks a channel id as a group conversation so a session
// reconstructed from the backing table (where no IsGroup flag is persisted) can
// still report group membership.
const groupChannelPrefix = "group:"
// SessionManager owns the channel↔session mapping. It backs the map with the
// SQLite table channel_sessions so a channel keeps its internal session across
// restarts, and it caches lookups in memory to avoid a query per message. The
@@ -109,6 +122,49 @@ func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMes
return s, nil
}
// List returns every known session, newest first, across the backing table and
// the in-memory cache. Rows that are still in the cache are returned with their
// full derived metadata (Owner/IsGroup/MemWritable from the original resolve);
// rows recovered from the table (for example from a previous process) are
// reconstructed from the persisted actor role. The returned slice is non-nil. It
// is safe for concurrent use.
func (m *SessionManager) List(ctx context.Context) ([]Session, error) {
rows, err := m.db.QueryContext(ctx, listSessionSQL)
if err != nil {
return nil, fmt.Errorf("session: list: %w", err)
}
defer func() { _ = rows.Close() }()
sessions := make([]Session, 0)
for rows.Next() {
var platform, cid, sessionID, roleStr string
if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil {
return nil, fmt.Errorf("session: scan list: %w", err)
}
key := platform + "\x00" + cid
if cached, ok := m.lookupCached(key); ok {
sessions = append(sessions, cached)
continue
}
role := adapter.Role(roleStr)
owner := role == adapter.RoleOwner
isGroup := strings.HasPrefix(cid, groupChannelPrefix)
sessions = append(sessions, Session{
Platform: platform,
ChannelID: cid,
SessionID: sessionID,
ActorRole: role,
Owner: owner,
IsGroup: isGroup,
MemWritable: owner && !isGroup,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("session: iterate list: %w", err)
}
return sessions, nil
}
// lookupCached returns the cached session for key under a read lock.
func (m *SessionManager) lookupCached(key string) (Session, bool) {
m.mu.RLock()
@@ -144,7 +200,7 @@ func (m *SessionManager) persist(ctx context.Context, platform, cid string, acto
func channelID(msg adapter.UnifiedMessage) string {
if msg.IsGroup() {
if msg.GroupID != "" {
return "group:" + msg.GroupID
return groupChannelPrefix + msg.GroupID
}
return msg.ChannelID
}
+88
View File
@@ -207,6 +207,94 @@ func TestGetOrCreateConcurrent(t *testing.T) {
}
}
func TestList(t *testing.T) {
t.Parallel()
m, _ := newTestManager(t)
ctx := context.Background()
if _, err := m.GetOrCreate(ctx, directMsg("o-100"), testOwner()); err != nil {
t.Fatalf("GetOrCreate(owner): %v", err)
}
if _, err := m.GetOrCreate(ctx, directMsg("stranger-1"), testOwner()); err != nil {
t.Fatalf("GetOrCreate(stranger): %v", err)
}
if _, err := m.GetOrCreate(ctx, groupMsg("g-member-1"), testOwner()); err != nil {
t.Fatalf("GetOrCreate(group): %v", err)
}
got, err := m.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 3 {
t.Fatalf("List = %d sessions, want 3", len(got))
}
var owner, stranger, group bool
for _, s := range got {
switch s.ChannelID {
case "direct-o-100":
if !s.Owner || !s.MemWritable || s.IsGroup {
t.Errorf("owner session flags = %+v, want owner/writable/not-group", s)
}
owner = true
case "direct-stranger-1":
if s.Owner || s.MemWritable || s.IsGroup {
t.Errorf("stranger session flags = %+v, want non-owner/read-only/not-group", s)
}
stranger = true
case groupChannelPrefix + "g1":
if !s.IsGroup || s.MemWritable {
t.Errorf("group session flags = %+v, want group/read-only", s)
}
group = true
}
}
if !owner || !stranger || !group {
t.Fatalf("missing sessions: owner=%v stranger=%v group=%v", owner, stranger, group)
}
}
func TestListRecoveredFromTable(t *testing.T) {
t.Parallel()
ctx := context.Background()
handle, err := db.Open(ctx, ":memory:", nil)
if err != nil {
t.Fatalf("db.Open = %v", err)
}
t.Cleanup(func() { _ = handle.Close() })
m1, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#1 = %v", err)
}
if _, err := m1.GetOrCreate(ctx, directMsg("o-100"), testOwner()); err != nil {
t.Fatalf("m1.GetOrCreate = %v", err)
}
// A brand-new manager over the same DB has an empty cache: List must
// reconstruct the session from the backing table (cross-restart path).
m2, err := New(ctx, handle, nil)
if err != nil {
t.Fatalf("New#2 = %v", err)
}
got, err := m2.List(ctx)
if err != nil {
t.Fatalf("List = %v", err)
}
if len(got) != 1 {
t.Fatalf("List = %d sessions, want 1", len(got))
}
s := got[0]
if s.ChannelID != "direct-o-100" || s.SessionID == "" {
t.Fatalf("recovered session = %+v, want direct-o-100", s)
}
if !s.Owner || !s.MemWritable {
t.Errorf("recovered owner flags = %+v, want owner/writable", s)
}
}
func TestGetOrCreateInvalidChannel(t *testing.T) {
t.Parallel()