From fd42a54d96fcd3342941caaeb61a4b0d5d3f1b4f Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 6 Mar 2026 23:55:07 +0000 Subject: recover: restore untracked work from recovery branch (no Gemini changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovered files with no Claude→Agent contamination: - docs/adr/002-task-state-machine.md - internal/api/logs.go/logs_test.go: task-level log streaming endpoint - internal/api/validate.go/validate_test.go: POST /api/tasks/validate - internal/api/server_test.go, storage/db_test.go: expanded test coverage - scripts/reset-failed-tasks, reset-running-tasks - web/app.js, index.html, style.css: frontend improvements - web/test/: active-tasks-tab, delete-button, filter-tabs, sort-tasks tests Manually applied from server.go diff (skipping Claude→Agent rename): - taskLogStore field + validateCmdPath field - DELETE /api/tasks/{id} route + handleDeleteTask - GET /api/tasks/{id}/logs/stream route - POST /api/tasks/{id}/resume route + handleResumeTimedOutTask - handleCancelTask: allow cancelling PENDING/QUEUED tasks directly Co-Authored-By: Claude Sonnet 4.6 --- internal/api/logs_test.go | 175 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) (limited to 'internal/api/logs_test.go') diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go index 4a0c9fd..52fa168 100644 --- a/internal/api/logs_test.go +++ b/internal/api/logs_test.go @@ -14,6 +14,26 @@ import ( "github.com/thepeterstone/claudomator/internal/storage" ) +// mockTaskLogStore implements taskLogStore for testing handleStreamTaskLogs. +type mockTaskLogStore struct { + getExecution func(id string) (*storage.Execution, error) + getLatestExecution func(taskID string) (*storage.Execution, error) +} + +func (m *mockTaskLogStore) GetExecution(id string) (*storage.Execution, error) { + return m.getExecution(id) +} + +func (m *mockTaskLogStore) GetLatestExecution(taskID string) (*storage.Execution, error) { + return m.getLatestExecution(taskID) +} + +func taskLogsMux(srv *Server) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/tasks/{id}/logs/stream", srv.handleStreamTaskLogs) + return mux +} + // mockLogStore implements logStore for testing. type mockLogStore struct { fn func(id string) (*storage.Execution, error) @@ -170,3 +190,158 @@ func TestHandleStreamLogs_RunningState_LiveTail(t *testing.T) { t.Errorf("body does not end with done event; got:\n%s", body) } } + +// --- Task-level SSE log streaming tests (handleStreamTaskLogs) --- + +// TestHandleStreamTaskLogs_TaskNotFound verifies that a task with no executions yields 404. +func TestHandleStreamTaskLogs_TaskNotFound(t *testing.T) { + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return nil, errors.New("not found") + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/nonexistent/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone verifies that a completed execution with no +// stdout log path emits only the done sentinel event. +func TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone(t *testing.T) { + exec := &storage.Execution{ + ID: "exec-task-empty", + TaskID: "task-no-log", + StartTime: time.Now(), + Status: "COMPLETED", + // StdoutPath intentionally empty + } + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return exec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + return exec, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-no-log/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Errorf("Content-Type: want text/event-stream, got %q", ct) + } + body := w.Body.String() + if body != "event: done\ndata: {}\n\n" { + t.Errorf("want only done event, got:\n%s", body) + } +} + +// TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone verifies that a COMPLETED +// execution streams SSE events and ends with a done event. +func TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + lines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Task output here"}]}}`, + `{"type":"result","cost_usd":0.007}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(lines), 0600); err != nil { + t.Fatal(err) + } + + exec := &storage.Execution{ + ID: "exec-task-done", + TaskID: "task-done", + StartTime: time.Now(), + Status: "COMPLETED", + StdoutPath: logPath, + } + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return exec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + return exec, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-done/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Task output here"`) { + t.Errorf("expected text event content in body; got:\n%s", body) + } + if !strings.Contains(body, `"type":"cost"`) { + t.Errorf("expected cost event in body; got:\n%s", body) + } + if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") { + t.Errorf("body does not end with done event; got:\n%s", body) + } +} + +// TestHandleStreamTaskLogs_RunningExecution_LiveTails verifies that a RUNNING execution is +// live-tailed and a done event is emitted once it transitions to a terminal state. +func TestHandleStreamTaskLogs_RunningExecution_LiveTails(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + logLines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Still running..."}]}}`, + `{"type":"result","cost_usd":0.003}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(logLines), 0600); err != nil { + t.Fatal(err) + } + + runningExec := &storage.Execution{ + ID: "exec-task-running", + TaskID: "task-running", + StartTime: time.Now(), + Status: "RUNNING", + StdoutPath: logPath, + } + + // getLatestExecution is called once (initial lookup); getExecution polls for state change. + var pollCount atomic.Int32 + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return runningExec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + n := pollCount.Add(1) + if n <= 1 { + return runningExec, nil + } + completed := *runningExec + completed.Status = "COMPLETED" + return &completed, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-running/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Still running..."`) { + t.Errorf("expected live-tail text in body; got:\n%s", body) + } + if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") { + t.Errorf("body does not end with done event; got:\n%s", body) + } +} -- cgit v1.2.3