diff --git a/components/backend/.golangci.yml b/components/backend/.golangci.yml index d8eaad144..e25d787f0 100644 --- a/components/backend/.golangci.yml +++ b/components/backend/.golangci.yml @@ -1,40 +1,20 @@ # golangci-lint configuration for vTeam backend -# Compatible with golangci-lint v2+ +# Compatible with golangci-lint v2.x # # This configuration is pragmatic for a Kubernetes-native application. # We focus on catching real bugs rather than style issues. +version: "2" + run: timeout: 5m linters: enable: - - govet # Reports suspicious constructs - - ineffassign # Detect ineffectual assignments - - staticcheck # Advanced static analysis (includes many useful checks) - - unused # Check for unused constants, variables, functions - - misspell # Find commonly misspelled words + - govet + - ineffassign + - staticcheck + - unused + - misspell disable: - - errcheck # Disabled: too many false positives with defer cleanup - -linters-settings: - staticcheck: - checks: ["all", "-SA1019"] # Disable deprecation warnings only - -issues: - max-issues-per-linter: 0 # Show all issues - max-same-issues: 0 # Show all instances - - exclude-rules: - # Exclude all linters from test files - - path: _test\.go - linters: - - staticcheck - - govet - - # Allow type assertions in K8s unstructured object parsing (intentional pattern) - - path: (handlers|jira)/.*\.go - text: "type assertion" - - # Show all issues - new: false + - errcheck diff --git a/components/backend/handlers/helpers.go b/components/backend/handlers/helpers.go index 17d2fcbe4..d6a147ed1 100644 --- a/components/backend/handlers/helpers.go +++ b/components/backend/handlers/helpers.go @@ -6,10 +6,14 @@ import ( "math" "time" + "github.com/gin-gonic/gin" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" ) -// GetProjectSettingsResource returns the GroupVersionResource for ProjectSettings +// GetProjectSettingsResource returns the GroupVersionResource for ProjectSettings CRD. +// Returns a GVR for group "vteam.ambient-code", version "v1alpha1", resource "projectsettings". func GetProjectSettingsResource() schema.GroupVersionResource { return schema.GroupVersionResource{ Group: "vteam.ambient-code", @@ -18,10 +22,16 @@ func GetProjectSettingsResource() schema.GroupVersionResource { } } -// RetryWithBackoff attempts an operation with exponential backoff -// Used for operations that may temporarily fail due to async resource creation -// This is a generic utility that can be used by any handler -// Checks for context cancellation between retries to avoid wasting resources +// RetryWithBackoff attempts an operation with exponential backoff. +// Used for operations that may temporarily fail due to async resource creation. +// +// Parameters: +// - maxRetries: Maximum number of retry attempts +// - initialDelay: Initial delay duration before first retry +// - maxDelay: Maximum delay duration cap for exponential backoff +// - operation: Function to execute that returns an error if it fails +// +// Returns an error if all retries are exhausted, nil if operation succeeds. func RetryWithBackoff(maxRetries int, initialDelay, maxDelay time.Duration, operation func() error) error { var lastErr error for i := 0; i < maxRetries; i++ { @@ -43,3 +53,91 @@ func RetryWithBackoff(maxRetries int, initialDelay, maxDelay time.Duration, oper } return fmt.Errorf("operation failed after %d retries: %w", maxRetries, lastErr) } + +// GetMetadataMap safely extracts the metadata field from an unstructured Kubernetes object. +// This function provides type-safe access with nil checking to prevent runtime panics. +// +// Parameters: +// - obj: Pointer to an unstructured.Unstructured object (Kubernetes Custom Resource) +// +// Returns: +// - map[string]interface{}: The metadata map if extraction succeeds, nil otherwise +// - bool: true if metadata was successfully extracted and has the expected type, false otherwise +// +// Use this instead of unsafe type assertions like: obj.Object["metadata"].(map[string]interface{}) +func GetMetadataMap(obj *unstructured.Unstructured) (map[string]interface{}, bool) { + if obj == nil || obj.Object == nil { + return nil, false + } + metadata, ok := obj.Object["metadata"].(map[string]interface{}) + return metadata, ok +} + +// GetSpecMap safely extracts the spec field from an unstructured Kubernetes object. +// This function provides type-safe access with nil checking to prevent runtime panics. +// +// Parameters: +// - obj: Pointer to an unstructured.Unstructured object (Kubernetes Custom Resource) +// +// Returns: +// - map[string]interface{}: The spec map if extraction succeeds, nil otherwise +// - bool: true if spec was successfully extracted and has the expected type, false otherwise +// +// Use this instead of unsafe type assertions like: obj.Object["spec"].(map[string]interface{}) +func GetSpecMap(obj *unstructured.Unstructured) (map[string]interface{}, bool) { + if obj == nil || obj.Object == nil { + return nil, false + } + spec, ok := obj.Object["spec"].(map[string]interface{}) + return spec, ok +} + +// GetStatusMap safely extracts the status field from an unstructured Kubernetes object. +// This function provides type-safe access with nil checking to prevent runtime panics. +// +// Parameters: +// - obj: Pointer to an unstructured.Unstructured object (Kubernetes Custom Resource) +// +// Returns: +// - map[string]interface{}: The status map if extraction succeeds, nil otherwise +// - bool: true if status was successfully extracted and has the expected type, false otherwise +// +// Use this instead of unsafe type assertions like: obj.Object["status"].(map[string]interface{}) +func GetStatusMap(obj *unstructured.Unstructured) (map[string]interface{}, bool) { + if obj == nil || obj.Object == nil { + return nil, false + } + status, ok := obj.Object["status"].(map[string]interface{}) + return status, ok +} + +// ResolveContentServiceName determines the correct content service name for a session. +// This function handles the logic of choosing between temp-content and ambient-content services. +// +// For completed/stopped sessions, a temporary service (temp-content-{session}) is created. +// For running sessions, the regular service (ambient-content-{session}) is used. +// This function tries the temp service first, and falls back to the regular service if not found. +// +// Parameters: +// - c: Gin context for the current HTTP request +// - project: The Kubernetes namespace/project name +// - session: The agentic session name +// +// Returns: +// - string: The resolved service name (either temp-content-{session} or ambient-content-{session}) +func ResolveContentServiceName(c *gin.Context, project, session string) string { + // Try temp service first (for completed sessions) + tempServiceName := fmt.Sprintf("temp-content-%s", session) + + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + // Check if temp service exists + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), tempServiceName, v1.GetOptions{}); err == nil { + // Temp service exists, use it + return tempServiceName + } + } + + // Fall back to regular service (for running sessions or if temp service doesn't exist) + return fmt.Sprintf("ambient-content-%s", session) +} diff --git a/components/backend/handlers/projects_auth.go b/components/backend/handlers/projects_auth.go new file mode 100644 index 000000000..519c861cf --- /dev/null +++ b/components/backend/handlers/projects_auth.go @@ -0,0 +1,108 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains authentication and authorization functions for project management. +package handlers + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + authv1 "k8s.io/api/authorization/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// GetClusterInfo handles GET /cluster-info +// Returns information about the cluster type (OpenShift vs vanilla Kubernetes) +// This endpoint does not require authentication as it's public cluster information +func GetClusterInfo(c *gin.Context) { + isOpenShift := isOpenShiftCluster() + + c.JSON(http.StatusOK, gin.H{ + "isOpenShift": isOpenShift, + }) +} + +// checkUserCanAccessNamespace uses SelfSubjectAccessReview to verify if user can access a namespace +// This is the proper Kubernetes-native way - lets RBAC engine determine access from ALL sources +// (RoleBindings, ClusterRoleBindings, groups, etc.) +func checkUserCanAccessNamespace(userClient *kubernetes.Clientset, namespace string) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Check if user can list agenticsessions in the namespace (a good proxy for project access) + ssar := &authv1.SelfSubjectAccessReview{ + Spec: authv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authv1.ResourceAttributes{ + Namespace: namespace, + Verb: "list", + Group: "vteam.ambient-code", + Resource: "agenticsessions", + }, + }, + } + + result, err := userClient.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, ssar, v1.CreateOptions{}) + if err != nil { + return false, err + } + + return result.Status.Allowed, nil +} + +// getUserSubjectFromContext extracts the user subject from the JWT token in the request +// Returns subject in format like "user@example.com" or "system:serviceaccount:namespace:name" +func getUserSubjectFromContext(c *gin.Context) (string, error) { + // Try to extract from ServiceAccount first + ns, saName, ok := ExtractServiceAccountFromAuth(c) + if ok { + return fmt.Sprintf("system:serviceaccount:%s:%s", ns, saName), nil + } + + // Otherwise try to get from context (set by middleware) + if userName, exists := c.Get("userName"); exists && userName != nil { + return fmt.Sprintf("%v", userName), nil + } + if userID, exists := c.Get("userID"); exists && userID != nil { + return fmt.Sprintf("%v", userID), nil + } + + return "", fmt.Errorf("no user subject found in token") +} + +// getUserSubjectKind returns "ServiceAccount" or "User" based on the subject format +func getUserSubjectKind(subject string) string { + if len(subject) > 22 && subject[:22] == "system:serviceaccount:" { + return "ServiceAccount" + } + return "User" +} + +// getUserSubjectName returns the name part of the subject +// For ServiceAccount: "system:serviceaccount:namespace:name" -> "name" +// For User: returns the subject as-is +func getUserSubjectName(subject string) string { + if getUserSubjectKind(subject) == "ServiceAccount" { + parts := strings.Split(subject, ":") + if len(parts) >= 4 { + return parts[3] + } + } + return subject +} + +// getUserSubjectNamespace returns the namespace for ServiceAccount subjects +// For ServiceAccount: "system:serviceaccount:namespace:name" -> "namespace" +// For User: returns empty string +func getUserSubjectNamespace(subject string) string { + if getUserSubjectKind(subject) == "ServiceAccount" { + parts := strings.Split(subject, ":") + if len(parts) >= 3 { + return parts[2] + } + } + return "" +} diff --git a/components/backend/handlers/projects.go b/components/backend/handlers/projects_crud.go similarity index 72% rename from components/backend/handlers/projects.go rename to components/backend/handlers/projects_crud.go index 389cc55e0..be1bb5729 100644 --- a/components/backend/handlers/projects.go +++ b/components/backend/handlers/projects_crud.go @@ -1,3 +1,5 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains CRUD operations for project management. package handlers import ( @@ -5,140 +7,18 @@ import ( "fmt" "log" "net/http" - "regexp" - "strings" - "sync" "time" "ambient-code-backend/types" "github.com/gin-gonic/gin" - authv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/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" k8stypes "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" ) -// Package-level variables for project handlers (set from main package) -var ( - // GetOpenShiftProjectResource returns the GVR for OpenShift Project resources - GetOpenShiftProjectResource func() schema.GroupVersionResource - // K8sClientProjects is the backend service account client used for namespace operations - // that require elevated permissions (e.g., creating namespaces, assigning roles) - K8sClientProjects *kubernetes.Clientset - // DynamicClientProjects is the backend SA dynamic client for OpenShift Project operations - DynamicClientProjects dynamic.Interface -) - -var ( - isOpenShiftCache bool - isOpenShiftOnce sync.Once -) - -// Default timeout for Kubernetes API operations -const defaultK8sTimeout = 10 * time.Second - -// Retry configuration constants -const ( - projectRetryAttempts = 5 - projectRetryInitialDelay = 200 * time.Millisecond - projectRetryMaxDelay = 2 * time.Second -) - -// Kubernetes namespace name validation pattern -var namespaceNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) - -// validateProjectName validates a project/namespace name according to Kubernetes naming rules -func validateProjectName(name string) error { - if name == "" { - return fmt.Errorf("project name is required") - } - if len(name) > 63 { - return fmt.Errorf("project name must be 63 characters or less") - } - if !namespaceNamePattern.MatchString(name) { - return fmt.Errorf("project name must be lowercase alphanumeric with hyphens (cannot start or end with hyphen)") - } - // Reserved namespaces - reservedNames := map[string]bool{ - "default": true, "kube-system": true, "kube-public": true, "kube-node-lease": true, - "openshift": true, "openshift-infra": true, "openshift-node": true, - } - if reservedNames[name] { - return fmt.Errorf("project name '%s' is reserved and cannot be used", name) - } - return nil -} - -// sanitizeForK8sName converts a user subject to a valid Kubernetes resource name -func sanitizeForK8sName(subject string) string { - // Remove system:serviceaccount: prefix if present - subject = strings.TrimPrefix(subject, "system:serviceaccount:") - - // Replace invalid characters with hyphens - reg := regexp.MustCompile(`[^a-z0-9-]`) - sanitized := reg.ReplaceAllString(strings.ToLower(subject), "-") - - // Remove leading/trailing hyphens - sanitized = strings.Trim(sanitized, "-") - - // Ensure it doesn't exceed 63 chars (leave room for prefix) - if len(sanitized) > 40 { - sanitized = sanitized[:40] - } - - return sanitized -} - -// isOpenShiftCluster detects if we're running on OpenShift by checking for the project.openshift.io API group -// Results are cached using sync.Once for thread-safe, race-free initialization -func isOpenShiftCluster() bool { - isOpenShiftOnce.Do(func() { - if K8sClientProjects == nil { - log.Printf("K8s client not initialized, assuming vanilla Kubernetes") - isOpenShiftCache = false - return - } - - // Try to list API groups and look for project.openshift.io - groups, err := K8sClientProjects.Discovery().ServerGroups() - if err != nil { - log.Printf("Failed to detect OpenShift (assuming vanilla Kubernetes): %v", err) - isOpenShiftCache = false - return - } - - for _, group := range groups.Groups { - if group.Name == "project.openshift.io" { - log.Printf("Detected OpenShift cluster") - isOpenShiftCache = true - return - } - } - - log.Printf("Detected vanilla Kubernetes cluster") - isOpenShiftCache = false - }) - return isOpenShiftCache -} - -// GetClusterInfo handles GET /cluster-info -// Returns information about the cluster type (OpenShift vs vanilla Kubernetes) -// This endpoint does not require authentication as it's public cluster information -func GetClusterInfo(c *gin.Context) { - isOpenShift := isOpenShiftCluster() - - c.JSON(http.StatusOK, gin.H{ - "isOpenShift": isOpenShift, - }) -} - // ListProjects handles GET /projects // Lists Namespaces (both platforms) using backend SA with label selector, // then uses SubjectAccessReview to verify user access to each namespace @@ -188,35 +68,6 @@ func ListProjects(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": projects}) } -// projectFromNamespace converts a Kubernetes Namespace to AmbientProject -// On OpenShift, extracts displayName and description from namespace annotations -func projectFromNamespace(ns *corev1.Namespace, isOpenShift bool) types.AmbientProject { - status := "Active" - if ns.Status.Phase != corev1.NamespaceActive { - status = string(ns.Status.Phase) - } - - displayName := "" - description := "" - - // On OpenShift, extract display metadata from annotations - if isOpenShift && ns.Annotations != nil { - displayName = ns.Annotations["openshift.io/display-name"] - description = ns.Annotations["openshift.io/description"] - } - - return types.AmbientProject{ - Name: ns.Name, - DisplayName: displayName, - Description: description, - Labels: ns.Labels, - Annotations: ns.Annotations, - CreationTimestamp: ns.CreationTimestamp.Format(time.RFC3339), - Status: status, - IsOpenShift: isOpenShift, - } -} - // CreateProject handles POST /projects // Unified approach for both Kubernetes and OpenShift: // 1. Creates namespace using backend SA (both platforms) @@ -382,8 +233,7 @@ func CreateProject(c *gin.Context) { } // Update Project annotations with display metadata - var unstruct *unstructured.Unstructured = projObj // Explicit type reference - meta, ok := unstruct.Object["metadata"].(map[string]interface{}) + meta, ok := GetMetadataMap(projObj) if !ok || meta == nil { meta = map[string]interface{}{} projObj.Object["metadata"] = meta @@ -654,83 +504,31 @@ func DeleteProject(c *gin.Context) { c.Status(http.StatusNoContent) } -// checkUserCanAccessNamespace uses SelfSubjectAccessReview to verify if user can access a namespace -// This is the proper Kubernetes-native way - lets RBAC engine determine access from ALL sources -// (RoleBindings, ClusterRoleBindings, groups, etc.) -func checkUserCanAccessNamespace(userClient *kubernetes.Clientset, namespace string) (bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - // Check if user can list agenticsessions in the namespace (a good proxy for project access) - ssar := &authv1.SelfSubjectAccessReview{ - Spec: authv1.SelfSubjectAccessReviewSpec{ - ResourceAttributes: &authv1.ResourceAttributes{ - Namespace: namespace, - Verb: "list", - Group: "vteam.ambient-code", - Resource: "agenticsessions", - }, - }, - } - - result, err := userClient.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, ssar, v1.CreateOptions{}) - if err != nil { - return false, err - } - - return result.Status.Allowed, nil -} - -// getUserSubjectFromContext extracts the user subject from the JWT token in the request -// Returns subject in format like "user@example.com" or "system:serviceaccount:namespace:name" -func getUserSubjectFromContext(c *gin.Context) (string, error) { - // Try to extract from ServiceAccount first - ns, saName, ok := ExtractServiceAccountFromAuth(c) - if ok { - return fmt.Sprintf("system:serviceaccount:%s:%s", ns, saName), nil - } - - // Otherwise try to get from context (set by middleware) - if userName, exists := c.Get("userName"); exists && userName != nil { - return fmt.Sprintf("%v", userName), nil - } - if userID, exists := c.Get("userID"); exists && userID != nil { - return fmt.Sprintf("%v", userID), nil +// projectFromNamespace converts a Kubernetes Namespace to AmbientProject +// On OpenShift, extracts displayName and description from namespace annotations +func projectFromNamespace(ns *corev1.Namespace, isOpenShift bool) types.AmbientProject { + status := "Active" + if ns.Status.Phase != corev1.NamespaceActive { + status = string(ns.Status.Phase) } - return "", fmt.Errorf("no user subject found in token") -} - -// getUserSubjectKind returns "ServiceAccount" or "User" based on the subject format -func getUserSubjectKind(subject string) string { - if len(subject) > 22 && subject[:22] == "system:serviceaccount:" { - return "ServiceAccount" - } - return "User" -} + displayName := "" + description := "" -// getUserSubjectName returns the name part of the subject -// For ServiceAccount: "system:serviceaccount:namespace:name" -> "name" -// For User: returns the subject as-is -func getUserSubjectName(subject string) string { - if getUserSubjectKind(subject) == "ServiceAccount" { - parts := strings.Split(subject, ":") - if len(parts) >= 4 { - return parts[3] - } + // On OpenShift, extract display metadata from annotations + if isOpenShift && ns.Annotations != nil { + displayName = ns.Annotations["openshift.io/display-name"] + description = ns.Annotations["openshift.io/description"] } - return subject -} -// getUserSubjectNamespace returns the namespace for ServiceAccount subjects -// For ServiceAccount: "system:serviceaccount:namespace:name" -> "namespace" -// For User: returns empty string -func getUserSubjectNamespace(subject string) string { - if getUserSubjectKind(subject) == "ServiceAccount" { - parts := strings.Split(subject, ":") - if len(parts) >= 3 { - return parts[2] - } + return types.AmbientProject{ + Name: ns.Name, + DisplayName: displayName, + Description: description, + Labels: ns.Labels, + Annotations: ns.Annotations, + CreationTimestamp: ns.CreationTimestamp.Format(time.RFC3339), + Status: status, + IsOpenShift: isOpenShift, } - return "" } diff --git a/components/backend/handlers/projects_types.go b/components/backend/handlers/projects_types.go new file mode 100644 index 000000000..25bdbc61d --- /dev/null +++ b/components/backend/handlers/projects_types.go @@ -0,0 +1,119 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains type definitions, package variables, constants, and validation functions for project management. +package handlers + +import ( + "fmt" + "log" + "regexp" + "strings" + "sync" + "time" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +// Package-level variables for project handlers (set from main package) +var ( + // GetOpenShiftProjectResource returns the GVR for OpenShift Project resources + GetOpenShiftProjectResource func() schema.GroupVersionResource + // K8sClientProjects is the backend service account client used for namespace operations + // that require elevated permissions (e.g., creating namespaces, assigning roles) + K8sClientProjects *kubernetes.Clientset + // DynamicClientProjects is the backend SA dynamic client for OpenShift Project operations + DynamicClientProjects dynamic.Interface +) + +var ( + isOpenShiftCache bool + isOpenShiftOnce sync.Once +) + +// Default timeout for Kubernetes API operations +const defaultK8sTimeout = 10 * time.Second + +// Retry configuration constants +const ( + projectRetryAttempts = 5 + projectRetryInitialDelay = 200 * time.Millisecond + projectRetryMaxDelay = 2 * time.Second +) + +// Kubernetes namespace name validation pattern +var namespaceNamePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + +// validateProjectName validates a project/namespace name according to Kubernetes naming rules +func validateProjectName(name string) error { + if name == "" { + return fmt.Errorf("project name is required") + } + if len(name) > 63 { + return fmt.Errorf("project name must be 63 characters or less") + } + if !namespaceNamePattern.MatchString(name) { + return fmt.Errorf("project name must be lowercase alphanumeric with hyphens (cannot start or end with hyphen)") + } + // Reserved namespaces + reservedNames := map[string]bool{ + "default": true, "kube-system": true, "kube-public": true, "kube-node-lease": true, + "openshift": true, "openshift-infra": true, "openshift-node": true, + } + if reservedNames[name] { + return fmt.Errorf("project name '%s' is reserved and cannot be used", name) + } + return nil +} + +// sanitizeForK8sName converts a user subject to a valid Kubernetes resource name +func sanitizeForK8sName(subject string) string { + // Remove system:serviceaccount: prefix if present + subject = strings.TrimPrefix(subject, "system:serviceaccount:") + + // Replace invalid characters with hyphens + reg := regexp.MustCompile(`[^a-z0-9-]`) + sanitized := reg.ReplaceAllString(strings.ToLower(subject), "-") + + // Remove leading/trailing hyphens + sanitized = strings.Trim(sanitized, "-") + + // Ensure it doesn't exceed 63 chars (leave room for prefix) + if len(sanitized) > 40 { + sanitized = sanitized[:40] + } + + return sanitized +} + +// isOpenShiftCluster detects if we're running on OpenShift by checking for the project.openshift.io API group +// Results are cached using sync.Once for thread-safe, race-free initialization +func isOpenShiftCluster() bool { + isOpenShiftOnce.Do(func() { + if K8sClientProjects == nil { + log.Printf("K8s client not initialized, assuming vanilla Kubernetes") + isOpenShiftCache = false + return + } + + // Try to list API groups and look for project.openshift.io + groups, err := K8sClientProjects.Discovery().ServerGroups() + if err != nil { + log.Printf("Failed to detect OpenShift (assuming vanilla Kubernetes): %v", err) + isOpenShiftCache = false + return + } + + for _, group := range groups.Groups { + if group.Name == "project.openshift.io" { + log.Printf("Detected OpenShift cluster") + isOpenShiftCache = true + return + } + } + + log.Printf("Detected vanilla Kubernetes cluster") + isOpenShiftCache = false + }) + return isOpenShiftCache +} diff --git a/components/backend/handlers/rfe.go b/components/backend/handlers/rfe.go deleted file mode 100644 index a8650081e..000000000 --- a/components/backend/handlers/rfe.go +++ /dev/null @@ -1,1208 +0,0 @@ -package handlers - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "strings" - "time" - - "ambient-code-backend/git" - "ambient-code-backend/types" - - "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" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" -) - -// Package-level variables for dependency injection (RFE-specific) -var ( - GetRFEWorkflowResource func() schema.GroupVersionResource - UpsertProjectRFEWorkflowCR func(dynamic.Interface, *types.RFEWorkflow) error - PerformRepoSeeding func(context.Context, *types.RFEWorkflow, string, string, string, string, string, string, string, string) (bool, error) - CheckRepoSeeding func(context.Context, string, *string, string) (bool, map[string]interface{}, error) - CheckBranchExists func(context.Context, string, string, string) (bool, error) - RfeFromUnstructured func(*unstructured.Unstructured) *types.RFEWorkflow -) - -// Type aliases for RFE workflow types -type RFEWorkflow = types.RFEWorkflow -type CreateRFEWorkflowRequest = types.CreateRFEWorkflowRequest -type GitRepository = types.GitRepository -type WorkflowJiraLink = types.WorkflowJiraLink - -// rfeLinkSessionRequest holds the request body for linking a session to an RFE workflow -type rfeLinkSessionRequest struct { - ExistingName string `json:"existingName"` - Phase string `json:"phase"` -} - -// normalizeRepoURL normalizes a repository URL for comparison -func normalizeRepoURL(repoURL string) string { - normalized := strings.ToLower(strings.TrimSpace(repoURL)) - // Remove .git suffix - normalized = strings.TrimSuffix(normalized, ".git") - // Remove trailing slash - normalized = strings.TrimSuffix(normalized, "/") - return normalized -} - -// validateUniqueRepositories checks that all repository URLs are unique -func validateUniqueRepositories(umbrellaRepo *GitRepository, supportingRepos []GitRepository) error { - seen := make(map[string]bool) - - // Check umbrella repo - if umbrellaRepo != nil && umbrellaRepo.URL != "" { - normalized := normalizeRepoURL(umbrellaRepo.URL) - seen[normalized] = true - } - - // Check supporting repos - for _, repo := range supportingRepos { - if repo.URL == "" { - continue - } - normalized := normalizeRepoURL(repo.URL) - if seen[normalized] { - return fmt.Errorf("duplicate repository URL detected: %s", repo.URL) - } - seen[normalized] = true - } - - return nil -} - -// ListProjectRFEWorkflows lists all RFE workflows for a project -func ListProjectRFEWorkflows(c *gin.Context) { - project := c.Param("projectName") - var workflows []RFEWorkflow - // Prefer CRD list with request-scoped client; fallback to file scan if unavailable or fails - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn != nil { - if list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: fmt.Sprintf("project=%s", project)}); err == nil { - for _, item := range list.Items { - wf := RfeFromUnstructured(&item) - if wf == nil { - continue - } - workflows = append(workflows, *wf) - } - } - } - if workflows == nil { - workflows = []RFEWorkflow{} - } - // Return slim summaries: omit artifacts/agentSessions/phaseResults/status/currentPhase - summaries := make([]map[string]interface{}, 0, len(workflows)) - for _, w := range workflows { - item := map[string]interface{}{ - "id": w.ID, - "title": w.Title, - "description": w.Description, - "branchName": w.BranchName, - "project": w.Project, - "workspacePath": w.WorkspacePath, - "createdAt": w.CreatedAt, - "updatedAt": w.UpdatedAt, - } - if w.UmbrellaRepo != nil { - u := map[string]interface{}{"url": w.UmbrellaRepo.URL} - if w.UmbrellaRepo.Branch != nil { - u["branch"] = *w.UmbrellaRepo.Branch - } - item["umbrellaRepo"] = u - } - if len(w.SupportingRepos) > 0 { - repos := make([]map[string]interface{}, 0, len(w.SupportingRepos)) - for _, r := range w.SupportingRepos { - rm := map[string]interface{}{"url": r.URL} - if r.Branch != nil { - rm["branch"] = *r.Branch - } - repos = append(repos, rm) - } - item["supportingRepos"] = repos - } - summaries = append(summaries, item) - } - c.JSON(http.StatusOK, gin.H{"workflows": summaries}) -} - -// CreateProjectRFEWorkflow creates a new RFE workflow for a project -func CreateProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - var req CreateRFEWorkflowRequest - bodyBytes, _ := c.GetRawData() - c.Request.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) - return - } - now := time.Now().UTC().Format(time.RFC3339) - workflowID := fmt.Sprintf("rfe-%d", time.Now().Unix()) - - // Branch name is required and generated by frontend (auto-populated from title, user-editable) - // Frontend generates: ambient-{first-three-words-from-title} - // Backend only validates that it's not empty and not a protected branch - branchName := strings.TrimSpace(req.BranchName) - if err := git.ValidateBranchName(branchName); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate no duplicate repository URLs - if err := validateUniqueRepositories(&req.UmbrellaRepo, req.SupportingRepos); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - workflow := &RFEWorkflow{ - ID: workflowID, - Title: req.Title, - Description: req.Description, - BranchName: branchName, - UmbrellaRepo: &req.UmbrellaRepo, - SupportingRepos: req.SupportingRepos, - WorkspacePath: req.WorkspacePath, - Project: project, - CreatedAt: now, - UpdatedAt: now, - } - _, reqDyn := GetK8sClientsForRequest(c) - if err := UpsertProjectRFEWorkflowCR(reqDyn, workflow); err != nil { - log.Printf("⚠️ Failed to upsert RFEWorkflow CR: %v", err) - } - - // Seeding (spec-kit + agents) is now handled by POST /seed endpoint after creation - - c.JSON(http.StatusCreated, workflow) -} - -// UpdateProjectRFEWorkflow updates an existing RFE workflow's repository configuration -// This is primarily used to fix repository URLs before seeding -func UpdateProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - var req types.UpdateRFEWorkflowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) - return - } - - // Get the workflow - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - - wf := RfeFromUnstructured(item) - if wf == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workflow"}) - return - } - - // Validate no duplicate repository URLs if repositories are being updated - if req.UmbrellaRepo != nil || req.SupportingRepos != nil { - umbrellaRepo := req.UmbrellaRepo - if umbrellaRepo == nil { - umbrellaRepo = wf.UmbrellaRepo - } - supportingRepos := req.SupportingRepos - if supportingRepos == nil { - supportingRepos = wf.SupportingRepos - } - if err := validateUniqueRepositories(umbrellaRepo, supportingRepos); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - } - - // Update the CR - obj := item.DeepCopy() - spec, ok := obj.Object["spec"].(map[string]interface{}) - if !ok { - spec = make(map[string]interface{}) - obj.Object["spec"] = spec - } - - // Update fields if provided - if req.Title != nil { - spec["title"] = *req.Title - } - if req.Description != nil { - spec["description"] = *req.Description - } - if req.UmbrellaRepo != nil { - spec["umbrellaRepo"] = map[string]interface{}{ - "url": req.UmbrellaRepo.URL, - "branch": req.UmbrellaRepo.Branch, - } - } - if req.SupportingRepos != nil { - repos := make([]interface{}, len(req.SupportingRepos)) - for i, r := range req.SupportingRepos { - repos[i] = map[string]interface{}{ - "url": r.URL, - "branch": r.Branch, - } - } - spec["supportingRepos"] = repos - } - if req.ParentOutcome != nil { - spec["parentOutcome"] = *req.ParentOutcome - } - - // Update the CR - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to update RFEWorkflow CR: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update workflow"}) - return - } - - // Convert back to RFEWorkflow type - updatedWf := RfeFromUnstructured(updated) - if updatedWf == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse updated workflow"}) - return - } - - c.JSON(http.StatusOK, updatedWf) -} - -// SeedProjectRFEWorkflow seeds the umbrella repo with spec-kit and agents via direct git operations -func SeedProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Get the workflow - gvr := GetRFEWorkflowResource() - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - wf := RfeFromUnstructured(item) - if wf == nil || wf.UmbrellaRepo == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) - return - } - - // Ensure we have a branch name - if wf.BranchName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Workflow missing branch name"}) - return - } - - // Get user ID from forwarded identity middleware - userID, _ := c.Get("userID") - userIDStr, ok := userID.(string) - if !ok || userIDStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) - return - } - - githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Read request body for optional agent source and spec-kit settings - type SeedRequest struct { - AgentSourceURL string `json:"agentSourceUrl,omitempty"` - AgentSourceBranch string `json:"agentSourceBranch,omitempty"` - AgentSourcePath string `json:"agentSourcePath,omitempty"` - SpecKitRepo string `json:"specKitRepo,omitempty"` - SpecKitVersion string `json:"specKitVersion,omitempty"` - SpecKitTemplate string `json:"specKitTemplate,omitempty"` - } - var req SeedRequest - _ = c.ShouldBindJSON(&req) - - // Defaults - agentURL := req.AgentSourceURL - if agentURL == "" { - agentURL = "https://github.com/ambient-code/vTeam.git" - } - agentBranch := req.AgentSourceBranch - if agentBranch == "" { - agentBranch = "main" - } - agentPath := req.AgentSourcePath - if agentPath == "" { - agentPath = "agents" - } - // Spec-kit configuration: request body > environment variables > hardcoded defaults - specKitRepo := req.SpecKitRepo - if specKitRepo == "" { - if envRepo := strings.TrimSpace(os.Getenv("SPEC_KIT_REPO")); envRepo != "" { - specKitRepo = envRepo - } else { - specKitRepo = "github/spec-kit" - } - } - specKitVersion := req.SpecKitVersion - if specKitVersion == "" { - if envVersion := strings.TrimSpace(os.Getenv("SPEC_KIT_VERSION")); envVersion != "" { - specKitVersion = envVersion - } else { - specKitVersion = "main" - } - } - specKitTemplate := req.SpecKitTemplate - if specKitTemplate == "" { - if envTemplate := strings.TrimSpace(os.Getenv("SPEC_KIT_TEMPLATE")); envTemplate != "" { - specKitTemplate = envTemplate - } else { - specKitTemplate = "spec-kit-template-claude-sh" - } - } - - // Perform seeding operations with platform-managed branch - branchExisted, seedErr := PerformRepoSeeding(c.Request.Context(), wf, wf.BranchName, githubToken, agentURL, agentBranch, agentPath, specKitRepo, specKitVersion, specKitTemplate) - - if seedErr != nil { - log.Printf("Failed to seed RFE workflow %s in project %s: %v", id, project, seedErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": seedErr.Error()}) - return - } - - message := "Repository seeded successfully" - if branchExisted { - message = fmt.Sprintf("Repository seeded successfully. Note: Branch '%s' already existed and will be modified by this RFE.", wf.BranchName) - } - - c.JSON(http.StatusOK, gin.H{ - "status": "completed", - "message": message, - "branchName": wf.BranchName, - "branchExisted": branchExisted, - }) -} - -// CheckProjectRFEWorkflowSeeding checks if the umbrella repo is seeded by querying GitHub API -func CheckProjectRFEWorkflowSeeding(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Get the workflow - gvr := GetRFEWorkflowResource() - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - wf := RfeFromUnstructured(item) - if wf == nil || wf.UmbrellaRepo == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) - return - } - - // Get user ID from forwarded identity middleware - userID, _ := c.Get("userID") - userIDStr, ok := userID.(string) - if !ok || userIDStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) - return - } - - githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Check if umbrella repo is seeded - use the generated feature branch, not the base branch - branchToCheck := wf.UmbrellaRepo.Branch - if wf.BranchName != "" { - branchToCheck = &wf.BranchName - } - umbrellaSeeded, umbrellaDetails, err := CheckRepoSeeding(c.Request.Context(), wf.UmbrellaRepo.URL, branchToCheck, githubToken) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Check if all supporting repos have the feature branch - supportingReposStatus := []map[string]interface{}{} - allSupportingReposSeeded := true - - for _, supportingRepo := range wf.SupportingRepos { - branchExists, err := CheckBranchExists(c.Request.Context(), supportingRepo.URL, wf.BranchName, githubToken) - if err != nil { - log.Printf("Warning: failed to check branch in supporting repo %s: %v", supportingRepo.URL, err) - allSupportingReposSeeded = false - supportingReposStatus = append(supportingReposStatus, map[string]interface{}{ - "repoURL": supportingRepo.URL, - "branchExists": false, - "error": err.Error(), - }) - continue - } - - if !branchExists { - allSupportingReposSeeded = false - } - - supportingReposStatus = append(supportingReposStatus, map[string]interface{}{ - "repoURL": supportingRepo.URL, - "branchExists": branchExists, - }) - } - - // Overall seeding is complete only if umbrella repo is seeded AND all supporting repos have the branch - isFullySeeded := umbrellaSeeded && allSupportingReposSeeded - - c.JSON(http.StatusOK, gin.H{ - "isSeeded": isFullySeeded, - "specRepo": gin.H{ - "isSeeded": umbrellaSeeded, - "details": umbrellaDetails, - }, - "supportingRepos": supportingReposStatus, - }) -} - -// GetProjectRFEWorkflow retrieves a specific RFE workflow by ID -func GetProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - // Try CRD with request-scoped client first - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - var wf *RFEWorkflow - var err error - if reqDyn != nil { - if item, gerr := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}); gerr == nil { - wf = RfeFromUnstructured(item) - err = nil - } else { - err = gerr - } - } - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - return - } - // Return slim object without artifacts/agentSessions/phaseResults/status/currentPhase - resp := map[string]interface{}{ - "id": wf.ID, - "title": wf.Title, - "description": wf.Description, - "branchName": wf.BranchName, - "project": wf.Project, - "workspacePath": wf.WorkspacePath, - "createdAt": wf.CreatedAt, - "updatedAt": wf.UpdatedAt, - } - if wf.ParentOutcome != nil { - resp["parentOutcome"] = *wf.ParentOutcome - } - if len(wf.JiraLinks) > 0 { - links := make([]map[string]interface{}, 0, len(wf.JiraLinks)) - for _, l := range wf.JiraLinks { - links = append(links, map[string]interface{}{"path": l.Path, "jiraKey": l.JiraKey}) - } - resp["jiraLinks"] = links - } - if wf.UmbrellaRepo != nil { - u := map[string]interface{}{"url": wf.UmbrellaRepo.URL} - if wf.UmbrellaRepo.Branch != nil { - u["branch"] = *wf.UmbrellaRepo.Branch - } - resp["umbrellaRepo"] = u - } - if len(wf.SupportingRepos) > 0 { - repos := make([]map[string]interface{}, 0, len(wf.SupportingRepos)) - for _, r := range wf.SupportingRepos { - rm := map[string]interface{}{"url": r.URL} - if r.Branch != nil { - rm["branch"] = *r.Branch - } - repos = append(repos, rm) - } - resp["supportingRepos"] = repos - } - c.JSON(http.StatusOK, resp) -} - -// GetProjectRFEWorkflowSummary computes derived phase/status and progress based on workspace files and linked sessions -// GET /api/projects/:projectName/rfe-workflows/:id/summary -func GetProjectRFEWorkflowSummary(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Determine workspace and expected files - // workspace content removed - specsItems := []contentListItem{} - - hasSpec := false - hasPlan := false - hasTasks := false - - // helper to scan a list for target filenames - scanFor := func(items []contentListItem) (bool, bool, bool) { - s, p, t := false, false, false - for _, it := range items { - if it.IsDir { - continue - } - switch strings.ToLower(it.Name) { - case "spec.md": - s = true - case "plan.md": - p = true - case "tasks.md": - t = true - } - } - return s, p, t - } - - // First check directly under specs/ - if len(specsItems) > 0 { - s, p, t := scanFor(specsItems) - hasSpec, hasPlan, hasTasks = s, p, t - // If not found, check first subfolder under specs/ - if !hasSpec && !hasPlan && !hasTasks { - for _, it := range specsItems { - if it.IsDir { - subItems := []contentListItem{} - s2, p2, t2 := scanFor(subItems) - hasSpec, hasPlan, hasTasks = s2, p2, t2 - break - } - } - } - } - - // Sessions: find linked sessions and compute running/failed flags - gvr := GetAgenticSessionV1Alpha1Resource() - _, reqDyn := GetK8sClientsForRequest(c) - anyRunning := false - anyFailed := false - if reqDyn != nil { - selector := fmt.Sprintf("rfe-workflow=%s,project=%s", id, project) - if list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: selector}); err == nil { - for _, item := range list.Items { - status, _ := item.Object["status"].(map[string]interface{}) - phaseStr := strings.ToLower(fmt.Sprintf("%v", status["phase"])) - if phaseStr == "running" || phaseStr == "creating" || phaseStr == "pending" { - anyRunning = true - } - if phaseStr == "failed" || phaseStr == "error" { - anyFailed = true - } - } - } - } - - // Derive phase and status - var phase string - switch { - case !hasSpec && !hasPlan && !hasTasks: - phase = "pre" - case !hasSpec: - phase = "specify" - case !hasPlan: - phase = "plan" - case !hasTasks: - phase = "tasks" - default: - phase = "completed" - } - - status := "not started" - if anyRunning { - status = "running" - } else if hasSpec || hasPlan || hasTasks { - status = "in progress" - } - if hasSpec && hasPlan && hasTasks && !anyRunning { - status = "completed" - } - if anyFailed && status != "running" { - status = "attention" - } - - progress := float64(0) - done := 0 - if hasSpec { - done++ - } - if hasPlan { - done++ - } - if hasTasks { - done++ - } - progress = float64(done) / 3.0 * 100.0 - - c.JSON(http.StatusOK, gin.H{ - "phase": phase, - "status": status, - "progress": progress, - "files": gin.H{ - "spec": hasSpec, - "plan": hasPlan, - "tasks": hasTasks, - }, - }) -} - -// DeleteProjectRFEWorkflow deletes an RFE workflow -func DeleteProjectRFEWorkflow(c *gin.Context) { - id := c.Param("id") - // Delete CR - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn != nil { - _ = reqDyn.Resource(gvr).Namespace(c.Param("projectName")).Delete(c.Request.Context(), id, v1.DeleteOptions{}) - } - c.JSON(http.StatusOK, gin.H{"message": "Workflow deleted successfully"}) -} - -// ListProjectRFEWorkflowSessions lists sessions linked to a project-scoped RFE workflow by label selector -func ListProjectRFEWorkflowSessions(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - gvr := GetAgenticSessionV1Alpha1Resource() - selector := fmt.Sprintf("rfe-workflow=%s,project=%s", id, project) - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: selector}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list sessions", "details": err.Error()}) - return - } - - // Return full session objects for UI - sessions := make([]map[string]interface{}, 0, len(list.Items)) - for _, item := range list.Items { - sessions = append(sessions, item.Object) - } - c.JSON(http.StatusOK, gin.H{"sessions": sessions}) -} - -// AddProjectRFEWorkflowSession adds/links an existing session to an RFE by applying labels -func AddProjectRFEWorkflowSession(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - var req rfeLinkSessionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) - return - } - if req.ExistingName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "existingName is required for linking in this version"}) - return - } - gvr := GetAgenticSessionV1Alpha1Resource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), req.ExistingName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch session", "details": err.Error()}) - return - } - meta, _ := obj.Object["metadata"].(map[string]interface{}) - labels, _ := meta["labels"].(map[string]interface{}) - if labels == nil { - labels = map[string]interface{}{} - meta["labels"] = labels - } - labels["project"] = project - labels["rfe-workflow"] = id - if req.Phase != "" { - labels["rfe-phase"] = req.Phase - } - // Update the resource - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session labels", "details": err.Error()}) - return - } - _ = updated - c.JSON(http.StatusOK, gin.H{"message": "Session linked to RFE", "session": req.ExistingName}) -} - -// RemoveProjectRFEWorkflowSession removes/unlinks a session from an RFE by clearing linkage labels (non-destructive) -func RemoveProjectRFEWorkflowSession(c *gin.Context) { - project := c.Param("projectName") - _ = project // currently unused but kept for parity/logging if needed - id := c.Param("id") - sessionName := c.Param("sessionName") - gvr := GetAgenticSessionV1Alpha1Resource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch session", "details": err.Error()}) - return - } - meta, _ := obj.Object["metadata"].(map[string]interface{}) - labels, _ := meta["labels"].(map[string]interface{}) - if labels != nil { - delete(labels, "rfe-workflow") - delete(labels, "rfe-phase") - } - if _, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session labels", "details": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "Session unlinked from RFE", "session": sessionName, "rfe": id}) -} - -// GetProjectRFEWorkflowAgents fetches agent definitions from the workflow's umbrella repository -// GET /api/projects/:projectName/rfe-workflows/:id/agents -func GetProjectRFEWorkflowAgents(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Get the workflow - gvr := GetRFEWorkflowResource() - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - wf := RfeFromUnstructured(item) - if wf == nil || wf.UmbrellaRepo == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) - return - } - - // Get user ID from forwarded identity middleware - userID, _ := c.Get("userID") - userIDStr, ok := userID.(string) - if !ok || userIDStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) - return - } - - githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Parse repo owner/name from umbrella repo URL - repoURL := wf.UmbrellaRepo.URL - owner, repoName, err := parseOwnerRepoFromURL(repoURL) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid repository URL: %v", err)}) - return - } - - // Get ref (branch) - use the generated feature branch, not the base branch - ref := "main" - if wf.BranchName != "" { - ref = wf.BranchName - } else if wf.UmbrellaRepo.Branch != nil { - ref = *wf.UmbrellaRepo.Branch - } - - // Fetch agents from .claude/agents directory - agents, err := fetchAgentsFromRepo(c.Request.Context(), owner, repoName, ref, githubToken) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"agents": agents}) -} - -// parseOwnerRepoFromURL extracts owner and repo name from a GitHub URL -func parseOwnerRepoFromURL(repoURL string) (string, string, error) { - // Remove .git suffix - repoURL = strings.TrimSuffix(repoURL, ".git") - - // Handle https://github.com/owner/repo - if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") { - parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(repoURL, "https://"), "http://"), "/") - if len(parts) >= 3 { - return parts[1], parts[2], nil - } - } - - // Handle git@github.com:owner/repo - if strings.Contains(repoURL, "@") { - parts := strings.Split(repoURL, ":") - if len(parts) == 2 { - repoParts := strings.Split(parts[1], "/") - if len(repoParts) == 2 { - return repoParts[0], repoParts[1], nil - } - } - } - - // Handle owner/repo format - parts := strings.Split(repoURL, "/") - if len(parts) == 2 { - return parts[0], parts[1], nil - } - - return "", "", fmt.Errorf("unable to parse repository URL") -} - -// Agent represents an agent definition from .claude/agents directory -type Agent struct { - Persona string `json:"persona"` - Name string `json:"name"` - Role string `json:"role"` - Description string `json:"description"` -} - -// fetchAgentsFromRepo fetches and parses agent definitions from .claude/agents directory -func fetchAgentsFromRepo(ctx context.Context, owner, repo, ref, token string) ([]Agent, error) { - api := "https://api.github.com" - agentsPath := ".claude/agents" - - // Fetch directory listing - treeURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentsPath, ref) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, treeURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("GitHub request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - // No .claude/agents directory - return empty array - return []Agent{}, nil - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("GitHub API error %d: %s", resp.StatusCode, string(body)) - } - - var treeEntries []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&treeEntries); err != nil { - return nil, fmt.Errorf("failed to parse GitHub response: %w", err) - } - - // Filter for .md files - var agentFiles []string - for _, entry := range treeEntries { - name, _ := entry["name"].(string) - typ, _ := entry["type"].(string) - if typ == "file" && strings.HasSuffix(name, ".md") { - agentFiles = append(agentFiles, name) - } - } - - // Fetch and parse each agent file - agents := make([]Agent, 0, len(agentFiles)) - for _, filename := range agentFiles { - agent, err := fetchAndParseAgentFile(ctx, api, owner, repo, ref, filename, token) - if err != nil { - log.Printf("Warning: failed to parse agent file %s: %v", filename, err) - continue - } - agents = append(agents, agent) - } - - return agents, nil -} - -// fetchAndParseAgentFile fetches a single agent file and parses its metadata -func fetchAndParseAgentFile(ctx context.Context, api, owner, repo, ref, filename, token string) (Agent, error) { - agentPath := fmt.Sprintf(".claude/agents/%s", filename) - url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentPath, ref) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return Agent{}, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return Agent{}, fmt.Errorf("GitHub request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return Agent{}, fmt.Errorf("GitHub returned status %d", resp.StatusCode) - } - - var fileData map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&fileData); err != nil { - return Agent{}, fmt.Errorf("failed to parse GitHub response: %w", err) - } - - // Decode base64 content - content, _ := fileData["content"].(string) - encoding, _ := fileData["encoding"].(string) - - var decodedContent string - if strings.ToLower(encoding) == "base64" { - raw := strings.ReplaceAll(content, "\n", "") - data, err := base64.StdEncoding.DecodeString(raw) - if err != nil { - return Agent{}, fmt.Errorf("failed to decode base64 content: %w", err) - } - decodedContent = string(data) - } else { - decodedContent = content - } - - // Parse persona from filename - persona := strings.TrimSuffix(filename, ".md") - - // Generate default name from filename - nameParts := strings.FieldsFunc(persona, func(r rune) bool { - return r == '-' || r == '_' - }) - for i, part := range nameParts { - if len(part) > 0 { - nameParts[i] = strings.ToUpper(part[:1]) + part[1:] - } - } - name := strings.Join(nameParts, " ") - - role := "" - description := "" - - // Try to extract metadata from YAML frontmatter - // Simple regex-based parsing (consider using a YAML library for production) - lines := strings.Split(decodedContent, "\n") - inFrontmatter := false - for i, line := range lines { - if i == 0 && strings.TrimSpace(line) == "---" { - inFrontmatter = true - continue - } - if inFrontmatter && strings.TrimSpace(line) == "---" { - break - } - if inFrontmatter { - if strings.HasPrefix(line, "name:") { - name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) - } else if strings.HasPrefix(line, "role:") { - role = strings.TrimSpace(strings.TrimPrefix(line, "role:")) - } else if strings.HasPrefix(line, "description:") { - description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) - } - } - } - - // If no description found, use first non-empty line after frontmatter - if description == "" { - afterFrontmatter := false - for _, line := range lines { - if afterFrontmatter { - trimmed := strings.TrimSpace(line) - if trimmed != "" && !strings.HasPrefix(trimmed, "#") { - description = trimmed - if len(description) > 150 { - description = description[:150] - } - break - } - } - if strings.TrimSpace(line) == "---" { - if afterFrontmatter { - break - } - afterFrontmatter = true - } - } - } - - if description == "" { - description = "No description available" - } - - return Agent{ - Persona: persona, - Name: name, - Role: role, - Description: description, - }, nil -} - -// GetWorkflowJira proxies Jira issue fetch for a linked path -// GET /api/projects/:projectName/rfe-workflows/:id/jira?path=... -func GetWorkflowJira(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - reqPath := strings.TrimSpace(c.Query("path")) - if reqPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) - return - } - _, reqDyn := GetK8sClientsForRequest(c) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - // Load workflow to find key - gvrWf := GetRFEWorkflowResource() - item, err := reqDyn.Resource(gvrWf).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - return - } - wf := RfeFromUnstructured(item) - var key string - for _, jl := range wf.JiraLinks { - if strings.TrimSpace(jl.Path) == reqPath { - key = jl.JiraKey - break - } - } - if key == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "No Jira linked for path"}) - return - } - // Load Jira creds - // Determine secret name - secretName := "ambient-runner-secrets" - if obj, err := reqDyn.Resource(GetProjectSettingsResource()).Namespace(project).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}); err == nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { - if v, ok := spec["runnerSecretsName"].(string); ok && strings.TrimSpace(v) != "" { - secretName = strings.TrimSpace(v) - } - } - } - sec, err := reqK8s.CoreV1().Secrets(project).Get(c.Request.Context(), secretName, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read runner secret", "details": err.Error()}) - return - } - get := func(k string) string { - if b, ok := sec.Data[k]; ok { - return string(b) - } - return "" - } - jiraURL := strings.TrimSpace(get("JIRA_URL")) - jiraToken := strings.TrimSpace(get("JIRA_API_TOKEN")) - if jiraURL == "" || jiraToken == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Missing Jira configuration in runner secret (JIRA_URL, JIRA_API_TOKEN required)"}) - return - } - // Determine auth header (Cloud vs Server/Data Center) - authHeader := "" - if strings.Contains(jiraURL, "atlassian.net") { - // Jira Cloud - assume token is email:api_token format - encoded := base64.StdEncoding.EncodeToString([]byte(jiraToken)) - authHeader = "Basic " + encoded - } else { - // Jira Server/Data Center - authHeader = "Bearer " + jiraToken - } - - jiraBase := strings.TrimRight(jiraURL, "/") - endpoint := fmt.Sprintf("%s/rest/api/2/issue/%s", jiraBase, url.PathEscape(key)) - httpReq, _ := http.NewRequest("GET", endpoint, nil) - httpReq.Header.Set("Authorization", authHeader) - httpClient := &http.Client{Timeout: 30 * time.Second} - httpResp, httpErr := httpClient.Do(httpReq) - if httpErr != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Jira request failed", "details": httpErr.Error()}) - return - } - defer httpResp.Body.Close() - respBody, _ := io.ReadAll(httpResp.Body) - c.Data(httpResp.StatusCode, "application/json", respBody) -} diff --git a/components/backend/handlers/rfe_agents.go b/components/backend/handlers/rfe_agents.go new file mode 100644 index 000000000..f683b8c20 --- /dev/null +++ b/components/backend/handlers/rfe_agents.go @@ -0,0 +1,404 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains agent operations for RFE workflows. +package handlers + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Agent represents an agent definition from .claude/agents directory +type Agent struct { + Persona string `json:"persona"` + Name string `json:"name"` + Role string `json:"role"` + Description string `json:"description"` +} + +// GetProjectRFEWorkflowAgents fetches agent definitions from the workflow's umbrella repository +// GET /api/projects/:projectName/rfe-workflows/:id/agents +func GetProjectRFEWorkflowAgents(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Get the workflow + gvr := GetRFEWorkflowResource() + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil || reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + } else if errors.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) + } else { + log.Printf("Failed to get workflow %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) + } + return + } + wf := RfeFromUnstructured(item) + if wf == nil || wf.UmbrellaRepo == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) + return + } + + // Get user ID from forwarded identity middleware + userID, _ := c.Get("userID") + userIDStr, ok := userID.(string) + if !ok || userIDStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) + return + } + + githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Parse repo owner/name from umbrella repo URL + repoURL := wf.UmbrellaRepo.URL + owner, repoName, err := parseOwnerRepoFromURL(repoURL) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid repository URL: %v", err)}) + return + } + + // Get ref (branch) - use the generated feature branch, not the base branch + ref := "main" + if wf.BranchName != "" { + ref = wf.BranchName + } else if wf.UmbrellaRepo.Branch != nil { + ref = *wf.UmbrellaRepo.Branch + } + + // Fetch agents from .claude/agents directory + agents, err := fetchAgentsFromRepo(c.Request.Context(), owner, repoName, ref, githubToken) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"agents": agents}) +} + +// parseOwnerRepoFromURL extracts owner and repo name from a GitHub URL +func parseOwnerRepoFromURL(repoURL string) (string, string, error) { + // Remove .git suffix + repoURL = strings.TrimSuffix(repoURL, ".git") + + // Handle https://github.com/owner/repo + if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") { + parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(repoURL, "https://"), "http://"), "/") + if len(parts) >= 3 { + return parts[1], parts[2], nil + } + } + + // Handle git@github.com:owner/repo + if strings.Contains(repoURL, "@") { + parts := strings.Split(repoURL, ":") + if len(parts) == 2 { + repoParts := strings.Split(parts[1], "/") + if len(repoParts) == 2 { + return repoParts[0], repoParts[1], nil + } + } + } + + // Handle owner/repo format + parts := strings.Split(repoURL, "/") + if len(parts) == 2 { + return parts[0], parts[1], nil + } + + return "", "", fmt.Errorf("unable to parse repository URL") +} + +// fetchAgentsFromRepo fetches and parses agent definitions from .claude/agents directory +func fetchAgentsFromRepo(ctx context.Context, owner, repo, ref, token string) ([]Agent, error) { + api := "https://api.github.com" + agentsPath := ".claude/agents" + + // Fetch directory listing + treeURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentsPath, ref) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, treeURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GitHub request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + // No .claude/agents directory - return empty array + return []Agent{}, nil + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error %d: %s", resp.StatusCode, string(body)) + } + + var treeEntries []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&treeEntries); err != nil { + return nil, fmt.Errorf("failed to parse GitHub response: %w", err) + } + + // Filter for .md files + var agentFiles []string + for _, entry := range treeEntries { + name, _ := entry["name"].(string) + typ, _ := entry["type"].(string) + if typ == "file" && strings.HasSuffix(name, ".md") { + agentFiles = append(agentFiles, name) + } + } + + // Fetch and parse each agent file + agents := make([]Agent, 0, len(agentFiles)) + for _, filename := range agentFiles { + agent, err := fetchAndParseAgentFile(ctx, api, owner, repo, ref, filename, token) + if err != nil { + log.Printf("Warning: failed to parse agent file %s: %v", filename, err) + continue + } + agents = append(agents, agent) + } + + return agents, nil +} + +// fetchAndParseAgentFile fetches a single agent file and parses its metadata +func fetchAndParseAgentFile(ctx context.Context, api, owner, repo, ref, filename, token string) (Agent, error) { + agentPath := fmt.Sprintf(".claude/agents/%s", filename) + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentPath, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Agent{}, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Agent{}, fmt.Errorf("GitHub request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Agent{}, fmt.Errorf("GitHub returned status %d", resp.StatusCode) + } + + var fileData map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&fileData); err != nil { + return Agent{}, fmt.Errorf("failed to parse GitHub response: %w", err) + } + + // Decode base64 content + content, _ := fileData["content"].(string) + encoding, _ := fileData["encoding"].(string) + + var decodedContent string + if strings.ToLower(encoding) == "base64" { + raw := strings.ReplaceAll(content, "\n", "") + data, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return Agent{}, fmt.Errorf("failed to decode base64 content: %w", err) + } + decodedContent = string(data) + } else { + decodedContent = content + } + + // Parse persona from filename + persona := strings.TrimSuffix(filename, ".md") + + // Generate default name from filename + nameParts := strings.FieldsFunc(persona, func(r rune) bool { + return r == '-' || r == '_' + }) + for i, part := range nameParts { + if len(part) > 0 { + nameParts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + name := strings.Join(nameParts, " ") + + role := "" + description := "" + + // Try to extract metadata from YAML frontmatter + // Simple regex-based parsing (consider using a YAML library for production) + lines := strings.Split(decodedContent, "\n") + inFrontmatter := false + for i, line := range lines { + if i == 0 && strings.TrimSpace(line) == "---" { + inFrontmatter = true + continue + } + if inFrontmatter && strings.TrimSpace(line) == "---" { + break + } + if inFrontmatter { + if strings.HasPrefix(line, "name:") { + name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) + } else if strings.HasPrefix(line, "role:") { + role = strings.TrimSpace(strings.TrimPrefix(line, "role:")) + } else if strings.HasPrefix(line, "description:") { + description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) + } + } + } + + // If no description found, use first non-empty line after frontmatter + if description == "" { + afterFrontmatter := false + for _, line := range lines { + if afterFrontmatter { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + description = trimmed + if len(description) > 150 { + description = description[:150] + } + break + } + } + if strings.TrimSpace(line) == "---" { + if afterFrontmatter { + break + } + afterFrontmatter = true + } + } + } + + if description == "" { + description = "No description available" + } + + return Agent{ + Persona: persona, + Name: name, + Role: role, + Description: description, + }, nil +} + +// GetWorkflowJira proxies Jira issue fetch for a linked path +// GET /api/projects/:projectName/rfe-workflows/:id/jira?path=... +func GetWorkflowJira(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + reqPath := strings.TrimSpace(c.Query("path")) + if reqPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) + return + } + _, reqDyn := GetK8sClientsForRequest(c) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqDyn == nil || reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + // Load workflow to find key + gvrWf := GetRFEWorkflowResource() + item, err := reqDyn.Resource(gvrWf).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + wf := RfeFromUnstructured(item) + var key string + for _, jl := range wf.JiraLinks { + if strings.TrimSpace(jl.Path) == reqPath { + key = jl.JiraKey + break + } + } + if key == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "No Jira linked for path"}) + return + } + // Load Jira creds + // Determine secret name + secretName := "ambient-runner-secrets" + if obj, err := reqDyn.Resource(GetProjectSettingsResource()).Namespace(project).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}); err == nil { + if spec, ok := GetSpecMap(obj); ok { + if v, ok := spec["runnerSecretsName"].(string); ok && strings.TrimSpace(v) != "" { + secretName = strings.TrimSpace(v) + } + } + } + sec, err := reqK8s.CoreV1().Secrets(project).Get(c.Request.Context(), secretName, v1.GetOptions{}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read runner secret", "details": err.Error()}) + return + } + get := func(k string) string { + if b, ok := sec.Data[k]; ok { + return string(b) + } + return "" + } + jiraURL := strings.TrimSpace(get("JIRA_URL")) + jiraToken := strings.TrimSpace(get("JIRA_API_TOKEN")) + if jiraURL == "" || jiraToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing Jira configuration in runner secret (JIRA_URL, JIRA_API_TOKEN required)"}) + return + } + // Determine auth header (Cloud vs Server/Data Center) + authHeader := "" + if strings.Contains(jiraURL, "atlassian.net") { + // Jira Cloud - assume token is email:api_token format + encoded := base64.StdEncoding.EncodeToString([]byte(jiraToken)) + authHeader = "Basic " + encoded + } else { + // Jira Server/Data Center + authHeader = "Bearer " + jiraToken + } + + jiraBase := strings.TrimRight(jiraURL, "/") + endpoint := fmt.Sprintf("%s/rest/api/2/issue/%s", jiraBase, url.PathEscape(key)) + httpReq, _ := http.NewRequest("GET", endpoint, nil) + httpReq.Header.Set("Authorization", authHeader) + httpClient := &http.Client{Timeout: 30 * time.Second} + httpResp, httpErr := httpClient.Do(httpReq) + if httpErr != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Jira request failed", "details": httpErr.Error()}) + return + } + defer httpResp.Body.Close() + respBody, _ := io.ReadAll(httpResp.Body) + c.Data(httpResp.StatusCode, "application/json", respBody) +} diff --git a/components/backend/handlers/rfe_crud.go b/components/backend/handlers/rfe_crud.go new file mode 100644 index 000000000..b3b03488b --- /dev/null +++ b/components/backend/handlers/rfe_crud.go @@ -0,0 +1,436 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains CRUD operations for RFE workflows. +package handlers + +import ( + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "ambient-code-backend/git" + "ambient-code-backend/types" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ListProjectRFEWorkflows lists all RFE workflows for a project +func ListProjectRFEWorkflows(c *gin.Context) { + project := c.Param("projectName") + var workflows []RFEWorkflow + // Prefer CRD list with request-scoped client; fallback to file scan if unavailable or fails + gvr := GetRFEWorkflowResource() + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn != nil { + if list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: fmt.Sprintf("project=%s", project)}); err == nil { + for _, item := range list.Items { + wf := RfeFromUnstructured(&item) + if wf == nil { + continue + } + workflows = append(workflows, *wf) + } + } + } + if workflows == nil { + workflows = []RFEWorkflow{} + } + // Return slim summaries: omit artifacts/agentSessions/phaseResults/status/currentPhase + summaries := make([]map[string]interface{}, 0, len(workflows)) + for _, w := range workflows { + item := map[string]interface{}{ + "id": w.ID, + "title": w.Title, + "description": w.Description, + "branchName": w.BranchName, + "project": w.Project, + "workspacePath": w.WorkspacePath, + "createdAt": w.CreatedAt, + "updatedAt": w.UpdatedAt, + } + if w.UmbrellaRepo != nil { + u := map[string]interface{}{"url": w.UmbrellaRepo.URL} + if w.UmbrellaRepo.Branch != nil { + u["branch"] = *w.UmbrellaRepo.Branch + } + item["umbrellaRepo"] = u + } + if len(w.SupportingRepos) > 0 { + repos := make([]map[string]interface{}, 0, len(w.SupportingRepos)) + for _, r := range w.SupportingRepos { + rm := map[string]interface{}{"url": r.URL} + if r.Branch != nil { + rm["branch"] = *r.Branch + } + repos = append(repos, rm) + } + item["supportingRepos"] = repos + } + summaries = append(summaries, item) + } + c.JSON(http.StatusOK, gin.H{"workflows": summaries}) +} + +// CreateProjectRFEWorkflow creates a new RFE workflow for a project +func CreateProjectRFEWorkflow(c *gin.Context) { + project := c.Param("projectName") + var req CreateRFEWorkflowRequest + bodyBytes, _ := c.GetRawData() + c.Request.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) + return + } + now := time.Now().UTC().Format(time.RFC3339) + workflowID := fmt.Sprintf("rfe-%d", time.Now().Unix()) + + // Branch name is required and generated by frontend (auto-populated from title, user-editable) + // Frontend generates: ambient-{first-three-words-from-title} + // Backend only validates that it's not empty and not a protected branch + branchName := strings.TrimSpace(req.BranchName) + if err := git.ValidateBranchName(branchName); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate no duplicate repository URLs + if err := validateUniqueRepositories(&req.UmbrellaRepo, req.SupportingRepos); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + workflow := &RFEWorkflow{ + ID: workflowID, + Title: req.Title, + Description: req.Description, + BranchName: branchName, + UmbrellaRepo: &req.UmbrellaRepo, + SupportingRepos: req.SupportingRepos, + WorkspacePath: req.WorkspacePath, + Project: project, + CreatedAt: now, + UpdatedAt: now, + } + _, reqDyn := GetK8sClientsForRequest(c) + if err := UpsertProjectRFEWorkflowCR(reqDyn, workflow); err != nil { + log.Printf("⚠️ Failed to upsert RFEWorkflow CR: %v", err) + } + + // Seeding (spec-kit + agents) is now handled by POST /seed endpoint after creation + + c.JSON(http.StatusCreated, workflow) +} + +// GetProjectRFEWorkflow retrieves a specific RFE workflow by ID +func GetProjectRFEWorkflow(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + // Try CRD with request-scoped client first + gvr := GetRFEWorkflowResource() + _, reqDyn := GetK8sClientsForRequest(c) + var wf *RFEWorkflow + var err error + if reqDyn != nil { + if item, gerr := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}); gerr == nil { + wf = RfeFromUnstructured(item) + err = nil + } else { + err = gerr + } + } + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + return + } + // Return slim object without artifacts/agentSessions/phaseResults/status/currentPhase + resp := map[string]interface{}{ + "id": wf.ID, + "title": wf.Title, + "description": wf.Description, + "branchName": wf.BranchName, + "project": wf.Project, + "workspacePath": wf.WorkspacePath, + "createdAt": wf.CreatedAt, + "updatedAt": wf.UpdatedAt, + } + if wf.ParentOutcome != nil { + resp["parentOutcome"] = *wf.ParentOutcome + } + if len(wf.JiraLinks) > 0 { + links := make([]map[string]interface{}, 0, len(wf.JiraLinks)) + for _, l := range wf.JiraLinks { + links = append(links, map[string]interface{}{"path": l.Path, "jiraKey": l.JiraKey}) + } + resp["jiraLinks"] = links + } + if wf.UmbrellaRepo != nil { + u := map[string]interface{}{"url": wf.UmbrellaRepo.URL} + if wf.UmbrellaRepo.Branch != nil { + u["branch"] = *wf.UmbrellaRepo.Branch + } + resp["umbrellaRepo"] = u + } + if len(wf.SupportingRepos) > 0 { + repos := make([]map[string]interface{}, 0, len(wf.SupportingRepos)) + for _, r := range wf.SupportingRepos { + rm := map[string]interface{}{"url": r.URL} + if r.Branch != nil { + rm["branch"] = *r.Branch + } + repos = append(repos, rm) + } + resp["supportingRepos"] = repos + } + c.JSON(http.StatusOK, resp) +} + +// UpdateProjectRFEWorkflow updates an existing RFE workflow's repository configuration +// This is primarily used to fix repository URLs before seeding +func UpdateProjectRFEWorkflow(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + var req types.UpdateRFEWorkflowRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) + return + } + + // Get the workflow + gvr := GetRFEWorkflowResource() + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + } else if errors.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) + } else { + log.Printf("Failed to get workflow %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) + } + return + } + + wf := RfeFromUnstructured(item) + if wf == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workflow"}) + return + } + + // Validate no duplicate repository URLs if repositories are being updated + if req.UmbrellaRepo != nil || req.SupportingRepos != nil { + umbrellaRepo := req.UmbrellaRepo + if umbrellaRepo == nil { + umbrellaRepo = wf.UmbrellaRepo + } + supportingRepos := req.SupportingRepos + if supportingRepos == nil { + supportingRepos = wf.SupportingRepos + } + if err := validateUniqueRepositories(umbrellaRepo, supportingRepos); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + + // Update the CR + obj := item.DeepCopy() + spec, ok := GetSpecMap(obj) + if !ok { + spec = make(map[string]interface{}) + obj.Object["spec"] = spec + } + + // Update fields if provided + if req.Title != nil { + spec["title"] = *req.Title + } + if req.Description != nil { + spec["description"] = *req.Description + } + if req.UmbrellaRepo != nil { + spec["umbrellaRepo"] = map[string]interface{}{ + "url": req.UmbrellaRepo.URL, + "branch": req.UmbrellaRepo.Branch, + } + } + if req.SupportingRepos != nil { + repos := make([]interface{}, len(req.SupportingRepos)) + for i, r := range req.SupportingRepos { + repos[i] = map[string]interface{}{ + "url": r.URL, + "branch": r.Branch, + } + } + spec["supportingRepos"] = repos + } + if req.ParentOutcome != nil { + spec["parentOutcome"] = *req.ParentOutcome + } + + // Update the CR + updated, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update RFEWorkflow CR: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update workflow"}) + return + } + + // Convert back to RFEWorkflow type + updatedWf := RfeFromUnstructured(updated) + if updatedWf == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse updated workflow"}) + return + } + + c.JSON(http.StatusOK, updatedWf) +} + +// GetProjectRFEWorkflowSummary computes derived phase/status and progress based on workspace files and linked sessions +// GET /api/projects/:projectName/rfe-workflows/:id/summary +func GetProjectRFEWorkflowSummary(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Determine workspace and expected files + // workspace content removed + specsItems := []contentListItem{} + + hasSpec := false + hasPlan := false + hasTasks := false + + // helper to scan a list for target filenames + scanFor := func(items []contentListItem) (bool, bool, bool) { + s, p, t := false, false, false + for _, it := range items { + if it.IsDir { + continue + } + switch strings.ToLower(it.Name) { + case "spec.md": + s = true + case "plan.md": + p = true + case "tasks.md": + t = true + } + } + return s, p, t + } + + // First check directly under specs/ + if len(specsItems) > 0 { + s, p, t := scanFor(specsItems) + hasSpec, hasPlan, hasTasks = s, p, t + // If not found, check first subfolder under specs/ + if !hasSpec && !hasPlan && !hasTasks { + for _, it := range specsItems { + if it.IsDir { + subItems := []contentListItem{} + s2, p2, t2 := scanFor(subItems) + hasSpec, hasPlan, hasTasks = s2, p2, t2 + break + } + } + } + } + + // Sessions: find linked sessions and compute running/failed flags + gvr := GetAgenticSessionV1Alpha1Resource() + _, reqDyn := GetK8sClientsForRequest(c) + anyRunning := false + anyFailed := false + if reqDyn != nil { + selector := fmt.Sprintf("rfe-workflow=%s,project=%s", id, project) + if list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: selector}); err == nil { + for _, item := range list.Items { + status, ok := GetStatusMap(&item) + if !ok { + status = make(map[string]interface{}) + } + phaseStr := strings.ToLower(fmt.Sprintf("%v", status["phase"])) + if phaseStr == "running" || phaseStr == "creating" || phaseStr == "pending" { + anyRunning = true + } + if phaseStr == "failed" || phaseStr == "error" { + anyFailed = true + } + } + } + } + + // Derive phase and status + var phase string + switch { + case !hasSpec && !hasPlan && !hasTasks: + phase = "pre" + case !hasSpec: + phase = "specify" + case !hasPlan: + phase = "plan" + case !hasTasks: + phase = "tasks" + default: + phase = "completed" + } + + status := "not started" + if anyRunning { + status = "running" + } else if hasSpec || hasPlan || hasTasks { + status = "in progress" + } + if hasSpec && hasPlan && hasTasks && !anyRunning { + status = "completed" + } + if anyFailed && status != "running" { + status = "attention" + } + + progress := float64(0) + done := 0 + if hasSpec { + done++ + } + if hasPlan { + done++ + } + if hasTasks { + done++ + } + progress = float64(done) / 3.0 * 100.0 + + c.JSON(http.StatusOK, gin.H{ + "phase": phase, + "status": status, + "progress": progress, + "files": gin.H{ + "spec": hasSpec, + "plan": hasPlan, + "tasks": hasTasks, + }, + }) +} + +// DeleteProjectRFEWorkflow deletes an RFE workflow +func DeleteProjectRFEWorkflow(c *gin.Context) { + id := c.Param("id") + // Delete CR + gvr := GetRFEWorkflowResource() + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn != nil { + _ = reqDyn.Resource(gvr).Namespace(c.Param("projectName")).Delete(c.Request.Context(), id, v1.DeleteOptions{}) + } + c.JSON(http.StatusOK, gin.H{"message": "Workflow deleted successfully"}) +} diff --git a/components/backend/handlers/rfe_seeding.go b/components/backend/handlers/rfe_seeding.go new file mode 100644 index 000000000..8d64bbead --- /dev/null +++ b/components/backend/handlers/rfe_seeding.go @@ -0,0 +1,237 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains seeding operations for RFE workflows. +package handlers + +import ( + "fmt" + "log" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// SeedRequest holds the request body for seeding an RFE workflow +type SeedRequest struct { + AgentSourceURL string `json:"agentSourceUrl,omitempty"` + AgentSourceBranch string `json:"agentSourceBranch,omitempty"` + AgentSourcePath string `json:"agentSourcePath,omitempty"` + SpecKitRepo string `json:"specKitRepo,omitempty"` + SpecKitVersion string `json:"specKitVersion,omitempty"` + SpecKitTemplate string `json:"specKitTemplate,omitempty"` +} + +// SeedProjectRFEWorkflow seeds the umbrella repo with spec-kit and agents via direct git operations +func SeedProjectRFEWorkflow(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Get the workflow + gvr := GetRFEWorkflowResource() + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil || reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + } else if errors.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) + } else { + log.Printf("Failed to get workflow %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) + } + return + } + wf := RfeFromUnstructured(item) + if wf == nil || wf.UmbrellaRepo == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) + return + } + + // Ensure we have a branch name + if wf.BranchName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Workflow missing branch name"}) + return + } + + // Get user ID from forwarded identity middleware + userID, _ := c.Get("userID") + userIDStr, ok := userID.(string) + if !ok || userIDStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) + return + } + + githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Read request body for optional agent source and spec-kit settings + var req SeedRequest + _ = c.ShouldBindJSON(&req) + + // Defaults + agentURL := req.AgentSourceURL + if agentURL == "" { + agentURL = "https://github.com/ambient-code/vTeam.git" + } + agentBranch := req.AgentSourceBranch + if agentBranch == "" { + agentBranch = "main" + } + agentPath := req.AgentSourcePath + if agentPath == "" { + agentPath = "agents" + } + // Spec-kit configuration: request body > environment variables > hardcoded defaults + specKitRepo := req.SpecKitRepo + if specKitRepo == "" { + if envRepo := strings.TrimSpace(os.Getenv("SPEC_KIT_REPO")); envRepo != "" { + specKitRepo = envRepo + } else { + specKitRepo = "github/spec-kit" + } + } + specKitVersion := req.SpecKitVersion + if specKitVersion == "" { + if envVersion := strings.TrimSpace(os.Getenv("SPEC_KIT_VERSION")); envVersion != "" { + specKitVersion = envVersion + } else { + specKitVersion = "main" + } + } + specKitTemplate := req.SpecKitTemplate + if specKitTemplate == "" { + if envTemplate := strings.TrimSpace(os.Getenv("SPEC_KIT_TEMPLATE")); envTemplate != "" { + specKitTemplate = envTemplate + } else { + specKitTemplate = "spec-kit-template-claude-sh" + } + } + + // Perform seeding operations with platform-managed branch + branchExisted, seedErr := PerformRepoSeeding(c.Request.Context(), wf, wf.BranchName, githubToken, agentURL, agentBranch, agentPath, specKitRepo, specKitVersion, specKitTemplate) + + if seedErr != nil { + log.Printf("Failed to seed RFE workflow %s in project %s: %v", id, project, seedErr) + c.JSON(http.StatusInternalServerError, gin.H{"error": seedErr.Error()}) + return + } + + message := "Repository seeded successfully" + if branchExisted { + message = fmt.Sprintf("Repository seeded successfully. Note: Branch '%s' already existed and will be modified by this RFE.", wf.BranchName) + } + + c.JSON(http.StatusOK, gin.H{ + "status": "completed", + "message": message, + "branchName": wf.BranchName, + "branchExisted": branchExisted, + }) +} + +// CheckProjectRFEWorkflowSeeding checks if the umbrella repo is seeded by querying GitHub API +func CheckProjectRFEWorkflowSeeding(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + + // Get the workflow + gvr := GetRFEWorkflowResource() + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil || reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) + } else if errors.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) + } else { + log.Printf("Failed to get workflow %s: %v", id, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) + } + return + } + wf := RfeFromUnstructured(item) + if wf == nil || wf.UmbrellaRepo == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) + return + } + + // Get user ID from forwarded identity middleware + userID, _ := c.Get("userID") + userIDStr, ok := userID.(string) + if !ok || userIDStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) + return + } + + githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check if umbrella repo is seeded - use the generated feature branch, not the base branch + branchToCheck := wf.UmbrellaRepo.Branch + if wf.BranchName != "" { + branchToCheck = &wf.BranchName + } + umbrellaSeeded, umbrellaDetails, err := CheckRepoSeeding(c.Request.Context(), wf.UmbrellaRepo.URL, branchToCheck, githubToken) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Check if all supporting repos have the feature branch + supportingReposStatus := []map[string]interface{}{} + allSupportingReposSeeded := true + + for _, supportingRepo := range wf.SupportingRepos { + branchExists, err := CheckBranchExists(c.Request.Context(), supportingRepo.URL, wf.BranchName, githubToken) + if err != nil { + log.Printf("Warning: failed to check branch in supporting repo %s: %v", supportingRepo.URL, err) + allSupportingReposSeeded = false + supportingReposStatus = append(supportingReposStatus, map[string]interface{}{ + "repoURL": supportingRepo.URL, + "branchExists": false, + "error": err.Error(), + }) + continue + } + + if !branchExists { + allSupportingReposSeeded = false + } + + supportingReposStatus = append(supportingReposStatus, map[string]interface{}{ + "repoURL": supportingRepo.URL, + "branchExists": branchExists, + }) + } + + // Overall seeding is complete only if umbrella repo is seeded AND all supporting repos have the branch + isFullySeeded := umbrellaSeeded && allSupportingReposSeeded + + c.JSON(http.StatusOK, gin.H{ + "isSeeded": isFullySeeded, + "specRepo": gin.H{ + "isSeeded": umbrellaSeeded, + "details": umbrellaDetails, + }, + "supportingRepos": supportingReposStatus, + }) +} diff --git a/components/backend/handlers/rfe_sessions.go b/components/backend/handlers/rfe_sessions.go new file mode 100644 index 000000000..86ba299c6 --- /dev/null +++ b/components/backend/handlers/rfe_sessions.go @@ -0,0 +1,126 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains session management operations for RFE workflows. +package handlers + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ListProjectRFEWorkflowSessions lists sessions linked to a project-scoped RFE workflow by label selector +func ListProjectRFEWorkflowSessions(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + gvr := GetAgenticSessionV1Alpha1Resource() + selector := fmt.Sprintf("rfe-workflow=%s,project=%s", id, project) + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: selector}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list sessions", "details": err.Error()}) + return + } + + // Return full session objects for UI + sessions := make([]map[string]interface{}, 0, len(list.Items)) + for _, item := range list.Items { + sessions = append(sessions, item.Object) + } + c.JSON(http.StatusOK, gin.H{"sessions": sessions}) +} + +// AddProjectRFEWorkflowSession adds/links an existing session to an RFE by applying labels +func AddProjectRFEWorkflowSession(c *gin.Context) { + project := c.Param("projectName") + id := c.Param("id") + var req rfeLinkSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + if req.ExistingName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "existingName is required for linking in this version"}) + return + } + gvr := GetAgenticSessionV1Alpha1Resource() + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), req.ExistingName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch session", "details": err.Error()}) + return + } + meta, ok := GetMetadataMap(obj) + if !ok { + meta = make(map[string]interface{}) + } + labels, _ := meta["labels"].(map[string]interface{}) + if labels == nil { + labels = map[string]interface{}{} + meta["labels"] = labels + } + labels["project"] = project + labels["rfe-workflow"] = id + if req.Phase != "" { + labels["rfe-phase"] = req.Phase + } + // Update the resource + updated, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session labels", "details": err.Error()}) + return + } + _ = updated + c.JSON(http.StatusOK, gin.H{"message": "Session linked to RFE", "session": req.ExistingName}) +} + +// RemoveProjectRFEWorkflowSession removes/unlinks a session from an RFE by clearing linkage labels (non-destructive) +func RemoveProjectRFEWorkflowSession(c *gin.Context) { + project := c.Param("projectName") + _ = project // currently unused but kept for parity/logging if needed + id := c.Param("id") + sessionName := c.Param("sessionName") + gvr := GetAgenticSessionV1Alpha1Resource() + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) + return + } + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch session", "details": err.Error()}) + return + } + meta, ok := GetMetadataMap(obj) + if !ok { + meta = make(map[string]interface{}) + } + labels, _ := meta["labels"].(map[string]interface{}) + if labels != nil { + delete(labels, "rfe-workflow") + delete(labels, "rfe-phase") + } + if _, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session labels", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Session unlinked from RFE", "session": sessionName, "rfe": id}) +} diff --git a/components/backend/handlers/rfe_types.go b/components/backend/handlers/rfe_types.go new file mode 100644 index 000000000..73223d5eb --- /dev/null +++ b/components/backend/handlers/rfe_types.go @@ -0,0 +1,72 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains type aliases, package variables, and validation functions for RFE workflows. +package handlers + +import ( + "fmt" + "strings" + + "ambient-code-backend/types" + + "context" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +// Package-level variables for dependency injection (RFE-specific) +var ( + GetRFEWorkflowResource func() schema.GroupVersionResource + UpsertProjectRFEWorkflowCR func(dynamic.Interface, *types.RFEWorkflow) error + PerformRepoSeeding func(context.Context, *types.RFEWorkflow, string, string, string, string, string, string, string, string) (bool, error) + CheckRepoSeeding func(context.Context, string, *string, string) (bool, map[string]interface{}, error) + CheckBranchExists func(context.Context, string, string, string) (bool, error) + RfeFromUnstructured func(*unstructured.Unstructured) *types.RFEWorkflow +) + +// Type aliases for RFE workflow types +type RFEWorkflow = types.RFEWorkflow +type CreateRFEWorkflowRequest = types.CreateRFEWorkflowRequest +type GitRepository = types.GitRepository +type WorkflowJiraLink = types.WorkflowJiraLink + +// rfeLinkSessionRequest holds the request body for linking a session to an RFE workflow +type rfeLinkSessionRequest struct { + ExistingName string `json:"existingName"` + Phase string `json:"phase"` +} + +// normalizeRepoURL normalizes a repository URL for comparison +func normalizeRepoURL(repoURL string) string { + normalized := strings.ToLower(strings.TrimSpace(repoURL)) + // Remove .git suffix + normalized = strings.TrimSuffix(normalized, ".git") + // Remove trailing slash + normalized = strings.TrimSuffix(normalized, "/") + return normalized +} + +// validateUniqueRepositories checks that all repository URLs are unique +func validateUniqueRepositories(umbrellaRepo *GitRepository, supportingRepos []GitRepository) error { + seen := make(map[string]bool) + + // Check umbrella repo + if umbrellaRepo != nil && umbrellaRepo.URL != "" { + normalized := normalizeRepoURL(umbrellaRepo.URL) + seen[normalized] = true + } + + // Check supporting repos + for _, repo := range supportingRepos { + if repo.URL == "" { + continue + } + normalized := normalizeRepoURL(repo.URL) + if seen[normalized] { + return fmt.Errorf("duplicate repository URL detected: %s", repo.URL) + } + seen[normalized] = true + } + + return nil +} diff --git a/components/backend/handlers/secrets.go b/components/backend/handlers/secrets.go index 13d569353..f756c7e17 100644 --- a/components/backend/handlers/secrets.go +++ b/components/backend/handlers/secrets.go @@ -67,7 +67,7 @@ func GetRunnerSecretsConfig(c *gin.Context) { secretName := "" if obj != nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { + if spec, ok := GetSpecMap(obj); ok { if v, ok := spec["runnerSecretsName"].(string); ok { secretName = v } @@ -107,7 +107,10 @@ func UpdateRunnerSecretsConfig(c *gin.Context) { } // Update spec.runnerSecretsName - spec, _ := obj.Object["spec"].(map[string]interface{}) + spec, ok := GetSpecMap(obj) + if !ok { + spec = make(map[string]interface{}) + } if spec == nil { spec = map[string]interface{}{} obj.Object["spec"] = spec @@ -138,7 +141,7 @@ func ListRunnerSecrets(c *gin.Context) { } secretName := "" if obj != nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { + if spec, ok := GetSpecMap(obj); ok { if v, ok := spec["runnerSecretsName"].(string); ok { secretName = v } diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go deleted file mode 100644 index e6896e414..000000000 --- a/components/backend/handlers/sessions.go +++ /dev/null @@ -1,2562 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "strings" - "time" - - "ambient-code-backend/types" - - "github.com/gin-gonic/gin" - authnv1 "k8s.io/api/authentication/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - ktypes "k8s.io/apimachinery/pkg/types" - intstr "k8s.io/apimachinery/pkg/util/intstr" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" -) - -// Package-level variables for session handlers (set from main package) -var ( - GetAgenticSessionV1Alpha1Resource func() schema.GroupVersionResource - DynamicClient dynamic.Interface - GetGitHubToken func(context.Context, *kubernetes.Clientset, dynamic.Interface, string, string) (string, error) - DeriveRepoFolderFromURL func(string) string -) - -// contentListItem represents a file/directory in the workspace -type contentListItem struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"isDir"` - Size int64 `json:"size"` - ModifiedAt string `json:"modifiedAt"` -} - -// parseSpec parses AgenticSessionSpec with v1alpha1 fields -func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { - result := types.AgenticSessionSpec{} - - if prompt, ok := spec["prompt"].(string); ok { - result.Prompt = prompt - } - - if interactive, ok := spec["interactive"].(bool); ok { - result.Interactive = interactive - } - - if displayName, ok := spec["displayName"].(string); ok { - result.DisplayName = displayName - } - - if project, ok := spec["project"].(string); ok { - result.Project = project - } - - if timeout, ok := spec["timeout"].(float64); ok { - result.Timeout = int(timeout) - } - - if llmSettings, ok := spec["llmSettings"].(map[string]interface{}); ok { - if model, ok := llmSettings["model"].(string); ok { - result.LLMSettings.Model = model - } - if temperature, ok := llmSettings["temperature"].(float64); ok { - result.LLMSettings.Temperature = temperature - } - if maxTokens, ok := llmSettings["maxTokens"].(float64); ok { - result.LLMSettings.MaxTokens = int(maxTokens) - } - } - - // environmentVariables passthrough - if env, ok := spec["environmentVariables"].(map[string]interface{}); ok { - resultEnv := make(map[string]string, len(env)) - for k, v := range env { - if s, ok := v.(string); ok { - resultEnv[k] = s - } - } - if len(resultEnv) > 0 { - result.EnvironmentVariables = resultEnv - } - } - - if userContext, ok := spec["userContext"].(map[string]interface{}); ok { - uc := &types.UserContext{} - if userID, ok := userContext["userId"].(string); ok { - uc.UserID = userID - } - if displayName, ok := userContext["displayName"].(string); ok { - uc.DisplayName = displayName - } - if groups, ok := userContext["groups"].([]interface{}); ok { - for _, group := range groups { - if groupStr, ok := group.(string); ok { - uc.Groups = append(uc.Groups, groupStr) - } - } - } - result.UserContext = uc - } - - if botAccount, ok := spec["botAccount"].(map[string]interface{}); ok { - ba := &types.BotAccountRef{} - if name, ok := botAccount["name"].(string); ok { - ba.Name = name - } - result.BotAccount = ba - } - - if resourceOverrides, ok := spec["resourceOverrides"].(map[string]interface{}); ok { - ro := &types.ResourceOverrides{} - if cpu, ok := resourceOverrides["cpu"].(string); ok { - ro.CPU = cpu - } - if memory, ok := resourceOverrides["memory"].(string); ok { - ro.Memory = memory - } - if storageClass, ok := resourceOverrides["storageClass"].(string); ok { - ro.StorageClass = storageClass - } - if priorityClass, ok := resourceOverrides["priorityClass"].(string); ok { - ro.PriorityClass = priorityClass - } - result.ResourceOverrides = ro - } - - // Multi-repo parsing (unified repos) - if arr, ok := spec["repos"].([]interface{}); ok { - repos := make([]types.SessionRepoMapping, 0, len(arr)) - for _, it := range arr { - m, ok := it.(map[string]interface{}) - if !ok { - continue - } - r := types.SessionRepoMapping{} - if in, ok := m["input"].(map[string]interface{}); ok { - ng := types.NamedGitRepo{} - if s, ok := in["url"].(string); ok { - ng.URL = s - } - if s, ok := in["branch"].(string); ok && strings.TrimSpace(s) != "" { - ng.Branch = types.StringPtr(s) - } - r.Input = ng - } - if out, ok := m["output"].(map[string]interface{}); ok { - og := &types.OutputNamedGitRepo{} - if s, ok := out["url"].(string); ok { - og.URL = s - } - if s, ok := out["branch"].(string); ok && strings.TrimSpace(s) != "" { - og.Branch = types.StringPtr(s) - } - r.Output = og - } - // Include per-repo status if present - if st, ok := m["status"].(string); ok { - r.Status = types.StringPtr(st) - } - if strings.TrimSpace(r.Input.URL) != "" { - repos = append(repos, r) - } - } - result.Repos = repos - } - if idx, ok := spec["mainRepoIndex"].(float64); ok { - idxInt := int(idx) - result.MainRepoIndex = &idxInt - } - - return result -} - -// parseStatus parses AgenticSessionStatus with v1alpha1 fields -func parseStatus(status map[string]interface{}) *types.AgenticSessionStatus { - result := &types.AgenticSessionStatus{} - - if phase, ok := status["phase"].(string); ok { - result.Phase = phase - } - - if message, ok := status["message"].(string); ok { - result.Message = message - } - - if startTime, ok := status["startTime"].(string); ok { - result.StartTime = &startTime - } - - if completionTime, ok := status["completionTime"].(string); ok { - result.CompletionTime = &completionTime - } - - if jobName, ok := status["jobName"].(string); ok { - result.JobName = jobName - } - - // New: result summary fields (top-level in status) - if st, ok := status["subtype"].(string); ok { - result.Subtype = st - } - - if ie, ok := status["is_error"].(bool); ok { - result.IsError = ie - } - if nt, ok := status["num_turns"].(float64); ok { - result.NumTurns = int(nt) - } - if sid, ok := status["session_id"].(string); ok { - result.SessionID = sid - } - if tcu, ok := status["total_cost_usd"].(float64); ok { - result.TotalCostUSD = &tcu - } - if usage, ok := status["usage"].(map[string]interface{}); ok { - result.Usage = usage - } - if res, ok := status["result"].(string); ok { - result.Result = &res - } - - if stateDir, ok := status["stateDir"].(string); ok { - result.StateDir = stateDir - } - - return result -} - -// V2 API Handlers - Multi-tenant session management - -func ListSessions(c *gin.Context) { - project := c.GetString("project") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - _ = reqK8s - gvr := GetAgenticSessionV1Alpha1Resource() - - list, err := reqDyn.Resource(gvr).Namespace(project).List(context.TODO(), v1.ListOptions{}) - if err != nil { - log.Printf("Failed to list agentic sessions in project %s: %v", project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list agentic sessions"}) - return - } - - var sessions []types.AgenticSession - for _, item := range list.Items { - session := types.AgenticSession{ - APIVersion: item.GetAPIVersion(), - Kind: item.GetKind(), - Metadata: item.Object["metadata"].(map[string]interface{}), - } - - if spec, ok := item.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } - - if status, ok := item.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) - } - - sessions = append(sessions, session) - } - - c.JSON(http.StatusOK, gin.H{"items": sessions}) -} - -func CreateSession(c *gin.Context) { - project := c.GetString("project") - // Use backend service account clients for CR writes - if DynamicClient == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) - return - } - var req types.CreateAgenticSessionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validation for multi-repo can be added here if needed - - // Set defaults for LLM settings if not provided - llmSettings := types.LLMSettings{ - Model: "sonnet", - Temperature: 0.7, - MaxTokens: 4000, - } - if req.LLMSettings != nil { - if req.LLMSettings.Model != "" { - llmSettings.Model = req.LLMSettings.Model - } - if req.LLMSettings.Temperature != 0 { - llmSettings.Temperature = req.LLMSettings.Temperature - } - if req.LLMSettings.MaxTokens != 0 { - llmSettings.MaxTokens = req.LLMSettings.MaxTokens - } - } - - timeout := 300 - if req.Timeout != nil { - timeout = *req.Timeout - } - - // Generate unique name - timestamp := time.Now().Unix() - name := fmt.Sprintf("agentic-session-%d", timestamp) - - // Create the custom resource - // Metadata - metadata := map[string]interface{}{ - "name": name, - "namespace": project, - } - if len(req.Labels) > 0 { - labels := map[string]interface{}{} - for k, v := range req.Labels { - labels[k] = v - } - metadata["labels"] = labels - } - if len(req.Annotations) > 0 { - annotations := map[string]interface{}{} - for k, v := range req.Annotations { - annotations[k] = v - } - metadata["annotations"] = annotations - } - - session := map[string]interface{}{ - "apiVersion": "vteam.ambient-code/v1alpha1", - "kind": "AgenticSession", - "metadata": metadata, - "spec": map[string]interface{}{ - "prompt": req.Prompt, - "displayName": req.DisplayName, - "project": project, - "llmSettings": map[string]interface{}{ - "model": llmSettings.Model, - "temperature": llmSettings.Temperature, - "maxTokens": llmSettings.MaxTokens, - }, - "timeout": timeout, - }, - "status": map[string]interface{}{ - "phase": "Pending", - }, - } - - // Optional environment variables passthrough (always, independent of git config presence) - envVars := make(map[string]string) - for k, v := range req.EnvironmentVariables { - envVars[k] = v - } - - // Handle session continuation - if req.ParentSessionID != "" { - envVars["PARENT_SESSION_ID"] = req.ParentSessionID - // Add annotation to track continuation lineage - if metadata["annotations"] == nil { - metadata["annotations"] = make(map[string]interface{}) - } - annotations := metadata["annotations"].(map[string]interface{}) - annotations["vteam.ambient-code/parent-session-id"] = req.ParentSessionID - log.Printf("Creating continuation session from parent %s", req.ParentSessionID) - - // Clean up temp-content pod from parent session to free the PVC - // This prevents Multi-Attach errors when the new session tries to mount the same workspace - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s != nil { - tempPodName := fmt.Sprintf("temp-content-%s", req.ParentSessionID) - if err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), tempPodName, v1.DeleteOptions{}); err != nil { - if !errors.IsNotFound(err) { - log.Printf("CreateSession: failed to delete temp-content pod %s (non-fatal): %v", tempPodName, err) - } - } else { - log.Printf("CreateSession: deleted temp-content pod %s to free PVC for continuation", tempPodName) - } - } - } - - if len(envVars) > 0 { - spec := session["spec"].(map[string]interface{}) - spec["environmentVariables"] = envVars - } - - // Interactive flag - if req.Interactive != nil { - session["spec"].(map[string]interface{})["interactive"] = *req.Interactive - } - - // AutoPushOnComplete flag - if req.AutoPushOnComplete != nil { - session["spec"].(map[string]interface{})["autoPushOnComplete"] = *req.AutoPushOnComplete - } - - // Set multi-repo configuration on spec - { - spec := session["spec"].(map[string]interface{}) - // Multi-repo pass-through (unified repos) - if len(req.Repos) > 0 { - arr := make([]map[string]interface{}, 0, len(req.Repos)) - for _, r := range req.Repos { - m := map[string]interface{}{} - in := map[string]interface{}{"url": r.Input.URL} - if r.Input.Branch != nil { - in["branch"] = *r.Input.Branch - } - m["input"] = in - if r.Output != nil { - out := map[string]interface{}{"url": r.Output.URL} - if r.Output.Branch != nil { - out["branch"] = *r.Output.Branch - } - m["output"] = out - } - // Remove default repo status; status will be set explicitly when pushed/abandoned - // m["status"] intentionally unset at creation time - arr = append(arr, m) - } - spec["repos"] = arr - } - if req.MainRepoIndex != nil { - spec["mainRepoIndex"] = *req.MainRepoIndex - } - } - - // Handle RFE workflow branch management - { - rfeWorkflowID := "" - // Check if RFE workflow ID is in labels - if len(req.Labels) > 0 { - if id, ok := req.Labels["rfe-workflow"]; ok { - rfeWorkflowID = id - } - } - - // If linked to an RFE workflow, fetch it and set the branch - if rfeWorkflowID != "" { - // Get request-scoped dynamic client for fetching RFE workflow - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn != nil { - rfeGvr := GetRFEWorkflowResource() - if rfeGvr != (schema.GroupVersionResource{}) { - rfeObj, err := reqDyn.Resource(rfeGvr).Namespace(project).Get(c.Request.Context(), rfeWorkflowID, v1.GetOptions{}) - if err == nil { - rfeWf := RfeFromUnstructured(rfeObj) - if rfeWf != nil && rfeWf.BranchName != "" { - // Access spec from session object - spec := session["spec"].(map[string]interface{}) - - // Override branch for all repos to use feature branch - if repos, ok := spec["repos"].([]map[string]interface{}); ok { - for i := range repos { - // Always override input branch with feature branch - if input, ok := repos[i]["input"].(map[string]interface{}); ok { - input["branch"] = rfeWf.BranchName - } - // Always override output branch with feature branch - if output, ok := repos[i]["output"].(map[string]interface{}); ok { - output["branch"] = rfeWf.BranchName - } - } - } - - log.Printf("Set RFE branch %s for session %s", rfeWf.BranchName, name) - } - } else { - log.Printf("Warning: Failed to fetch RFE workflow %s: %v", rfeWorkflowID, err) - } - } - } - } - } - - // Add userContext derived from authenticated caller; ignore client-supplied userId - { - uidVal, _ := c.Get("userID") - uid, _ := uidVal.(string) - uid = strings.TrimSpace(uid) - if uid != "" { - displayName := "" - if v, ok := c.Get("userName"); ok { - if s, ok2 := v.(string); ok2 { - displayName = s - } - } - groups := []string{} - if v, ok := c.Get("userGroups"); ok { - if gg, ok2 := v.([]string); ok2 { - groups = gg - } - } - // Fallbacks for non-identity fields only - if displayName == "" && req.UserContext != nil { - displayName = req.UserContext.DisplayName - } - if len(groups) == 0 && req.UserContext != nil { - groups = req.UserContext.Groups - } - session["spec"].(map[string]interface{})["userContext"] = map[string]interface{}{ - "userId": uid, - "displayName": displayName, - "groups": groups, - } - } - } - - // Add botAccount if provided - if req.BotAccount != nil { - session["spec"].(map[string]interface{})["botAccount"] = map[string]interface{}{ - "name": req.BotAccount.Name, - } - } - - // Add resourceOverrides if provided - if req.ResourceOverrides != nil { - resourceOverrides := make(map[string]interface{}) - if req.ResourceOverrides.CPU != "" { - resourceOverrides["cpu"] = req.ResourceOverrides.CPU - } - if req.ResourceOverrides.Memory != "" { - resourceOverrides["memory"] = req.ResourceOverrides.Memory - } - if req.ResourceOverrides.StorageClass != "" { - resourceOverrides["storageClass"] = req.ResourceOverrides.StorageClass - } - if req.ResourceOverrides.PriorityClass != "" { - resourceOverrides["priorityClass"] = req.ResourceOverrides.PriorityClass - } - if len(resourceOverrides) > 0 { - session["spec"].(map[string]interface{})["resourceOverrides"] = resourceOverrides - } - } - - gvr := GetAgenticSessionV1Alpha1Resource() - obj := &unstructured.Unstructured{Object: session} - - created, err := DynamicClient.Resource(gvr).Namespace(project).Create(context.TODO(), obj, v1.CreateOptions{}) - if err != nil { - log.Printf("Failed to create agentic session in project %s: %v", project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agentic session"}) - return - } - - // Best-effort prefill of agent markdown into PVC workspace for immediate UI availability - // Uses AGENT_PERSONAS or AGENT_PERSONA if provided in request environment variables - func() { - defer func() { _ = recover() }() - personasCsv := "" - if v, ok := req.EnvironmentVariables["AGENT_PERSONAS"]; ok && strings.TrimSpace(v) != "" { - personasCsv = v - } else if v, ok := req.EnvironmentVariables["AGENT_PERSONA"]; ok && strings.TrimSpace(v) != "" { - personasCsv = v - } - if strings.TrimSpace(personasCsv) == "" { - return - } - // content service removed; skip workspace path handling - // Write each agent markdown - for _, p := range strings.Split(personasCsv, ",") { - persona := strings.TrimSpace(p) - if persona == "" { - continue - } - // ambient-content removed: skip agent prefill writes - } - }() - - // Preferred method: provision a per-session ServiceAccount token for the runner (backend SA) - if err := provisionRunnerTokenForSession(c, K8sClient, DynamicClient, project, name); err != nil { - // Non-fatal: log and continue. Operator may retry later if implemented. - log.Printf("Warning: failed to provision runner token for session %s/%s: %v", project, name, err) - } - - c.JSON(http.StatusCreated, gin.H{ - "message": "Agentic session created successfully", - "name": name, - "uid": created.GetUID(), - }) -} - -// provisionRunnerTokenForSession creates a per-session ServiceAccount, grants minimal RBAC, -// mints a short-lived token, stores it in a Secret, and annotates the AgenticSession with the Secret name. -func provisionRunnerTokenForSession(c *gin.Context, reqK8s *kubernetes.Clientset, reqDyn dynamic.Interface, project string, sessionName string) error { - // Load owning AgenticSession to parent all resources - gvr := GetAgenticSessionV1Alpha1Resource() - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) - if err != nil { - return fmt.Errorf("get AgenticSession: %w", err) - } - ownerRef := v1.OwnerReference{ - APIVersion: obj.GetAPIVersion(), - Kind: obj.GetKind(), - Name: obj.GetName(), - UID: obj.GetUID(), - Controller: types.BoolPtr(true), - } - - // Create ServiceAccount - saName := fmt.Sprintf("ambient-session-%s", sessionName) - sa := &corev1.ServiceAccount{ - ObjectMeta: v1.ObjectMeta{ - Name: saName, - Namespace: project, - Labels: map[string]string{"app": "ambient-runner"}, - OwnerReferences: []v1.OwnerReference{ownerRef}, - }, - } - if _, err := reqK8s.CoreV1().ServiceAccounts(project).Create(c.Request.Context(), sa, v1.CreateOptions{}); err != nil { - if !errors.IsAlreadyExists(err) { - return fmt.Errorf("create SA: %w", err) - } - } - - // Create Role with least-privilege for updating AgenticSession status and annotations - roleName := fmt.Sprintf("ambient-session-%s-role", sessionName) - role := &rbacv1.Role{ - ObjectMeta: v1.ObjectMeta{ - Name: roleName, - Namespace: project, - OwnerReferences: []v1.OwnerReference{ownerRef}, - }, - Rules: []rbacv1.PolicyRule{ - { - APIGroups: []string{"vteam.ambient-code"}, - Resources: []string{"agenticsessions/status"}, - Verbs: []string{"get", "update", "patch"}, - }, - { - APIGroups: []string{"vteam.ambient-code"}, - Resources: []string{"agenticsessions"}, - Verbs: []string{"get", "list", "watch", "update", "patch"}, // Added update, patch for annotations - }, - { - APIGroups: []string{"authorization.k8s.io"}, - Resources: []string{"selfsubjectaccessreviews"}, - Verbs: []string{"create"}, - }, - }, - } - // Try to create or update the Role to ensure it has latest permissions - if _, err := reqK8s.RbacV1().Roles(project).Create(c.Request.Context(), role, v1.CreateOptions{}); err != nil { - if errors.IsAlreadyExists(err) { - // Role exists - update it to ensure it has the latest permissions (including update/patch) - log.Printf("Role %s already exists, updating with latest permissions", roleName) - if _, err := reqK8s.RbacV1().Roles(project).Update(c.Request.Context(), role, v1.UpdateOptions{}); err != nil { - return fmt.Errorf("update Role: %w", err) - } - log.Printf("Successfully updated Role %s with annotation update permissions", roleName) - } else { - return fmt.Errorf("create Role: %w", err) - } - } - - // Bind Role to the ServiceAccount - rbName := fmt.Sprintf("ambient-session-%s-rb", sessionName) - rb := &rbacv1.RoleBinding{ - ObjectMeta: v1.ObjectMeta{ - Name: rbName, - Namespace: project, - OwnerReferences: []v1.OwnerReference{ownerRef}, - }, - RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: roleName}, - Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: saName, Namespace: project}}, - } - if _, err := reqK8s.RbacV1().RoleBindings(project).Create(context.TODO(), rb, v1.CreateOptions{}); err != nil { - if !errors.IsAlreadyExists(err) { - return fmt.Errorf("create RoleBinding: %w", err) - } - } - - // Mint short-lived K8s ServiceAccount token for CR status updates - tr := &authnv1.TokenRequest{Spec: authnv1.TokenRequestSpec{}} - tok, err := reqK8s.CoreV1().ServiceAccounts(project).CreateToken(c.Request.Context(), saName, tr, v1.CreateOptions{}) - if err != nil { - return fmt.Errorf("mint token: %w", err) - } - k8sToken := tok.Status.Token - if strings.TrimSpace(k8sToken) == "" { - return fmt.Errorf("received empty token for SA %s", saName) - } - - // Only store the K8s token; GitHub tokens are minted on-demand by the runner - secretData := map[string]string{ - "k8s-token": k8sToken, - } - - // Store token in a Secret (update if exists to refresh token) - secretName := fmt.Sprintf("ambient-runner-token-%s", sessionName) - sec := &corev1.Secret{ - ObjectMeta: v1.ObjectMeta{ - Name: secretName, - Namespace: project, - Labels: map[string]string{"app": "ambient-runner-token"}, - OwnerReferences: []v1.OwnerReference{ownerRef}, - }, - Type: corev1.SecretTypeOpaque, - StringData: secretData, - } - - // Try to create the secret - if _, err := reqK8s.CoreV1().Secrets(project).Create(c.Request.Context(), sec, v1.CreateOptions{}); err != nil { - if errors.IsAlreadyExists(err) { - // Secret exists - update it with fresh token - log.Printf("Updating existing secret %s with fresh token", secretName) - if _, err := reqK8s.CoreV1().Secrets(project).Update(c.Request.Context(), sec, v1.UpdateOptions{}); err != nil { - return fmt.Errorf("update Secret: %w", err) - } - log.Printf("Successfully updated secret %s with fresh token", secretName) - } else { - return fmt.Errorf("create Secret: %w", err) - } - } - - // Annotate the AgenticSession with the Secret and SA names (conflict-safe patch) - patch := map[string]interface{}{ - "metadata": map[string]interface{}{ - "annotations": map[string]string{ - "ambient-code.io/runner-token-secret": secretName, - "ambient-code.io/runner-sa": saName, - }, - }, - } - b, _ := json.Marshal(patch) - if _, err := reqDyn.Resource(gvr).Namespace(project).Patch(c.Request.Context(), obj.GetName(), ktypes.MergePatchType, b, v1.PatchOptions{}); err != nil { - return fmt.Errorf("annotate AgenticSession: %w", err) - } - - return nil -} - -func GetSession(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - _ = reqK8s - gvr := GetAgenticSessionV1Alpha1Resource() - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) - return - } - - session := types.AgenticSession{ - APIVersion: item.GetAPIVersion(), - Kind: item.GetKind(), - Metadata: item.Object["metadata"].(map[string]interface{}), - } - - if spec, ok := item.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } - - if status, ok := item.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) - } - - c.JSON(http.StatusOK, session) -} - -// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/token -// Auth: Authorization: Bearer (K8s SA token with audience "ambient-backend") -// Validates the token via TokenReview, ensures SA matches CR annotation, and returns a short-lived GitHub token. -func MintSessionGitHubToken(c *gin.Context) { - project := c.Param("projectName") - sessionName := c.Param("sessionName") - - rawAuth := strings.TrimSpace(c.GetHeader("Authorization")) - if rawAuth == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing Authorization header"}) - return - } - parts := strings.SplitN(rawAuth, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid Authorization header"}) - return - } - token := strings.TrimSpace(parts[1]) - if token == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "empty token"}) - return - } - - // TokenReview using default audience (works with standard SA tokens) - tr := &authnv1.TokenReview{Spec: authnv1.TokenReviewSpec{Token: token}} - rv, err := K8sClient.AuthenticationV1().TokenReviews().Create(c.Request.Context(), tr, v1.CreateOptions{}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "token review failed"}) - return - } - if rv.Status.Error != "" || !rv.Status.Authenticated { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"}) - return - } - subj := strings.TrimSpace(rv.Status.User.Username) - const pfx = "system:serviceaccount:" - if !strings.HasPrefix(subj, pfx) { - c.JSON(http.StatusForbidden, gin.H{"error": "subject is not a service account"}) - return - } - rest := strings.TrimPrefix(subj, pfx) - segs := strings.SplitN(rest, ":", 2) - if len(segs) != 2 { - c.JSON(http.StatusForbidden, gin.H{"error": "invalid service account subject"}) - return - } - nsFromToken, saFromToken := segs[0], segs[1] - if nsFromToken != project { - c.JSON(http.StatusForbidden, gin.H{"error": "namespace mismatch"}) - return - } - - // Load session and verify SA matches annotation - gvr := GetAgenticSessionV1Alpha1Resource() - obj, err := DynamicClient.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read session"}) - return - } - meta, _ := obj.Object["metadata"].(map[string]interface{}) - anns, _ := meta["annotations"].(map[string]interface{}) - expectedSA := "" - if anns != nil { - if v, ok := anns["ambient-code.io/runner-sa"].(string); ok { - expectedSA = strings.TrimSpace(v) - } - } - if expectedSA == "" || expectedSA != saFromToken { - c.JSON(http.StatusForbidden, gin.H{"error": "service account not authorized for session"}) - return - } - - // Read authoritative userId from spec.userContext.userId - spec, _ := obj.Object["spec"].(map[string]interface{}) - userId := "" - if spec != nil { - if uc, ok := spec["userContext"].(map[string]interface{}); ok { - if v, ok := uc["userId"].(string); ok { - userId = strings.TrimSpace(v) - } - } - } - if userId == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "session missing user context"}) - return - } - - // Get GitHub token (GitHub App or PAT fallback via project runner secret) - tokenStr, err := GetGitHubToken(c.Request.Context(), K8sClient, DynamicClient, project, userId) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - // Note: PATs don't have expiration, so we omit expiresAt for simplicity - // Runners should treat all tokens as short-lived and request new ones as needed - c.JSON(http.StatusOK, gin.H{"token": tokenStr}) -} - -func PatchSession(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - _, reqDyn := GetK8sClientsForRequest(c) - - var patch map[string]interface{} - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - gvr := GetAgenticSessionV1Alpha1Resource() - - // Get current resource - item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) - return - } - - // Apply patch to metadata annotations - if metaPatch, ok := patch["metadata"].(map[string]interface{}); ok { - if annsPatch, ok := metaPatch["annotations"].(map[string]interface{}); ok { - metadata := item.Object["metadata"].(map[string]interface{}) - if metadata["annotations"] == nil { - metadata["annotations"] = make(map[string]interface{}) - } - anns := metadata["annotations"].(map[string]interface{}) - for k, v := range annsPatch { - anns[k] = v - } - } - } - - // Update the resource - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to patch agentic session %s: %v", sessionName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to patch session"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Session patched successfully", "annotations": updated.GetAnnotations()}) -} - -func UpdateSession(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - _ = reqK8s - - var req types.CreateAgenticSessionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - gvr := GetAgenticSessionV1Alpha1Resource() - - // Get current resource with brief retry to avoid race on creation - var item *unstructured.Unstructured - var err error - for attempt := 0; attempt < 5; attempt++ { - item, err = reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err == nil { - break - } - if errors.IsNotFound(err) { - time.Sleep(300 * time.Millisecond) - continue - } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) - return - } - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - - // Update spec - spec := item.Object["spec"].(map[string]interface{}) - spec["prompt"] = req.Prompt - spec["displayName"] = req.DisplayName - - if req.LLMSettings != nil { - llmSettings := make(map[string]interface{}) - if req.LLMSettings.Model != "" { - llmSettings["model"] = req.LLMSettings.Model - } - if req.LLMSettings.Temperature != 0 { - llmSettings["temperature"] = req.LLMSettings.Temperature - } - if req.LLMSettings.MaxTokens != 0 { - llmSettings["maxTokens"] = req.LLMSettings.MaxTokens - } - spec["llmSettings"] = llmSettings - } - - if req.Timeout != nil { - spec["timeout"] = *req.Timeout - } - - // Update the resource - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to update agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session"}) - return - } - - // Parse and return updated session - session := types.AgenticSession{ - APIVersion: updated.GetAPIVersion(), - Kind: updated.GetKind(), - Metadata: updated.Object["metadata"].(map[string]interface{}), - } - - if spec, ok := updated.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } - - if status, ok := updated.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) - } - - c.JSON(http.StatusOK, session) -} - -// PUT /api/projects/:projectName/agentic-sessions/:sessionName/displayname -// updateSessionDisplayName updates only the spec.displayName field on the AgenticSession -func UpdateSessionDisplayName(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - _, reqDyn := GetK8sClientsForRequest(c) - - var req struct { - DisplayName string `json:"displayName" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - gvr := GetAgenticSessionV1Alpha1Resource() - - // Retrieve current resource - item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) - return - } - - // Update only displayName in spec - spec, ok := item.Object["spec"].(map[string]interface{}) - if !ok { - spec = make(map[string]interface{}) - item.Object["spec"] = spec - } - spec["displayName"] = req.DisplayName - - // Persist the change - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to update display name for agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update display name"}) - return - } - - // Respond with updated session summary - session := types.AgenticSession{ - APIVersion: updated.GetAPIVersion(), - Kind: updated.GetKind(), - Metadata: updated.Object["metadata"].(map[string]interface{}), - } - if s, ok := updated.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(s) - } - if st, ok := updated.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(st) - } - - c.JSON(http.StatusOK, session) -} - -func DeleteSession(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - _ = reqK8s - gvr := GetAgenticSessionV1Alpha1Resource() - - err := reqDyn.Resource(gvr).Namespace(project).Delete(context.TODO(), sessionName, v1.DeleteOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - log.Printf("Failed to delete agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agentic session"}) - return - } - - c.Status(http.StatusNoContent) -} - -func CloneSession(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - _, reqDyn := GetK8sClientsForRequest(c) - - var req types.CloneSessionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - gvr := GetAgenticSessionV1Alpha1Resource() - - // Get source session - sourceItem, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Source session not found"}) - return - } - log.Printf("Failed to get source agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source agentic session"}) - return - } - - // Validate target project exists and is managed by Ambient via OpenShift Project - projGvr := GetOpenShiftProjectResource() - projObj, err := reqDyn.Resource(projGvr).Get(context.TODO(), req.TargetProject, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Target project not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate target project"}) - return - } - - isAmbient := false - if meta, ok := projObj.Object["metadata"].(map[string]interface{}); ok { - if raw, ok := meta["labels"].(map[string]interface{}); ok { - if v, ok := raw["ambient-code.io/managed"].(string); ok && v == "true" { - isAmbient = true - } - } - } - if !isAmbient { - c.JSON(http.StatusForbidden, gin.H{"error": "Target project is not managed by Ambient"}) - return - } - - // Ensure unique target session name in target namespace; if exists, append "-duplicate" (and numeric suffix) - newName := strings.TrimSpace(req.NewSessionName) - if newName == "" { - newName = sessionName - } - finalName := newName - conflicted := false - for i := 0; i < 50; i++ { - _, getErr := reqDyn.Resource(gvr).Namespace(req.TargetProject).Get(context.TODO(), finalName, v1.GetOptions{}) - if errors.IsNotFound(getErr) { - break - } - if getErr != nil && !errors.IsNotFound(getErr) { - // On unexpected error, still attempt to proceed with a duplicate suffix to reduce collision chance - log.Printf("cloneSession: name check encountered error for %s/%s: %v", req.TargetProject, finalName, getErr) - } - conflicted = true - if i == 0 { - finalName = fmt.Sprintf("%s-duplicate", newName) - } else { - finalName = fmt.Sprintf("%s-duplicate-%d", newName, i+1) - } - } - - // Create cloned session - clonedSession := map[string]interface{}{ - "apiVersion": "vteam.ambient-code/v1alpha1", - "kind": "AgenticSession", - "metadata": map[string]interface{}{ - "name": finalName, - "namespace": req.TargetProject, - }, - "spec": sourceItem.Object["spec"], - "status": map[string]interface{}{ - "phase": "Pending", - }, - } - - // Update project in spec - clonedSpec := clonedSession["spec"].(map[string]interface{}) - clonedSpec["project"] = req.TargetProject - if conflicted { - if dn, ok := clonedSpec["displayName"].(string); ok && strings.TrimSpace(dn) != "" { - clonedSpec["displayName"] = fmt.Sprintf("%s (Duplicate)", dn) - } else { - clonedSpec["displayName"] = fmt.Sprintf("%s (Duplicate)", finalName) - } - } - - obj := &unstructured.Unstructured{Object: clonedSession} - - created, err := reqDyn.Resource(gvr).Namespace(req.TargetProject).Create(context.TODO(), obj, v1.CreateOptions{}) - if err != nil { - log.Printf("Failed to create cloned agentic session in project %s: %v", req.TargetProject, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cloned agentic session"}) - return - } - - // Parse and return created session - session := types.AgenticSession{ - APIVersion: created.GetAPIVersion(), - Kind: created.GetKind(), - Metadata: created.Object["metadata"].(map[string]interface{}), - } - - if spec, ok := created.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } - - if status, ok := created.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) - } - - c.JSON(http.StatusCreated, session) -} - -// ensureRunnerRolePermissions updates the runner role to ensure it has all required permissions -// This is useful for existing sessions that were created before we added new permissions -func ensureRunnerRolePermissions(c *gin.Context, reqK8s *kubernetes.Clientset, project string, sessionName string) error { - roleName := fmt.Sprintf("ambient-session-%s-role", sessionName) - - // Get existing role - existingRole, err := reqK8s.RbacV1().Roles(project).Get(c.Request.Context(), roleName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - log.Printf("Role %s not found for session %s - will be created by operator", roleName, sessionName) - return nil - } - return fmt.Errorf("get role: %w", err) - } - - // Check if role has selfsubjectaccessreviews permission - hasSelfSubjectAccessReview := false - for _, rule := range existingRole.Rules { - for _, apiGroup := range rule.APIGroups { - if apiGroup == "authorization.k8s.io" { - for _, resource := range rule.Resources { - if resource == "selfsubjectaccessreviews" { - hasSelfSubjectAccessReview = true - break - } - } - } - } - } - - if hasSelfSubjectAccessReview { - log.Printf("Role %s already has selfsubjectaccessreviews permission", roleName) - return nil - } - - // Add missing permission - log.Printf("Updating role %s to add selfsubjectaccessreviews permission", roleName) - existingRole.Rules = append(existingRole.Rules, rbacv1.PolicyRule{ - APIGroups: []string{"authorization.k8s.io"}, - Resources: []string{"selfsubjectaccessreviews"}, - Verbs: []string{"create"}, - }) - - _, err = reqK8s.RbacV1().Roles(project).Update(c.Request.Context(), existingRole, v1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("update role: %w", err) - } - - log.Printf("Successfully updated role %s with selfsubjectaccessreviews permission", roleName) - return nil -} - -func StartSession(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - gvr := GetAgenticSessionV1Alpha1Resource() - - // Get current resource - item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) - return - } - - // Ensure runner role has required permissions (update if needed for existing sessions) - if err := ensureRunnerRolePermissions(c, reqK8s, project, sessionName); err != nil { - log.Printf("Warning: failed to ensure runner role permissions for %s: %v", sessionName, err) - // Non-fatal - continue with restart - } - - // Clean up temp-content pod if it exists to free the PVC - // This prevents Multi-Attach errors when the session job tries to mount the workspace - if reqK8s != nil { - tempPodName := fmt.Sprintf("temp-content-%s", sessionName) - if err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), tempPodName, v1.DeleteOptions{}); err != nil { - if !errors.IsNotFound(err) { - log.Printf("StartSession: failed to delete temp-content pod %s (non-fatal): %v", tempPodName, err) - } - } else { - log.Printf("StartSession: deleted temp-content pod %s to free PVC", tempPodName) - } - } - - // Check if this is a continuation (session is in a terminal phase) - // Terminal phases from CRD: Completed, Failed, Stopped, Error - isActualContinuation := false - currentPhase := "" - if currentStatus, ok := item.Object["status"].(map[string]interface{}); ok { - if phase, ok := currentStatus["phase"].(string); ok { - currentPhase = phase - terminalPhases := []string{"Completed", "Failed", "Stopped", "Error"} - for _, terminalPhase := range terminalPhases { - if phase == terminalPhase { - isActualContinuation = true - log.Printf("StartSession: Detected continuation - session is in terminal phase: %s", phase) - break - } - } - } - } - - if !isActualContinuation { - log.Printf("StartSession: Not a continuation - current phase is: %s (not in terminal phases)", currentPhase) - } - - // Only set parent session annotation if this is an actual continuation - // Don't set it on first start, even though StartSession can be called for initial creation - if isActualContinuation { - annotations := item.GetAnnotations() - if annotations == nil { - annotations = make(map[string]string) - } - annotations["vteam.ambient-code/parent-session-id"] = sessionName - item.SetAnnotations(annotations) - log.Printf("StartSession: Set parent-session-id annotation to %s for continuation (has completion time)", sessionName) - - // For headless sessions being continued, force interactive mode - if spec, ok := item.Object["spec"].(map[string]interface{}); ok { - if interactive, ok := spec["interactive"].(bool); !ok || !interactive { - // Session was headless, convert to interactive - spec["interactive"] = true - log.Printf("StartSession: Converting headless session to interactive for continuation") - } - } - - // Update the metadata and spec to persist the annotation and interactive flag - item, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to update agentic session metadata %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session metadata"}) - return - } - - // Regenerate runner token for continuation (old token may have expired) - log.Printf("StartSession: Regenerating runner token for session continuation") - if err := provisionRunnerTokenForSession(c, reqK8s, reqDyn, project, sessionName); err != nil { - log.Printf("Warning: failed to regenerate runner token for session %s/%s: %v", project, sessionName, err) - // Non-fatal: continue anyway, operator may retry - } else { - log.Printf("StartSession: Successfully regenerated runner token for continuation") - - // Delete the old job so operator creates a new one - // This ensures fresh token and clean state - jobName := fmt.Sprintf("ambient-runner-%s", sessionName) - log.Printf("StartSession: Deleting old job %s to allow operator to create fresh one", jobName) - if err := reqK8s.BatchV1().Jobs(project).Delete(c.Request.Context(), jobName, v1.DeleteOptions{ - PropagationPolicy: func() *v1.DeletionPropagation { p := v1.DeletePropagationBackground; return &p }(), - }); err != nil { - if !errors.IsNotFound(err) { - log.Printf("Warning: failed to delete old job %s: %v", jobName, err) - } else { - log.Printf("StartSession: Job %s already gone", jobName) - } - } else { - log.Printf("StartSession: Successfully deleted old job %s", jobName) - } - } - } else { - log.Printf("StartSession: Not setting parent-session-id (first run, no completion time)") - } - - // Now update status to trigger start (using the fresh object from Update) - if item.Object["status"] == nil { - item.Object["status"] = make(map[string]interface{}) - } - - status := item.Object["status"].(map[string]interface{}) - // Set to Pending so operator will process it (operator only acts on Pending phase) - status["phase"] = "Pending" - status["message"] = "Session restart requested" - // Clear completion time from previous run - delete(status, "completionTime") - // Update start time for this run - status["startTime"] = time.Now().Format(time.RFC3339) - - // Update the status subresource (must use UpdateStatus, not Update) - updated, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to start agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start agentic session"}) - return - } - - // Parse and return updated session - session := types.AgenticSession{ - APIVersion: updated.GetAPIVersion(), - Kind: updated.GetKind(), - Metadata: updated.Object["metadata"].(map[string]interface{}), - } - - if spec, ok := updated.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } - - if status, ok := updated.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) - } - - c.JSON(http.StatusAccepted, session) -} - -func StopSession(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - gvr := GetAgenticSessionV1Alpha1Resource() - - // Get current resource - item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) - return - } - - // Check current status - status, ok := item.Object["status"].(map[string]interface{}) - if !ok { - status = make(map[string]interface{}) - item.Object["status"] = status - } - - currentPhase, _ := status["phase"].(string) - if currentPhase == "Completed" || currentPhase == "Failed" || currentPhase == "Stopped" { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Cannot stop session in %s state", currentPhase)}) - return - } - - log.Printf("Attempting to stop agentic session %s in project %s (current phase: %s)", sessionName, project, currentPhase) - - // Get job name from status - jobName, jobExists := status["jobName"].(string) - if !jobExists || jobName == "" { - // Try to derive job name if not in status - jobName = fmt.Sprintf("%s-job", sessionName) - log.Printf("Job name not in status, trying derived name: %s", jobName) - } - - // Delete the job and its pods - log.Printf("Attempting to delete job %s for session %s", jobName, sessionName) - - // First, delete the job itself with foreground propagation - deletePolicy := v1.DeletePropagationForeground - err = reqK8s.BatchV1().Jobs(project).Delete(context.TODO(), jobName, v1.DeleteOptions{ - PropagationPolicy: &deletePolicy, - }) - if err != nil { - if errors.IsNotFound(err) { - log.Printf("Job %s not found (may have already completed or been deleted)", jobName) - } else { - log.Printf("Failed to delete job %s: %v", jobName, err) - // Don't fail the request if job deletion fails - continue with status update - log.Printf("Continuing with status update despite job deletion failure") - } - } else { - log.Printf("Successfully deleted job %s for agentic session %s", jobName, sessionName) - } - - // Then, explicitly delete all pods for this job (by job-name label) - podSelector := fmt.Sprintf("job-name=%s", jobName) - log.Printf("Deleting pods with job-name selector: %s", podSelector) - err = reqK8s.CoreV1().Pods(project).DeleteCollection(context.TODO(), v1.DeleteOptions{}, v1.ListOptions{ - LabelSelector: podSelector, - }) - if err != nil && !errors.IsNotFound(err) { - log.Printf("Failed to delete pods for job %s: %v (continuing anyway)", jobName, err) - } else { - log.Printf("Successfully deleted pods for job %s", jobName) - } - - // Also delete any pods labeled with this session (in case owner refs are lost) - sessionPodSelector := fmt.Sprintf("agentic-session=%s", sessionName) - log.Printf("Deleting pods with agentic-session selector: %s", sessionPodSelector) - err = reqK8s.CoreV1().Pods(project).DeleteCollection(context.TODO(), v1.DeleteOptions{}, v1.ListOptions{ - LabelSelector: sessionPodSelector, - }) - if err != nil && !errors.IsNotFound(err) { - log.Printf("Failed to delete session pods: %v (continuing anyway)", err) - } else { - log.Printf("Successfully deleted session-labeled pods") - } - - // Update status to Stopped - status["phase"] = "Stopped" - status["message"] = "Session stopped by user" - status["completionTime"] = time.Now().Format(time.RFC3339) - - // Also set interactive: true in spec so session can be restarted - if spec, ok := item.Object["spec"].(map[string]interface{}); ok { - if interactive, ok := spec["interactive"].(bool); !ok || !interactive { - log.Printf("Setting interactive: true for stopped session %s to allow restart", sessionName) - spec["interactive"] = true - // Update spec first (must use Update, not UpdateStatus) - item, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to update session spec for %s: %v (continuing with status update)", sessionName, err) - // Continue anyway - status update is more important - } - } - } - - // Update the resource using UpdateStatus for status subresource - updated, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - if errors.IsNotFound(err) { - // Session was deleted while we were trying to update it - log.Printf("Agentic session %s was deleted during stop operation", sessionName) - c.JSON(http.StatusOK, gin.H{"message": "Session no longer exists (already deleted)"}) - return - } - log.Printf("Failed to update agentic session status %s: %v", sessionName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) - return - } - - // Parse and return updated session - session := types.AgenticSession{ - APIVersion: updated.GetAPIVersion(), - Kind: updated.GetKind(), - Metadata: updated.Object["metadata"].(map[string]interface{}), - } - - if spec, ok := updated.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } - - if status, ok := updated.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) - } - - log.Printf("Successfully stopped agentic session %s", sessionName) - c.JSON(http.StatusAccepted, session) -} - -// PUT /api/projects/:projectName/agentic-sessions/:sessionName/status -// updateSessionStatus writes selected fields to PVC-backed files and updates CR status -func UpdateSessionStatus(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - _, reqDyn := GetK8sClientsForRequest(c) - - var statusUpdate map[string]interface{} - if err := c.ShouldBindJSON(&statusUpdate); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - gvr := GetAgenticSessionV1Alpha1Resource() - - // Get current resource - item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) - return - } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) - return - } - - // Ensure status map - if item.Object["status"] == nil { - item.Object["status"] = make(map[string]interface{}) - } - status := item.Object["status"].(map[string]interface{}) - - // Accept standard fields and result summary fields from runner - allowed := map[string]struct{}{ - "phase": {}, "completionTime": {}, "cost": {}, "message": {}, - "subtype": {}, "duration_ms": {}, "duration_api_ms": {}, "is_error": {}, - "num_turns": {}, "session_id": {}, "total_cost_usd": {}, "usage": {}, "result": {}, - } - for k := range statusUpdate { - if _, ok := allowed[k]; !ok { - delete(statusUpdate, k) - } - } - - // Merge remaining fields into status - for k, v := range statusUpdate { - status[k] = v - } - - // Update only the status subresource (requires agenticsessions/status perms) - if _, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}); err != nil { - log.Printf("Failed to update agentic session status %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "agentic session status updated"}) -} - -// SpawnContentPod creates a temporary pod for workspace access on completed sessions -// POST /api/projects/:projectName/agentic-sessions/:sessionName/spawn-content-pod -func SpawnContentPod(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } - sessionName := c.Param("sessionName") - - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - - podName := fmt.Sprintf("temp-content-%s", sessionName) - - // Check if already exists - if existing, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), podName, v1.GetOptions{}); err == nil { - ready := false - for _, cond := range existing.Status.Conditions { - if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { - ready = true - break - } - } - c.JSON(http.StatusOK, gin.H{"status": "exists", "podName": podName, "ready": ready}) - return - } - - // Verify PVC exists - pvcName := fmt.Sprintf("ambient-workspace-%s", sessionName) - if _, err := reqK8s.CoreV1().PersistentVolumeClaims(project).Get(c.Request.Context(), pvcName, v1.GetOptions{}); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace PVC not found"}) - return - } - - // Get content service image from env - contentImage := os.Getenv("CONTENT_SERVICE_IMAGE") - if contentImage == "" { - contentImage = "quay.io/ambient_code/vteam_backend:latest" - } - imagePullPolicy := corev1.PullIfNotPresent - if os.Getenv("IMAGE_PULL_POLICY") == "Always" { - imagePullPolicy = corev1.PullAlways - } - - // Create temporary pod - pod := &corev1.Pod{ - ObjectMeta: v1.ObjectMeta{ - Name: podName, - Namespace: project, - Labels: map[string]string{ - "app": "temp-content-service", - "temp-content-for-session": sessionName, - }, - Annotations: map[string]string{ - "vteam.ambient-code/ttl": "900", - "vteam.ambient-code/created-at": time.Now().Format(time.RFC3339), - }, - }, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - Containers: []corev1.Container{ - { - Name: "content", - Image: contentImage, - ImagePullPolicy: imagePullPolicy, - Env: []corev1.EnvVar{ - {Name: "CONTENT_SERVICE_MODE", Value: "true"}, - {Name: "STATE_BASE_DIR", Value: "/workspace"}, - }, - Ports: []corev1.ContainerPort{{ContainerPort: 8080, Name: "http"}}, - ReadinessProbe: &corev1.Probe{ - ProbeHandler: corev1.ProbeHandler{ - HTTPGet: &corev1.HTTPGetAction{ - Path: "/health", - Port: intstr.FromString("http"), - }, - }, - InitialDelaySeconds: 2, - PeriodSeconds: 2, - }, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "workspace", - MountPath: "/workspace", - ReadOnly: false, - }, - }, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("100m"), - corev1.ResourceMemory: resource.MustParse("128Mi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("500m"), - corev1.ResourceMemory: resource.MustParse("512Mi"), - }, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: "workspace", - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: pvcName, - }, - }, - }, - }, - }, - } - - created, err := reqK8s.CoreV1().Pods(project).Create(c.Request.Context(), pod, v1.CreateOptions{}) - if err != nil { - log.Printf("Failed to create temp content pod: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create pod: %v", err)}) - return - } - - // Create service - svc := &corev1.Service{ - ObjectMeta: v1.ObjectMeta{ - Name: fmt.Sprintf("temp-content-%s", sessionName), - Namespace: project, - Labels: map[string]string{ - "app": "temp-content-service", - "temp-content-for-session": sessionName, - }, - OwnerReferences: []v1.OwnerReference{ - { - APIVersion: "v1", - Kind: "Pod", - Name: podName, - UID: created.UID, - Controller: types.BoolPtr(true), - }, - }, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{ - "temp-content-for-session": sessionName, - }, - Ports: []corev1.ServicePort{ - {Port: 8080, TargetPort: intstr.FromString("http")}, - }, - }, - } - - if _, err := reqK8s.CoreV1().Services(project).Create(c.Request.Context(), svc, v1.CreateOptions{}); err != nil && !errors.IsAlreadyExists(err) { - log.Printf("Failed to create temp service: %v", err) - } - - c.JSON(http.StatusOK, gin.H{ - "status": "creating", - "podName": podName, - }) -} - -// GetContentPodStatus checks if temporary content pod is ready -// GET /api/projects/:projectName/agentic-sessions/:sessionName/content-pod-status -func GetContentPodStatus(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } - sessionName := c.Param("sessionName") - - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - - podName := fmt.Sprintf("temp-content-%s", sessionName) - pod, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), podName, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"status": "not_found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get pod"}) - return - } - - ready := false - for _, cond := range pod.Status.Conditions { - if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { - ready = true - break - } - } - - c.JSON(http.StatusOK, gin.H{ - "status": string(pod.Status.Phase), - "ready": ready, - "podName": podName, - "createdAt": pod.CreationTimestamp.Format(time.RFC3339), - }) -} - -// DeleteContentPod removes temporary content pod -// DELETE /api/projects/:projectName/agentic-sessions/:sessionName/content-pod -func DeleteContentPod(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } - sessionName := c.Param("sessionName") - - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - - podName := fmt.Sprintf("temp-content-%s", sessionName) - err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), podName, v1.DeleteOptions{}) - if err != nil && !errors.IsNotFound(err) { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pod"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "content pod deleted"}) -} - -// GetSessionK8sResources returns job, pod, and PVC information for a session -// GET /api/projects/:projectName/agentic-sessions/:sessionName/k8s-resources -func GetSessionK8sResources(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } - sessionName := c.Param("sessionName") - - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - - // Get session to find job name - gvr := GetAgenticSessionV1Alpha1Resource() - session, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) - return - } - - status, _ := session.Object["status"].(map[string]interface{}) - jobName, _ := status["jobName"].(string) - if jobName == "" { - jobName = fmt.Sprintf("%s-job", sessionName) - } - - result := map[string]interface{}{} - - // Get Job status - job, err := reqK8s.BatchV1().Jobs(project).Get(c.Request.Context(), jobName, v1.GetOptions{}) - jobExists := err == nil - - if jobExists { - result["jobName"] = jobName - jobStatus := "Unknown" - if job.Status.Active > 0 { - jobStatus = "Active" - } else if job.Status.Succeeded > 0 { - jobStatus = "Succeeded" - } else if job.Status.Failed > 0 { - jobStatus = "Failed" - } - result["jobStatus"] = jobStatus - result["jobConditions"] = job.Status.Conditions - } else if errors.IsNotFound(err) { - // Job not found - don't return job info at all - log.Printf("GetSessionK8sResources: Job %s not found, omitting from response", jobName) - // Don't include jobName or jobStatus in result - } else { - // Other error - still show job name but with error status - result["jobName"] = jobName - result["jobStatus"] = "Error" - log.Printf("GetSessionK8sResources: Error getting job %s: %v", jobName, err) - } - - // Get Pods for this job (only if job exists) - podInfos := []map[string]interface{}{} - if jobExists { - pods, err := reqK8s.CoreV1().Pods(project).List(c.Request.Context(), v1.ListOptions{ - LabelSelector: fmt.Sprintf("job-name=%s", jobName), - }) - if err == nil { - for _, pod := range pods.Items { - // Check if pod is terminating (has DeletionTimestamp) - podPhase := string(pod.Status.Phase) - if pod.DeletionTimestamp != nil { - podPhase = "Terminating" - } - - containerInfos := []map[string]interface{}{} - for _, cs := range pod.Status.ContainerStatuses { - state := "Unknown" - var exitCode *int32 - var reason string - if cs.State.Running != nil { - state = "Running" - // If pod is terminating but container still shows running, mark it as terminating - if pod.DeletionTimestamp != nil { - state = "Terminating" - } - } else if cs.State.Terminated != nil { - state = "Terminated" - exitCode = &cs.State.Terminated.ExitCode - reason = cs.State.Terminated.Reason - } else if cs.State.Waiting != nil { - state = "Waiting" - reason = cs.State.Waiting.Reason - } - containerInfos = append(containerInfos, map[string]interface{}{ - "name": cs.Name, - "state": state, - "exitCode": exitCode, - "reason": reason, - }) - } - podInfos = append(podInfos, map[string]interface{}{ - "name": pod.Name, - "phase": podPhase, - "containers": containerInfos, - }) - } - } - } - - // Check for temp-content pod - tempPodName := fmt.Sprintf("temp-content-%s", sessionName) - tempPod, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), tempPodName, v1.GetOptions{}) - if err == nil { - tempPodPhase := string(tempPod.Status.Phase) - if tempPod.DeletionTimestamp != nil { - tempPodPhase = "Terminating" - } - - containerInfos := []map[string]interface{}{} - for _, cs := range tempPod.Status.ContainerStatuses { - state := "Unknown" - var exitCode *int32 - var reason string - if cs.State.Running != nil { - state = "Running" - // If pod is terminating but container still shows running, mark as terminating - if tempPod.DeletionTimestamp != nil { - state = "Terminating" - } - } else if cs.State.Terminated != nil { - state = "Terminated" - exitCode = &cs.State.Terminated.ExitCode - reason = cs.State.Terminated.Reason - } else if cs.State.Waiting != nil { - state = "Waiting" - reason = cs.State.Waiting.Reason - } - containerInfos = append(containerInfos, map[string]interface{}{ - "name": cs.Name, - "state": state, - "exitCode": exitCode, - "reason": reason, - }) - } - podInfos = append(podInfos, map[string]interface{}{ - "name": tempPod.Name, - "phase": tempPodPhase, - "containers": containerInfos, - "isTempPod": true, - }) - } - - result["pods"] = podInfos - - // Get PVC info - always use session's own PVC name - // Note: If session was created with parent_session_id (via API), the operator handles PVC reuse - pvcName := fmt.Sprintf("ambient-workspace-%s", sessionName) - pvc, err := reqK8s.CoreV1().PersistentVolumeClaims(project).Get(c.Request.Context(), pvcName, v1.GetOptions{}) - result["pvcName"] = pvcName - if err == nil { - result["pvcExists"] = true - if storage, ok := pvc.Status.Capacity[corev1.ResourceStorage]; ok { - result["pvcSize"] = storage.String() - } - } else { - result["pvcExists"] = false - } - - c.JSON(http.StatusOK, result) -} - -// setRepoStatus updates status.repos[idx] with status and diff info -func setRepoStatus(dyn dynamic.Interface, project, sessionName string, repoIndex int, newStatus string) error { - gvr := GetAgenticSessionV1Alpha1Resource() - item, err := dyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) - if err != nil { - return err - } - - // Get repo name from spec.repos[repoIndex] - spec, _ := item.Object["spec"].(map[string]interface{}) - specRepos, _ := spec["repos"].([]interface{}) - if repoIndex < 0 || repoIndex >= len(specRepos) { - return fmt.Errorf("repo index out of range") - } - specRepo, _ := specRepos[repoIndex].(map[string]interface{}) - repoName := "" - if name, ok := specRepo["name"].(string); ok { - repoName = name - } else if input, ok := specRepo["input"].(map[string]interface{}); ok { - if url, ok := input["url"].(string); ok { - repoName = DeriveRepoFolderFromURL(url) - } - } - if repoName == "" { - repoName = fmt.Sprintf("repo-%d", repoIndex) - } - - // Ensure status.repos exists - if item.Object["status"] == nil { - item.Object["status"] = make(map[string]interface{}) - } - status := item.Object["status"].(map[string]interface{}) - statusRepos, _ := status["repos"].([]interface{}) - if statusRepos == nil { - statusRepos = []interface{}{} - } - - // Find or create status entry for this repo - repoStatus := map[string]interface{}{ - "name": repoName, - "status": newStatus, - "last_updated": time.Now().Format(time.RFC3339), - } - - // Update existing or append new - found := false - for i, r := range statusRepos { - if rm, ok := r.(map[string]interface{}); ok { - if n, ok := rm["name"].(string); ok && n == repoName { - rm["status"] = newStatus - rm["last_updated"] = time.Now().Format(time.RFC3339) - statusRepos[i] = rm - found = true - break - } - } - } - if !found { - statusRepos = append(statusRepos, repoStatus) - } - - status["repos"] = statusRepos - item.Object["status"] = status - - updated, err := dyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) - if err != nil { - log.Printf("setRepoStatus: update failed project=%s session=%s repoIndex=%d status=%s err=%v", project, sessionName, repoIndex, newStatus, err) - return err - } - if updated != nil { - log.Printf("setRepoStatus: update ok project=%s session=%s repo=%s status=%s", project, sessionName, repoName, newStatus) - } - return nil -} - -// listSessionWorkspace proxies to per-job content service for directory listing -func ListSessionWorkspace(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } - session := c.Param("sessionName") - - if project == "" { - log.Printf("ListSessionWorkspace: project is empty, session=%s", session) - c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) - return - } - - rel := strings.TrimSpace(c.Query("path")) - // Build absolute workspace path using plain session (no url.PathEscape to match FS paths) - absPath := "/sessions/" + session + "/workspace" - if rel != "" { - absPath += "/" + rel - } - - // Call per-job service or temp service for completed sessions - token := c.GetHeader("Authorization") - if strings.TrimSpace(token) == "" { - token = c.GetHeader("X-Forwarded-Access-Token") - } - - // Try temp service first (for completed sessions), then regular service - serviceName := fmt.Sprintf("temp-content-%s", session) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s != nil { - if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { - // Temp service doesn't exist, use regular service - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - } else { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - - endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) - u := fmt.Sprintf("%s/content/list?path=%s", endpoint, url.QueryEscape(absPath)) - log.Printf("ListSessionWorkspace: project=%s session=%s endpoint=%s", project, session, endpoint) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", token) - } - client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Do(req) - if err != nil { - log.Printf("ListSessionWorkspace: content service request failed: %v", err) - // Soften error to 200 with empty list so UI doesn't spam - c.JSON(http.StatusOK, gin.H{"items": []any{}}) - return - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - - // If content service returns 404, check if it's because workspace doesn't exist yet - if resp.StatusCode == http.StatusNotFound { - log.Printf("ListSessionWorkspace: workspace not found (may not be created yet by runner)") - // Return empty list instead of error for better UX during session startup - c.JSON(http.StatusOK, gin.H{"items": []any{}}) - return - } - - c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) -} - -// getSessionWorkspaceFile reads a file via content service -func GetSessionWorkspaceFile(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } - session := c.Param("sessionName") - - if project == "" { - log.Printf("GetSessionWorkspaceFile: project is empty, session=%s", session) - c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) - return - } - - sub := strings.TrimPrefix(c.Param("path"), "/") - absPath := "/sessions/" + session + "/workspace/" + sub - token := c.GetHeader("Authorization") - if strings.TrimSpace(token) == "" { - token = c.GetHeader("X-Forwarded-Access-Token") - } - - // Try temp service first (for completed sessions), then regular service - serviceName := fmt.Sprintf("temp-content-%s", session) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s != nil { - if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - } else { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - - endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) - u := fmt.Sprintf("%s/content/file?path=%s", endpoint, url.QueryEscape(absPath)) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", token) - } - client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Do(req) - if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) - return - } - defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) -} - -// putSessionWorkspaceFile writes a file via content service -func PutSessionWorkspaceFile(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } - session := c.Param("sessionName") - - if project == "" { - log.Printf("PutSessionWorkspaceFile: project is empty, session=%s", session) - c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) - return - } - sub := strings.TrimPrefix(c.Param("path"), "/") - absPath := "/sessions/" + session + "/workspace/" + sub - token := c.GetHeader("Authorization") - if strings.TrimSpace(token) == "" { - token = c.GetHeader("X-Forwarded-Access-Token") - } - - // Try temp service first (for completed sessions), then regular service - serviceName := fmt.Sprintf("temp-content-%s", session) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s != nil { - if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { - // Temp service doesn't exist, use regular service - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - } else { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - - endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) - log.Printf("PutSessionWorkspaceFile: using service %s for session %s", serviceName, session) - payload, _ := io.ReadAll(c.Request.Body) - wreq := struct { - Path string `json:"path"` - Content string `json:"content"` - Encoding string `json:"encoding"` - }{Path: absPath, Content: string(payload), Encoding: "utf8"} - b, _ := json.Marshal(wreq) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/write", strings.NewReader(string(b))) - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", token) - } - req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Do(req) - if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) - return - } - defer resp.Body.Close() - rb, _ := io.ReadAll(resp.Body) - c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), rb) -} - -// pushSessionRepo proxies a push request for a given session repo to the per-job content service. -// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/push -// Body: { repoIndex: number, commitMessage?: string, branch?: string } -func PushSessionRepo(c *gin.Context) { - project := c.Param("projectName") - session := c.Param("sessionName") - - var body struct { - RepoIndex int `json:"repoIndex"` - CommitMessage string `json:"commitMessage"` - } - if err := c.BindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } - log.Printf("pushSessionRepo: request project=%s session=%s repoIndex=%d commitLen=%d", project, session, body.RepoIndex, len(strings.TrimSpace(body.CommitMessage))) - - // Try temp service first (for completed sessions), then regular service - serviceName := fmt.Sprintf("temp-content-%s", session) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s != nil { - if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - } else { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) - log.Printf("pushSessionRepo: using service %s", serviceName) - - // Simplified: 1) get session; 2) compute repoPath from INPUT repo folder; 3) get output url/branch; 4) proxy - resolvedRepoPath := "" - // default branch when not defined on output - resolvedBranch := fmt.Sprintf("sessions/%s", session) - resolvedOutputURL := "" - if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { - gvr := GetAgenticSessionV1Alpha1Resource() - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read session"}) - return - } - spec, _ := obj.Object["spec"].(map[string]interface{}) - repos, _ := spec["repos"].([]interface{}) - if body.RepoIndex < 0 || body.RepoIndex >= len(repos) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repo index"}) - return - } - rm, _ := repos[body.RepoIndex].(map[string]interface{}) - // Derive repoPath from input URL folder name - if in, ok := rm["input"].(map[string]interface{}); ok { - if urlv, ok2 := in["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { - folder := DeriveRepoFolderFromURL(strings.TrimSpace(urlv)) - if folder != "" { - resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, folder) - } - } - } - if out, ok := rm["output"].(map[string]interface{}); ok { - if urlv, ok2 := out["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { - resolvedOutputURL = strings.TrimSpace(urlv) - } - if bs, ok2 := out["branch"].(string); ok2 && strings.TrimSpace(bs) != "" { - resolvedBranch = strings.TrimSpace(bs) - } else if bv, ok2 := out["branch"].(*string); ok2 && bv != nil && strings.TrimSpace(*bv) != "" { - resolvedBranch = strings.TrimSpace(*bv) - } - } - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "no dynamic client"}) - return - } - // If input URL missing or unparsable, fall back to numeric index path (last resort) - if strings.TrimSpace(resolvedRepoPath) == "" { - if body.RepoIndex >= 0 { - resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) - } else { - resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace", session) - } - } - if strings.TrimSpace(resolvedOutputURL) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing output repo url"}) - return - } - log.Printf("pushSessionRepo: resolved repoPath=%q outputUrl=%q branch=%q", resolvedRepoPath, resolvedOutputURL, resolvedBranch) - - payload := map[string]interface{}{ - "repoPath": resolvedRepoPath, - "commitMessage": body.CommitMessage, - "branch": resolvedBranch, - "outputRepoUrl": resolvedOutputURL, - } - b, _ := json.Marshal(payload) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/push", strings.NewReader(string(b))) - if v := c.GetHeader("Authorization"); v != "" { - req.Header.Set("Authorization", v) - } - if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { - req.Header.Set("X-Forwarded-Access-Token", v) - } - req.Header.Set("Content-Type", "application/json") - - // Attach short-lived GitHub token for one-shot authenticated push - if reqK8s, reqDyn := GetK8sClientsForRequest(c); reqK8s != nil { - // Load session to get authoritative userId - gvr := GetAgenticSessionV1Alpha1Resource() - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) - if err == nil { - spec, _ := obj.Object["spec"].(map[string]interface{}) - userId := "" - if spec != nil { - if uc, ok := spec["userContext"].(map[string]interface{}); ok { - if v, ok := uc["userId"].(string); ok { - userId = strings.TrimSpace(v) - } - } - } - if userId != "" { - if tokenStr, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userId); err == nil && strings.TrimSpace(tokenStr) != "" { - req.Header.Set("X-GitHub-Token", tokenStr) - log.Printf("pushSessionRepo: attached short-lived GitHub token for project=%s session=%s", project, session) - } else if err != nil { - log.Printf("pushSessionRepo: failed to resolve GitHub token: %v", err) - } - } else { - log.Printf("pushSessionRepo: session %s/%s missing userContext.userId; proceeding without token", project, session) - } - } else { - log.Printf("pushSessionRepo: failed to read session for token attach: %v", err) - } - } - - log.Printf("pushSessionRepo: proxy push project=%s session=%s repoIndex=%d repoPath=%s endpoint=%s", project, session, body.RepoIndex, resolvedRepoPath, endpoint+"/content/github/push") - resp, err := http.DefaultClient.Do(req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - log.Printf("pushSessionRepo: content returned status=%d body.snip=%q", resp.StatusCode, func() string { - s := string(bodyBytes) - if len(s) > 1500 { - return s[:1500] + "..." - } - return s - }()) - c.Data(resp.StatusCode, "application/json", bodyBytes) - return - } - if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { - log.Printf("pushSessionRepo: setting repo status to 'pushed' for repoIndex=%d", body.RepoIndex) - if err := setRepoStatus(reqDyn, project, session, body.RepoIndex, "pushed"); err != nil { - log.Printf("pushSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) - } - } else { - log.Printf("pushSessionRepo: no dynamic client; cannot set repo status project=%s session=%s", project, session) - } - log.Printf("pushSessionRepo: content push succeeded status=%d body.len=%d", resp.StatusCode, len(bodyBytes)) - c.Data(http.StatusOK, "application/json", bodyBytes) -} - -// abandonSessionRepo instructs sidecar to discard local changes for a repo -func AbandonSessionRepo(c *gin.Context) { - project := c.Param("projectName") - session := c.Param("sessionName") - var body struct { - RepoIndex int `json:"repoIndex"` - RepoPath string `json:"repoPath"` - } - if err := c.BindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } - - // Try temp service first (for completed sessions), then regular service - serviceName := fmt.Sprintf("temp-content-%s", session) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s != nil { - if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - } else { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) - log.Printf("AbandonSessionRepo: using service %s", serviceName) - repoPath := strings.TrimSpace(body.RepoPath) - if repoPath == "" { - if body.RepoIndex >= 0 { - repoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) - } else { - repoPath = fmt.Sprintf("/sessions/%s/workspace", session) - } - } - payload := map[string]interface{}{ - "repoPath": repoPath, - } - b, _ := json.Marshal(payload) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/abandon", strings.NewReader(string(b))) - if v := c.GetHeader("Authorization"); v != "" { - req.Header.Set("Authorization", v) - } - if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { - req.Header.Set("X-Forwarded-Access-Token", v) - } - req.Header.Set("Content-Type", "application/json") - log.Printf("abandonSessionRepo: proxy abandon project=%s session=%s repoIndex=%d repoPath=%s", project, session, body.RepoIndex, repoPath) - resp, err := http.DefaultClient.Do(req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - log.Printf("abandonSessionRepo: content returned status=%d body=%s", resp.StatusCode, string(bodyBytes)) - c.Data(resp.StatusCode, "application/json", bodyBytes) - return - } - if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { - if err := setRepoStatus(reqDyn, project, session, body.RepoIndex, "abandoned"); err != nil { - log.Printf("abandonSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) - } - } else { - log.Printf("abandonSessionRepo: no dynamic client; cannot set repo status project=%s session=%s", project, session) - } - c.Data(http.StatusOK, "application/json", bodyBytes) -} - -// diffSessionRepo proxies diff counts for a given session repo to the content sidecar -// GET /api/projects/:projectName/agentic-sessions/:sessionName/github/diff?repoIndex=0&repoPath=... -func DiffSessionRepo(c *gin.Context) { - project := c.Param("projectName") - session := c.Param("sessionName") - repoIndexStr := strings.TrimSpace(c.Query("repoIndex")) - repoPath := strings.TrimSpace(c.Query("repoPath")) - if repoPath == "" && repoIndexStr != "" { - repoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, repoIndexStr) - } - if repoPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing repoPath/repoIndex"}) - return - } - - // Try temp service first (for completed sessions), then regular service - serviceName := fmt.Sprintf("temp-content-%s", session) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s != nil { - if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - } else { - serviceName = fmt.Sprintf("ambient-content-%s", session) - } - endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) - log.Printf("DiffSessionRepo: using service %s", serviceName) - url := fmt.Sprintf("%s/content/github/diff?repoPath=%s", endpoint, url.QueryEscape(repoPath)) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, url, nil) - if v := c.GetHeader("Authorization"); v != "" { - req.Header.Set("Authorization", v) - } - if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { - req.Header.Set("X-Forwarded-Access-Token", v) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "files": gin.H{ - "added": 0, - "removed": 0, - }, - "total_added": 0, - "total_removed": 0, - }) - return - } - defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) - c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) -} diff --git a/components/backend/handlers/sessions_control.go b/components/backend/handlers/sessions_control.go new file mode 100644 index 000000000..f821a94c1 --- /dev/null +++ b/components/backend/handlers/sessions_control.go @@ -0,0 +1,450 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains session control operations (start, stop, status update). +package handlers + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "ambient-code-backend/types" + + "github.com/gin-gonic/gin" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// StartSession starts or restarts an agentic session. +// For continuations, it sets parent-session-id annotation and regenerates tokens. +func StartSession(c *gin.Context) { + project := c.GetString("project") + sessionName := c.Param("sessionName") + reqK8s, reqDyn := GetK8sClientsForRequest(c) + gvr := GetAgenticSessionV1Alpha1Resource() + + // Get current resource + item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) + return + } + + // Ensure runner role has required permissions (update if needed for existing sessions) + if err := ensureRunnerRolePermissions(c, reqK8s, project, sessionName); err != nil { + log.Printf("Warning: failed to ensure runner role permissions for %s: %v", sessionName, err) + // Non-fatal - continue with restart + } + + // Clean up temp-content pod if it exists to free the PVC + // This prevents Multi-Attach errors when the session job tries to mount the workspace + if reqK8s != nil { + tempPodName := fmt.Sprintf("temp-content-%s", sessionName) + if err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), tempPodName, v1.DeleteOptions{}); err != nil { + if !errors.IsNotFound(err) { + log.Printf("StartSession: failed to delete temp-content pod %s (non-fatal): %v", tempPodName, err) + } + } else { + log.Printf("StartSession: deleted temp-content pod %s to free PVC", tempPodName) + } + } + + // Check if this is a continuation (session is in a terminal phase) + // Terminal phases from CRD: Completed, Failed, Stopped, Error + isActualContinuation := false + currentPhase := "" + if currentStatus, ok := GetStatusMap(item); ok { + if phase, ok := currentStatus["phase"].(string); ok { + currentPhase = phase + terminalPhases := []string{"Completed", "Failed", "Stopped", "Error"} + for _, terminalPhase := range terminalPhases { + if phase == terminalPhase { + isActualContinuation = true + log.Printf("StartSession: Detected continuation - session is in terminal phase: %s", phase) + break + } + } + } + } + + if !isActualContinuation { + log.Printf("StartSession: Not a continuation - current phase is: %s (not in terminal phases)", currentPhase) + } + + // Only set parent session annotation if this is an actual continuation + // Don't set it on first start, even though StartSession can be called for initial creation + if isActualContinuation { + annotations := item.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations["vteam.ambient-code/parent-session-id"] = sessionName + item.SetAnnotations(annotations) + log.Printf("StartSession: Set parent-session-id annotation to %s for continuation (has completion time)", sessionName) + + // For headless sessions being continued, force interactive mode + if spec, ok := GetSpecMap(item); ok { + if interactive, ok := spec["interactive"].(bool); !ok || !interactive { + // Session was headless, convert to interactive + spec["interactive"] = true + log.Printf("StartSession: Converting headless session to interactive for continuation") + } + } + + // Update the metadata and spec to persist the annotation and interactive flag + item, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update agentic session metadata %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session metadata"}) + return + } + + // Regenerate runner token for continuation (old token may have expired) + log.Printf("StartSession: Regenerating runner token for session continuation") + if err := provisionRunnerTokenForSession(c, reqK8s, reqDyn, project, sessionName); err != nil { + log.Printf("Warning: failed to regenerate runner token for session %s/%s: %v", project, sessionName, err) + // Non-fatal: continue anyway, operator may retry + } else { + log.Printf("StartSession: Successfully regenerated runner token for continuation") + + // Delete the old job so operator creates a new one + // This ensures fresh token and clean state + jobName := fmt.Sprintf("ambient-runner-%s", sessionName) + log.Printf("StartSession: Deleting old job %s to allow operator to create fresh one", jobName) + propagationPolicy := v1.DeletePropagationBackground + if err := reqK8s.BatchV1().Jobs(project).Delete(c.Request.Context(), jobName, v1.DeleteOptions{ + PropagationPolicy: &propagationPolicy, + }); err != nil { + if !errors.IsNotFound(err) { + log.Printf("Warning: failed to delete old job %s: %v", jobName, err) + } else { + log.Printf("StartSession: Job %s already gone", jobName) + } + } else { + log.Printf("StartSession: Successfully deleted old job %s", jobName) + } + } + } else { + log.Printf("StartSession: Not setting parent-session-id (first run, no completion time)") + } + + // Now update status to trigger start (using the fresh object from Update) + if item.Object["status"] == nil { + item.Object["status"] = make(map[string]interface{}) + } + + status, ok := GetStatusMap(item) + if !ok { + status = make(map[string]interface{}) + item.Object["status"] = status + } + + // Set to Pending so operator will process it (operator only acts on Pending phase) + status["phase"] = "Pending" + status["message"] = "Session restart requested" + // Clear completion time from previous run + delete(status, "completionTime") + // Update start time for this run + status["startTime"] = time.Now().Format(time.RFC3339) + + // Update the status subresource (must use UpdateStatus, not Update) + updated, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to start agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start agentic session"}) + return + } + + // Parse and return updated session + metadata, ok := GetMetadataMap(updated) + if !ok { + log.Printf("Updated session %s missing metadata", sessionName) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session data"}) + return + } + + session := types.AgenticSession{ + APIVersion: updated.GetAPIVersion(), + Kind: updated.GetKind(), + Metadata: metadata, + } + + if spec, ok := GetSpecMap(updated); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := GetStatusMap(updated); ok { + session.Status = parseStatus(status) + } + + c.JSON(http.StatusAccepted, session) +} + +// StopSession stops a running agentic session by deleting its job and pods. +func StopSession(c *gin.Context) { + project := c.GetString("project") + sessionName := c.Param("sessionName") + reqK8s, reqDyn := GetK8sClientsForRequest(c) + gvr := GetAgenticSessionV1Alpha1Resource() + + // Get current resource + item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) + return + } + + // Check current status + status, ok := GetStatusMap(item) + if !ok { + status = make(map[string]interface{}) + item.Object["status"] = status + } + + currentPhase, _ := status["phase"].(string) + if currentPhase == "Completed" || currentPhase == "Failed" || currentPhase == "Stopped" { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Cannot stop session in %s state", currentPhase)}) + return + } + + log.Printf("Attempting to stop agentic session %s in project %s (current phase: %s)", sessionName, project, currentPhase) + + // Get job name from status + jobName, jobExists := status["jobName"].(string) + if !jobExists || jobName == "" { + // Try to derive job name if not in status + jobName = fmt.Sprintf("%s-job", sessionName) + log.Printf("Job name not in status, trying derived name: %s", jobName) + } + + // Delete the job and its pods + log.Printf("Attempting to delete job %s for session %s", jobName, sessionName) + + // First, delete the job itself with foreground propagation + deletePolicy := v1.DeletePropagationForeground + err = reqK8s.BatchV1().Jobs(project).Delete(context.TODO(), jobName, v1.DeleteOptions{ + PropagationPolicy: &deletePolicy, + }) + if err != nil { + if errors.IsNotFound(err) { + log.Printf("Job %s not found (may have already completed or been deleted)", jobName) + } else { + log.Printf("Failed to delete job %s: %v", jobName, err) + // Don't fail the request if job deletion fails - continue with status update + log.Printf("Continuing with status update despite job deletion failure") + } + } else { + log.Printf("Successfully deleted job %s for agentic session %s", jobName, sessionName) + } + + // Then, explicitly delete all pods for this job (by job-name label) + podSelector := fmt.Sprintf("job-name=%s", jobName) + log.Printf("Deleting pods with job-name selector: %s", podSelector) + err = reqK8s.CoreV1().Pods(project).DeleteCollection(context.TODO(), v1.DeleteOptions{}, v1.ListOptions{ + LabelSelector: podSelector, + }) + if err != nil && !errors.IsNotFound(err) { + log.Printf("Failed to delete pods for job %s: %v (continuing anyway)", jobName, err) + } else { + log.Printf("Successfully deleted pods for job %s", jobName) + } + + // Also delete any pods labeled with this session (in case owner refs are lost) + sessionPodSelector := fmt.Sprintf("agentic-session=%s", sessionName) + log.Printf("Deleting pods with agentic-session selector: %s", sessionPodSelector) + err = reqK8s.CoreV1().Pods(project).DeleteCollection(context.TODO(), v1.DeleteOptions{}, v1.ListOptions{ + LabelSelector: sessionPodSelector, + }) + if err != nil && !errors.IsNotFound(err) { + log.Printf("Failed to delete session pods: %v (continuing anyway)", err) + } else { + log.Printf("Successfully deleted session-labeled pods") + } + + // Update status to Stopped + status["phase"] = "Stopped" + status["message"] = "Session stopped by user" + status["completionTime"] = time.Now().Format(time.RFC3339) + + // Also set interactive: true in spec so session can be restarted + if spec, ok := GetSpecMap(item); ok { + if interactive, ok := spec["interactive"].(bool); !ok || !interactive { + log.Printf("Setting interactive: true for stopped session %s to allow restart", sessionName) + spec["interactive"] = true + // Update spec first (must use Update, not UpdateStatus) + item, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update session spec for %s: %v (continuing with status update)", sessionName, err) + // Continue anyway - status update is more important + } + } + } + + // Update the resource using UpdateStatus for status subresource + updated, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + if errors.IsNotFound(err) { + // Session was deleted while we were trying to update it + log.Printf("Agentic session %s was deleted during stop operation", sessionName) + c.JSON(http.StatusOK, gin.H{"message": "Session no longer exists (already deleted)"}) + return + } + log.Printf("Failed to update agentic session status %s: %v", sessionName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) + return + } + + // Parse and return updated session + metadata, ok := GetMetadataMap(updated) + if !ok { + log.Printf("Updated session %s missing metadata", sessionName) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session data"}) + return + } + + session := types.AgenticSession{ + APIVersion: updated.GetAPIVersion(), + Kind: updated.GetKind(), + Metadata: metadata, + } + + if spec, ok := GetSpecMap(updated); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := GetStatusMap(updated); ok { + session.Status = parseStatus(status) + } + + log.Printf("Successfully stopped agentic session %s", sessionName) + c.JSON(http.StatusAccepted, session) +} + +// UpdateSessionStatus writes selected fields to PVC-backed files and updates CR status. +// PUT /api/projects/:projectName/agentic-sessions/:sessionName/status +func UpdateSessionStatus(c *gin.Context) { + project := c.GetString("project") + sessionName := c.Param("sessionName") + _, reqDyn := GetK8sClientsForRequest(c) + + var statusUpdate map[string]interface{} + if err := c.ShouldBindJSON(&statusUpdate); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + // Get current resource + item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) + return + } + + // Ensure status map + if item.Object["status"] == nil { + item.Object["status"] = make(map[string]interface{}) + } + status, ok := GetStatusMap(item) + if !ok { + status = make(map[string]interface{}) + item.Object["status"] = status + } + + // Accept standard fields and result summary fields from runner + allowed := map[string]struct{}{ + "phase": {}, "completionTime": {}, "cost": {}, "message": {}, + "subtype": {}, "duration_ms": {}, "duration_api_ms": {}, "is_error": {}, + "num_turns": {}, "session_id": {}, "total_cost_usd": {}, "usage": {}, "result": {}, + } + for k := range statusUpdate { + if _, ok := allowed[k]; !ok { + delete(statusUpdate, k) + } + } + + // Merge remaining fields into status + for k, v := range statusUpdate { + status[k] = v + } + + // Update only the status subresource (requires agenticsessions/status perms) + if _, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}); err != nil { + log.Printf("Failed to update agentic session status %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "agentic session status updated"}) +} + +// ensureRunnerRolePermissions checks and updates runner role permissions if needed. +// This is used to add missing permissions to existing sessions during restart. +func ensureRunnerRolePermissions(c *gin.Context, reqK8s *kubernetes.Clientset, project string, sessionName string) error { + roleName := fmt.Sprintf("ambient-session-%s-role", sessionName) + + // Get existing role + existingRole, err := reqK8s.RbacV1().Roles(project).Get(c.Request.Context(), roleName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + log.Printf("Role %s not found for session %s - will be created by operator", roleName, sessionName) + return nil + } + return fmt.Errorf("get role: %w", err) + } + + // Check if role has selfsubjectaccessreviews permission + hasSelfSubjectAccessReview := false + for _, rule := range existingRole.Rules { + for _, apiGroup := range rule.APIGroups { + if apiGroup == "authorization.k8s.io" { + for _, resource := range rule.Resources { + if resource == "selfsubjectaccessreviews" { + hasSelfSubjectAccessReview = true + break + } + } + } + } + } + + if hasSelfSubjectAccessReview { + log.Printf("Role %s already has selfsubjectaccessreviews permission", roleName) + return nil + } + + // Add missing permission + log.Printf("Updating role %s to add selfsubjectaccessreviews permission", roleName) + existingRole.Rules = append(existingRole.Rules, rbacv1.PolicyRule{ + APIGroups: []string{"authorization.k8s.io"}, + Resources: []string{"selfsubjectaccessreviews"}, + Verbs: []string{"create"}, + }) + + _, err = reqK8s.RbacV1().Roles(project).Update(c.Request.Context(), existingRole, v1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("update role: %w", err) + } + + log.Printf("Successfully updated role %s with selfsubjectaccessreviews permission", roleName) + return nil +} diff --git a/components/backend/handlers/sessions_crud.go b/components/backend/handlers/sessions_crud.go new file mode 100644 index 000000000..a5fc4a52f --- /dev/null +++ b/components/backend/handlers/sessions_crud.go @@ -0,0 +1,1046 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains CRUD operations for agentic sessions. +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + "ambient-code-backend/types" + + "github.com/gin-gonic/gin" + authnv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/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" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +// ListSessions lists all agentic sessions in the project namespace. +// V2 API Handler for multi-tenant session management. +func ListSessions(c *gin.Context) { + project := c.GetString("project") + _, reqDyn := GetK8sClientsForRequest(c) + gvr := GetAgenticSessionV1Alpha1Resource() + + list, err := reqDyn.Resource(gvr).Namespace(project).List(context.TODO(), v1.ListOptions{}) + if err != nil { + log.Printf("Failed to list agentic sessions in project %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list agentic sessions"}) + return + } + + var sessions []types.AgenticSession + for _, item := range list.Items { + metadata, ok := GetMetadataMap(&item) + if !ok { + log.Printf("Warning: session missing metadata, skipping") + continue + } + + session := types.AgenticSession{ + APIVersion: item.GetAPIVersion(), + Kind: item.GetKind(), + Metadata: metadata, + } + + if spec, ok := GetSpecMap(&item); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := GetStatusMap(&item); ok { + session.Status = parseStatus(status) + } + + sessions = append(sessions, session) + } + + c.JSON(http.StatusOK, gin.H{"items": sessions}) +} + +// CreateSession creates a new agentic session with support for multi-repo configuration and RFE workflows. +func CreateSession(c *gin.Context) { + project := c.GetString("project") + // Use backend service account clients for CR writes + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + var req types.CreateAgenticSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validation for multi-repo can be added here if needed + + // Set defaults for LLM settings if not provided + llmSettings := types.LLMSettings{ + Model: "sonnet", + Temperature: 0.7, + MaxTokens: 4000, + } + if req.LLMSettings != nil { + if req.LLMSettings.Model != "" { + llmSettings.Model = req.LLMSettings.Model + } + if req.LLMSettings.Temperature != 0 { + llmSettings.Temperature = req.LLMSettings.Temperature + } + if req.LLMSettings.MaxTokens != 0 { + llmSettings.MaxTokens = req.LLMSettings.MaxTokens + } + } + + timeout := 300 + if req.Timeout != nil { + timeout = *req.Timeout + } + + // Generate unique name + timestamp := time.Now().Unix() + name := fmt.Sprintf("agentic-session-%d", timestamp) + + // Create the custom resource + // Metadata + metadata := map[string]interface{}{ + "name": name, + "namespace": project, + } + if len(req.Labels) > 0 { + labels := map[string]interface{}{} + for k, v := range req.Labels { + labels[k] = v + } + metadata["labels"] = labels + } + if len(req.Annotations) > 0 { + annotations := map[string]interface{}{} + for k, v := range req.Annotations { + annotations[k] = v + } + metadata["annotations"] = annotations + } + + session := map[string]interface{}{ + "apiVersion": "vteam.ambient-code/v1alpha1", + "kind": "AgenticSession", + "metadata": metadata, + "spec": map[string]interface{}{ + "prompt": req.Prompt, + "displayName": req.DisplayName, + "project": project, + "llmSettings": map[string]interface{}{ + "model": llmSettings.Model, + "temperature": llmSettings.Temperature, + "maxTokens": llmSettings.MaxTokens, + }, + "timeout": timeout, + }, + "status": map[string]interface{}{ + "phase": "Pending", + }, + } + + // Optional environment variables passthrough (always, independent of git config presence) + envVars := make(map[string]string) + for k, v := range req.EnvironmentVariables { + envVars[k] = v + } + + // Handle session continuation + if req.ParentSessionID != "" { + envVars["PARENT_SESSION_ID"] = req.ParentSessionID + // Add annotation to track continuation lineage + if metadata["annotations"] == nil { + metadata["annotations"] = make(map[string]interface{}) + } + annotations := metadata["annotations"].(map[string]interface{}) + annotations["vteam.ambient-code/parent-session-id"] = req.ParentSessionID + log.Printf("Creating continuation session from parent %s", req.ParentSessionID) + + // Clean up temp-content pod from parent session to free the PVC + // This prevents Multi-Attach errors when the new session tries to mount the same workspace + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + log.Printf("CreateSession: Cannot cleanup temp pod, no K8s client available (non-fatal)") + } else { + tempPodName := fmt.Sprintf("temp-content-%s", req.ParentSessionID) + if err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), tempPodName, v1.DeleteOptions{}); err != nil { + if !errors.IsNotFound(err) { + log.Printf("CreateSession: failed to delete temp-content pod %s (non-fatal): %v", tempPodName, err) + } + } else { + log.Printf("CreateSession: deleted temp-content pod %s to free PVC for continuation", tempPodName) + } + } + } + + // Get spec for modifications (we know it exists since we just created the session object) + sessionSpec, ok := session["spec"].(map[string]interface{}) + if !ok { + log.Printf("Warning: session spec has unexpected type") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error creating session"}) + return + } + + if len(envVars) > 0 { + sessionSpec["environmentVariables"] = envVars + } + + // Interactive flag + if req.Interactive != nil { + sessionSpec["interactive"] = *req.Interactive + } + + // AutoPushOnComplete flag + if req.AutoPushOnComplete != nil { + sessionSpec["autoPushOnComplete"] = *req.AutoPushOnComplete + } + + // Set multi-repo configuration on spec + // Multi-repo pass-through (unified repos) + if len(req.Repos) > 0 { + arr := make([]map[string]interface{}, 0, len(req.Repos)) + for _, r := range req.Repos { + m := map[string]interface{}{} + in := map[string]interface{}{"url": r.Input.URL} + if r.Input.Branch != nil { + in["branch"] = *r.Input.Branch + } + m["input"] = in + if r.Output != nil { + out := map[string]interface{}{"url": r.Output.URL} + if r.Output.Branch != nil { + out["branch"] = *r.Output.Branch + } + m["output"] = out + } + // Remove default repo status; status will be set explicitly when pushed/abandoned + // m["status"] intentionally unset at creation time + arr = append(arr, m) + } + sessionSpec["repos"] = arr + } + if req.MainRepoIndex != nil { + sessionSpec["mainRepoIndex"] = *req.MainRepoIndex + } + + // Handle RFE workflow branch management + { + rfeWorkflowID := "" + // Check if RFE workflow ID is in labels + if len(req.Labels) > 0 { + if id, ok := req.Labels["rfe-workflow"]; ok { + rfeWorkflowID = id + } + } + + // If linked to an RFE workflow, fetch it and set the branch + if rfeWorkflowID != "" { + // Get request-scoped dynamic client for fetching RFE workflow + _, reqDyn := GetK8sClientsForRequest(c) + if reqDyn != nil { + rfeGvr := GetRFEWorkflowResource() + if rfeGvr != (schema.GroupVersionResource{}) { + rfeObj, err := reqDyn.Resource(rfeGvr).Namespace(project).Get(c.Request.Context(), rfeWorkflowID, v1.GetOptions{}) + if err == nil { + rfeWf := RfeFromUnstructured(rfeObj) + if rfeWf != nil && rfeWf.BranchName != "" { + // Override branch for all repos to use feature branch + if repos, ok := sessionSpec["repos"].([]map[string]interface{}); ok { + for i := range repos { + // Always override input branch with feature branch + if input, ok := repos[i]["input"].(map[string]interface{}); ok { + input["branch"] = rfeWf.BranchName + } + // Always override output branch with feature branch + if output, ok := repos[i]["output"].(map[string]interface{}); ok { + output["branch"] = rfeWf.BranchName + } + } + } + + log.Printf("Set RFE branch %s for session %s", rfeWf.BranchName, name) + } + } else { + log.Printf("Warning: Failed to fetch RFE workflow %s: %v", rfeWorkflowID, err) + } + } + } + } + } + + // Add userContext derived from authenticated caller; ignore client-supplied userId + { + uidVal, _ := c.Get("userID") + uid, _ := uidVal.(string) + uid = strings.TrimSpace(uid) + if uid != "" { + displayName := "" + if v, ok := c.Get("userName"); ok { + if s, ok2 := v.(string); ok2 { + displayName = s + } + } + groups := []string{} + if v, ok := c.Get("userGroups"); ok { + if gg, ok2 := v.([]string); ok2 { + groups = gg + } + } + // Fallbacks for non-identity fields only + if displayName == "" && req.UserContext != nil { + displayName = req.UserContext.DisplayName + } + if len(groups) == 0 && req.UserContext != nil { + groups = req.UserContext.Groups + } + sessionSpec["userContext"] = map[string]interface{}{ + "userId": uid, + "displayName": displayName, + "groups": groups, + } + } + } + + // Add botAccount if provided + if req.BotAccount != nil { + sessionSpec["botAccount"] = map[string]interface{}{ + "name": req.BotAccount.Name, + } + } + + // Add resourceOverrides if provided + if req.ResourceOverrides != nil { + resourceOverrides := make(map[string]interface{}) + if req.ResourceOverrides.CPU != "" { + resourceOverrides["cpu"] = req.ResourceOverrides.CPU + } + if req.ResourceOverrides.Memory != "" { + resourceOverrides["memory"] = req.ResourceOverrides.Memory + } + if req.ResourceOverrides.StorageClass != "" { + resourceOverrides["storageClass"] = req.ResourceOverrides.StorageClass + } + if req.ResourceOverrides.PriorityClass != "" { + resourceOverrides["priorityClass"] = req.ResourceOverrides.PriorityClass + } + if len(resourceOverrides) > 0 { + sessionSpec["resourceOverrides"] = resourceOverrides + } + } + + gvr := GetAgenticSessionV1Alpha1Resource() + obj := &unstructured.Unstructured{Object: session} + + created, err := DynamicClient.Resource(gvr).Namespace(project).Create(context.TODO(), obj, v1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create agentic session in project %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agentic session"}) + return + } + + // Best-effort prefill of agent markdown into PVC workspace for immediate UI availability + // Uses AGENT_PERSONAS or AGENT_PERSONA if provided in request environment variables + func() { + defer func() { _ = recover() }() + personasCsv := "" + if v, ok := req.EnvironmentVariables["AGENT_PERSONAS"]; ok && strings.TrimSpace(v) != "" { + personasCsv = v + } else if v, ok := req.EnvironmentVariables["AGENT_PERSONA"]; ok && strings.TrimSpace(v) != "" { + personasCsv = v + } + if strings.TrimSpace(personasCsv) == "" { + return + } + // content service removed; skip workspace path handling + // Write each agent markdown + for _, p := range strings.Split(personasCsv, ",") { + persona := strings.TrimSpace(p) + if persona == "" { + continue + } + // ambient-content removed: skip agent prefill writes + } + }() + + // Preferred method: provision a per-session ServiceAccount token for the runner (backend SA) + if err := provisionRunnerTokenForSession(c, K8sClient, DynamicClient, project, name); err != nil { + // Non-fatal: log and continue. Operator may retry later if implemented. + log.Printf("Warning: failed to provision runner token for session %s/%s: %v", project, name, err) + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Agentic session created successfully", + "name": name, + "uid": created.GetUID(), + }) +} + +// provisionRunnerTokenForSession creates a per-session ServiceAccount, grants minimal RBAC, +// mints a short-lived token, stores it in a Secret, and annotates the AgenticSession with the Secret name. +func provisionRunnerTokenForSession(c *gin.Context, reqK8s *kubernetes.Clientset, reqDyn dynamic.Interface, project string, sessionName string) error { + // Load owning AgenticSession to parent all resources + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + if err != nil { + return fmt.Errorf("get AgenticSession: %w", err) + } + ownerRef := v1.OwnerReference{ + APIVersion: obj.GetAPIVersion(), + Kind: obj.GetKind(), + Name: obj.GetName(), + UID: obj.GetUID(), + Controller: types.BoolPtr(true), + } + + // Create ServiceAccount + saName := fmt.Sprintf("ambient-session-%s", sessionName) + sa := &corev1.ServiceAccount{ + ObjectMeta: v1.ObjectMeta{ + Name: saName, + Namespace: project, + Labels: map[string]string{"app": "ambient-runner"}, + OwnerReferences: []v1.OwnerReference{ownerRef}, + }, + } + if _, err := reqK8s.CoreV1().ServiceAccounts(project).Create(c.Request.Context(), sa, v1.CreateOptions{}); err != nil { + if !errors.IsAlreadyExists(err) { + return fmt.Errorf("create SA: %w", err) + } + } + + // Create Role with least-privilege for updating AgenticSession status and annotations + roleName := fmt.Sprintf("ambient-session-%s-role", sessionName) + role := &rbacv1.Role{ + ObjectMeta: v1.ObjectMeta{ + Name: roleName, + Namespace: project, + OwnerReferences: []v1.OwnerReference{ownerRef}, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"vteam.ambient-code"}, + Resources: []string{"agenticsessions/status"}, + Verbs: []string{"get", "update", "patch"}, + }, + { + APIGroups: []string{"vteam.ambient-code"}, + Resources: []string{"agenticsessions"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, // Added update, patch for annotations + }, + { + APIGroups: []string{"authorization.k8s.io"}, + Resources: []string{"selfsubjectaccessreviews"}, + Verbs: []string{"create"}, + }, + }, + } + // Try to create or update the Role to ensure it has latest permissions + if _, err := reqK8s.RbacV1().Roles(project).Create(c.Request.Context(), role, v1.CreateOptions{}); err != nil { + if errors.IsAlreadyExists(err) { + // Role exists - update it to ensure it has the latest permissions (including update/patch) + log.Printf("Role %s already exists, updating with latest permissions", roleName) + if _, err := reqK8s.RbacV1().Roles(project).Update(c.Request.Context(), role, v1.UpdateOptions{}); err != nil { + return fmt.Errorf("update Role: %w", err) + } + log.Printf("Successfully updated Role %s with annotation update permissions", roleName) + } else { + return fmt.Errorf("create Role: %w", err) + } + } + + // Bind Role to the ServiceAccount + rbName := fmt.Sprintf("ambient-session-%s-rb", sessionName) + rb := &rbacv1.RoleBinding{ + ObjectMeta: v1.ObjectMeta{ + Name: rbName, + Namespace: project, + OwnerReferences: []v1.OwnerReference{ownerRef}, + }, + RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: roleName}, + Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: saName, Namespace: project}}, + } + if _, err := reqK8s.RbacV1().RoleBindings(project).Create(context.TODO(), rb, v1.CreateOptions{}); err != nil { + if !errors.IsAlreadyExists(err) { + return fmt.Errorf("create RoleBinding: %w", err) + } + } + + // Mint short-lived K8s ServiceAccount token for CR status updates + tr := &authnv1.TokenRequest{Spec: authnv1.TokenRequestSpec{}} + tok, err := reqK8s.CoreV1().ServiceAccounts(project).CreateToken(c.Request.Context(), saName, tr, v1.CreateOptions{}) + if err != nil { + return fmt.Errorf("mint token: %w", err) + } + k8sToken := tok.Status.Token + if strings.TrimSpace(k8sToken) == "" { + return fmt.Errorf("received empty token for SA %s", saName) + } + + // Only store the K8s token; GitHub tokens are minted on-demand by the runner + secretData := map[string]string{ + "k8s-token": k8sToken, + } + + // Create Secret (with OwnerReference to be cleaned up when Session is deleted) + secretName := fmt.Sprintf("ambient-session-%s-token", sessionName) + secret := &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: secretName, + Namespace: project, + OwnerReferences: []v1.OwnerReference{ownerRef}, + }, + Type: corev1.SecretTypeOpaque, + StringData: secretData, + } + if _, err := reqK8s.CoreV1().Secrets(project).Create(c.Request.Context(), secret, v1.CreateOptions{}); err != nil { + if errors.IsAlreadyExists(err) { + // Update existing secret with new token + if _, err := reqK8s.CoreV1().Secrets(project).Update(c.Request.Context(), secret, v1.UpdateOptions{}); err != nil { + return fmt.Errorf("update Secret: %w", err) + } + } else { + return fmt.Errorf("create Secret: %w", err) + } + } + + // Annotate the session with the secret name (runner will look for it) + patch := []map[string]interface{}{ + { + "op": "add", + "path": "/metadata/annotations/vteam.ambient-code~1runner-token-secret", + "value": secretName, + }, + } + patchData, _ := json.Marshal(patch) + if _, err := reqDyn.Resource(gvr).Namespace(project).Patch(c.Request.Context(), sessionName, k8stypes.JSONPatchType, patchData, v1.PatchOptions{}); err != nil { + // Fall back to regular merge patch if JSON patch fails + mergePatch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "vteam.ambient-code/runner-token-secret": secretName, + }, + }, + } + patchData, _ := json.Marshal(mergePatch) + if _, err := reqDyn.Resource(gvr).Namespace(project).Patch(c.Request.Context(), sessionName, k8stypes.MergePatchType, patchData, v1.PatchOptions{}); err != nil { + return fmt.Errorf("annotate AgenticSession: %w", err) + } + } + + log.Printf("Provisioned runner token for session %s/%s", project, sessionName) + return nil +} + +// GetSession retrieves a single agentic session by name. +func GetSession(c *gin.Context) { + project := c.GetString("project") + name := c.Param("name") + + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Session name is required"}) + return + } + + _, reqDyn := GetK8sClientsForRequest(c) + gvr := GetAgenticSessionV1Alpha1Resource() + + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), name, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + } else { + log.Printf("Failed to get agentic session %s in project %s: %v", name, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) + } + return + } + + metadata, ok := GetMetadataMap(obj) + if !ok { + log.Printf("Session %s missing metadata", name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session data"}) + return + } + + session := types.AgenticSession{ + APIVersion: obj.GetAPIVersion(), + Kind: obj.GetKind(), + Metadata: metadata, + } + + if spec, ok := GetSpecMap(obj); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := GetStatusMap(obj); ok { + session.Status = parseStatus(status) + } + + c.JSON(http.StatusOK, session) +} + +// PatchSession patches an agentic session's annotations. +// Only annotations are supported for patching currently. +func PatchSession(c *gin.Context) { + project := c.GetString("project") + name := c.Param("name") + + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Session name is required"}) + return + } + + var patchReq map[string]interface{} + if err := c.ShouldBindJSON(&patchReq); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Only support patching annotations + if metadata, ok := patchReq["metadata"].(map[string]interface{}); ok { + if _, hasAnnotations := metadata["annotations"]; !hasAnnotations { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only metadata.annotations can be patched"}) + return + } + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only metadata.annotations can be patched"}) + return + } + + // Use backend service account for writes + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + // Create merge patch + patchData, err := json.Marshal(patchReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal patch data"}) + return + } + + // Apply patch + patched, err := DynamicClient.Resource(gvr).Namespace(project).Patch(context.TODO(), name, k8stypes.MergePatchType, patchData, v1.PatchOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + } else { + log.Printf("Failed to patch agentic session %s in project %s: %v", name, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to patch agentic session"}) + } + return + } + + metadata, ok := GetMetadataMap(patched) + if !ok { + log.Printf("Patched session %s missing metadata", name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session data"}) + return + } + + session := types.AgenticSession{ + APIVersion: patched.GetAPIVersion(), + Kind: patched.GetKind(), + Metadata: metadata, + } + + if spec, ok := GetSpecMap(patched); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := GetStatusMap(patched); ok { + session.Status = parseStatus(status) + } + + c.JSON(http.StatusOK, session) +} + +// UpdateSession updates an agentic session's prompt, displayName, LLMSettings, and timeout. +func UpdateSession(c *gin.Context) { + project := c.GetString("project") + name := c.Param("name") + + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Session name is required"}) + return + } + + var updateReq types.UpdateAgenticSessionRequest + if err := c.ShouldBindJSON(&updateReq); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Use backend service account for writes + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + // Get the existing session + existing, err := DynamicClient.Resource(gvr).Namespace(project).Get(context.TODO(), name, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + } else { + log.Printf("Failed to get agentic session %s in project %s: %v", name, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) + } + return + } + + // Update the spec fields + spec, ok := GetSpecMap(existing) + if !ok { + log.Printf("Session %s missing spec", name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session data"}) + return + } + + if updateReq.Prompt != nil { + spec["prompt"] = *updateReq.Prompt + } + if updateReq.DisplayName != nil { + spec["displayName"] = *updateReq.DisplayName + } + if updateReq.Timeout != nil { + spec["timeout"] = *updateReq.Timeout + } + + // Update LLM settings if provided + if updateReq.LLMSettings != nil { + llmSettings, ok := spec["llmSettings"].(map[string]interface{}) + if !ok { + llmSettings = make(map[string]interface{}) + } + if updateReq.LLMSettings.Model != "" { + llmSettings["model"] = updateReq.LLMSettings.Model + } + if updateReq.LLMSettings.Temperature != 0 { + llmSettings["temperature"] = updateReq.LLMSettings.Temperature + } + if updateReq.LLMSettings.MaxTokens != 0 { + llmSettings["maxTokens"] = updateReq.LLMSettings.MaxTokens + } + spec["llmSettings"] = llmSettings + } + + // Update the resource + updated, err := DynamicClient.Resource(gvr).Namespace(project).Update(context.TODO(), existing, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update agentic session %s in project %s: %v", name, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session"}) + return + } + + metadata, ok := GetMetadataMap(updated) + if !ok { + log.Printf("Updated session %s missing metadata", name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session data"}) + return + } + + session := types.AgenticSession{ + APIVersion: updated.GetAPIVersion(), + Kind: updated.GetKind(), + Metadata: metadata, + } + + if spec, ok := GetSpecMap(updated); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := GetStatusMap(updated); ok { + session.Status = parseStatus(status) + } + + c.JSON(http.StatusOK, session) +} + +// UpdateSessionDisplayName updates only the displayName field of an agentic session. +// This is a convenience endpoint for updating just the display name. +func UpdateSessionDisplayName(c *gin.Context) { + project := c.GetString("project") + name := c.Param("name") + + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Session name is required"}) + return + } + + var updateReq struct { + DisplayName string `json:"displayName" binding:"required"` + } + if err := c.ShouldBindJSON(&updateReq); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Use backend service account for writes + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + // Create a merge patch to update just the displayName + patch := map[string]interface{}{ + "spec": map[string]interface{}{ + "displayName": updateReq.DisplayName, + }, + } + + patchData, err := json.Marshal(patch) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal patch data"}) + return + } + + // Apply the patch + updated, err := DynamicClient.Resource(gvr).Namespace(project).Patch(context.TODO(), name, k8stypes.MergePatchType, patchData, v1.PatchOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + } else { + log.Printf("Failed to update display name for agentic session %s in project %s: %v", name, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update display name"}) + } + return + } + + metadata, ok := GetMetadataMap(updated) + if !ok { + log.Printf("Updated session %s missing metadata", name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session data"}) + return + } + + session := types.AgenticSession{ + APIVersion: updated.GetAPIVersion(), + Kind: updated.GetKind(), + Metadata: metadata, + } + + if spec, ok := GetSpecMap(updated); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := GetStatusMap(updated); ok { + session.Status = parseStatus(status) + } + + c.JSON(http.StatusOK, session) +} + +// DeleteSession deletes an agentic session. +func DeleteSession(c *gin.Context) { + project := c.GetString("project") + name := c.Param("name") + + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Session name is required"}) + return + } + + // Use backend service account for writes + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + if err := DynamicClient.Resource(gvr).Namespace(project).Delete(context.TODO(), name, v1.DeleteOptions{}); err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + } else { + log.Printf("Failed to delete agentic session %s in project %s: %v", name, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agentic session"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Session deleted successfully"}) +} + +// CloneSession clones an existing agentic session to another project. +// It supports cross-project cloning for OpenShift environments. +func CloneSession(c *gin.Context) { + sourceProject := c.GetString("project") + sourceName := c.Param("name") + + if sourceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Session name is required"}) + return + } + + var cloneReq types.CloneAgenticSessionRequest + if err := c.ShouldBindJSON(&cloneReq); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Use backend service account for writes + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + // Get the source session using request-scoped clients + _, reqDyn := GetK8sClientsForRequest(c) + sourceObj, err := reqDyn.Resource(gvr).Namespace(sourceProject).Get(context.TODO(), sourceName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Source session not found"}) + } else { + log.Printf("Failed to get source session %s in project %s: %v", sourceName, sourceProject, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source session"}) + } + return + } + + // Determine target project + targetProject := sourceProject + if cloneReq.TargetProject != "" { + targetProject = cloneReq.TargetProject + + // For cross-project cloning, verify user has access to target project + if targetProject != sourceProject { + // Check if OpenShift project resource exists + if GetOpenShiftProjectResource != nil && GetOpenShiftProjectResource() != (schema.GroupVersionResource{}) { + projGvr := GetOpenShiftProjectResource() + if _, err := reqDyn.Resource(projGvr).Get(context.TODO(), targetProject, v1.GetOptions{}); err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Target project not found"}) + } else { + log.Printf("Failed to verify access to target project %s: %v", targetProject, err) + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to target project"}) + } + return + } + } + } + } + + // Check for naming conflicts if a specific target name is requested + targetName := cloneReq.TargetSessionName + if targetName == "" { + // Generate unique name if not provided + timestamp := time.Now().Unix() + targetName = fmt.Sprintf("agentic-session-%d", timestamp) + } else { + // Check if name already exists in target project + if _, err := DynamicClient.Resource(gvr).Namespace(targetProject).Get(context.TODO(), targetName, v1.GetOptions{}); err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "Session with target name already exists in target project"}) + return + } else if !errors.IsNotFound(err) { + log.Printf("Failed to check target session existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify target name availability"}) + return + } + } + + // Create a deep copy of the source session + sourceSpec, ok := GetSpecMap(sourceObj) + if !ok { + log.Printf("Source session %s missing spec", sourceName) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid source session data"}) + return + } + + clonedMetadata := map[string]interface{}{ + "name": targetName, + "namespace": targetProject, + } + + // Copy labels if present + if labels := sourceObj.GetLabels(); len(labels) > 0 { + newLabels := make(map[string]interface{}) + for k, v := range labels { + newLabels[k] = v + } + clonedMetadata["labels"] = newLabels + } + + // Copy annotations, excluding system annotations + if annotations := sourceObj.GetAnnotations(); len(annotations) > 0 { + newAnnotations := make(map[string]interface{}) + for k, v := range annotations { + // Skip system annotations like runner token secrets + if !strings.HasPrefix(k, "vteam.ambient-code/runner-token") && + !strings.HasPrefix(k, "kubectl.kubernetes.io/") { + newAnnotations[k] = v + } + } + // Add clone metadata + newAnnotations["vteam.ambient-code/cloned-from"] = fmt.Sprintf("%s/%s", sourceProject, sourceName) + newAnnotations["vteam.ambient-code/cloned-at"] = time.Now().UTC().Format(time.RFC3339) + clonedMetadata["annotations"] = newAnnotations + } + + clonedSession := map[string]interface{}{ + "apiVersion": sourceObj.GetAPIVersion(), + "kind": sourceObj.GetKind(), + "metadata": clonedMetadata, + "spec": sourceSpec, + } + + // Update spec with clone request overrides + spec := sourceSpec + if cloneReq.DisplayName != "" { + spec["displayName"] = cloneReq.DisplayName + } + if cloneReq.Prompt != "" { + spec["prompt"] = cloneReq.Prompt + } + + // Update project field in spec to target project + spec["project"] = targetProject + + // Reset status for new session + clonedSession["status"] = map[string]interface{}{ + "phase": "Pending", + } + + // Create the cloned session + obj := &unstructured.Unstructured{Object: clonedSession} + created, err := DynamicClient.Resource(gvr).Namespace(targetProject).Create(context.TODO(), obj, v1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create cloned session in project %s: %v", targetProject, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cloned session"}) + return + } + + // Provision runner token for cloned session + if err := provisionRunnerTokenForSession(c, K8sClient, DynamicClient, targetProject, targetName); err != nil { + log.Printf("Warning: failed to provision runner token for cloned session %s/%s: %v", targetProject, targetName, err) + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Session cloned successfully", + "name": targetName, + "targetProject": targetProject, + "uid": created.GetUID(), + }) +} diff --git a/components/backend/handlers/sessions_git.go b/components/backend/handlers/sessions_git.go new file mode 100644 index 000000000..41e192617 --- /dev/null +++ b/components/backend/handlers/sessions_git.go @@ -0,0 +1,475 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains git/GitHub operations for agentic sessions. +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + authnv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" +) + +// MintSessionGitHubToken generates a GitHub token for authenticated session operations. +// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/token +func MintSessionGitHubToken(c *gin.Context) { + project := c.Param("projectName") + sessionName := c.Param("sessionName") + + rawAuth := strings.TrimSpace(c.GetHeader("Authorization")) + if rawAuth == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing Authorization header"}) + return + } + parts := strings.SplitN(rawAuth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid Authorization header"}) + return + } + token := strings.TrimSpace(parts[1]) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "empty token"}) + return + } + + // TokenReview using default audience (works with standard SA tokens) + tr := &authnv1.TokenReview{Spec: authnv1.TokenReviewSpec{Token: token}} + rv, err := K8sClient.AuthenticationV1().TokenReviews().Create(c.Request.Context(), tr, v1.CreateOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token review failed"}) + return + } + if rv.Status.Error != "" || !rv.Status.Authenticated { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthenticated"}) + return + } + subj := strings.TrimSpace(rv.Status.User.Username) + const pfx = "system:serviceaccount:" + if !strings.HasPrefix(subj, pfx) { + c.JSON(http.StatusForbidden, gin.H{"error": "subject is not a service account"}) + return + } + rest := strings.TrimPrefix(subj, pfx) + segs := strings.SplitN(rest, ":", 2) + if len(segs) != 2 { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid service account subject"}) + return + } + nsFromToken, saFromToken := segs[0], segs[1] + if nsFromToken != project { + c.JSON(http.StatusForbidden, gin.H{"error": "namespace mismatch"}) + return + } + + // Load session and verify SA matches annotation + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := DynamicClient.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read session"}) + return + } + meta, ok := GetMetadataMap(obj) + if !ok { + meta = make(map[string]interface{}) + } + anns, _ := meta["annotations"].(map[string]interface{}) + expectedSA := "" + if anns != nil { + if v, ok := anns["ambient-code.io/runner-sa"].(string); ok { + expectedSA = strings.TrimSpace(v) + } + } + if expectedSA == "" || expectedSA != saFromToken { + c.JSON(http.StatusForbidden, gin.H{"error": "service account not authorized for session"}) + return + } + + // Read authoritative userId from spec.userContext.userId + spec, ok := GetSpecMap(obj) + if !ok { + spec = make(map[string]interface{}) + } + userId := "" + if spec != nil { + if uc, ok := spec["userContext"].(map[string]interface{}); ok { + if v, ok := uc["userId"].(string); ok { + userId = strings.TrimSpace(v) + } + } + } + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session missing user context"}) + return + } + + // Get GitHub token (GitHub App or PAT fallback via project runner secret) + tokenStr, err := GetGitHubToken(c.Request.Context(), K8sClient, DynamicClient, project, userId) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + // Note: PATs don't have expiration, so we omit expiresAt for simplicity + // Runners should treat all tokens as short-lived and request new ones as needed + c.JSON(http.StatusOK, gin.H{"token": tokenStr}) +} + +// setRepoStatus updates the status of a repository in the session CR. +func setRepoStatus(dyn dynamic.Interface, project, sessionName string, repoIndex int, newStatus string) error { + gvr := GetAgenticSessionV1Alpha1Resource() + item, err := dyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) + if err != nil { + return err + } + + // Get repo name from spec.repos[repoIndex] + spec, ok := GetSpecMap(item) + if !ok { + spec = make(map[string]interface{}) + } + specRepos, _ := spec["repos"].([]interface{}) + if repoIndex < 0 || repoIndex >= len(specRepos) { + return fmt.Errorf("repo index out of range") + } + specRepo, _ := specRepos[repoIndex].(map[string]interface{}) + repoName := "" + if name, ok := specRepo["name"].(string); ok { + repoName = name + } else if input, ok := specRepo["input"].(map[string]interface{}); ok { + if url, ok := input["url"].(string); ok { + repoName = DeriveRepoFolderFromURL(url) + } + } + if repoName == "" { + repoName = fmt.Sprintf("repo-%d", repoIndex) + } + + // Ensure status.repos exists + if item.Object["status"] == nil { + item.Object["status"] = make(map[string]interface{}) + } + status, ok := GetStatusMap(item) + if !ok { + status = make(map[string]interface{}) + item.Object["status"] = status + } + statusRepos, _ := status["repos"].([]interface{}) + if statusRepos == nil { + statusRepos = []interface{}{} + } + + // Find or create status entry for this repo + repoStatus := map[string]interface{}{ + "name": repoName, + "status": newStatus, + "last_updated": time.Now().Format(time.RFC3339), + } + + // Update existing or append new + found := false + for i, r := range statusRepos { + if rm, ok := r.(map[string]interface{}); ok { + if n, ok := rm["name"].(string); ok && n == repoName { + rm["status"] = newStatus + rm["last_updated"] = time.Now().Format(time.RFC3339) + statusRepos[i] = rm + found = true + break + } + } + } + if !found { + statusRepos = append(statusRepos, repoStatus) + } + + status["repos"] = statusRepos + item.Object["status"] = status + + updated, err := dyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("setRepoStatus: update failed project=%s session=%s repoIndex=%d status=%s err=%v", project, sessionName, repoIndex, newStatus, err) + return err + } + if updated != nil { + log.Printf("setRepoStatus: update ok project=%s session=%s repo=%s status=%s", project, sessionName, repoName, newStatus) + } + return nil +} + +// PushSessionRepo pushes changes from a session workspace to the output repository. +// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/push +func PushSessionRepo(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + + var body struct { + RepoIndex int `json:"repoIndex"` + CommitMessage string `json:"commitMessage"` + } + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } + log.Printf("pushSessionRepo: request project=%s session=%s repoIndex=%d commitLen=%d", project, session, body.RepoIndex, len(strings.TrimSpace(body.CommitMessage))) + + // Resolve the correct content service (temp-content for completed, ambient-content for running) + serviceName := ResolveContentServiceName(c, project, session) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("pushSessionRepo: using service %s", serviceName) + + // Simplified: 1) get session; 2) compute repoPath from INPUT repo folder; 3) get output url/branch; 4) proxy + resolvedRepoPath := "" + // default branch when not defined on output + resolvedBranch := fmt.Sprintf("sessions/%s", session) + resolvedOutputURL := "" + if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read session"}) + return + } + spec, ok := GetSpecMap(obj) + if !ok { + spec = make(map[string]interface{}) + } + repos, _ := spec["repos"].([]interface{}) + if body.RepoIndex < 0 || body.RepoIndex >= len(repos) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repo index"}) + return + } + rm, _ := repos[body.RepoIndex].(map[string]interface{}) + // Derive repoPath from input URL folder name + if in, ok := rm["input"].(map[string]interface{}); ok { + if urlv, ok2 := in["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { + folder := DeriveRepoFolderFromURL(strings.TrimSpace(urlv)) + if folder != "" { + resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, folder) + } + } + } + if out, ok := rm["output"].(map[string]interface{}); ok { + if urlv, ok2 := out["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { + resolvedOutputURL = strings.TrimSpace(urlv) + } + if bs, ok2 := out["branch"].(string); ok2 && strings.TrimSpace(bs) != "" { + resolvedBranch = strings.TrimSpace(bs) + } else if bv, ok2 := out["branch"].(*string); ok2 && bv != nil && strings.TrimSpace(*bv) != "" { + resolvedBranch = strings.TrimSpace(*bv) + } + } + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "no dynamic client"}) + return + } + // If input URL missing or unparsable, fall back to numeric index path (last resort) + if strings.TrimSpace(resolvedRepoPath) == "" { + if body.RepoIndex >= 0 { + resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) + } else { + resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace", session) + } + } + if strings.TrimSpace(resolvedOutputURL) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing output repo url"}) + return + } + log.Printf("pushSessionRepo: resolved repoPath=%q outputUrl=%q branch=%q", resolvedRepoPath, resolvedOutputURL, resolvedBranch) + + payload := map[string]interface{}{ + "repoPath": resolvedRepoPath, + "commitMessage": body.CommitMessage, + "branch": resolvedBranch, + "outputRepoUrl": resolvedOutputURL, + } + b, _ := json.Marshal(payload) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/push", strings.NewReader(string(b))) + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) + } + if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { + req.Header.Set("X-Forwarded-Access-Token", v) + } + req.Header.Set("Content-Type", "application/json") + + // Attach short-lived GitHub token for one-shot authenticated push + if reqK8s, reqDyn := GetK8sClientsForRequest(c); reqK8s != nil { + // Load session to get authoritative userId + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err == nil { + spec, ok := GetSpecMap(obj) + if !ok { + spec = make(map[string]interface{}) + } + userId := "" + if spec != nil { + if uc, ok := spec["userContext"].(map[string]interface{}); ok { + if v, ok := uc["userId"].(string); ok { + userId = strings.TrimSpace(v) + } + } + } + if userId != "" { + if tokenStr, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userId); err == nil && strings.TrimSpace(tokenStr) != "" { + req.Header.Set("X-GitHub-Token", tokenStr) + log.Printf("pushSessionRepo: attached short-lived GitHub token for project=%s session=%s", project, session) + } else if err != nil { + log.Printf("pushSessionRepo: failed to resolve GitHub token: %v", err) + } + } else { + log.Printf("pushSessionRepo: session %s/%s missing userContext.userId; proceeding without token", project, session) + } + } else { + log.Printf("pushSessionRepo: failed to read session for token attach: %v", err) + } + } + + log.Printf("pushSessionRepo: proxy push project=%s session=%s repoIndex=%d repoPath=%s endpoint=%s", project, session, body.RepoIndex, resolvedRepoPath, endpoint+"/content/github/push") + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("pushSessionRepo: content returned status=%d body.snip=%q", resp.StatusCode, func() string { + s := string(bodyBytes) + if len(s) > 1500 { + return s[:1500] + "..." + } + return s + }()) + c.Data(resp.StatusCode, "application/json", bodyBytes) + return + } + if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { + log.Printf("pushSessionRepo: setting repo status to 'pushed' for repoIndex=%d", body.RepoIndex) + if err := setRepoStatus(reqDyn, project, session, body.RepoIndex, "pushed"); err != nil { + log.Printf("pushSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) + } + } else { + log.Printf("pushSessionRepo: no dynamic client; cannot set repo status project=%s session=%s", project, session) + } + log.Printf("pushSessionRepo: content push succeeded status=%d body.len=%d", resp.StatusCode, len(bodyBytes)) + c.Data(http.StatusOK, "application/json", bodyBytes) +} + +// AbandonSessionRepo marks a repository as abandoned without pushing changes. +// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/abandon +func AbandonSessionRepo(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + var body struct { + RepoIndex int `json:"repoIndex"` + RepoPath string `json:"repoPath"` + } + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } + + // Resolve the correct content service (temp-content for completed, ambient-content for running) + serviceName := ResolveContentServiceName(c, project, session) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("AbandonSessionRepo: using service %s", serviceName) + repoPath := strings.TrimSpace(body.RepoPath) + if repoPath == "" { + if body.RepoIndex >= 0 { + repoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) + } else { + repoPath = fmt.Sprintf("/sessions/%s/workspace", session) + } + } + payload := map[string]interface{}{ + "repoPath": repoPath, + } + b, _ := json.Marshal(payload) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/abandon", strings.NewReader(string(b))) + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) + } + if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { + req.Header.Set("X-Forwarded-Access-Token", v) + } + req.Header.Set("Content-Type", "application/json") + log.Printf("abandonSessionRepo: proxy abandon project=%s session=%s repoIndex=%d repoPath=%s", project, session, body.RepoIndex, repoPath) + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("abandonSessionRepo: content returned status=%d body=%s", resp.StatusCode, string(bodyBytes)) + c.Data(resp.StatusCode, "application/json", bodyBytes) + return + } + if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { + if err := setRepoStatus(reqDyn, project, session, body.RepoIndex, "abandoned"); err != nil { + log.Printf("abandonSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) + } + } else { + log.Printf("abandonSessionRepo: no dynamic client; cannot set repo status project=%s session=%s", project, session) + } + c.Data(http.StatusOK, "application/json", bodyBytes) +} + +// DiffSessionRepo returns git diff for changes in a session workspace repository. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/github/diff +func DiffSessionRepo(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + repoIndexStr := strings.TrimSpace(c.Query("repoIndex")) + repoPath := strings.TrimSpace(c.Query("repoPath")) + if repoPath == "" && repoIndexStr != "" { + repoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, repoIndexStr) + } + if repoPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing repoPath/repoIndex"}) + return + } + + // Resolve the correct content service (temp-content for completed, ambient-content for running) + serviceName := ResolveContentServiceName(c, project, session) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("DiffSessionRepo: using service %s", serviceName) + urlStr := fmt.Sprintf("%s/content/github/diff?repoPath=%s", endpoint, url.QueryEscape(repoPath)) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, urlStr, nil) + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) + } + if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { + req.Header.Set("X-Forwarded-Access-Token", v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "files": gin.H{ + "added": 0, + "removed": 0, + }, + "total_added": 0, + "total_removed": 0, + }) + return + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) +} diff --git a/components/backend/handlers/sessions_k8s.go b/components/backend/handlers/sessions_k8s.go new file mode 100644 index 000000000..ed6c887bb --- /dev/null +++ b/components/backend/handlers/sessions_k8s.go @@ -0,0 +1,425 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains Kubernetes operations for agentic sessions. +package handlers + +import ( + "fmt" + "log" + "net/http" + "os" + "time" + + "ambient-code-backend/types" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + intstr "k8s.io/apimachinery/pkg/util/intstr" +) + +// SpawnContentPod creates a temporary content service pod for a session. +// This pod serves workspace content over HTTP before the session starts. +// POST /api/projects/:projectName/agentic-sessions/:sessionName/content-pod +func SpawnContentPod(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + podName := fmt.Sprintf("temp-content-%s", sessionName) + + // Check if already exists + if existing, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), podName, v1.GetOptions{}); err == nil { + ready := false + for _, cond := range existing.Status.Conditions { + if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { + ready = true + break + } + } + c.JSON(http.StatusOK, gin.H{"status": "exists", "podName": podName, "ready": ready}) + return + } + + // Verify PVC exists + pvcName := fmt.Sprintf("ambient-workspace-%s", sessionName) + if _, err := reqK8s.CoreV1().PersistentVolumeClaims(project).Get(c.Request.Context(), pvcName, v1.GetOptions{}); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workspace PVC not found"}) + return + } + + // Get content service image from env + contentImage := os.Getenv("CONTENT_SERVICE_IMAGE") + if contentImage == "" { + contentImage = "quay.io/ambient_code/vteam_backend:latest" + } + imagePullPolicy := corev1.PullIfNotPresent + if os.Getenv("IMAGE_PULL_POLICY") == "Always" { + imagePullPolicy = corev1.PullAlways + } + + // Create temporary pod + pod := &corev1.Pod{ + ObjectMeta: v1.ObjectMeta{ + Name: podName, + Namespace: project, + Labels: map[string]string{ + "app": "temp-content-service", + "temp-content-for-session": sessionName, + }, + Annotations: map[string]string{ + "vteam.ambient-code/ttl": "900", + "vteam.ambient-code/created-at": time.Now().Format(time.RFC3339), + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "content", + Image: contentImage, + ImagePullPolicy: imagePullPolicy, + Env: []corev1.EnvVar{ + {Name: "CONTENT_SERVICE_MODE", Value: "true"}, + {Name: "STATE_BASE_DIR", Value: "/workspace"}, + }, + Ports: []corev1.ContainerPort{{ContainerPort: 8080, Name: "http"}}, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/health", + Port: intstr.FromString("http"), + }, + }, + InitialDelaySeconds: 2, + PeriodSeconds: 2, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "workspace", + MountPath: "/workspace", + ReadOnly: false, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("512Mi"), + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "workspace", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: pvcName, + }, + }, + }, + }, + }, + } + + created, err := reqK8s.CoreV1().Pods(project).Create(c.Request.Context(), pod, v1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create temp content pod: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create pod: %v", err)}) + return + } + + // Create service + svc := &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Name: fmt.Sprintf("temp-content-%s", sessionName), + Namespace: project, + Labels: map[string]string{ + "app": "temp-content-service", + "temp-content-for-session": sessionName, + }, + OwnerReferences: []v1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Pod", + Name: podName, + UID: created.UID, + Controller: types.BoolPtr(true), + }, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "temp-content-for-session": sessionName, + }, + Ports: []corev1.ServicePort{ + {Port: 8080, TargetPort: intstr.FromString("http")}, + }, + }, + } + + if _, err := reqK8s.CoreV1().Services(project).Create(c.Request.Context(), svc, v1.CreateOptions{}); err != nil && !errors.IsAlreadyExists(err) { + log.Printf("Failed to create temp service: %v", err) + } + + c.JSON(http.StatusOK, gin.H{ + "status": "creating", + "podName": podName, + }) +} + +// GetContentPodStatus checks if temporary content pod is ready. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/content-pod-status +func GetContentPodStatus(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + podName := fmt.Sprintf("temp-content-%s", sessionName) + pod, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), podName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"status": "not_found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get pod"}) + return + } + + ready := false + for _, cond := range pod.Status.Conditions { + if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { + ready = true + break + } + } + + c.JSON(http.StatusOK, gin.H{ + "status": string(pod.Status.Phase), + "ready": ready, + "podName": podName, + "createdAt": pod.CreationTimestamp.Format(time.RFC3339), + }) +} + +// DeleteContentPod removes temporary content pod. +// DELETE /api/projects/:projectName/agentic-sessions/:sessionName/content-pod +func DeleteContentPod(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + podName := fmt.Sprintf("temp-content-%s", sessionName) + err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), podName, v1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pod"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "content pod deleted"}) +} + +// GetSessionK8sResources returns job, pod, and PVC information for a session. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/k8s-resources +func GetSessionK8sResources(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + // Get session to find job name + gvr := GetAgenticSessionV1Alpha1Resource() + session, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + status, ok := GetStatusMap(session) + if !ok { + status = make(map[string]interface{}) + } + jobName, _ := status["jobName"].(string) + if jobName == "" { + jobName = fmt.Sprintf("%s-job", sessionName) + } + + result := map[string]interface{}{} + + // Get Job status + job, err := reqK8s.BatchV1().Jobs(project).Get(c.Request.Context(), jobName, v1.GetOptions{}) + jobExists := err == nil + + if jobExists { + result["jobName"] = jobName + jobStatus := "Unknown" + if job.Status.Active > 0 { + jobStatus = "Active" + } else if job.Status.Succeeded > 0 { + jobStatus = "Succeeded" + } else if job.Status.Failed > 0 { + jobStatus = "Failed" + } + result["jobStatus"] = jobStatus + result["jobConditions"] = job.Status.Conditions + } else if errors.IsNotFound(err) { + // Job not found - don't return job info at all + log.Printf("GetSessionK8sResources: Job %s not found, omitting from response", jobName) + // Don't include jobName or jobStatus in result + } else { + // Other error - still show job name but with error status + result["jobName"] = jobName + result["jobStatus"] = "Error" + log.Printf("GetSessionK8sResources: Error getting job %s: %v", jobName, err) + } + + // Get Pods for this job (only if job exists) + podInfos := []map[string]interface{}{} + if jobExists { + pods, err := reqK8s.CoreV1().Pods(project).List(c.Request.Context(), v1.ListOptions{ + LabelSelector: fmt.Sprintf("job-name=%s", jobName), + }) + if err == nil { + for _, pod := range pods.Items { + // Check if pod is terminating (has DeletionTimestamp) + podPhase := string(pod.Status.Phase) + if pod.DeletionTimestamp != nil { + podPhase = "Terminating" + } + + containerInfos := []map[string]interface{}{} + for _, cs := range pod.Status.ContainerStatuses { + state := "Unknown" + var exitCode *int32 + var reason string + if cs.State.Running != nil { + state = "Running" + // If pod is terminating but container still shows running, mark it as terminating + if pod.DeletionTimestamp != nil { + state = "Terminating" + } + } else if cs.State.Terminated != nil { + state = "Terminated" + exitCode = &cs.State.Terminated.ExitCode + reason = cs.State.Terminated.Reason + } else if cs.State.Waiting != nil { + state = "Waiting" + reason = cs.State.Waiting.Reason + } + containerInfos = append(containerInfos, map[string]interface{}{ + "name": cs.Name, + "state": state, + "exitCode": exitCode, + "reason": reason, + }) + } + podInfos = append(podInfos, map[string]interface{}{ + "name": pod.Name, + "phase": podPhase, + "containers": containerInfos, + }) + } + } + } + + // Check for temp-content pod + tempPodName := fmt.Sprintf("temp-content-%s", sessionName) + tempPod, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), tempPodName, v1.GetOptions{}) + if err == nil { + tempPodPhase := string(tempPod.Status.Phase) + if tempPod.DeletionTimestamp != nil { + tempPodPhase = "Terminating" + } + + containerInfos := []map[string]interface{}{} + for _, cs := range tempPod.Status.ContainerStatuses { + state := "Unknown" + var exitCode *int32 + var reason string + if cs.State.Running != nil { + state = "Running" + // If pod is terminating but container still shows running, mark as terminating + if tempPod.DeletionTimestamp != nil { + state = "Terminating" + } + } else if cs.State.Terminated != nil { + state = "Terminated" + exitCode = &cs.State.Terminated.ExitCode + reason = cs.State.Terminated.Reason + } else if cs.State.Waiting != nil { + state = "Waiting" + reason = cs.State.Waiting.Reason + } + containerInfos = append(containerInfos, map[string]interface{}{ + "name": cs.Name, + "state": state, + "exitCode": exitCode, + "reason": reason, + }) + } + podInfos = append(podInfos, map[string]interface{}{ + "name": tempPod.Name, + "phase": tempPodPhase, + "containers": containerInfos, + "isTempPod": true, + }) + } + + result["pods"] = podInfos + + // Get PVC info - always use session's own PVC name + // Note: If session was created with parent_session_id (via API), the operator handles PVC reuse + pvcName := fmt.Sprintf("ambient-workspace-%s", sessionName) + pvc, err := reqK8s.CoreV1().PersistentVolumeClaims(project).Get(c.Request.Context(), pvcName, v1.GetOptions{}) + result["pvcName"] = pvcName + if err == nil { + result["pvcExists"] = true + if storage, ok := pvc.Status.Capacity[corev1.ResourceStorage]; ok { + result["pvcSize"] = storage.String() + } + } else { + result["pvcExists"] = false + } + + c.JSON(http.StatusOK, result) +} diff --git a/components/backend/handlers/sessions_types.go b/components/backend/handlers/sessions_types.go new file mode 100644 index 000000000..268078929 --- /dev/null +++ b/components/backend/handlers/sessions_types.go @@ -0,0 +1,231 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains shared types and helper functions for session handlers. +package handlers + +import ( + "context" + + "ambient-code-backend/types" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +// Package-level variables for session handlers (set from main package) +var ( + GetAgenticSessionV1Alpha1Resource func() schema.GroupVersionResource + DynamicClient dynamic.Interface + GetGitHubToken func(context.Context, *kubernetes.Clientset, dynamic.Interface, string, string) (string, error) + DeriveRepoFolderFromURL func(string) string +) + +// contentListItem represents a file/directory in the workspace +type contentListItem struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"isDir"` + Size int64 `json:"size"` + ModifiedAt string `json:"modifiedAt"` +} + +// parseSpec parses AgenticSessionSpec with v1alpha1 fields +func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { + result := types.AgenticSessionSpec{} + + if prompt, ok := spec["prompt"].(string); ok { + result.Prompt = prompt + } + + if interactive, ok := spec["interactive"].(bool); ok { + result.Interactive = interactive + } + + if displayName, ok := spec["displayName"].(string); ok { + result.DisplayName = displayName + } + + if project, ok := spec["project"].(string); ok { + result.Project = project + } + + if timeout, ok := spec["timeout"].(float64); ok { + result.Timeout = int(timeout) + } + + if llmSettings, ok := spec["llmSettings"].(map[string]interface{}); ok { + if model, ok := llmSettings["model"].(string); ok { + result.LLMSettings.Model = model + } + if temperature, ok := llmSettings["temperature"].(float64); ok { + result.LLMSettings.Temperature = temperature + } + if maxTokens, ok := llmSettings["maxTokens"].(float64); ok { + result.LLMSettings.MaxTokens = int(maxTokens) + } + } + + // environmentVariables passthrough + if env, ok := spec["environmentVariables"].(map[string]interface{}); ok { + resultEnv := make(map[string]string, len(env)) + for k, v := range env { + if s, ok := v.(string); ok { + resultEnv[k] = s + } + } + if len(resultEnv) > 0 { + result.EnvironmentVariables = resultEnv + } + } + + if userContext, ok := spec["userContext"].(map[string]interface{}); ok { + uc := &types.UserContext{} + if userID, ok := userContext["userId"].(string); ok { + uc.UserID = userID + } + if displayName, ok := userContext["displayName"].(string); ok { + uc.DisplayName = displayName + } + if groups, ok := userContext["groups"].([]interface{}); ok { + uc.Groups = make([]string, len(groups)) + for i, g := range groups { + if groupStr, ok := g.(string); ok { + uc.Groups[i] = groupStr + } + } + } + result.UserContext = uc + } + + if botAccount, ok := spec["botAccount"].(map[string]interface{}); ok { + ba := &types.BotAccountRef{} + if name, ok := botAccount["name"].(string); ok { + ba.Name = name + } + result.BotAccount = ba + } + + // Parse repos (multi-repo support) + if repos, ok := spec["repos"].([]interface{}); ok { + for _, r := range repos { + if repoMap, ok := r.(map[string]interface{}); ok { + mapping := types.SessionRepoMapping{} + + // Parse input + if input, ok := repoMap["input"].(map[string]interface{}); ok { + if url, ok := input["url"].(string); ok { + mapping.Input.URL = url + } + if branch, ok := input["branch"].(string); ok { + mapping.Input.Branch = &branch + } + } + + // Parse output + if output, ok := repoMap["output"].(map[string]interface{}); ok { + outRepo := &types.OutputNamedGitRepo{} + if url, ok := output["url"].(string); ok { + outRepo.URL = url + } + if branch, ok := output["branch"].(string); ok { + outRepo.Branch = &branch + } + mapping.Output = outRepo + } + + // Parse status + if status, ok := repoMap["status"].(string); ok { + mapping.Status = &status + } + + result.Repos = append(result.Repos, mapping) + } + } + } + + // Parse mainRepoIndex + if mainRepoIndex, ok := spec["mainRepoIndex"].(float64); ok { + idx := int(mainRepoIndex) + result.MainRepoIndex = &idx + } + + // Parse resourceOverrides + if ro, ok := spec["resourceOverrides"].(map[string]interface{}); ok { + overrides := &types.ResourceOverrides{} + + if cpu, ok := ro["cpu"].(string); ok { + overrides.CPU = cpu + } + + if memory, ok := ro["memory"].(string); ok { + overrides.Memory = memory + } + + if storageClass, ok := ro["storageClass"].(string); ok { + overrides.StorageClass = storageClass + } + + if priorityClass, ok := ro["priorityClass"].(string); ok { + overrides.PriorityClass = priorityClass + } + + result.ResourceOverrides = overrides + } + + return result +} + +// parseStatus parses AgenticSessionStatus including v1alpha1 runner output fields +func parseStatus(status map[string]interface{}) *types.AgenticSessionStatus { + if status == nil { + return nil + } + + result := &types.AgenticSessionStatus{} + + if phase, ok := status["phase"].(string); ok { + result.Phase = phase + } + if message, ok := status["message"].(string); ok { + result.Message = message + } + if startTime, ok := status["startTime"].(string); ok { + result.StartTime = &startTime + } + if completionTime, ok := status["completionTime"].(string); ok { + result.CompletionTime = &completionTime + } + if jobName, ok := status["jobName"].(string); ok { + result.JobName = jobName + } + if stateDir, ok := status["stateDir"].(string); ok { + result.StateDir = stateDir + } + + // Parse runner output fields from v1alpha1 + if subtype, ok := status["subtype"].(string); ok { + result.Subtype = subtype + } + if isError, ok := status["is_error"].(bool); ok { + result.IsError = isError + } + if numTurns, ok := status["num_turns"].(float64); ok { + result.NumTurns = int(numTurns) + } + if sessionID, ok := status["session_id"].(string); ok { + result.SessionID = sessionID + } + if totalCostUSD, ok := status["total_cost_usd"].(float64); ok { + result.TotalCostUSD = &totalCostUSD + } + if usage, ok := status["usage"].(map[string]interface{}); ok { + result.Usage = usage + } + + // Parse result if present + if r, ok := status["result"].(string); ok { + result.Result = &r + } + + return result +} diff --git a/components/backend/handlers/sessions_workspace.go b/components/backend/handlers/sessions_workspace.go new file mode 100644 index 000000000..c156ba8d1 --- /dev/null +++ b/components/backend/handlers/sessions_workspace.go @@ -0,0 +1,197 @@ +// Package handlers provides HTTP handlers for the backend API. +// This file contains workspace file operations for agentic sessions. +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ListSessionWorkspace lists files and directories in a session's workspace. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/workspace +func ListSessionWorkspace(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + session := c.Param("sessionName") + + if project == "" { + log.Printf("ListSessionWorkspace: project is empty, session=%s", session) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + + rel := strings.TrimSpace(c.Query("path")) + // Build absolute workspace path using plain session (no url.PathEscape to match FS paths) + absPath := "/sessions/" + session + "/workspace" + if rel != "" { + absPath += "/" + rel + } + + // Call per-job service or temp service for completed sessions + token := c.GetHeader("Authorization") + if strings.TrimSpace(token) == "" { + token = c.GetHeader("X-Forwarded-Access-Token") + } + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + // Temp service doesn't exist, use regular service + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + u := fmt.Sprintf("%s/content/list?path=%s", endpoint, url.QueryEscape(absPath)) + log.Printf("ListSessionWorkspace: project=%s session=%s endpoint=%s", project, session, endpoint) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", token) + } + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("ListSessionWorkspace: content service request failed: %v", err) + // Soften error to 200 with empty list so UI doesn't spam + c.JSON(http.StatusOK, gin.H{"items": []any{}}) + return + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + + // If content service returns 404, check if it's because workspace doesn't exist yet + if resp.StatusCode == http.StatusNotFound { + log.Printf("ListSessionWorkspace: workspace not found (may not be created yet by runner)") + // Return empty list instead of error for better UX during session startup + c.JSON(http.StatusOK, gin.H{"items": []any{}}) + return + } + + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) +} + +// GetSessionWorkspaceFile retrieves a specific file from a session's workspace. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/workspace/*path +func GetSessionWorkspaceFile(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + session := c.Param("sessionName") + + if project == "" { + log.Printf("GetSessionWorkspaceFile: project is empty, session=%s", session) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + + sub := strings.TrimPrefix(c.Param("path"), "/") + absPath := "/sessions/" + session + "/workspace/" + sub + token := c.GetHeader("Authorization") + if strings.TrimSpace(token) == "" { + token = c.GetHeader("X-Forwarded-Access-Token") + } + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + u := fmt.Sprintf("%s/content/file?path=%s", endpoint, url.QueryEscape(absPath)) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", token) + } + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) +} + +// PutSessionWorkspaceFile writes or updates a file in a session's workspace. +// PUT /api/projects/:projectName/agentic-sessions/:sessionName/workspace/*path +func PutSessionWorkspaceFile(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + session := c.Param("sessionName") + + if project == "" { + log.Printf("PutSessionWorkspaceFile: project is empty, session=%s", session) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + sub := strings.TrimPrefix(c.Param("path"), "/") + absPath := "/sessions/" + session + "/workspace/" + sub + token := c.GetHeader("Authorization") + if strings.TrimSpace(token) == "" { + token = c.GetHeader("X-Forwarded-Access-Token") + } + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + // Temp service doesn't exist, use regular service + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("PutSessionWorkspaceFile: using service %s for session %s", serviceName, session) + payload, _ := io.ReadAll(c.Request.Body) + wreq := struct { + Path string `json:"path"` + Content string `json:"content"` + Encoding string `json:"encoding"` + }{Path: absPath, Content: string(payload), Encoding: "utf8"} + b, _ := json.Marshal(wreq) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/write", strings.NewReader(string(b))) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", token) + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), rb) +} diff --git a/components/backend/types/session.go b/components/backend/types/session.go index be275ce7a..78fef497f 100644 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -84,3 +84,17 @@ type CloneSessionRequest struct { TargetProject string `json:"targetProject" binding:"required"` NewSessionName string `json:"newSessionName" binding:"required"` } + +type UpdateAgenticSessionRequest struct { + Prompt *string `json:"prompt,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Timeout *int `json:"timeout,omitempty"` + LLMSettings *LLMSettings `json:"llmSettings,omitempty"` +} + +type CloneAgenticSessionRequest struct { + TargetProject string `json:"targetProject,omitempty"` + TargetSessionName string `json:"targetSessionName,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Prompt string `json:"prompt,omitempty"` +} diff --git a/components/operator/.golangci.yml b/components/operator/.golangci.yml index ec5064830..c58ee4740 100644 --- a/components/operator/.golangci.yml +++ b/components/operator/.golangci.yml @@ -1,38 +1,19 @@ # golangci-lint configuration for vTeam operator -# Compatible with golangci-lint v2+ +# Compatible with golangci-lint v2.x # # Pragmatic configuration for Kubernetes operator development. +version: "2" + run: timeout: 5m linters: enable: - - govet # Reports suspicious constructs - - ineffassign # Detect ineffectual assignments - - staticcheck # Advanced static analysis - - unused # Check for unused constants, variables, functions - - misspell # Find commonly misspelled words + - govet + - ineffassign + - staticcheck + - unused + - misspell disable: - - errcheck # Disabled: too many false positives with defer cleanup - -linters-settings: - staticcheck: - checks: ["all", "-SA1019"] # Disable deprecation warnings only - -issues: - max-issues-per-linter: 0 - max-same-issues: 0 - - exclude-rules: - # Exclude linters from test files - - path: _test\.go - linters: - - staticcheck - - govet - - # Allow type assertions in K8s reconciliation (intentional pattern) - - path: internal/handlers/ - text: "type assertion" - - new: false + - errcheck