summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 04:24:19 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 04:24:19 +0000
commitd75a231d8865d9b14fbe3d608c9aa1bffb7ed386 (patch)
tree1875147811c1cbf5b85260dc5fc5be8986902f68 /internal
parent16ff7ca46bfb4af44af488fa95bd3f8e981f4db2 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/api/deployment.go36
-rw-r--r--internal/api/server.go1
-rw-r--r--internal/api/server_test.go77
-rw-r--r--internal/deployment/deployment.go43
4 files changed, 157 insertions, 0 deletions
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
+}