From 3957e2cc72aba69b0a7bcc7811e8bbd32ad9f96c Mon Sep 17 00:00:00 2001 From: Hoshina Date: Wed, 1 Apr 2026 16:25:05 +0800 Subject: [PATCH] feat(session): persist scope metadata and aliases --- pkg/agent/loop.go | 41 +++++ pkg/memory/jsonl.go | 171 ++++++++++++++++++-- pkg/memory/jsonl_test.go | 55 +++++++ pkg/session/jsonl_backend.go | 64 ++++++++ pkg/session/jsonl_backend_test.go | 28 ++++ web/backend/api/session.go | 253 +++++++++++++++++++----------- web/backend/api/session_test.go | 77 +++++++++ 7 files changed, 585 insertions(+), 104 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b4574bbb0..ef4680e45 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -74,6 +74,7 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { SessionKey string // Session identifier for history/context + SessionAliases []string // Compatibility aliases for the session key Channel string // Target channel for tool execution ChatID string // Target chat ID for tool execution MessageID string // Current inbound platform message ID @@ -1475,6 +1476,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) opts := processOptions{ SessionKey: sessionKey, + SessionAliases: buildSessionAliases(sessionKey, allocation.SessionKey, msg.SessionKey), Channel: msg.Channel, ChatID: msg.ChatID, MessageID: msg.MessageID, @@ -1547,6 +1549,43 @@ func resolveScopeKey(routeSessionKey, msgSessionKey string) string { return routeSessionKey } +func buildSessionAliases(canonicalKey string, keys ...string) []string { + if len(keys) == 0 { + return nil + } + aliases := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + canonicalKey = strings.TrimSpace(canonicalKey) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" || key == canonicalKey { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + aliases = append(aliases, key) + } + if len(aliases) == 0 { + return nil + } + return aliases +} + +func ensureSessionMetadata(store session.SessionStore, key string, scope *session.SessionScope, aliases []string) { + if key == "" || scope == nil { + return + } + metaStore, ok := store.(interface { + EnsureSessionMetadata(sessionKey string, scope *session.SessionScope, aliases []string) + }) + if !ok { + return + } + metaStore.EnsureSessionMetadata(key, scope, aliases) +} + func (al *AgentLoop) allocateRouteSession(route routing.ResolvedRoute, msg bus.InboundMessage) session.Allocation { return session.AllocateRouteSession(session.AllocationInput{ AgentID: route.AgentID, @@ -1668,6 +1707,8 @@ func (al *AgentLoop) runAgentLoop( } } + ensureSessionMetadata(agent.Sessions, opts.SessionKey, opts.SessionScope, opts.SessionAliases) + turnScope := al.newTurnEventScope( agent.ID, opts.SessionKey, diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index afe374166..70c55329f 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -32,14 +32,19 @@ const ( maxLineSize = 10 * 1024 * 1024 // 10 MB ) -// sessionMeta holds per-session metadata stored in a .meta.json file. -type sessionMeta struct { - Key string `json:"key"` - Summary string `json:"summary"` - Skip int `json:"skip"` - Count int `json:"count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// SessionMeta holds per-session metadata stored in a .meta.json file. +// +// Scope is stored as raw JSON so pkg/memory can stay decoupled from the +// higher-level session package while still preserving structured scope data. +type SessionMeta struct { + Key string `json:"key"` + Summary string `json:"summary"` + Skip int `json:"skip"` + Count int `json:"count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Scope json.RawMessage `json:"scope,omitempty"` + Aliases []string `json:"aliases,omitempty"` } // JSONLStore implements Store using append-only JSONL files. @@ -98,25 +103,31 @@ func sanitizeKey(key string) string { // readMeta loads the metadata file for a session. // Returns a zero-value sessionMeta if the file does not exist. -func (s *JSONLStore) readMeta(key string) (sessionMeta, error) { +func (s *JSONLStore) readMeta(key string) (SessionMeta, error) { data, err := os.ReadFile(s.metaPath(key)) if os.IsNotExist(err) { - return sessionMeta{Key: key}, nil + return SessionMeta{Key: key}, nil } if err != nil { - return sessionMeta{}, fmt.Errorf("memory: read meta: %w", err) + return SessionMeta{}, fmt.Errorf("memory: read meta: %w", err) } - var meta sessionMeta + var meta SessionMeta err = json.Unmarshal(data, &meta) if err != nil { - return sessionMeta{}, fmt.Errorf("memory: decode meta: %w", err) + return SessionMeta{}, fmt.Errorf("memory: decode meta: %w", err) + } + if meta.Key == "" { + meta.Key = key } return meta, nil } // writeMeta atomically writes the metadata file using the project's // standard WriteFileAtomic (temp + fsync + rename). -func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { +func (s *JSONLStore) writeMeta(key string, meta SessionMeta) error { + if strings.TrimSpace(meta.Key) == "" { + meta.Key = key + } data, err := json.MarshalIndent(meta, "", " ") if err != nil { return fmt.Errorf("memory: encode meta: %w", err) @@ -124,6 +135,138 @@ func (s *JSONLStore) writeMeta(key string, meta sessionMeta) error { return fileutil.WriteFileAtomic(s.metaPath(key), data, 0o644) } +func cloneRawJSON(data json.RawMessage) json.RawMessage { + if len(data) == 0 { + return nil + } + return append(json.RawMessage(nil), data...) +} + +func normalizeAliases(canonicalKey string, aliases []string) []string { + if len(aliases) == 0 { + return nil + } + normalized := make([]string, 0, len(aliases)) + seen := make(map[string]struct{}, len(aliases)) + canonicalKey = strings.TrimSpace(canonicalKey) + for _, alias := range aliases { + alias = strings.TrimSpace(alias) + if alias == "" || alias == canonicalKey { + continue + } + if _, ok := seen[alias]; ok { + continue + } + seen[alias] = struct{}{} + normalized = append(normalized, alias) + } + if len(normalized) == 0 { + return nil + } + return normalized +} + +func (s *JSONLStore) sessionExists(key string) bool { + if key == "" { + return false + } + if _, err := os.Stat(s.jsonlPath(key)); err == nil { + return true + } + if _, err := os.Stat(s.metaPath(key)); err == nil { + return true + } + return false +} + +// GetSessionMeta returns the current metadata snapshot for sessionKey. +func (s *JSONLStore) GetSessionMeta(_ context.Context, sessionKey string) (SessionMeta, error) { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return SessionMeta{}, err + } + meta.Scope = cloneRawJSON(meta.Scope) + if len(meta.Aliases) > 0 { + meta.Aliases = append([]string(nil), meta.Aliases...) + } + return meta, nil +} + +// UpsertSessionMeta stores structured session metadata while preserving +// summary/count/skip timestamps maintained by the core JSONL store. +func (s *JSONLStore) UpsertSessionMeta( + _ context.Context, + sessionKey string, + scope json.RawMessage, + aliases []string, +) error { + l := s.sessionLock(sessionKey) + l.Lock() + defer l.Unlock() + + meta, err := s.readMeta(sessionKey) + if err != nil { + return err + } + meta.Scope = cloneRawJSON(scope) + meta.Aliases = normalizeAliases(sessionKey, aliases) + now := time.Now() + if meta.CreatedAt.IsZero() { + meta.CreatedAt = now + } + meta.UpdatedAt = now + + return s.writeMeta(sessionKey, meta) +} + +// ResolveSessionKey returns the canonical session key for a candidate key. +// It first checks direct key existence, then scans metadata aliases on miss. +func (s *JSONLStore) ResolveSessionKey(_ context.Context, sessionKey string) (string, bool, error) { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return "", false, nil + } + if s.sessionExists(sessionKey) { + return sessionKey, true, nil + } + + entries, err := os.ReadDir(s.dir) + if err != nil { + return "", false, fmt.Errorf("memory: read sessions dir: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + data, readErr := os.ReadFile(filepath.Join(s.dir, entry.Name())) + if readErr != nil { + return "", false, fmt.Errorf("memory: read meta: %w", readErr) + } + var meta SessionMeta + if err := json.Unmarshal(data, &meta); err != nil { + return "", false, fmt.Errorf("memory: decode meta: %w", err) + } + if meta.Key == "" { + continue + } + if meta.Key == sessionKey { + return meta.Key, true, nil + } + for _, alias := range meta.Aliases { + if alias == sessionKey { + return meta.Key, true, nil + } + } + } + + return "", false, nil +} + // readMessages reads valid JSON lines from a .jsonl file, skipping // the first `skip` lines without unmarshaling them. This avoids the // cost of json.Unmarshal on logically truncated messages. diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 356ff14ff..ef739e49b 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -2,8 +2,10 @@ package memory import ( "context" + "encoding/json" "os" "path/filepath" + "reflect" "sync" "testing" @@ -241,6 +243,59 @@ func TestSetSummary_GetSummary(t *testing.T) { } } +func TestSessionMetaScopeAndAliasesPersist(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + scope := json.RawMessage(`{"version":1,"channel":"telegram","values":{"chat":"group:c1"}}`) + aliases := []string{"legacy:one", "legacy:one", "canonical"} + if err := store.UpsertSessionMeta(ctx, "canonical", scope, aliases); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + + meta, err := store.GetSessionMeta(ctx, "canonical") + if err != nil { + t.Fatalf("GetSessionMeta() error = %v", err) + } + var gotScope map[string]any + if err := json.Unmarshal(meta.Scope, &gotScope); err != nil { + t.Fatalf("Unmarshal(meta.Scope) error = %v", err) + } + var wantScope map[string]any + if err := json.Unmarshal(scope, &wantScope); err != nil { + t.Fatalf("Unmarshal(scope) error = %v", err) + } + if !reflect.DeepEqual(gotScope, wantScope) { + t.Fatalf("meta.Scope = %#v, want %#v", gotScope, wantScope) + } + if len(meta.Aliases) != 1 || meta.Aliases[0] != "legacy:one" { + t.Fatalf("meta.Aliases = %#v, want [legacy:one]", meta.Aliases) + } +} + +func TestResolveSessionKeyByAlias(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + if err := store.AddMessage(ctx, "canonical", "user", "hello"); err != nil { + t.Fatalf("AddMessage() error = %v", err) + } + if err := store.UpsertSessionMeta(ctx, "canonical", nil, []string{"legacy:key"}); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + + resolved, found, err := store.ResolveSessionKey(ctx, "legacy:key") + if err != nil { + t.Fatalf("ResolveSessionKey() error = %v", err) + } + if !found { + t.Fatal("ResolveSessionKey() did not find alias") + } + if resolved != "canonical" { + t.Fatalf("resolved = %q, want %q", resolved, "canonical") + } +} + func TestTruncateHistory_KeepLast(t *testing.T) { store := newTestStore(t) ctx := context.Background() diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go index 7f470de15..38a0c160e 100644 --- a/pkg/session/jsonl_backend.go +++ b/pkg/session/jsonl_backend.go @@ -2,6 +2,7 @@ package session import ( "context" + "encoding/json" "log" "github.com/sipeed/picoclaw/pkg/memory" @@ -15,24 +16,82 @@ type JSONLBackend struct { store memory.Store } +type metaAwareStore interface { + GetSessionMeta(ctx context.Context, sessionKey string) (memory.SessionMeta, error) + UpsertSessionMeta(ctx context.Context, sessionKey string, scope json.RawMessage, aliases []string) error + ResolveSessionKey(ctx context.Context, sessionKey string) (string, bool, error) +} + +// MetadataAwareSessionStore exposes structured session metadata operations. +type MetadataAwareSessionStore interface { + EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string) + ResolveSessionKey(sessionKey string) string +} + // NewJSONLBackend wraps a memory.Store for use as a SessionStore. func NewJSONLBackend(store memory.Store) *JSONLBackend { return &JSONLBackend{store: store} } +func (b *JSONLBackend) resolveSessionKey(sessionKey string) string { + metaStore, ok := b.store.(metaAwareStore) + if !ok { + return sessionKey + } + resolved, found, err := metaStore.ResolveSessionKey(context.Background(), sessionKey) + if err != nil { + log.Printf("session: resolve session key: %v", err) + return sessionKey + } + if found && resolved != "" { + return resolved + } + return sessionKey +} + +// ResolveSessionKey maps aliases onto their canonical session key when the +// underlying store supports structured metadata. Unknown aliases fall back to +// the original input so existing callers remain compatible. +func (b *JSONLBackend) ResolveSessionKey(sessionKey string) string { + return b.resolveSessionKey(sessionKey) +} + +// EnsureSessionMetadata persists scope and alias metadata for a session. +func (b *JSONLBackend) EnsureSessionMetadata(sessionKey string, scope *SessionScope, aliases []string) { + metaStore, ok := b.store.(metaAwareStore) + if !ok { + return + } + var rawScope json.RawMessage + if scope != nil { + data, err := json.Marshal(scope) + if err != nil { + log.Printf("session: encode session scope: %v", err) + return + } + rawScope = data + } + if err := metaStore.UpsertSessionMeta(context.Background(), sessionKey, rawScope, aliases); err != nil { + log.Printf("session: upsert session metadata: %v", err) + } +} + func (b *JSONLBackend) AddMessage(sessionKey, role, content string) { + sessionKey = b.resolveSessionKey(sessionKey) if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil { log.Printf("session: add message: %v", err) } } func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) { + sessionKey = b.resolveSessionKey(sessionKey) if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil { log.Printf("session: add full message: %v", err) } } func (b *JSONLBackend) GetHistory(key string) []providers.Message { + key = b.resolveSessionKey(key) msgs, err := b.store.GetHistory(context.Background(), key) if err != nil { log.Printf("session: get history: %v", err) @@ -42,6 +101,7 @@ func (b *JSONLBackend) GetHistory(key string) []providers.Message { } func (b *JSONLBackend) GetSummary(key string) string { + key = b.resolveSessionKey(key) summary, err := b.store.GetSummary(context.Background(), key) if err != nil { log.Printf("session: get summary: %v", err) @@ -51,18 +111,21 @@ func (b *JSONLBackend) GetSummary(key string) string { } func (b *JSONLBackend) SetSummary(key, summary string) { + key = b.resolveSessionKey(key) if err := b.store.SetSummary(context.Background(), key, summary); err != nil { log.Printf("session: set summary: %v", err) } } func (b *JSONLBackend) SetHistory(key string, history []providers.Message) { + key = b.resolveSessionKey(key) if err := b.store.SetHistory(context.Background(), key, history); err != nil { log.Printf("session: set history: %v", err) } } func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { + key = b.resolveSessionKey(key) if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil { log.Printf("session: truncate history: %v", err) } @@ -72,6 +135,7 @@ func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { // immediately, the data is already durable. Save runs compaction to reclaim // space from logically truncated messages (no-op when there are none). func (b *JSONLBackend) Save(key string) error { + key = b.resolveSessionKey(key) return b.store.Compact(context.Background(), key) } diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go index 40fa019cb..32a69377b 100644 --- a/pkg/session/jsonl_backend_test.go +++ b/pkg/session/jsonl_backend_test.go @@ -177,3 +177,31 @@ func TestJSONLBackend_SummarizeFlow(t *testing.T) { t.Errorf("first message = %q, want %q", history[0].Content, "msg 16") } } + +func TestJSONLBackend_ResolveAliasAndPersistMetadata(t *testing.T) { + b := newBackend(t) + + b.EnsureSessionMetadata("canonical", &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "telegram", + Account: "default", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "group:c1", + }, + }, []string{"legacy"}) + + if got := b.ResolveSessionKey("legacy"); got != "canonical" { + t.Fatalf("ResolveSessionKey() = %q, want %q", got, "canonical") + } + + b.AddMessage("legacy", "user", "hello through alias") + history := b.GetHistory("canonical") + if len(history) != 1 { + t.Fatalf("len(history) = %d, want 1", len(history)) + } + if history[0].Content != "hello through alias" { + t.Fatalf("history[0].Content = %q, want %q", history[0].Content, "hello through alias") + } +} diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 42d451a05..d00fa84c8 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -13,7 +13,9 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" ) // registerSessionRoutes binds session list and detail endpoints to the ServeMux. @@ -42,15 +44,6 @@ type sessionListItem struct { Updated string `json:"updated"` } -type sessionMetaFile struct { - Key string `json:"key"` - Summary string `json:"summary"` - Skip int `json:"skip"` - Count int `json:"count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - // picoSessionPrefix is the key prefix used by the gateway's routing for Pico // channel sessions. The full key format is: // @@ -60,10 +53,9 @@ type sessionMetaFile struct { // // agent_main_pico_direct_pico_.json const ( - picoSessionPrefix = "agent:main:pico:direct:pico:" - sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_" - maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB - maxSessionTitleRunes = 60 + picoSessionPrefix = "agent:main:pico:direct:pico:" + maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB + maxSessionTitleRunes = 60 ) // extractPicoSessionID extracts the session UUID from a full session key. @@ -75,15 +67,11 @@ func extractPicoSessionID(key string) (string, bool) { return "", false } -func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) { - if strings.HasPrefix(key, sanitizedPicoSessionPrefix) { - return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true - } - return "", false -} - func sanitizeSessionKey(key string) string { - return strings.ReplaceAll(key, ":", "_") + key = strings.ReplaceAll(key, ":", "_") + key = strings.ReplaceAll(key, "/", "_") + key = strings.ReplaceAll(key, "\\", "_") + return key } func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) { @@ -100,18 +88,18 @@ func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) return sess, nil } -func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) { +func (h *Handler) readSessionMeta(path, sessionKey string) (memory.SessionMeta, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { - return sessionMetaFile{Key: sessionKey}, nil + return memory.SessionMeta{Key: sessionKey}, nil } if err != nil { - return sessionMetaFile{}, err + return memory.SessionMeta{}, err } - var meta sessionMetaFile + var meta memory.SessionMeta if err := json.Unmarshal(data, &meta); err != nil { - return sessionMetaFile{}, err + return memory.SessionMeta{}, err } if meta.Key == "" { meta.Key = sessionKey @@ -154,8 +142,7 @@ func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Messag return msgs, nil } -func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { - sessionKey := picoSessionPrefix + sessionID +func (h *Handler) readJSONLSession(dir, sessionKey string) (sessionFile, error) { base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) jsonlPath := base + ".jsonl" metaPath := base + ".meta.json" @@ -192,6 +179,100 @@ func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { }, nil } +type picoJSONLSessionRef struct { + ID string + Key string +} + +func extractPicoSessionIDFromScope(scope session.SessionScope) (string, bool) { + if !strings.EqualFold(strings.TrimSpace(scope.Channel), "pico") { + return "", false + } + + candidates := []string{ + strings.TrimSpace(scope.Values["sender"]), + strings.TrimSpace(scope.Values["chat"]), + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if idx := strings.Index(candidate, "pico:"); idx >= 0 { + sessionID := strings.TrimSpace(candidate[idx+len("pico:"):]) + if sessionID != "" { + return sessionID, true + } + } + } + return "", false +} + +func sessionRefFromMeta(meta memory.SessionMeta) (picoJSONLSessionRef, bool) { + if sessionID, ok := extractPicoSessionID(meta.Key); ok { + return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true + } + for _, alias := range meta.Aliases { + if sessionID, ok := extractPicoSessionID(alias); ok { + return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true + } + } + if len(meta.Scope) == 0 { + return picoJSONLSessionRef{}, false + } + var scope session.SessionScope + if err := json.Unmarshal(meta.Scope, &scope); err != nil { + return picoJSONLSessionRef{}, false + } + sessionID, ok := extractPicoSessionIDFromScope(scope) + if !ok { + return picoJSONLSessionRef{}, false + } + return picoJSONLSessionRef{ID: sessionID, Key: meta.Key}, true +} + +func (h *Handler) findPicoJSONLSessions(dir string) ([]picoJSONLSessionRef, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + refs := make([]picoJSONLSessionRef, 0) + seen := make(map[string]struct{}) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + metaPath := filepath.Join(dir, entry.Name()) + meta, err := h.readSessionMeta(metaPath, "") + if err != nil { + continue + } + ref, ok := sessionRefFromMeta(meta) + if !ok || ref.Key == "" || ref.ID == "" { + continue + } + if _, exists := seen[ref.ID]; exists { + continue + } + seen[ref.ID] = struct{}{} + refs = append(refs, ref) + } + return refs, nil +} + +func (h *Handler) findPicoJSONLSession(dir, sessionID string) (picoJSONLSessionRef, error) { + refs, err := h.findPicoJSONLSessions(dir) + if err != nil { + return picoJSONLSessionRef{}, err + } + for _, ref := range refs { + if ref.ID == sessionID { + return ref, nil + } + } + return picoJSONLSessionRef{}, os.ErrNotExist +} + func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { preview := "" for _, msg := range sess.Messages { @@ -295,66 +376,45 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { items := []sessionListItem{} seen := make(map[string]struct{}) + if refs, findErr := h.findPicoJSONLSessions(dir); findErr == nil { + for _, ref := range refs { + sess, loadErr := h.readJSONLSession(dir, ref.Key) + if loadErr != nil || isEmptySession(sess) { + continue + } + seen[ref.ID] = struct{}{} + items = append(items, buildSessionListItem(ref.ID, sess)) + } + } + for _, entry := range entries { if entry.IsDir() { continue } - name := entry.Name() - var ( - sessionID string - sess sessionFile - loadErr error - ok bool - ) - - switch { - case strings.HasSuffix(name, ".jsonl"): - sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl")) - if !ok { - continue - } - sess, loadErr = h.readJSONLSession(dir, sessionID) - if loadErr == nil && isEmptySession(sess) { - continue - } - case strings.HasSuffix(name, ".meta.json"): - continue - case filepath.Ext(name) == ".json": - base := strings.TrimSuffix(name, ".json") - if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { - if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found { - if jsonlSess, jsonlErr := h.readJSONLSession( - dir, - jsonlSessionID, - ); jsonlErr == nil && - !isEmptySession(jsonlSess) { - continue - } - } - } - data, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - continue - } - if err := json.Unmarshal(data, &sess); err != nil { - continue - } - if isEmptySession(sess) { - continue - } - sessionID, ok = extractPicoSessionID(sess.Key) - if !ok { - continue - } - if _, exists := seen[sessionID]; exists { - continue - } - default: + if strings.HasSuffix(name, ".meta.json") || filepath.Ext(name) != ".json" { continue } - if loadErr != nil { + base := strings.TrimSuffix(name, ".json") + if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + + var sess sessionFile + if err := json.Unmarshal(data, &sess); err != nil { + continue + } + if isEmptySession(sess) { + continue + } + sessionID, ok := extractPicoSessionID(sess.Key) + if !ok { continue } if _, exists := seen[sessionID]; exists { @@ -416,7 +476,12 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { return } - sess, err := h.readJSONLSession(dir, sessionID) + ref, refErr := h.findPicoJSONLSession(dir, sessionID) + var sess sessionFile + err = refErr + if refErr == nil { + sess, err = h.readJSONLSession(dir, ref.Key) + } if err == nil && isEmptySession(sess) { err = os.ErrNotExist } @@ -480,20 +545,28 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { return } - base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) - jsonlPath := base + ".jsonl" - metaPath := base + ".meta.json" - legacyPath := base + ".json" - removed := false - for _, path := range []string{jsonlPath, metaPath, legacyPath} { - if err := os.Remove(path); err != nil { - if os.IsNotExist(err) { - continue + if ref, err := h.findPicoJSONLSession(dir, sessionID); err == nil { + base := filepath.Join(dir, sanitizeSessionKey(ref.Key)) + for _, path := range []string{base + ".jsonl", base + ".meta.json"} { + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + continue + } + http.Error(w, "failed to delete session", http.StatusInternalServerError) + return } + removed = true + } + } + + legacyPath := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json") + if err := os.Remove(legacyPath); err != nil { + if !os.IsNotExist(err) { http.Error(w, "failed to delete session", http.StatusInternalServerError) return } + } else { removed = true } diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index 21ef5b5b8..eeb477c66 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -215,6 +215,83 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { } } +func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := "sk_v1_scope_discovery" + addErr := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "scope discovered session", + }) + if addErr != nil { + t.Fatalf("AddFullMessage() error = %v", addErr) + } + summaryErr := store.SetSummary(nil, sessionKey, "scope summary") + if summaryErr != nil { + t.Fatalf("SetSummary() error = %v", summaryErr) + } + + scopeData, err := json.Marshal(session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: "main", + Channel: "pico", + Account: "default", + Dimensions: []string{"sender"}, + Values: map[string]string{ + "sender": "pico:scope-jsonl", + }, + }) + if err != nil { + t.Fatalf("Marshal(scope) error = %v", err) + } + if err := store.UpsertSessionMeta(nil, sessionKey, scopeData, nil); err != nil { + t.Fatalf("UpsertSessionMeta() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal(list) error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "scope-jsonl" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "scope-jsonl") + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/scope-jsonl", nil) + mux.ServeHTTP(detailRec, detailReq) + if detailRec.Code != http.StatusOK { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusOK, detailRec.Body.String()) + } + + deleteRec := httptest.NewRecorder() + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/sessions/scope-jsonl", nil) + mux.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusNoContent { + t.Fatalf("delete status = %d, want %d, body=%s", deleteRec.Code, http.StatusNoContent, deleteRec.Body.String()) + } +} + func TestHandleDeleteSession_JSONLStorage(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup()