Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions internal/runtime/session_logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package runtime

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

const logViewerPersistDir = "log-viewer"

// SessionLogEntry 描述会话维度的日志查看器持久化条目。
type SessionLogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Source string `json:"source"`
Message string `json:"message"`
}

// LoadSessionLogEntries 按会话 ID 读取日志查看器持久化数据。
func (s *Service) LoadSessionLogEntries(ctx context.Context, sessionID string) ([]SessionLogEntry, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
path, err := s.sessionLogEntriesPath(sessionID)
if err != nil || path == "" {
return nil, err
}
payload, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("runtime: read session log entries: %w", err)
}
entries := make([]SessionLogEntry, 0)
if err := json.Unmarshal(payload, &entries); err != nil {
return nil, fmt.Errorf("runtime: decode session log entries: %w", err)
}
return append([]SessionLogEntry(nil), entries...), nil
}

// SaveSessionLogEntries 将日志查看器条目写入会话维度持久化存储。
func (s *Service) SaveSessionLogEntries(ctx context.Context, sessionID string, entries []SessionLogEntry) error {
if err := ctx.Err(); err != nil {
return err
}
path, err := s.sessionLogEntriesPath(sessionID)
if err != nil || path == "" {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("runtime: ensure session log directory: %w", err)
}
payload, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("runtime: encode session log entries: %w", err)
}
if err := os.WriteFile(path, payload, 0o600); err != nil {
return fmt.Errorf("runtime: write session log entries: %w", err)
}
return nil
}

// sessionLogEntriesPath 生成会话日志文件路径,并确保命名稳定且避免会话 ID 冲突。
func (s *Service) sessionLogEntriesPath(sessionID string) (string, error) {
normalizedSessionID := strings.TrimSpace(sessionID)
if normalizedSessionID == "" {
return "", nil
}
if s == nil || s.configManager == nil {
return "", errors.New("runtime: config manager is not initialized")
}
baseDir := strings.TrimSpace(s.configManager.BaseDir())
if baseDir == "" {
return "", errors.New("runtime: config base directory is empty")
}
sum := sha256.Sum256([]byte(normalizedSessionID))
fileName := fmt.Sprintf("%s_%s.json", sanitizeSessionLogPrefix(normalizedSessionID), hex.EncodeToString(sum[:8]))
return filepath.Join(baseDir, logViewerPersistDir, fileName), nil
}

// sanitizeSessionLogPrefix 生成可读前缀,便于排查文件,同时不参与唯一性判定。
func sanitizeSessionLogPrefix(sessionID string) string {
var b strings.Builder
for _, r := range sessionID {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '-':
b.WriteRune(r)
default:
if b.Len() > 0 {
b.WriteByte('_')
}
}
if b.Len() >= 24 {
break
}
}
prefix := strings.Trim(b.String(), "_")
if prefix == "" {
return "session"
}
return prefix
}
114 changes: 114 additions & 0 deletions internal/runtime/session_logs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package runtime

import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"

"neo-code/internal/config"
)

func newSessionLogTestService(t *testing.T) *Service {
t.Helper()
cfg := config.StaticDefaults()
cfg.Workdir = t.TempDir()
manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg))
if _, err := manager.Load(context.Background()); err != nil {
t.Fatalf("Load() error = %v", err)
}
return &Service{configManager: manager}
}

func TestSessionLogEntriesPathAndSanitizePrefix(t *testing.T) {
service := newSessionLogTestService(t)

pathA, err := service.sessionLogEntriesPath("a:b")
if err != nil || pathA == "" {
t.Fatalf("sessionLogEntriesPath(a:b) err=%v path=%q", err, pathA)
}
pathB, err := service.sessionLogEntriesPath("a/b")
if err != nil || pathB == "" {
t.Fatalf("sessionLogEntriesPath(a/b) err=%v path=%q", err, pathB)
}
if pathA == pathB {
t.Fatalf("expected different file names for potential sanitize collision ids, got %q", pathA)
}
if got := sanitizeSessionLogPrefix(" /a:b?c* "); got == "" {
t.Fatal("expected sanitizeSessionLogPrefix to produce fallback prefix")
}
if got := sanitizeSessionLogPrefix("___"); got != "session" {
t.Fatalf("sanitizeSessionLogPrefix(___)=%q, want session", got)
}
}

func TestLoadAndSaveSessionLogEntries(t *testing.T) {
service := newSessionLogTestService(t)
sessionID := "session-one"
source := []SessionLogEntry{
{Timestamp: time.Unix(1700000000, 0), Level: "info", Source: "tool", Message: "ok"},
}

if err := service.SaveSessionLogEntries(context.Background(), sessionID, source); err != nil {
t.Fatalf("SaveSessionLogEntries() error = %v", err)
}
loaded, err := service.LoadSessionLogEntries(context.Background(), sessionID)
if err != nil {
t.Fatalf("LoadSessionLogEntries() error = %v", err)
}
if len(loaded) != 1 || loaded[0].Message != "ok" {
t.Fatalf("unexpected loaded entries: %+v", loaded)
}

missing, err := service.LoadSessionLogEntries(context.Background(), "missing-session")
if err != nil {
t.Fatalf("LoadSessionLogEntries(missing) error = %v", err)
}
if len(missing) != 0 {
t.Fatalf("expected missing session to return empty entries, got %+v", missing)
}
}

func TestSessionLogEntriesErrorBranches(t *testing.T) {
service := newSessionLogTestService(t)

if err := service.SaveSessionLogEntries(context.Background(), "", nil); err != nil {
t.Fatalf("SaveSessionLogEntries(blank) should skip, got err=%v", err)
}
if _, err := service.LoadSessionLogEntries(context.Background(), ""); err != nil {
t.Fatalf("LoadSessionLogEntries(blank) should skip, got err=%v", err)
}

path, err := service.sessionLogEntriesPath("bad-json")
if err != nil {
t.Fatalf("sessionLogEntriesPath() error = %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(path, []byte("{invalid"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := service.LoadSessionLogEntries(context.Background(), "bad-json"); err == nil {
t.Fatal("expected invalid json load error")
}

brokenService := &Service{configManager: nil}
if err := brokenService.SaveSessionLogEntries(context.Background(), "id", nil); err == nil {
t.Fatal("expected save error when config manager is nil")
}
if _, err := brokenService.LoadSessionLogEntries(context.Background(), "id"); err == nil {
t.Fatal("expected load error when config manager is nil")
}

cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := service.SaveSessionLogEntries(cancelled, "id", nil); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled error on save, got %v", err)
}
if _, err := service.LoadSessionLogEntries(cancelled, "id"); !errors.Is(err, context.Canceled) {
t.Fatalf("expected canceled error on load, got %v", err)
}
}
1 change: 1 addition & 0 deletions internal/tui/core/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ type appRuntimeState struct {
viewDirty bool
logViewerVisible bool
logViewerOffset int
logViewerPrevStatus string
logEntries []logEntry
logPersistDirty bool
logPersistVersion int
Expand Down
Loading
Loading