From 7aca339b77962bb24cfe2eef69f9ff0ef22d7682 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Mon, 20 Apr 2026 10:06:10 +0000 Subject: [PATCH] fix(tui): move log persistence to runtime and harden viewer state Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: creatang <165447160+creatang@users.noreply.github.com> --- internal/runtime/session_logs.go | 116 ++++++++++++++++++++++++ internal/runtime/session_logs_test.go | 114 +++++++++++++++++++++++ internal/tui/core/app/app.go | 1 + internal/tui/core/app/update.go | 124 +++++++++++++++++++------- internal/tui/core/app/update_test.go | 122 +++++++++++++++++++------ 5 files changed, 418 insertions(+), 59 deletions(-) create mode 100644 internal/runtime/session_logs.go create mode 100644 internal/runtime/session_logs_test.go diff --git a/internal/runtime/session_logs.go b/internal/runtime/session_logs.go new file mode 100644 index 00000000..49a32ffb --- /dev/null +++ b/internal/runtime/session_logs.go @@ -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 +} diff --git a/internal/runtime/session_logs_test.go b/internal/runtime/session_logs_test.go new file mode 100644 index 00000000..9feca663 --- /dev/null +++ b/internal/runtime/session_logs_test.go @@ -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) + } +} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 35ec9fc0..334af827 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -132,6 +132,7 @@ type appRuntimeState struct { viewDirty bool logViewerVisible bool logViewerOffset int + logViewerPrevStatus string logEntries []logEntry logPersistDirty bool logPersistVersion int diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 70d21ab7..490ff4da 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "path/filepath" "strings" "time" @@ -50,10 +49,14 @@ const providerAddManualModelsJSONTemplate = "[\n {\n \"id\": \"model-id\",\n const sessionSwitchBusyMessage = "cannot switch sessions while run or compact is active" const logViewerEntryLimit = 500 -const logViewerPersistDir = "log-viewer" const logViewerPersistDebounce = 300 * time.Millisecond const footerErrorFlashDuration = 4 * time.Second +type sessionLogPersistenceRuntime interface { + LoadSessionLogEntries(ctx context.Context, sessionID string) ([]agentruntime.SessionLogEntry, error) + SaveSessionLogEntries(ctx context.Context, sessionID string, entries []agentruntime.SessionLogEntry) error +} + var panelOrder = []panel{panelTranscript, panelInput} var supportsUserEnvPersistence = config.SupportsUserEnvPersistence var persistProviderUserEnvVar = config.PersistUserEnvVar @@ -323,6 +326,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.logViewerVisible = true a.logViewerOffset = 0 a.viewDirty = true + a.logViewerPrevStatus = strings.TrimSpace(a.state.StatusText) a.state.StatusText = "Log viewer" a.applyComponentLayout(false) return a, tea.Batch(cmds...) @@ -1738,7 +1742,7 @@ func (a *App) handleLogViewerKey(msg tea.KeyMsg) bool { switch { case key.Matches(msg, a.keys.LogViewer), key.Matches(msg, a.keys.FocusInput): a.logViewerVisible = false - a.state.StatusText = statusReady + a.restoreStatusAfterLogViewer() a.applyComponentLayout(false) a.viewDirty = true case key.Matches(msg, a.keys.ScrollUp): @@ -1800,7 +1804,7 @@ func (a *App) scrollLogViewer(delta int, height int) { func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool { if a.transcriptScrollbarDrag { switch { - case msg.Action == tea.MouseActionMotion: + case msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion: a.setTranscriptOffsetFromScrollbarY(msg.Y) return true case msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease: @@ -2629,19 +2633,20 @@ func (a *App) loadLogEntriesForSession(sessionID string) { } func (a *App) readLogEntriesForSession(sessionID string) []logEntry { - logPath := a.sessionLogEntriesPath(sessionID) - if logPath == "" { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { return nil } - data, err := os.ReadFile(logPath) - if err != nil { + runtimeWithPersistence := a.sessionLogRuntime() + if runtimeWithPersistence == nil { return nil } - var entries []logEntry - if err := json.Unmarshal(data, &entries); err != nil { + entries, err := runtimeWithPersistence.LoadSessionLogEntries(context.Background(), sessionID) + if err != nil { + a.reportLogPersistenceError("load", err) return nil } - return clampLogEntries(entries) + return clampLogEntries(fromRuntimeSessionLogEntries(entries)) } func (a *App) persistLogEntriesForActiveSession() { @@ -2651,42 +2656,95 @@ func (a *App) persistLogEntriesForActiveSession() { return } - logPath := a.sessionLogEntriesPath(sessionID) - if logPath == "" { + runtimeWithPersistence := a.sessionLogRuntime() + if runtimeWithPersistence == nil { a.logPersistDirty = false return } - if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + if err := runtimeWithPersistence.SaveSessionLogEntries( + context.Background(), + sessionID, + toRuntimeSessionLogEntries(clampLogEntries(a.logEntries)), + ); err != nil { + a.reportLogPersistenceError("save", err) + a.logPersistVersion++ + a.deferredLogPersistCmd = scheduleLogPersistFlush(a.logPersistVersion) return } - payload, _ := json.Marshal(clampLogEntries(a.logEntries)) - _ = os.WriteFile(logPath, payload, 0o600) a.logPersistDirty = false } -func (a App) sessionLogEntriesPath(sessionID string) string { - sessionID = strings.TrimSpace(sessionID) - baseDir := strings.TrimSpace(a.configManager.BaseDir()) - if sessionID == "" || baseDir == "" { - return "" +// sessionLogRuntime 返回支持会话日志读写的 runtime 适配能力。 +func (a *App) sessionLogRuntime() sessionLogPersistenceRuntime { + runtimeWithPersistence, ok := a.runtime.(sessionLogPersistenceRuntime) + if !ok { + return nil } - name := sanitizeSessionIDForFilename(sessionID) - if name == "" { - return "" + return runtimeWithPersistence +} + +// reportLogPersistenceError 统一处理日志持久化失败提示,避免错误静默吞掉。 +func (a *App) reportLogPersistenceError(action string, err error) { + if err == nil { + return } - return filepath.Join(baseDir, logViewerPersistDir, name+".json") + message := fmt.Sprintf("Failed to %s log entries: %v", strings.TrimSpace(action), err) + a.state.StatusText = message + a.showFooterError(message) } -func sanitizeSessionIDForFilename(sessionID string) string { - var b strings.Builder - for _, r := range sessionID { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { - b.WriteRune(r) - continue +// restoreStatusAfterLogViewer 在关闭日志视图时恢复可读状态,避免覆盖真实运行态。 +func (a *App) restoreStatusAfterLogViewer() { + defer func() { a.logViewerPrevStatus = "" }() + if executionError := strings.TrimSpace(a.state.ExecutionError); executionError != "" { + a.state.StatusText = executionError + return + } + if a.state.IsCompacting { + a.state.StatusText = statusCompacting + return + } + if a.state.IsAgentRunning { + if strings.TrimSpace(a.state.CurrentTool) != "" { + a.state.StatusText = statusRunningTool + } else { + a.state.StatusText = statusThinking } - b.WriteByte('_') + return + } + if prev := strings.TrimSpace(a.logViewerPrevStatus); prev != "" { + a.state.StatusText = prev + return + } + a.state.StatusText = statusReady +} + +// toRuntimeSessionLogEntries 转换日志条目到 runtime 持久化模型。 +func toRuntimeSessionLogEntries(entries []logEntry) []agentruntime.SessionLogEntry { + converted := make([]agentruntime.SessionLogEntry, 0, len(entries)) + for _, entry := range entries { + converted = append(converted, agentruntime.SessionLogEntry{ + Timestamp: entry.Timestamp, + Level: entry.Level, + Source: entry.Source, + Message: entry.Message, + }) + } + return converted +} + +// fromRuntimeSessionLogEntries 将 runtime 持久化模型恢复为 TUI 展示模型。 +func fromRuntimeSessionLogEntries(entries []agentruntime.SessionLogEntry) []logEntry { + converted := make([]logEntry, 0, len(entries)) + for _, entry := range entries { + converted = append(converted, logEntry{ + Timestamp: entry.Timestamp, + Level: entry.Level, + Source: entry.Source, + Message: entry.Message, + }) } - return strings.TrimSpace(strings.Trim(b.String(), "_")) + return converted } func clampLogEntries(entries []logEntry) []logEntry { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index a9f4a2a1..f7cea97c 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -2,7 +2,6 @@ package tui import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -123,6 +122,9 @@ type stubRuntime struct { listSessionsErr error loadSessions map[string]agentsession.Session loadSessionErr error + logEntriesBySID map[string][]agentruntime.SessionLogEntry + loadLogErr error + saveLogErr error } type snapshotRuntime struct { @@ -133,7 +135,10 @@ type snapshotRuntime struct { } func newStubRuntime() *stubRuntime { - return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} + return &stubRuntime{ + events: make(chan agentruntime.RuntimeEvent), + logEntriesBySID: make(map[string][]agentruntime.SessionLogEntry), + } } func (s *stubRuntime) PrepareUserInput(ctx context.Context, input agentruntime.PrepareInput) (agentruntime.UserInput, error) { @@ -230,6 +235,26 @@ func (s *stubRuntime) ListSessionSkills(ctx context.Context, sessionID string) ( return nil, nil } +func (s *stubRuntime) LoadSessionLogEntries(ctx context.Context, sessionID string) ([]agentruntime.SessionLogEntry, error) { + if s.loadLogErr != nil { + return nil, s.loadLogErr + } + entries := s.logEntriesBySID[strings.TrimSpace(sessionID)] + return append([]agentruntime.SessionLogEntry(nil), entries...), nil +} + +func (s *stubRuntime) SaveSessionLogEntries( + ctx context.Context, + sessionID string, + entries []agentruntime.SessionLogEntry, +) error { + if s.saveLogErr != nil { + return s.saveLogErr + } + s.logEntriesBySID[strings.TrimSpace(sessionID)] = append([]agentruntime.SessionLogEntry(nil), entries...) + return nil +} + func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { return agentsession.NewWithWorkdir("draft", workdir), nil } @@ -3519,7 +3544,7 @@ func TestTranscriptManualScrollPersistsWhileBusy(t *testing.T) { } func TestSessionLogViewerPersistenceAndCap(t *testing.T) { - app, _ := newTestApp(t) + app, runtime := newTestApp(t) app.setActiveSessionID("session-one") for i := 0; i < 520; i++ { @@ -3536,8 +3561,8 @@ func TestSessionLogViewerPersistenceAndCap(t *testing.T) { } model, _ := app.Update(logPersistFlushMsg{Version: app.logPersistVersion}) app = model.(App) - if _, err := os.Stat(app.sessionLogEntriesPath("session-one")); err != nil { - t.Fatalf("expected persisted log file for session-one, got err=%v", err) + if got := runtime.logEntriesBySID["session-one"]; len(got) != logViewerEntryLimit { + t.Fatalf("expected runtime persisted %d entries, got %d", logViewerEntryLimit, len(got)) } app.setActiveSessionID("session-two") @@ -3639,6 +3664,34 @@ func TestHandleLogViewerKeyAndScrollBranches(t *testing.T) { } } +func TestRestoreStatusAfterLogViewerUsesRuntimeState(t *testing.T) { + app, _ := newTestApp(t) + + app.logViewerPrevStatus = "Manual status" + app.state.ExecutionError = "runtime failed" + app.restoreStatusAfterLogViewer() + if app.state.StatusText != "runtime failed" { + t.Fatalf("expected execution error to win, got %q", app.state.StatusText) + } + + app.logViewerPrevStatus = "Manual status" + app.state.ExecutionError = "" + app.state.IsCompacting = true + app.restoreStatusAfterLogViewer() + if app.state.StatusText != statusCompacting { + t.Fatalf("expected compacting status, got %q", app.state.StatusText) + } + + app.logViewerPrevStatus = "Manual status" + app.state.IsCompacting = false + app.state.IsAgentRunning = true + app.state.CurrentTool = "bash" + app.restoreStatusAfterLogViewer() + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status, got %q", app.state.StatusText) + } +} + func TestHandleLogViewerMouseAndClampOffset(t *testing.T) { app, _ := newTestApp(t) app.width = 100 @@ -3695,26 +3748,33 @@ func TestSetTranscriptOffsetFromScrollbarY(t *testing.T) { } } -func TestSessionLogHelpersAndSwitchBootstrap(t *testing.T) { +func TestHandleTranscriptMouseDragSupportsMotionType(t *testing.T) { app, _ := newTestApp(t) - baseDir := app.configManager.BaseDir() - path := app.sessionLogEntriesPath("session-A") - if !strings.HasPrefix(path, baseDir) || !strings.Contains(path, "log-viewer") { - t.Fatalf("expected session log path under config dir, got %q", path) + app.width = 100 + app.height = 28 + app.applyComponentLayout(true) + app.setTranscriptContent(strings.Repeat("line\n", 200)) + + _, y, _, h := app.transcriptScrollbarBounds() + app.transcriptScrollbarDrag = true + before := app.transcript.YOffset + handled := app.handleTranscriptMouse(tea.MouseMsg{Y: y + h - 1, Type: tea.MouseMotion}) + if !handled { + t.Fatal("expected mouse motion type during drag to be handled") } - if got := sanitizeSessionIDForFilename(" /a:b?c* "); got != "a_b_c" { - t.Fatalf("unexpected sanitized session id: %q", got) + if app.transcript.YOffset == before { + t.Fatalf("expected drag motion to update offset, still %d", app.transcript.YOffset) } +} + +func TestSessionLogHelpersAndSwitchBootstrap(t *testing.T) { + app, _ := newTestApp(t) now := time.Unix(1_700_001_000, 0) app.logEntries = []logEntry{{Timestamp: now, Level: "info", Source: "bootstrap", Message: "in-memory"}} - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir failed: %v", err) - } - fileEntries := []logEntry{{Timestamp: now, Level: "info", Source: "file", Message: "persisted"}} - payload, _ := json.Marshal(fileEntries) - if err := os.WriteFile(path, payload, 0o600); err != nil { - t.Fatalf("write failed: %v", err) + runtime := app.runtime.(*stubRuntime) + runtime.logEntriesBySID["session-A"] = []agentruntime.SessionLogEntry{ + {Timestamp: now, Level: "info", Source: "file", Message: "persisted"}, } app.setActiveSessionID("session-A") @@ -3727,12 +3787,9 @@ func TestSessionLogHelpersAndSwitchBootstrap(t *testing.T) { t.Fatalf("expected clearing active session to reset log state") } - invalidPath := app.sessionLogEntriesPath("session-invalid") - if err := os.WriteFile(invalidPath, []byte("{invalid json"), 0o600); err != nil { - t.Fatalf("write invalid json failed: %v", err) - } + runtime.loadLogErr = errors.New("decode failed") if got := app.readLogEntriesForSession("session-invalid"); got != nil { - t.Fatalf("expected invalid json log file to return nil entries, got %+v", got) + t.Fatalf("expected load error branch to return nil entries, got %+v", got) } } @@ -3834,9 +3891,22 @@ func TestReadAndPersistLogEntriesGuardBranches(t *testing.T) { app.state.ActiveSessionID = "" app.persistLogEntriesForActiveSession() +} - if got := app.sessionLogEntriesPath("___"); got != "" { - t.Fatalf("expected sanitized empty session id to yield empty path, got %q", got) +func TestPersistLogEntriesRetryOnSaveFailure(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-save-error" + app.logEntries = []logEntry{{Timestamp: time.Now(), Level: "info", Source: "test", Message: "m"}} + app.logPersistDirty = true + app.logPersistVersion = 1 + runtime.saveLogErr = errors.New("disk full") + + app.persistLogEntriesForActiveSession() + if !app.logPersistDirty { + t.Fatal("expected dirty flag to remain true after save failure") + } + if app.deferredLogPersistCmd == nil { + t.Fatal("expected deferred persist command to be rescheduled on save failure") } }