diff --git a/components/backend/handlers/middleware_test.go b/components/backend/handlers/middleware_test.go index f9cc67498..badbc8cd7 100644 --- a/components/backend/handlers/middleware_test.go +++ b/components/backend/handlers/middleware_test.go @@ -267,7 +267,7 @@ var _ = Describe("Middleware Handlers", Label(test_constants.LabelUnit, test_con }) Describe("ExtractServiceAccountFromAuth", func() { - It("Should extract service account from token review", func() { + It("Should extract service account from X-Remote-User header", func() { context := httpUtils.CreateTestGinContext("GET", "/api/projects/test-project/sessions", nil) // ExtractServiceAccountFromAuth reads the X-Remote-User header @@ -275,7 +275,7 @@ var _ = Describe("Middleware Handlers", Label(test_constants.LabelUnit, test_con context.Request.Header.Set("X-Remote-User", "system:serviceaccount:test-project:test-sa") namespace, serviceAccount, found := ExtractServiceAccountFromAuth(context) - Expect(found).To(BeTrue(), "Should find service account from token") + Expect(found).To(BeTrue(), "Should find service account from X-Remote-User header") Expect(namespace).To(Equal("test-project")) Expect(serviceAccount).To(Equal("test-sa")) logger.Log("Extracted service account: %s/%s", namespace, serviceAccount) diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 524ea7f59..295d24c42 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -1158,7 +1158,17 @@ func PatchSession(c *gin.Context) { anns = map[string]interface{}{} } for k, v := range annsPatch { - anns[k] = v + switch vv := v.(type) { + case nil: + delete(anns, k) + case string: + anns[k] = vv + default: + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Invalid annotation value for key %q: must be string or null", k), + }) + return + } } _ = unstructured.SetNestedMap(metadata, anns, "annotations") _ = unstructured.SetNestedMap(item.Object, metadata, "metadata") diff --git a/components/backend/handlers/sessions_logs.go b/components/backend/handlers/sessions_logs.go new file mode 100644 index 000000000..5fe460711 --- /dev/null +++ b/components/backend/handlers/sessions_logs.go @@ -0,0 +1,122 @@ +package handlers + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + defaultTailLines = int64(1000) + maxTailLines = int64(10000) + maxLogBytes = 10 * 1024 * 1024 // 10MB cap on log response size +) + +// GetSessionLogs returns container logs for the session's runner pod. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/logs +// +// Query params: +// - tailLines: number of lines from the end (default 1000, max 10000) +// - container: specific container name (optional) +func GetSessionLogs(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + if !isValidKubernetesName(sessionName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session name format"}) + return + } + safeSessionName := SanitizeForLog(sessionName) + + // Validate query params before any K8s calls + tailLines := defaultTailLines + if tl := c.Query("tailLines"); tl != "" { + parsed, err := strconv.ParseInt(tl, 10, 64) + if err != nil || parsed < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "tailLines must be a positive integer"}) + return + } + if parsed > maxTailLines { + parsed = maxTailLines + } + tailLines = parsed + } + + container := c.Query("container") + + k8sClt, k8sDyn := GetK8sClientsForRequest(c) + if k8sClt == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + // Verify the session CR exists before attempting pod log retrieval + gvr := GetAgenticSessionV1Alpha1Resource() + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + _, err := k8sDyn.Resource(gvr).Namespace(project).Get(ctx, sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + if errors.IsForbidden(err) { + log.Printf("GetSessionLogs: access denied for session %s/%s", project, safeSessionName) + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + log.Printf("GetSessionLogs: failed to verify session %s/%s: %v", project, safeSessionName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify session"}) + return + } + + // Pod naming convention: {sessionName}-runner + // Must match operator pod creation in internal/controller/reconcile_phases.go + podName := fmt.Sprintf("%s-runner", sessionName) + + logOpts := &corev1.PodLogOptions{ + TailLines: &tailLines, + } + if container != "" { + logOpts.Container = container + } + + logReq := k8sClt.CoreV1().Pods(project).GetLogs(podName, logOpts) + logStream, err := logReq.Stream(ctx) + if err != nil { + if errors.IsNotFound(err) { + // Pod doesn't exist (not yet created or already cleaned up) — return empty 200 + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte("")) + return + } + if errors.IsForbidden(err) { + log.Printf("GetSessionLogs: access denied for pod %s in project %s", safeSessionName, project) + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + log.Printf("GetSessionLogs: failed to get logs for pod %s in project %s: %v", safeSessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"}) + return + } + defer logStream.Close() + + // Stream logs directly to the client with a size cap to prevent OOM + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, io.LimitReader(logStream, maxLogBytes)); err != nil { + log.Printf("GetSessionLogs: error streaming logs for pod %s in project %s: %v", safeSessionName, project, err) + } +} diff --git a/components/backend/handlers/sessions_metrics.go b/components/backend/handlers/sessions_metrics.go new file mode 100644 index 000000000..0458b8f4b --- /dev/null +++ b/components/backend/handlers/sessions_metrics.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// GetSessionMetrics returns usage metrics extracted from the session CR status. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/metrics +func GetSessionMetrics(c *gin.Context) { + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + if !isValidKubernetesName(sessionName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session name format"}) + return + } + safeSessionName := SanitizeForLog(sessionName) + + k8sClt, k8sDyn := GetK8sClientsForRequest(c) + if k8sClt == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + item, err := k8sDyn.Resource(gvr).Namespace(project).Get(ctx, sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + if errors.IsForbidden(err) { + log.Printf("GetSessionMetrics: access denied for session %s/%s", project, safeSessionName) + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + log.Printf("GetSessionMetrics: failed to get session %s/%s: %v", project, safeSessionName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + metrics := gin.H{ + "sessionId": sessionName, + } + + // Extract timing info from status using unstructured helpers + if phase, ok, _ := unstructured.NestedString(item.Object, "status", "phase"); ok { + metrics["phase"] = phase + } + if startTime, ok, _ := unstructured.NestedString(item.Object, "status", "startTime"); ok { + metrics["startTime"] = startTime + + // Calculate duration if possible + start, err := time.Parse(time.RFC3339, startTime) + if err == nil { + var end time.Time + if completionTime, ok, _ := unstructured.NestedString(item.Object, "status", "completionTime"); ok && completionTime != "" { + end, err = time.Parse(time.RFC3339, completionTime) + if err != nil { + // Malformed completionTime — skip both fields to avoid misleading data + end = time.Time{} + } else { + metrics["completionTime"] = completionTime + } + } else { + end = time.Now() + } + if !end.IsZero() { + metrics["durationSeconds"] = int(end.Sub(start).Seconds()) + } + } + } + if sdkRestartCount, ok, _ := unstructured.NestedInt64(item.Object, "status", "sdkRestartCount"); ok { + metrics["restartCount"] = int(sdkRestartCount) + } + + // Extract timeout from spec + if timeout, ok, _ := unstructured.NestedInt64(item.Object, "spec", "timeout"); ok { + metrics["timeoutSeconds"] = int(timeout) + } + + // Extract any usage annotations (token counts, tool calls, etc.) + annotations := item.GetAnnotations() + usage := gin.H{} + for k, v := range annotations { + // Look for usage-related annotations + switch k { + case "ambient-code.io/input-tokens": + usage["inputTokens"] = v + case "ambient-code.io/output-tokens": + usage["outputTokens"] = v + case "ambient-code.io/total-cost": + usage["totalCost"] = v + case "ambient-code.io/tool-calls": + usage["toolCalls"] = v + } + } + if len(usage) > 0 { + metrics["usage"] = usage + } + + c.JSON(http.StatusOK, metrics) +} diff --git a/components/backend/handlers/sessions_sub_test.go b/components/backend/handlers/sessions_sub_test.go new file mode 100644 index 000000000..75523706d --- /dev/null +++ b/components/backend/handlers/sessions_sub_test.go @@ -0,0 +1,341 @@ +//go:build test + +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "ambient-code-backend/tests/config" + test_constants "ambient-code-backend/tests/constants" + "ambient-code-backend/tests/logger" + "ambient-code-backend/tests/test_utils" + + "github.com/gin-gonic/gin" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var _ = Describe("Session Sub-Resource Handlers", Label(test_constants.LabelUnit, test_constants.LabelHandlers, test_constants.LabelSessions), func() { + var ( + httpUtils *test_utils.HTTPTestUtils + k8sUtils *test_utils.K8sTestUtils + ctx context.Context + testNamespace string + sessionGVR schema.GroupVersionResource + randomName string + testToken string + ) + + BeforeEach(func() { + logger.Log("Setting up Session Sub-Resource Handler test") + + httpUtils = test_utils.NewHTTPTestUtils() + k8sUtils = test_utils.NewK8sTestUtils(false, *config.TestNamespace) + ctx = context.Background() + randomName = strconv.FormatInt(time.Now().UnixNano(), 10) + testNamespace = "test-sub-" + randomName + + sessionGVR = schema.GroupVersionResource{ + Group: "vteam.ambient-code", + Version: "v1alpha1", + Resource: "agenticsessions", + } + + SetupHandlerDependencies(k8sUtils) + + _, err := k8sUtils.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: v1.ObjectMeta{Name: testNamespace}, + }, v1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + Expect(err).NotTo(HaveOccurred()) + } + + _, err = k8sUtils.CreateTestRole(ctx, testNamespace, "test-full-access-role", []string{"get", "list", "create", "update", "delete", "patch"}, "*", "") + Expect(err).NotTo(HaveOccurred()) + + token, _, err := httpUtils.SetValidTestToken( + k8sUtils, + testNamespace, + []string{"get", "list", "create", "update", "delete", "patch"}, + "*", + "", + "test-full-access-role", + ) + Expect(err).NotTo(HaveOccurred()) + testToken = token + }) + + AfterEach(func() { + if k8sUtils != nil && testNamespace != "" { + _ = k8sUtils.K8sClient.CoreV1().Namespaces().Delete(ctx, testNamespace, v1.DeleteOptions{}) + } + }) + + Describe("GetSessionMetrics", func() { + Context("When session exists with status fields", func() { + BeforeEach(func() { + session := &unstructured.Unstructured{} + session.SetAPIVersion("vteam.ambient-code/v1alpha1") + session.SetKind("AgenticSession") + session.SetName("metrics-session-" + randomName) + session.SetNamespace(testNamespace) + session.SetAnnotations(map[string]string{ + "ambient-code.io/input-tokens": "1500", + "ambient-code.io/output-tokens": "3200", + "ambient-code.io/total-cost": "0.05", + "ambient-code.io/tool-calls": "12", + }) + + _ = unstructured.SetNestedField(session.Object, "Running", "status", "phase") + _ = unstructured.SetNestedField(session.Object, "2026-03-04T10:00:00Z", "status", "startTime") + _ = unstructured.SetNestedField(session.Object, int64(300), "spec", "timeout") + + _, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(testNamespace).Create( + ctx, session, v1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + logger.Log("Created metrics test session with annotations") + }) + + It("Should return metrics with usage data", func() { + sessionName := "metrics-session-" + randomName + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", testNamespace, sessionName) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: sessionName}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("sessionId")) + Expect(response["sessionId"]).To(Equal(sessionName)) + Expect(response).To(HaveKey("phase")) + Expect(response["phase"]).To(Equal("Running")) + Expect(response).To(HaveKey("startTime")) + Expect(response).To(HaveKey("durationSeconds")) + Expect(response).To(HaveKey("timeoutSeconds")) + Expect(response["timeoutSeconds"]).To(BeNumerically("==", 300)) + + // Check usage annotations + Expect(response).To(HaveKey("usage")) + usage, ok := response["usage"].(map[string]interface{}) + Expect(ok).To(BeTrue(), "usage should be a map") + Expect(usage["inputTokens"]).To(Equal("1500")) + Expect(usage["outputTokens"]).To(Equal("3200")) + Expect(usage["totalCost"]).To(Equal("0.05")) + Expect(usage["toolCalls"]).To(Equal("12")) + + logger.Log("Metrics with usage data returned successfully") + }) + }) + + Context("When session exists with completion time", func() { + BeforeEach(func() { + session := &unstructured.Unstructured{} + session.SetAPIVersion("vteam.ambient-code/v1alpha1") + session.SetKind("AgenticSession") + session.SetName("completed-session-" + randomName) + session.SetNamespace(testNamespace) + + _ = unstructured.SetNestedField(session.Object, "Completed", "status", "phase") + _ = unstructured.SetNestedField(session.Object, "2026-03-04T10:00:00Z", "status", "startTime") + _ = unstructured.SetNestedField(session.Object, "2026-03-04T10:05:00Z", "status", "completionTime") + + _, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(testNamespace).Create( + ctx, session, v1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + logger.Log("Created completed test session") + }) + + It("Should calculate duration from start and completion times", func() { + sessionName := "completed-session-" + randomName + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", testNamespace, sessionName) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: sessionName}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response["durationSeconds"]).To(BeNumerically("==", 300), "5 minutes = 300 seconds") + Expect(response).To(HaveKey("completionTime")) + + logger.Log("Duration calculated correctly for completed session") + }) + }) + + Context("When session does not exist", func() { + It("Should return 404 Not Found", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/non-existent/metrics", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "non-existent"}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusNotFound) + httpUtils.AssertErrorMessage("Session not found") + + logger.Log("404 returned for non-existent session metrics") + }) + }) + + Context("When session has no usage annotations", func() { + BeforeEach(func() { + session := &unstructured.Unstructured{} + session.SetAPIVersion("vteam.ambient-code/v1alpha1") + session.SetKind("AgenticSession") + session.SetName("no-usage-session-" + randomName) + session.SetNamespace(testNamespace) + + _ = unstructured.SetNestedField(session.Object, "Pending", "status", "phase") + + _, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(testNamespace).Create( + ctx, session, v1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Should return metrics without usage field", func() { + sessionName := "no-usage-session-" + randomName + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", testNamespace, sessionName) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: sessionName}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusOK) + + var response map[string]interface{} + httpUtils.GetResponseJSON(&response) + Expect(response).To(HaveKey("sessionId")) + Expect(response).NotTo(HaveKey("usage"), "Should not include usage when no usage annotations exist") + + logger.Log("Metrics without usage returned correctly") + }) + }) + + Context("When no auth token is provided", func() { + It("Should return 401 Unauthorized", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/metrics", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + // Deliberately NOT setting auth header + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionMetrics(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + logger.Log("401 returned for unauthenticated metrics request") + }) + }) + }) + + Describe("GetSessionLogs", func() { + // Note: GetSessionLogs calls k8sClt.CoreV1().Pods().GetLogs() which requires + // a real or fake pod. The fake k8s client doesn't implement pod log streaming, + // so we test the input validation and auth paths here. Integration tests + // cover the full streaming path. + + Context("When no auth token is provided", func() { + It("Should return 401 Unauthorized", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusUnauthorized) + + logger.Log("401 returned for unauthenticated logs request") + }) + }) + + Context("When tailLines parameter is invalid", func() { + It("Should return 400 for non-numeric tailLines", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs?tailLines=abc", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("tailLines must be a positive integer") + + logger.Log("400 returned for non-numeric tailLines") + }) + + It("Should return 400 for negative tailLines", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs?tailLines=-5", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("tailLines must be a positive integer") + + logger.Log("400 returned for negative tailLines") + }) + + It("Should return 400 for zero tailLines", func() { + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/any-session/logs?tailLines=0", testNamespace) + context := httpUtils.CreateTestGinContext("GET", path, nil) + httpUtils.SetAuthHeader(testToken) + httpUtils.SetProjectContext(testNamespace) + context.Params = gin.Params{ + {Key: "sessionName", Value: "any-session"}, + } + + GetSessionLogs(context) + + httpUtils.AssertHTTPStatus(http.StatusBadRequest) + httpUtils.AssertErrorMessage("tailLines must be a positive integer") + + logger.Log("400 returned for zero tailLines") + }) + }) + }) +}) diff --git a/components/backend/routes.go b/components/backend/routes.go index 4d5bac93b..1715f48b1 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -52,6 +52,8 @@ func registerRoutes(r *gin.Engine) { // Removed: git/pull, git/push, git/synchronize, git/create-branch, git/list-branches - agent handles all git operations projectGroup.GET("/agentic-sessions/:sessionName/git/list-branches", handlers.GitListBranchesSession) projectGroup.GET("/agentic-sessions/:sessionName/pod-events", handlers.GetSessionPodEvents) + projectGroup.GET("/agentic-sessions/:sessionName/logs", handlers.GetSessionLogs) + projectGroup.GET("/agentic-sessions/:sessionName/metrics", handlers.GetSessionMetrics) projectGroup.POST("/agentic-sessions/:sessionName/workflow", handlers.SelectWorkflow) projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata) projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo) diff --git a/components/public-api/handlers/integration_test.go b/components/public-api/handlers/integration_test.go index 5f1485dd4..bb7dd75dd 100644 --- a/components/public-api/handlers/integration_test.go +++ b/components/public-api/handlers/integration_test.go @@ -26,7 +26,11 @@ func setupTestRouter() *gin.Engine { v1.GET("/sessions", ListSessions) v1.POST("/sessions", CreateSession) v1.GET("/sessions/:id", GetSession) + v1.PATCH("/sessions/:id", PatchSession) v1.DELETE("/sessions/:id", DeleteSession) + v1.GET("/sessions/:id/logs", GetSessionLogs) + v1.GET("/sessions/:id/transcript", GetSessionTranscript) + v1.GET("/sessions/:id/metrics", GetSessionMetrics) } return r @@ -108,9 +112,9 @@ func TestE2E_CreateSession(t *testing.T) { t.Errorf("Expected status 201, got %d: %s", w.Code, w.Body.String()) } - // Verify request body was transformed correctly - if !strings.Contains(requestBody, "prompt") { - t.Errorf("Expected request body to contain 'prompt', got %s", requestBody) + // Verify request body was transformed correctly (task maps to initialPrompt for backend) + if !strings.Contains(requestBody, "initialPrompt") { + t.Errorf("Expected request body to contain 'initialPrompt', got %s", requestBody) } } diff --git a/components/public-api/handlers/sessions.go b/components/public-api/handlers/sessions.go index 758914e18..5cac2d166 100644 --- a/components/public-api/handlers/sessions.go +++ b/components/public-api/handlers/sessions.go @@ -6,6 +6,7 @@ import ( "io" "log" "net/http" + "strings" "ambient-code-public-api/types" @@ -21,6 +22,12 @@ func ListSessions(c *gin.Context) { } path := fmt.Sprintf("/api/projects/%s/agentic-sessions", project) + // Forward query parameters (e.g., labelSelector) verbatim to the backend. + // The backend enforces its own validation and RBAC via the user's token. + if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { + path = path + "?" + rawQuery + } + resp, err := ProxyRequest(c, http.MethodGet, path, nil) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) @@ -123,20 +130,42 @@ func CreateSession(c *gin.Context) { // Transform to backend format backendReq := map[string]interface{}{ - "prompt": req.Task, + "initialPrompt": req.Task, } if req.Model != "" { - backendReq["model"] = req.Model + backendReq["llmSettings"] = map[string]interface{}{ + "model": req.Model, + } + } + if req.DisplayName != "" { + backendReq["displayName"] = req.DisplayName + } + if req.Timeout != nil { + backendReq["timeout"] = *req.Timeout + } + if len(req.Labels) > 0 { + keys := make([]string, 0, len(req.Labels)) + for k := range req.Labels { + keys = append(keys, k) + } + if key, prefix, ok := validateLabelKeys(keys); !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("label key %q uses reserved prefix %q", key, prefix)}) + return + } + // Send as annotations (not labels) so the write/read paths are symmetric. + // The read path extracts user labels from metadata.annotations. + backendReq["annotations"] = req.Labels } if len(req.Repos) > 0 { repos := make([]map[string]interface{}, len(req.Repos)) for i, r := range req.Repos { - repos[i] = map[string]interface{}{ - "input": map[string]interface{}{ - "url": r.URL, - "branch": r.Branch, - }, + repo := map[string]interface{}{ + "url": r.URL, + } + if r.Branch != "" { + repo["branch"] = r.Branch } + repos[i] = repo } backendReq["repos"] = repos } @@ -247,6 +276,12 @@ func transformSession(data map[string]interface{}) types.SessionResponse { if creationTimestamp, ok := metadata["creationTimestamp"].(string); ok { session.CreatedAt = creationTimestamp } + if annotationsRaw, ok := metadata["annotations"].(map[string]interface{}); ok { + labels := filterUserLabels(annotationsRaw) + if len(labels) > 0 { + session.Labels = labels + } + } } // If no metadata, try top-level name (list response format) @@ -258,12 +293,30 @@ func transformSession(data map[string]interface{}) types.SessionResponse { // Extract spec if spec, ok := data["spec"].(map[string]interface{}); ok { - if prompt, ok := spec["prompt"].(string); ok { + if prompt, ok := spec["initialPrompt"].(string); ok { + session.Task = prompt + } + if prompt, ok := spec["prompt"].(string); ok && session.Task == "" { session.Task = prompt } if model, ok := spec["model"].(string); ok { session.Model = model } + if llm, ok := spec["llmSettings"].(map[string]interface{}); ok { + if model, ok := llm["model"].(string); ok && session.Model == "" { + session.Model = model + } + } + if displayName, ok := spec["displayName"].(string); ok { + session.DisplayName = displayName + } + if timeout, ok := spec["timeout"].(float64); ok { + t := int(timeout) + session.Timeout = &t + } + if reposRaw, ok := spec["repos"].([]interface{}); ok { + session.Repos = extractRepos(reposRaw) + } } // Extract status @@ -290,6 +343,80 @@ func transformSession(data map[string]interface{}) types.SessionResponse { return session } +// internalLabelPrefixes are K8s/system label prefixes that should not be exposed to users +var internalLabelPrefixes = []string{ + "app.kubernetes.io/", + "kubectl.kubernetes.io/", + "meta.kubernetes.io/", + "vteam.ambient-code/", + "ambient-code.io/", +} + +// validateLabelKeys checks that no label keys use reserved internal prefixes +func validateLabelKeys(keys []string) (string, string, bool) { + for _, k := range keys { + for _, prefix := range internalLabelPrefixes { + if strings.HasPrefix(k, prefix) { + return k, prefix, false + } + } + } + return "", "", true +} + +// filterUserLabels returns only user-defined labels, stripping internal/system labels +func filterUserLabels(labelsRaw map[string]interface{}) map[string]string { + labels := make(map[string]string) + for k, v := range labelsRaw { + internal := false + for _, prefix := range internalLabelPrefixes { + if strings.HasPrefix(k, prefix) { + internal = true + break + } + } + if !internal { + if s, ok := v.(string); ok { + labels[k] = s + } + } + } + return labels +} + +// extractRepos converts the repos array from the backend response to typed Repo objects +func extractRepos(reposRaw []interface{}) []types.Repo { + repos := make([]types.Repo, 0, len(reposRaw)) + for _, r := range reposRaw { + repoMap, ok := r.(map[string]interface{}) + if !ok { + continue + } + + repo := types.Repo{} + + // Handle both flat format (url/branch at top level) and nested format (input.url/input.branch) + if url, ok := repoMap["url"].(string); ok { + repo.URL = url + } + if branch, ok := repoMap["branch"].(string); ok { + repo.Branch = branch + } + if input, ok := repoMap["input"].(map[string]interface{}); ok { + if url, ok := input["url"].(string); ok { + repo.URL = url + } + if branch, ok := input["branch"].(string); ok { + repo.Branch = branch + } + } + if repo.URL != "" { + repos = append(repos, repo) + } + } + return repos +} + // normalizePhase converts K8s phase to simplified status func normalizePhase(phase string) string { switch phase { diff --git a/components/public-api/handlers/sessions_patch.go b/components/public-api/handlers/sessions_patch.go new file mode 100644 index 000000000..cb2fde76b --- /dev/null +++ b/components/public-api/handlers/sessions_patch.go @@ -0,0 +1,249 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "ambient-code-public-api/types" + + "github.com/gin-gonic/gin" +) + +// PatchSession handles PATCH /v1/sessions/:id +// It inspects the request body and routes to the correct backend endpoint: +// - stopped: false → POST /start (resume session) +// - stopped: true → POST /stop (stop session) +// - displayName/timeout → PUT (update session spec) +// - labels/removeLabels → PATCH (update annotations) +func PatchSession(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + var req types.PatchSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Classify the request into categories + hasStopped := req.Stopped != nil + hasUpdate := req.DisplayName != nil || req.Timeout != nil + hasLabels := len(req.Labels) > 0 || len(req.RemoveLabels) > 0 + + // Count how many categories are present + categories := 0 + if hasStopped { + categories++ + } + if hasUpdate { + categories++ + } + if hasLabels { + categories++ + } + + if categories == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Request body must contain at least one of: stopped, displayName, timeout, labels, removeLabels"}) + return + } + if categories > 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot mix stopped, spec updates (displayName/timeout), and label changes in the same request"}) + return + } + + basePath := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s", project, sessionID) + + switch { + case hasStopped: + patchSessionStartStop(c, basePath, *req.Stopped) + case hasUpdate: + patchSessionUpdate(c, basePath, req) + case hasLabels: + patchSessionLabels(c, basePath, req) + } +} + +// patchSessionStartStop routes to the backend start or stop endpoint +func patchSessionStartStop(c *gin.Context, basePath string, stopped bool) { + var path string + if stopped { + path = basePath + "/stop" + } else { + path = basePath + "/start" + } + + resp, err := ProxyRequest(c, http.MethodPost, path, nil) + if err != nil { + log.Printf("Backend request failed for start/stop session %s: %v", c.Param("id"), err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + // Parse and transform the response + var backendResp map[string]interface{} + if err := json.Unmarshal(body, &backendResp); err != nil { + log.Printf("Failed to parse backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, transformSession(backendResp)) +} + +// patchSessionUpdate routes to the backend PUT (UpdateSession) endpoint +func patchSessionUpdate(c *gin.Context, basePath string, req types.PatchSessionRequest) { + // Transform to backend UpdateAgenticSessionRequest format + backendReq := map[string]interface{}{} + if req.DisplayName != nil { + backendReq["displayName"] = *req.DisplayName + } + if req.Timeout != nil { + backendReq["timeout"] = *req.Timeout + } + + reqBody, err := json.Marshal(backendReq) + if err != nil { + log.Printf("Failed to marshal update request: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + resp, err := ProxyRequest(c, http.MethodPut, basePath, reqBody) + if err != nil { + log.Printf("Backend request failed for update session %s: %v", c.Param("id"), err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + var backendResp map[string]interface{} + if err := json.Unmarshal(body, &backendResp); err != nil { + log.Printf("Failed to parse backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + c.JSON(http.StatusOK, transformSession(backendResp)) +} + +// patchSessionLabels routes to the backend PATCH endpoint for annotation changes +func patchSessionLabels(c *gin.Context, basePath string, req types.PatchSessionRequest) { + // Validate label keys don't use reserved prefixes + allKeys := make([]string, 0, len(req.Labels)+len(req.RemoveLabels)) + for k := range req.Labels { + allKeys = append(allKeys, k) + } + allKeys = append(allKeys, req.RemoveLabels...) + if key, prefix, ok := validateLabelKeys(allKeys); !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("label key %q uses reserved prefix %q", key, prefix)}) + return + } + + // Transform labels to backend annotation format: + // {"metadata": {"annotations": {"key": "value"}}} for adds + // {"metadata": {"annotations": {"key": null}}} for removes + annotations := map[string]interface{}{} + + for k, v := range req.Labels { + annotations[k] = v + } + for _, k := range req.RemoveLabels { + annotations[k] = nil + } + + backendReq := map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": annotations, + }, + } + + reqBody, err := json.Marshal(backendReq) + if err != nil { + log.Printf("Failed to marshal label patch request: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + resp, err := ProxyRequest(c, http.MethodPatch, basePath, reqBody) + if err != nil { + log.Printf("Backend request failed for label patch session %s: %v", c.Param("id"), err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + // Follow-up GET to return full session DTO (consistent with other PATCH responses). + // Note: the resource could be modified or deleted between the PATCH and GET; + // the fallback below handles this race safely by returning minimal success info. + getResp, err := ProxyRequest(c, http.MethodGet, basePath, nil) + if err != nil { + // PATCH succeeded but GET failed — return success with minimal info + log.Printf("Label patch succeeded but follow-up GET failed: %v", err) + c.JSON(http.StatusOK, gin.H{"message": "Labels updated", "id": c.Param("id")}) + return + } + defer getResp.Body.Close() + + getBody, err := io.ReadAll(getResp.Body) + if err != nil || getResp.StatusCode != http.StatusOK { + log.Printf("Label patch succeeded but follow-up GET returned unexpected result for session %s", c.Param("id")) + c.JSON(http.StatusOK, gin.H{"message": "Labels updated", "id": c.Param("id")}) + return + } + + var backendResp map[string]interface{} + if err := json.Unmarshal(getBody, &backendResp); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "Labels updated", "id": c.Param("id")}) + return + } + + c.JSON(http.StatusOK, transformSession(backendResp)) +} diff --git a/components/public-api/handlers/sessions_patch_test.go b/components/public-api/handlers/sessions_patch_test.go new file mode 100644 index 000000000..b28687977 --- /dev/null +++ b/components/public-api/handlers/sessions_patch_test.go @@ -0,0 +1,272 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestE2E_PatchSession_Stop(t *testing.T) { + methodReceived := "" + pathReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methodReceived = r.Method + pathReceived = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + "creationTimestamp": "2026-01-29T10:00:00Z", + }, + "spec": map[string]interface{}{ + "initialPrompt": "Fix the bug", + }, + "status": map[string]interface{}{ + "phase": "Completed", + }, + }) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"stopped": true}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if methodReceived != http.MethodPost { + t.Errorf("Expected POST to backend, got %s", methodReceived) + } + if !strings.HasSuffix(pathReceived, "/stop") { + t.Errorf("Expected path ending in /stop, got %s", pathReceived) + } +} + +func TestE2E_PatchSession_Start(t *testing.T) { + pathReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pathReceived = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + }, + "status": map[string]interface{}{ + "phase": "Running", + }, + }) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"stopped": false}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + if !strings.HasSuffix(pathReceived, "/start") { + t.Errorf("Expected path ending in /start, got %s", pathReceived) + } +} + +func TestE2E_PatchSession_Update(t *testing.T) { + methodReceived := "" + var receivedBody map[string]interface{} + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methodReceived = r.Method + json.NewDecoder(r.Body).Decode(&receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + }, + "spec": map[string]interface{}{ + "displayName": "New Name", + "timeout": float64(900), + }, + "status": map[string]interface{}{ + "phase": "Pending", + }, + }) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"displayName": "New Name", "timeout": 900}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if methodReceived != http.MethodPut { + t.Errorf("Expected PUT, got %s", methodReceived) + } + + // Verify the backend received the correct fields + if receivedBody["displayName"] != "New Name" { + t.Errorf("Expected displayName 'New Name', got %v", receivedBody["displayName"]) + } + if receivedBody["timeout"] != float64(900) { + t.Errorf("Expected timeout 900, got %v", receivedBody["timeout"]) + } +} + +func TestE2E_PatchSession_Labels(t *testing.T) { + var receivedBody map[string]interface{} + patchReceived := false + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPatch { + patchReceived = true + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Session patched successfully", + "annotations": map[string]string{"env": "prod"}, + }) + } else if r.Method == http.MethodGet { + // Follow-up GET to return full session DTO + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "session-123", + "creationTimestamp": "2026-03-04T10:00:00Z", + "annotations": map[string]interface{}{"env": "prod"}, + }, + "spec": map[string]interface{}{"initialPrompt": "test"}, + "status": map[string]interface{}{"phase": "Running"}, + }) + } + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"labels": {"env": "prod"}, "removeLabels": ["old-label"]}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if !patchReceived { + t.Errorf("Expected PATCH to be sent to backend") + } + + // Verify transformation to annotation format + metadata, ok := receivedBody["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("Expected metadata in request body") + } + annotations, ok := metadata["annotations"].(map[string]interface{}) + if !ok { + t.Fatal("Expected annotations in metadata") + } + if annotations["env"] != "prod" { + t.Errorf("Expected annotation env=prod, got %v", annotations["env"]) + } + if annotations["old-label"] != nil { + t.Errorf("Expected annotation old-label=nil, got %v", annotations["old-label"]) + } + + // Verify response is a full session DTO (from follow-up GET) + var respBody map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &respBody) + if respBody["id"] != "session-123" { + t.Errorf("Expected session id in response, got %v", respBody["id"]) + } +} + +func TestE2E_PatchSession_ReservedLabelPrefix(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"labels": {"ambient-code.io/evil": "injected"}}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for reserved label prefix, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + if !strings.Contains(body, "reserved prefix") { + t.Errorf("Expected error about reserved prefix, got: %s", body) + } +} + +func TestE2E_PatchSession_EmptyBody(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for empty body, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestE2E_PatchSession_MixedCategories(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/v1/sessions/session-123", + strings.NewReader(`{"stopped": true, "displayName": "New Name"}`)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for mixed categories, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/components/public-api/handlers/sessions_sub.go b/components/public-api/handlers/sessions_sub.go new file mode 100644 index 000000000..8aaa64f9d --- /dev/null +++ b/components/public-api/handlers/sessions_sub.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "fmt" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetSessionTranscript handles GET /v1/sessions/:id/transcript +// Proxies to backend GET /api/projects/{p}/agentic-sessions/{s}/export +func GetSessionTranscript(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/export", project, sessionID) + + // Forward query params (e.g., format) verbatim to the backend. + // The backend enforces its own validation and RBAC via the user's token. + if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { + path = path + "?" + rawQuery + } + + resp, err := ProxyRequest(c, http.MethodGet, path, nil) + if err != nil { + log.Printf("Backend request failed for transcript %s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + // For non-OK responses, buffer to forward the error body + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend error response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + // Stream the transcript response directly to the client + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Header("Content-Type", contentType) + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, resp.Body); err != nil { + log.Printf("GetSessionTranscript: error streaming backend response for %s: %v", sessionID, err) + } +} + +// GetSessionLogs handles GET /v1/sessions/:id/logs +// Proxies to backend GET /api/projects/{p}/agentic-sessions/{s}/logs +func GetSessionLogs(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/logs", project, sessionID) + + // Forward query params (tailLines, container) verbatim to the backend. + // The backend enforces its own validation and RBAC via the user's token. + if rawQuery := c.Request.URL.RawQuery; rawQuery != "" { + path = path + "?" + rawQuery + } + + resp, err := ProxyRequest(c, http.MethodGet, path, nil) + if err != nil { + log.Printf("Backend request failed for logs %s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + // For non-OK responses, buffer to forward the error body + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend error response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + // Stream the log response directly to the client to avoid buffering up to + // 10 MB (the backend's LimitReader cap) per concurrent request. + c.Header("Content-Type", "text/plain; charset=utf-8") + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, resp.Body); err != nil { + log.Printf("GetSessionLogs: error streaming backend response for %s: %v", sessionID, err) + } +} + +// GetSessionMetrics handles GET /v1/sessions/:id/metrics +// Proxies to backend GET /api/projects/{p}/agentic-sessions/{s}/metrics +func GetSessionMetrics(c *gin.Context) { + project := GetProject(c) + if !ValidateProjectName(project) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project name"}) + return + } + sessionID := c.Param("id") + if !ValidateSessionID(sessionID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + path := fmt.Sprintf("/api/projects/%s/agentic-sessions/%s/metrics", project, sessionID) + + resp, err := ProxyRequest(c, http.MethodGet, path, nil) + if err != nil { + log.Printf("Backend request failed for metrics %s: %v", sessionID, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Backend unavailable"}) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read backend response: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) + return + } + + if resp.StatusCode != http.StatusOK { + forwardErrorResponse(c, resp.StatusCode, body) + return + } + + c.Data(http.StatusOK, "application/json", body) +} diff --git a/components/public-api/handlers/sessions_sub_test.go b/components/public-api/handlers/sessions_sub_test.go new file mode 100644 index 000000000..b908d1056 --- /dev/null +++ b/components/public-api/handlers/sessions_sub_test.go @@ -0,0 +1,320 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestE2E_GetSessionTranscript(t *testing.T) { + exportData := map[string]interface{}{ + "sessionId": "session-123", + "projectName": "test-project", + "aguiEvents": []interface{}{}, + } + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/export") { + t.Errorf("Expected path to contain /export, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(exportData) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/transcript", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Expected JSON response: %v", err) + } + if resp["sessionId"] != "session-123" { + t.Errorf("Expected sessionId session-123, got %v", resp["sessionId"]) + } +} + +func TestE2E_GetSessionLogs(t *testing.T) { + logOutput := "2026-01-29T10:00:00Z Starting session\n2026-01-29T10:00:01Z Running task\n" + tailLinesReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/logs") { + t.Errorf("Expected path to contain /logs, got %s", r.URL.Path) + } + tailLinesReceived = r.URL.Query().Get("tailLines") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(logOutput)) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/logs?tailLines=500", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if tailLinesReceived != "500" { + t.Errorf("Expected tailLines=500 forwarded, got %q", tailLinesReceived) + } + + if !strings.Contains(w.Body.String(), "Starting session") { + t.Errorf("Expected log output in response, got %s", w.Body.String()) + } +} + +func TestE2E_GetSessionMetrics(t *testing.T) { + metricsData := map[string]interface{}{ + "sessionId": "session-123", + "phase": "Running", + "durationSeconds": 120, + } + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/metrics") { + t.Errorf("Expected path to contain /metrics, got %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(metricsData) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/metrics", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Expected JSON response: %v", err) + } + if resp["sessionId"] != "session-123" { + t.Errorf("Expected sessionId session-123, got %v", resp["sessionId"]) + } +} + +func TestE2E_GetSessionLogs_InvalidSessionID(t *testing.T) { + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/INVALID_ID/logs", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid session ID, got %d", w.Code) + } +} + +func TestE2E_GetSessionTranscript_BackendError(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Session not found"}) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/transcript", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestE2E_GetSessionLogs_QueryParamForwarding(t *testing.T) { + queryReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queryReceived = r.URL.RawQuery + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions/session-123/logs?tailLines=100&container=runner", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if !strings.Contains(queryReceived, "tailLines=100") { + t.Errorf("Expected tailLines forwarded, got query: %s", queryReceived) + } + if !strings.Contains(queryReceived, "container=runner") { + t.Errorf("Expected container forwarded, got query: %s", queryReceived) + } +} + +func TestE2E_ListSessions_LabelSelector(t *testing.T) { + queryReceived := "" + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queryReceived = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}}) + })) + defer backend.Close() + + originalURL := BackendURL + BackendURL = backend.URL + defer func() { BackendURL = originalURL }() + + router := setupTestRouter() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/sessions?labelSelector=env%3Dprod", nil) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("X-Ambient-Project", "test-project") + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if !strings.Contains(queryReceived, "labelSelector=env%3Dprod") { + t.Errorf("Expected labelSelector forwarded, got query: %s", queryReceived) + } +} + +func TestFilterUserLabels(t *testing.T) { + input := map[string]interface{}{ + "env": "staging", + "team": "platform", + "app.kubernetes.io/managed-by": "helm", + "vteam.ambient-code/session": "abc", + "ambient-code.io/runner-sa": "my-sa", + "not-a-string": 12345, + } + + result := filterUserLabels(input) + + if result["env"] != "staging" { + t.Errorf("Expected env=staging, got %q", result["env"]) + } + if result["team"] != "platform" { + t.Errorf("Expected team=platform, got %q", result["team"]) + } + if _, ok := result["app.kubernetes.io/managed-by"]; ok { + t.Error("Expected K8s label to be filtered") + } + if _, ok := result["vteam.ambient-code/session"]; ok { + t.Error("Expected vteam label to be filtered") + } + if _, ok := result["ambient-code.io/runner-sa"]; ok { + t.Error("Expected ambient-code label to be filtered") + } + if _, ok := result["not-a-string"]; ok { + t.Error("Expected non-string value to be filtered") + } +} + +func TestExtractRepos(t *testing.T) { + tests := []struct { + name string + input []interface{} + expected int + firstURL string + }{ + { + name: "Flat format", + input: []interface{}{ + map[string]interface{}{ + "url": "https://github.com/org/repo", + "branch": "main", + }, + }, + expected: 1, + firstURL: "https://github.com/org/repo", + }, + { + name: "Nested input format", + input: []interface{}{ + map[string]interface{}{ + "input": map[string]interface{}{ + "url": "https://github.com/org/repo2", + "branch": "dev", + }, + }, + }, + expected: 1, + firstURL: "https://github.com/org/repo2", + }, + { + name: "Empty repos", + input: []interface{}{}, + expected: 0, + }, + { + name: "Invalid entry skipped", + input: []interface{}{ + "not-a-map", + map[string]interface{}{ + "url": "https://github.com/org/valid", + }, + }, + expected: 1, + firstURL: "https://github.com/org/valid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractRepos(tt.input) + if len(result) != tt.expected { + t.Errorf("Expected %d repos, got %d", tt.expected, len(result)) + } + if tt.expected > 0 && result[0].URL != tt.firstURL { + t.Errorf("Expected first URL %q, got %q", tt.firstURL, result[0].URL) + } + }) + } +} diff --git a/components/public-api/handlers/sessions_test.go b/components/public-api/handlers/sessions_test.go index 43e7ff920..e07ed63ec 100644 --- a/components/public-api/handlers/sessions_test.go +++ b/components/public-api/handlers/sessions_test.go @@ -9,6 +9,8 @@ import ( "github.com/gin-gonic/gin" ) +func intPtr(v int) *int { return &v } + func TestTransformSession(t *testing.T) { tests := []struct { name string @@ -21,10 +23,24 @@ func TestTransformSession(t *testing.T) { "metadata": map[string]interface{}{ "name": "session-123", "creationTimestamp": "2026-01-29T10:00:00Z", + "annotations": map[string]interface{}{ + "env": "staging", + "app.kubernetes.io/foo": "bar", // should be filtered + }, }, "spec": map[string]interface{}{ - "prompt": "Fix the bug", - "model": "claude-sonnet-4", + "initialPrompt": "Fix the bug", + "displayName": "Bug Fix Session", + "timeout": float64(600), + "llmSettings": map[string]interface{}{ + "model": "claude-sonnet-4", + }, + "repos": []interface{}{ + map[string]interface{}{ + "url": "https://github.com/org/repo", + "branch": "main", + }, + }, }, "status": map[string]interface{}{ "phase": "Running", @@ -32,11 +48,15 @@ func TestTransformSession(t *testing.T) { }, }, expected: types.SessionResponse{ - ID: "session-123", - Status: "running", - Task: "Fix the bug", - Model: "claude-sonnet-4", - CreatedAt: "2026-01-29T10:00:00Z", + ID: "session-123", + Status: "running", + Task: "Fix the bug", + Model: "claude-sonnet-4", + DisplayName: "Bug Fix Session", + Timeout: intPtr(600), + CreatedAt: "2026-01-29T10:00:00Z", + Repos: []types.Repo{{URL: "https://github.com/org/repo", Branch: "main"}}, + Labels: map[string]string{"env": "staging"}, }, }, { @@ -129,6 +149,9 @@ func TestTransformSession(t *testing.T) { if result.Model != tt.expected.Model { t.Errorf("Model = %q, want %q", result.Model, tt.expected.Model) } + if result.DisplayName != tt.expected.DisplayName { + t.Errorf("DisplayName = %q, want %q", result.DisplayName, tt.expected.DisplayName) + } if result.CreatedAt != tt.expected.CreatedAt { t.Errorf("CreatedAt = %q, want %q", result.CreatedAt, tt.expected.CreatedAt) } @@ -141,6 +164,31 @@ func TestTransformSession(t *testing.T) { if result.Error != tt.expected.Error { t.Errorf("Error = %q, want %q", result.Error, tt.expected.Error) } + if (result.Timeout == nil) != (tt.expected.Timeout == nil) || (result.Timeout != nil && tt.expected.Timeout != nil && *result.Timeout != *tt.expected.Timeout) { + t.Errorf("Timeout = %v, want %v", result.Timeout, tt.expected.Timeout) + } + if len(result.Repos) != len(tt.expected.Repos) { + t.Errorf("Repos len = %d, want %d", len(result.Repos), len(tt.expected.Repos)) + } else { + for i, r := range result.Repos { + if r.URL != tt.expected.Repos[i].URL { + t.Errorf("Repos[%d].URL = %q, want %q", i, r.URL, tt.expected.Repos[i].URL) + } + if r.Branch != tt.expected.Repos[i].Branch { + t.Errorf("Repos[%d].Branch = %q, want %q", i, r.Branch, tt.expected.Repos[i].Branch) + } + } + } + if len(tt.expected.Labels) > 0 { + if len(result.Labels) != len(tt.expected.Labels) { + t.Errorf("Labels count = %d, want %d", len(result.Labels), len(tt.expected.Labels)) + } + for k, v := range tt.expected.Labels { + if result.Labels[k] != v { + t.Errorf("Labels[%q] = %q, want %q", k, result.Labels[k], v) + } + } + } }) } } @@ -241,6 +289,59 @@ func TestForwardErrorResponse(t *testing.T) { } } +func TestValidateLabelKeys(t *testing.T) { + tests := []struct { + name string + keys []string + wantOK bool + wantKey string + wantPrefix string + }{ + {"valid user keys", []string{"env", "team", "custom-label"}, true, "", ""}, + {"app.kubernetes.io prefix rejected", []string{"app.kubernetes.io/name"}, false, "app.kubernetes.io/name", "app.kubernetes.io/"}, + {"vteam.ambient-code prefix rejected", []string{"vteam.ambient-code/session"}, false, "vteam.ambient-code/session", "vteam.ambient-code/"}, + {"ambient-code.io prefix rejected", []string{"ambient-code.io/runner-sa"}, false, "ambient-code.io/runner-sa", "ambient-code.io/"}, + {"mixed valid and reserved", []string{"env", "ambient-code.io/evil"}, false, "ambient-code.io/evil", "ambient-code.io/"}, + {"empty keys list", []string{}, true, "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, prefix, ok := validateLabelKeys(tt.keys) + if ok != tt.wantOK { + t.Errorf("validateLabelKeys() ok = %v, want %v", ok, tt.wantOK) + } + if key != tt.wantKey { + t.Errorf("validateLabelKeys() key = %q, want %q", key, tt.wantKey) + } + if prefix != tt.wantPrefix { + t.Errorf("validateLabelKeys() prefix = %q, want %q", prefix, tt.wantPrefix) + } + }) + } +} + +func TestFilterUserLabels_EmptyValues(t *testing.T) { + input := map[string]interface{}{ + "tag": "", + "env": "prod", + "app.kubernetes.io/foo": "filtered", + } + result := filterUserLabels(input) + + if val, exists := result["tag"]; !exists { + t.Error("expected empty-string label key to be present in result") + } else if val != "" { + t.Errorf("expected empty-string label value, got %q", val) + } + if result["env"] != "prod" { + t.Errorf("expected env=prod, got %q", result["env"]) + } + if _, exists := result["app.kubernetes.io/foo"]; exists { + t.Error("expected internal label to be filtered out") + } +} + func TestTransformSession_TypeSafety(t *testing.T) { // Test that transformSession handles incorrect types gracefully tests := []struct { diff --git a/components/public-api/main.go b/components/public-api/main.go index 97cf378b0..887a8aa95 100644 --- a/components/public-api/main.go +++ b/components/public-api/main.go @@ -91,11 +91,17 @@ func main() { v1 := r.Group("/v1") v1.Use(handlers.AuthMiddleware()) { - // Sessions + // Sessions CRUD v1.GET("/sessions", handlers.ListSessions) v1.POST("/sessions", handlers.CreateSession) v1.GET("/sessions/:id", handlers.GetSession) + v1.PATCH("/sessions/:id", handlers.PatchSession) v1.DELETE("/sessions/:id", handlers.DeleteSession) + + // Session sub-resources + v1.GET("/sessions/:id/logs", handlers.GetSessionLogs) + v1.GET("/sessions/:id/transcript", handlers.GetSessionTranscript) + v1.GET("/sessions/:id/metrics", handlers.GetSessionMetrics) } // Get port from environment or default to 8081 diff --git a/components/public-api/types/dto.go b/components/public-api/types/dto.go index 9faafa8d1..d83517661 100644 --- a/components/public-api/types/dto.go +++ b/components/public-api/types/dto.go @@ -2,14 +2,18 @@ package types // SessionResponse is the simplified session response for the public API type SessionResponse struct { - ID string `json:"id"` - Status string `json:"status"` // "pending", "running", "completed", "failed" - Task string `json:"task"` - Model string `json:"model,omitempty"` - CreatedAt string `json:"createdAt"` - CompletedAt string `json:"completedAt,omitempty"` - Result string `json:"result,omitempty"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Status string `json:"status"` // "pending", "running", "completed", "failed" + Task string `json:"task"` + Model string `json:"model,omitempty"` + DisplayName string `json:"displayName,omitempty"` + CreatedAt string `json:"createdAt"` + CompletedAt string `json:"completedAt,omitempty"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Repos []Repo `json:"repos,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Timeout *int `json:"timeout,omitempty"` } // SessionListResponse is the response for listing sessions @@ -20,9 +24,21 @@ type SessionListResponse struct { // CreateSessionRequest is the request body for creating a session type CreateSessionRequest struct { - Task string `json:"task" binding:"required"` - Model string `json:"model,omitempty"` - Repos []Repo `json:"repos,omitempty"` + Task string `json:"task" binding:"required"` + Model string `json:"model,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Repos []Repo `json:"repos,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Timeout *int `json:"timeout,omitempty"` +} + +// PatchSessionRequest is the request body for patching a session +type PatchSessionRequest struct { + Stopped *bool `json:"stopped,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Timeout *int `json:"timeout,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + RemoveLabels []string `json:"removeLabels,omitempty"` } // Repo represents a repository configuration diff --git a/e2e/scripts/deploy.sh b/e2e/scripts/deploy.sh index 9071ad6b2..f795c7f85 100755 --- a/e2e/scripts/deploy.sh +++ b/e2e/scripts/deploy.sh @@ -50,13 +50,14 @@ echo " Using overlay: kind" # Check for image overrides in .env (already sourced above) if [ -f ".env" ]; then # Log image overrides - if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}${IMAGE_STATE_SYNC:-}" ]; then + if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}${IMAGE_STATE_SYNC:-}${IMAGE_PUBLIC_API:-}" ]; then echo " Image overrides from .env:" [ -n "${IMAGE_BACKEND:-}" ] && echo " Backend: ${IMAGE_BACKEND}" [ -n "${IMAGE_FRONTEND:-}" ] && echo " Frontend: ${IMAGE_FRONTEND}" [ -n "${IMAGE_OPERATOR:-}" ] && echo " Operator: ${IMAGE_OPERATOR}" [ -n "${IMAGE_RUNNER:-}" ] && echo " Runner: ${IMAGE_RUNNER}" [ -n "${IMAGE_STATE_SYNC:-}" ] && echo " State-sync: ${IMAGE_STATE_SYNC}" + [ -n "${IMAGE_PUBLIC_API:-}" ] && echo " Public-API: ${IMAGE_PUBLIC_API}" fi fi @@ -70,7 +71,8 @@ kubectl kustomize ../components/manifests/overlays/kind/ | \ sed "s|quay.io/ambient_code/vteam_operator:latest|${IMAGE_OPERATOR:-quay.io/ambient_code/vteam_operator:latest}|g" | \ sed "s|quay.io/ambient_code/vteam_claude_runner:latest|${IMAGE_RUNNER:-quay.io/ambient_code/vteam_claude_runner:latest}|g" | \ sed "s|quay.io/ambient_code/vteam_state_sync:latest|${IMAGE_STATE_SYNC:-quay.io/ambient_code/vteam_state_sync:latest}|g" | \ - if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}" ]; then + sed "s|quay.io/ambient_code/vteam_public_api:latest|${IMAGE_PUBLIC_API:-quay.io/ambient_code/vteam_public_api:latest}|g" | \ + if [ -n "${IMAGE_BACKEND:-}${IMAGE_FRONTEND:-}${IMAGE_OPERATOR:-}${IMAGE_RUNNER:-}${IMAGE_STATE_SYNC:-}${IMAGE_PUBLIC_API:-}" ]; then sed "s|imagePullPolicy: Always|imagePullPolicy: IfNotPresent|g" else cat