Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f9948f8
feat(public-api): complete session API surface for mcp-acp integration
adalton Mar 4, 2026
2c1d28b
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 4, 2026
080b205
style: apply gofmt formatting
adalton Mar 4, 2026
e29b679
fix: address review feedback — unstructured helpers, bind error, quer…
adalton Mar 4, 2026
44f9dc9
fix: filter kubectl/meta K8s prefixes, align auth pattern, refine com…
adalton Mar 5, 2026
2408a9b
Merge branch 'main' into andalton/mcp-update
jeremyeder Mar 7, 2026
b73fded
fix: sanitize sessionName param in logs/metrics handlers
adalton Mar 9, 2026
5df0c94
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 9, 2026
0f39434
Merge branch 'andalton/mcp-update' of github.com:ambient-code/platfor…
adalton Mar 9, 2026
11813e0
fix: stream logs in public-api proxy, clarify SanitizeForLog usage
adalton Mar 9, 2026
44a8a09
fix: handle K8s 403, stream transcript, align update status codes, ad…
adalton Mar 9, 2026
a4df45e
fix: add project/session context to error log messages
adalton Mar 9, 2026
ee74c91
fix: verify session CR before log retrieval, fix duration on bad time…
adalton Mar 9, 2026
ca25630
fix: validate query params before session CR check in GetSessionLogs
adalton Mar 9, 2026
4e29939
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 9, 2026
e9c26ef
Merge branch 'main' into andalton/mcp-update
adalton Mar 13, 2026
b6ecde0
fix: address code review feedback on public-api session endpoints
adalton Mar 13, 2026
e912364
Merge branch 'main' into andalton/mcp-update
adalton Mar 13, 2026
dad993e
Merge branch 'main' into andalton/mcp-update
adalton Mar 13, 2026
55f1123
Merge branch 'main' into andalton/mcp-update
adalton Mar 15, 2026
47b5bc3
Merge branch 'main' into andalton/mcp-update
adalton Mar 16, 2026
0fdcc01
Merge branch 'main' into andalton/mcp-update
adalton Mar 18, 2026
77fe084
fix: validate annotation value types in PatchSession to prevent K8s 5…
adalton Mar 18, 2026
b1bacbb
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 18, 2026
115b0c9
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 18, 2026
d3cdcf7
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 19, 2026
9bd300d
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 19, 2026
672676b
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 20, 2026
153b11f
Merge remote-tracking branch 'origin/main' into andalton/mcp-update
adalton Mar 23, 2026
818b330
Merge branch 'main' into andalton/mcp-update
adalton Mar 24, 2026
7c545b1
Merge branch 'main' into andalton/mcp-update
adalton Mar 24, 2026
bff0f6d
Merge branch 'main' into andalton/mcp-update
adalton Mar 25, 2026
05a3066
Merge branch 'main' into andalton/mcp-update
adalton Mar 25, 2026
003a4bf
Merge branch 'main' into andalton/mcp-update
adalton Mar 25, 2026
f174841
Merge branch 'main' into andalton/mcp-update
adalton Mar 26, 2026
d8d249c
Merge remote-tracking branch 'origin/andalton/mcp-update' into andalt…
adalton Mar 26, 2026
235c98b
Merge branch 'main' into andalton/mcp-update
adalton Mar 26, 2026
8ed4ce3
Merge branch 'main' into andalton/mcp-update
adalton Mar 27, 2026
67fc5af
Merge branch 'main' into andalton/mcp-update
adalton Mar 27, 2026
00b2cf4
Merge branch 'main' into andalton/mcp-update
adalton Mar 27, 2026
aa998bf
Merge branch 'main' into andalton/mcp-update
adalton Mar 30, 2026
58e50eb
Merge branch 'main' into andalton/mcp-update
adalton Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/backend/handlers/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,15 @@ 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
// (set by OpenShift OAuth proxy) to identify service accounts
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)
Expand Down
12 changes: 11 additions & 1 deletion components/backend/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
122 changes: 122 additions & 0 deletions components/backend/handlers/sessions_logs.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
118 changes: 118 additions & 0 deletions components/backend/handlers/sessions_metrics.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading