From d75a231d8865d9b14fbe3d608c9aa1bffb7ed386 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 16 Mar 2026 04:24:19 +0000 Subject: feat: add deployment status endpoint for tasks Adds GET /api/tasks/{id}/deployment-status which checks whether the currently-deployed server binary includes the fix commits from the task's latest execution. Uses git merge-base --is-ancestor to compare commit hashes against the running version. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/deployment.go | 36 ++++++++++++++++++ internal/api/server.go | 1 + internal/api/server_test.go | 77 +++++++++++++++++++++++++++++++++++++++ internal/deployment/deployment.go | 43 ++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 internal/api/deployment.go create mode 100644 internal/deployment/deployment.go diff --git a/internal/api/deployment.go b/internal/api/deployment.go new file mode 100644 index 0000000..d927545 --- /dev/null +++ b/internal/api/deployment.go @@ -0,0 +1,36 @@ +package api + +import ( + "database/sql" + "net/http" + + "github.com/thepeterstone/claudomator/internal/deployment" +) + +// handleGetDeploymentStatus returns whether the currently-deployed server +// includes the commits that were produced by this task's latest execution. +// GET /api/tasks/{id}/deployment-status +func (s *Server) handleGetDeploymentStatus(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + tk, err := s.store.GetTask(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + + exec, err := s.store.GetLatestExecution(tk.ID) + if err != nil { + if err == sql.ErrNoRows { + // No execution yet — return status with no fix commits. + status := deployment.Check(nil, tk.Agent.ProjectDir) + writeJSON(w, http.StatusOK, status) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + status := deployment.Check(exec.Commits, tk.Agent.ProjectDir) + writeJSON(w, http.StatusOK, status) +} diff --git a/internal/api/server.go b/internal/api/server.go index 65b0181..7988c4c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -116,6 +116,7 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/scripts/{name}", s.handleScript) s.mux.HandleFunc("GET /api/ws", s.handleWebSocket) s.mux.HandleFunc("GET /api/workspaces", s.handleListWorkspaces) + s.mux.HandleFunc("GET /api/tasks/{id}/deployment-status", s.handleGetDeploymentStatus) s.mux.HandleFunc("GET /api/health", s.handleHealth) s.mux.Handle("GET /", http.FileServerFS(webui.Files)) } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 4899a5c..5c0deba 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1704,3 +1704,80 @@ func TestListExecutions_IncludesChangestats(t *testing.T) { t.Errorf("lines_removed: want 20, got %v", csMap["lines_removed"]) } } + +// TestDeploymentStatus_ReturnsStatusForReadyTask verifies that +// GET /api/tasks/{id}/deployment-status returns a valid deployment status +// with deployed_commit, fix_commits, and includes_fix fields. +func TestDeploymentStatus_ReturnsStatusForReadyTask(t *testing.T) { + srv, store := testServer(t) + + // Create a READY task using the walk-path helper. + tk := createTaskWithState(t, store, "deploy-status-task-1", task.StateReady) + + // Create an execution with commits. + exec := &storage.Execution{ + ID: "deploy-exec-1", + TaskID: tk.ID, + StartTime: time.Now(), + EndTime: time.Now(), + Status: "COMPLETED", + Commits: []task.GitCommit{ + {Hash: "abc123def456", Message: "fix: resolve the bug"}, + }, + } + if err := store.CreateExecution(exec); err != nil { + t.Fatal(err) + } + + // GET /api/tasks/{id}/deployment-status + req := httptest.NewRequest("GET", "/api/tasks/deploy-status-task-1/deployment-status", 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 resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + // deployed_commit must be present (will be "dev" in test environment). + if _, ok := resp["deployed_commit"]; !ok { + t.Error("response missing deployed_commit field") + } + // fix_commits must be an array. + fixCommits, ok := resp["fix_commits"] + if !ok { + t.Fatal("response missing fix_commits field") + } + commits, ok := fixCommits.([]interface{}) + if !ok { + t.Fatalf("fix_commits is not an array: %T", fixCommits) + } + if len(commits) != 1 { + t.Fatalf("fix_commits length: want 1, got %d", len(commits)) + } + commit := commits[0].(map[string]interface{}) + if commit["hash"] != "abc123def456" { + t.Errorf("fix_commits[0].hash: want abc123def456, got %v", commit["hash"]) + } + // includes_fix must be present (false in test env since version is "dev"). + if _, ok := resp["includes_fix"]; !ok { + t.Error("response missing includes_fix field") + } +} + +// TestDeploymentStatus_NotFound returns 404 for unknown task. +func TestDeploymentStatus_NotFound(t *testing.T) { + srv, _ := testServer(t) + + req := httptest.NewRequest("GET", "/api/tasks/nonexistent-task/deployment-status", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", w.Code) + } +} diff --git a/internal/deployment/deployment.go b/internal/deployment/deployment.go new file mode 100644 index 0000000..0ba436d --- /dev/null +++ b/internal/deployment/deployment.go @@ -0,0 +1,43 @@ +package deployment + +import ( + "os/exec" + + "github.com/thepeterstone/claudomator/internal/task" + "github.com/thepeterstone/claudomator/internal/version" +) + +// Status describes whether the currently-deployed server includes a set of fix commits. +type Status struct { + DeployedCommit string `json:"deployed_commit"` + FixCommits []task.GitCommit `json:"fix_commits"` + IncludesFix bool `json:"includes_fix"` +} + +// Check returns the deployment status for a set of fix commits given the project directory. +// It uses `git merge-base --is-ancestor` to check whether each fix commit is an ancestor +// of the deployed server's commit. Returns a non-nil Status in all cases; IncludesFix is +// false when the check cannot be performed (missing git, "dev" version, no commits, etc.). +func Check(fixCommits []task.GitCommit, projectDir string) *Status { + deployedCommit := version.Version() + status := &Status{ + DeployedCommit: deployedCommit, + FixCommits: fixCommits, + } + if deployedCommit == "dev" || projectDir == "" || len(fixCommits) == 0 { + return status + } + + // All fix commits must be ancestors of (or equal to) the deployed commit. + for _, commit := range fixCommits { + if commit.Hash == "" { + continue + } + cmd := exec.Command("git", "-C", projectDir, "merge-base", "--is-ancestor", commit.Hash, deployedCommit) + if err := cmd.Run(); err != nil { + return status + } + } + status.IncludesFix = true + return status +} -- cgit v1.2.3