From 1b2deb13daa788dc43d98caeaa9507254b1ca283 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 16 Mar 2026 20:01:59 +0000 Subject: feat: display deployment status badge on READY task cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add deployment_status field to task list/get API responses for READY tasks. The field includes deployed_commit, fix_commits, and includes_fix so the UI can show whether the deployed server includes each fix. - internal/api/task_view.go: taskView struct + enrichTask() helper - handleListTasks/handleGetTask: return enriched taskView responses - web/app.js: export renderDeploymentBadge(); add badge to READY cards - web/test/deployment-badge.test.mjs: 8 tests for renderDeploymentBadge - web/style.css: .deployment-badge--deployed / --pending styles - server_test.go: 3 new tests (red→green) for enriched task responses Co-Authored-By: Claude Sonnet 4.6 --- internal/api/server.go | 8 ++- internal/api/server_test.go | 134 +++++++++++++++++++++++++++++++++++++ internal/api/task_view.go | 41 ++++++++++++ web/app.js | 26 +++++++ web/style.css | 21 ++++++ web/test/deployment-badge.test.mjs | 72 ++++++++++++++++++++ 6 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 internal/api/task_view.go create mode 100644 web/test/deployment-badge.test.mjs diff --git a/internal/api/server.go b/internal/api/server.go index 8a20349..800ad3e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -514,7 +514,11 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { if tasks == nil { tasks = []*task.Task{} } - writeJSON(w, http.StatusOK, tasks) + views := make([]*taskView, len(tasks)) + for i, tk := range tasks { + views[i] = s.enrichTask(tk) + } + writeJSON(w, http.StatusOK, views) } func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { @@ -524,7 +528,7 @@ func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) return } - writeJSON(w, http.StatusOK, t) + writeJSON(w, http.StatusOK, s.enrichTask(t)) } func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 516e289..83f83f4 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1781,3 +1781,137 @@ func TestDeploymentStatus_NotFound(t *testing.T) { t.Fatalf("want 404, got %d", w.Code) } } + +// TestListTasks_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks +// returns a deployment_status field for READY tasks containing deployed_commit, +// fix_commits, and includes_fix. +func TestListTasks_ReadyTask_IncludesDeploymentStatus(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "enrich-list-ready-1", task.StateReady) + exec := &storage.Execution{ + ID: "enrich-list-exec-1", + TaskID: tk.ID, + StartTime: time.Now(), + EndTime: time.Now(), + Status: "COMPLETED", + Commits: []task.GitCommit{{Hash: "aabbcc", Message: "fix: list test"}}, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/tasks", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var tasks []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil { + t.Fatalf("decode: %v", err) + } + + var found map[string]interface{} + for _, tsk := range tasks { + if tsk["id"] == tk.ID { + found = tsk + break + } + } + if found == nil { + t.Fatalf("task %q not found in list response", tk.ID) + } + + ds, ok := found["deployment_status"].(map[string]interface{}) + if !ok { + t.Fatalf("READY task missing deployment_status field; got: %v", found["deployment_status"]) + } + if _, ok := ds["deployed_commit"]; !ok { + t.Error("deployment_status missing deployed_commit") + } + if _, ok := ds["includes_fix"]; !ok { + t.Error("deployment_status missing includes_fix") + } +} + +// TestGetTask_ReadyTask_IncludesDeploymentStatus verifies that GET /api/tasks/{id} +// returns a deployment_status field for a READY task. +func TestGetTask_ReadyTask_IncludesDeploymentStatus(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "enrich-get-ready-1", task.StateReady) + exec := &storage.Execution{ + ID: "enrich-get-exec-1", + TaskID: tk.ID, + StartTime: time.Now(), + EndTime: time.Now(), + Status: "COMPLETED", + Commits: []task.GitCommit{{Hash: "ddeeff", Message: "fix: get test"}}, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest("GET", "/api/tasks/"+tk.ID, nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + ds, ok := resp["deployment_status"].(map[string]interface{}) + if !ok { + t.Fatalf("READY task GET response missing deployment_status; got: %v", resp["deployment_status"]) + } + if _, ok := ds["deployed_commit"]; !ok { + t.Error("deployment_status missing deployed_commit") + } + if _, ok := ds["includes_fix"]; !ok { + t.Error("deployment_status missing includes_fix") + } +} + +// TestListTasks_NonReadyTask_OmitsDeploymentStatus verifies that non-READY tasks +// (e.g. PENDING) do not include a deployment_status field. +func TestListTasks_NonReadyTask_OmitsDeploymentStatus(t *testing.T) { + srv, store := testServer(t) + + tk := createTaskWithState(t, store, "enrich-list-pending-1", task.StatePending) + + req := httptest.NewRequest("GET", "/api/tasks", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + + var tasks []map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil { + t.Fatalf("decode: %v", err) + } + + var found map[string]interface{} + for _, tsk := range tasks { + if tsk["id"] == tk.ID { + found = tsk + break + } + } + if found == nil { + t.Fatalf("task %q not found in list", tk.ID) + } + + if _, ok := found["deployment_status"]; ok { + t.Error("PENDING task should not include deployment_status field") + } +} diff --git a/internal/api/task_view.go b/internal/api/task_view.go new file mode 100644 index 0000000..5791058 --- /dev/null +++ b/internal/api/task_view.go @@ -0,0 +1,41 @@ +package api + +import ( + "database/sql" + + "github.com/thepeterstone/claudomator/internal/deployment" + "github.com/thepeterstone/claudomator/internal/task" +) + +// taskView wraps a task with computed fields that are derived from execution +// history and deployment state. It is used as the JSON response type for task +// list and get endpoints so that callers receive enriched data in one request. +type taskView struct { + *task.Task + Changestats *task.Changestats `json:"changestats,omitempty"` + DeploymentStatus *deployment.Status `json:"deployment_status,omitempty"` +} + +// enrichTask fetches the latest execution for the given task and attaches +// changestats and deployment_status fields for READY tasks. +// Non-READY tasks are returned without these extra fields. +func (s *Server) enrichTask(tk *task.Task) *taskView { + view := &taskView{Task: tk} + + if tk.State != task.StateReady { + return view + } + + exec, err := s.store.GetLatestExecution(tk.ID) + if err != nil { + if err == sql.ErrNoRows { + // No execution yet — still include deployment status (empty commits). + view.DeploymentStatus = deployment.Check(nil, tk.Agent.ProjectDir) + } + return view + } + + view.Changestats = exec.Changestats + view.DeploymentStatus = deployment.Check(exec.Commits, tk.Agent.ProjectDir) + return view +} diff --git a/web/app.js b/web/app.js index e1782dd..9708727 100644 --- a/web/app.js +++ b/web/app.js @@ -96,6 +96,26 @@ export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefi return span; } +// Returns a element indicating whether the +// currently-deployed server includes the task's fix commits. +// Returns null if status is null/undefined or doc is null. +// Accepts an optional doc parameter for testability (defaults to document). +export function renderDeploymentBadge(status, doc = (typeof document !== 'undefined' ? document : null)) { + if (status == null || doc == null) return null; + const span = doc.createElement('span'); + if (status.includes_fix) { + span.className = 'deployment-badge deployment-badge--deployed'; + span.textContent = '✓ Deployed'; + } else { + span.className = 'deployment-badge deployment-badge--pending'; + span.textContent = '⚠ Not deployed'; + } + if (status.deployed_commit) { + span.title = `Deployed commit: ${status.deployed_commit.slice(0, 8)}`; + } + return span; +} + function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; const cut = text.lastIndexOf(' ', maxLen); @@ -153,6 +173,12 @@ function createTaskCard(task) { if (csBadge) card.appendChild(csBadge); } + // Deployment status badge for READY tasks + if (task.state === 'READY' && task.deployment_status != null) { + const depBadge = renderDeploymentBadge(task.deployment_status); + if (depBadge) card.appendChild(depBadge); + } + // Footer: action buttons based on state // Interrupted states (CANCELLED, FAILED, BUDGET_EXCEEDED) show both Resume and Restart. // TIMED_OUT shows Resume only. Others show a single action. diff --git a/web/style.css b/web/style.css index e7d1de4..1b6fb69 100644 --- a/web/style.css +++ b/web/style.css @@ -863,6 +863,27 @@ dialog label select:focus { white-space: nowrap; } +.deployment-badge { + display: inline-block; + font-size: 0.72rem; + font-weight: 600; + padding: 0.15em 0.45em; + border-radius: 0.25rem; + white-space: nowrap; +} + +.deployment-badge--deployed { + background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent); + color: var(--success, #16a34a); + border: 1px solid color-mix(in srgb, var(--success, #22c55e) 35%, transparent); +} + +.deployment-badge--pending { + background: color-mix(in srgb, var(--warn, #f59e0b) 15%, transparent); + color: var(--warn, #b45309); + border: 1px solid color-mix(in srgb, var(--warn, #f59e0b) 35%, transparent); +} + .btn-view-logs { font-size: 0.72rem; font-weight: 600; diff --git a/web/test/deployment-badge.test.mjs b/web/test/deployment-badge.test.mjs new file mode 100644 index 0000000..afb4a59 --- /dev/null +++ b/web/test/deployment-badge.test.mjs @@ -0,0 +1,72 @@ +// deployment-badge.test.mjs — Unit tests for deployment status badge. +// +// Run with: node --test web/test/deployment-badge.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderDeploymentBadge } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + const el = { + tag, + className: '', + textContent: '', + title: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + return el; + }, + }; +} + +describe('renderDeploymentBadge', () => { + it('returns null for null status', () => { + const el = renderDeploymentBadge(null, makeDoc()); + assert.equal(el, null); + }); + + it('returns null for undefined status', () => { + const el = renderDeploymentBadge(undefined, makeDoc()); + assert.equal(el, null); + }); + + it('returns element with deployment-badge class for valid status', () => { + const status = { deployed_commit: 'abc123', fix_commits: [], includes_fix: false }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el, 'element should not be null'); + assert.ok(el.className.includes('deployment-badge'), `className should include deployment-badge, got: ${el.className}`); + }); + + it('shows "Deployed" text when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.textContent.includes('Deployed'), `expected "Deployed" in "${el.textContent}"`); + }); + + it('shows "Not deployed" text when includes_fix is false', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: false }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.textContent.includes('Not deployed'), `expected "Not deployed" in "${el.textContent}"`); + }); + + it('applies deployed class when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.className.includes('deployment-badge--deployed'), `className: ${el.className}`); + }); + + it('applies pending class when includes_fix is false', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: false }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.className.includes('deployment-badge--pending'), `className: ${el.className}`); + }); + + it('returns null for doc=null', () => { + const status = { deployed_commit: 'abc', fix_commits: [], includes_fix: false }; + const el = renderDeploymentBadge(status, null); + assert.equal(el, null); + }); +}); -- cgit v1.2.3