From ce540e586e38c0faf380de481a82450965c4adee Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Mon, 11 May 2026 18:09:35 +0000 Subject: [PATCH 1/3] feat: add custom workflow sources frontend UI and settings management - Add WorkflowSource types and API client functions - Add workflow sources React Query hooks with cache invalidation - Update port/adapter layer for workflow sources CRUD - Add Workflow Sources settings section in workspace settings - Update session creation dialog to group workflows by source - Add SelectLabel and SelectSeparator UI components Addresses: ambient-code/platform#1549 Co-Authored-By: Claude Opus 4.6 --- .../src/components/create-session-dialog.tsx | 51 +++++++-- .../workspace-sections/settings-section.tsx | 107 ++++++++++++++++++ .../src/services/adapters/v1/workflows.ts | 2 + .../frontend/src/services/api/workflows.ts | 16 +++ .../frontend/src/services/ports/types.ts | 1 + .../frontend/src/services/ports/workflows.ts | 4 +- .../src/services/queries/use-workflows.ts | 25 +++- components/frontend/src/types/workflow.ts | 17 +++ 8 files changed, 212 insertions(+), 11 deletions(-) diff --git a/components/frontend/src/components/create-session-dialog.tsx b/components/frontend/src/components/create-session-dialog.tsx index a1650d01c..7e5a915da 100755 --- a/components/frontend/src/components/create-session-dialog.tsx +++ b/components/frontend/src/components/create-session-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useMemo, useRef } from "react"; +import { Fragment, useEffect, useState, useMemo, useRef } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -34,6 +34,8 @@ import { Select, SelectContent, SelectItem, + SelectLabel, + SelectSeparator, SelectTrigger, SelectValue, } from "@/components/ui/select"; @@ -154,6 +156,22 @@ export function CreateSessionDialog({ form.resetField("model", { defaultValue: "" }); }; + const { ootbGroup, customGroups } = useMemo(() => { + const enabled = ootbWorkflows.filter(w => w.enabled); + const ootb = enabled.filter(w => !w.source || w.source === 'ootb').sort((a, b) => a.name.localeCompare(b.name)); + const bySource = new Map(); + for (const w of enabled) { + if (w.source && w.source !== 'ootb') { + const arr = bySource.get(w.source) ?? []; + arr.push(w); + bySource.set(w.source, arr); + } + } + const sorted = Array.from(bySource.entries()).sort(([a], [b]) => a.localeCompare(b)); + for (const [, wfs] of sorted) wfs.sort((a, b) => a.name.localeCompare(b.name)); + return { ootbGroup: ootb, customGroups: sorted }; + }, [ootbWorkflows]); + const selectedWorkflowDescription = useMemo(() => { if (selectedWorkflow === "none") return "A general chat session with no structured workflow."; if (selectedWorkflow === "custom") return "Load a workflow from a custom Git repository."; @@ -394,14 +412,29 @@ export function CreateSessionDialog({ General chat - {ootbWorkflows - .filter(w => w.enabled) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((workflow) => ( - - {workflow.name} - - ))} + {ootbGroup.length > 0 && ( + <> + + Built-in Workflows + {ootbGroup.map((workflow) => ( + + {workflow.name} + + ))} + + )} + {customGroups.map(([source, workflows]) => ( + + + {source} + {workflows.map((workflow) => ( + + {workflow.name} + + ))} + + ))} + Custom workflow... diff --git a/components/frontend/src/components/workspace-sections/settings-section.tsx b/components/frontend/src/components/workspace-sections/settings-section.tsx index 1842f39d1..48da4e585 100755 --- a/components/frontend/src/components/workspace-sections/settings-section.tsx +++ b/components/frontend/src/components/workspace-sections/settings-section.tsx @@ -14,6 +14,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { toast } from "sonner"; import { useProject, useUpdateProject } from "@/services/queries/use-projects"; import { useSecretsValues, useUpdateSecrets, useIntegrationSecrets, useUpdateIntegrationSecrets } from "@/services/queries/use-secrets"; +import { useWorkflowSources, useUpdateWorkflowSources } from "@/services/queries/use-workflows"; +import type { WorkflowSource } from "@/types/workflow"; import { useClusterInfo } from "@/hooks/use-cluster-info"; import { FeatureFlagsSection } from "./feature-flags-section"; import { ProjectMcpSection } from "./project-mcp-section"; @@ -46,6 +48,8 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { const [s3SecretKey, setS3SecretKey] = useState(""); const [showS3SecretKey, setShowS3SecretKey] = useState(false); const [s3Expanded, setS3Expanded] = useState(false); + const [workflowSources, setWorkflowSources] = useState([]); + const [workflowSourcesExpanded, setWorkflowSourcesExpanded] = useState(false); // Derive runner API key definitions from the runner-types registry. // Falls back to a hardcoded list if the fetch fails. @@ -96,6 +100,8 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { const updateProjectMutation = useUpdateProject(); const updateSecretsMutation = useUpdateSecrets(); const updateIntegrationSecretsMutation = useUpdateIntegrationSecrets(); + const { data: workflowSourcesData } = useWorkflowSources(projectName); + const updateWorkflowSourcesMutation = useUpdateWorkflowSources(projectName); // Sync project data to form useEffect(() => { @@ -127,6 +133,36 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { } }, [runnerSecrets, integrationSecrets, FIXED_KEYS, allRequiredSecrets]); + // Sync workflow sources from query + useEffect(() => { + if (workflowSourcesData?.sources) { + setWorkflowSources(workflowSourcesData.sources); + } + }, [workflowSourcesData]); + + const addWorkflowSource = () => { + setWorkflowSources(prev => [...prev, { name: '', gitUrl: '', branch: '', path: '' }]); + }; + + const removeWorkflowSource = (idx: number) => { + setWorkflowSources(prev => prev.filter((_, i) => i !== idx)); + }; + + const updateWorkflowSource = (idx: number, field: keyof WorkflowSource, value: string) => { + setWorkflowSources(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s)); + }; + + const handleSaveWorkflowSources = () => { + const validSources = workflowSources.filter(s => s.name.trim() && s.gitUrl.trim()); + updateWorkflowSourcesMutation.mutate( + { sources: validSources }, + { + onSuccess: () => toast.success("Workflow sources saved successfully"), + onError: (error) => toast.error(error instanceof Error ? error.message : "Failed to save workflow sources"), + } + ); + }; + const handleSave = () => { if (!project) return; updateProjectMutation.mutate( @@ -617,6 +653,77 @@ export function SettingsSection({ projectName }: SettingsSectionProps) { + + + Workflow Sources + + Configure custom Git repositories containing workflow definitions. Workflows from these repos will appear alongside built-in workflows when creating sessions. + + + + +
+ + {workflowSourcesExpanded && ( +
+ {workflowSources.map((source, idx) => ( +
+
+ Source {idx + 1} + +
+
+
+ + updateWorkflowSource(idx, 'name', e.target.value)} placeholder="My Team Workflows" /> +
+
+ + updateWorkflowSource(idx, 'gitUrl', e.target.value)} placeholder="https://github.com/org/workflows.git" /> +
+
+ + updateWorkflowSource(idx, 'branch', e.target.value)} placeholder="main" /> +
+
+ + updateWorkflowSource(idx, 'path', e.target.value)} placeholder="workflows" /> +
+
+
+ ))} + +
+ +
+
+ )} +
+
+
+ diff --git a/components/frontend/src/services/adapters/v1/workflows.ts b/components/frontend/src/services/adapters/v1/workflows.ts index 26bd636af..7ca59ab71 100644 --- a/components/frontend/src/services/adapters/v1/workflows.ts +++ b/components/frontend/src/services/adapters/v1/workflows.ts @@ -7,6 +7,8 @@ export function createWorkflowsAdapter(api: WorkflowsApi): WorkflowsPort { return { listOOTBWorkflows: api.listOOTBWorkflows, getWorkflowMetadata: api.getWorkflowMetadata, + getWorkflowSources: api.getWorkflowSources, + updateWorkflowSources: api.updateWorkflowSources, } } diff --git a/components/frontend/src/services/api/workflows.ts b/components/frontend/src/services/api/workflows.ts index ccdad97fa..94e5b6d1e 100644 --- a/components/frontend/src/services/api/workflows.ts +++ b/components/frontend/src/services/api/workflows.ts @@ -1,4 +1,5 @@ import { apiClient } from "./client"; +import type { WorkflowSourcesConfig } from "@/types/workflow"; export type OOTBWorkflow = { id: string; @@ -8,6 +9,7 @@ export type OOTBWorkflow = { branch: string; path?: string; enabled: boolean; + source?: string; }; export type ListOOTBWorkflowsResponse = { @@ -59,3 +61,17 @@ export async function getWorkflowMetadata( ); return response; } + +export async function getWorkflowSources(projectName: string): Promise { + return apiClient.get(`/projects/${projectName}/workflow-sources`); +} + +export async function updateWorkflowSources( + projectName: string, + config: WorkflowSourcesConfig +): Promise { + return apiClient.put( + `/projects/${projectName}/workflow-sources`, + config + ); +} diff --git a/components/frontend/src/services/ports/types.ts b/components/frontend/src/services/ports/types.ts index 48894703e..cdeed2e2e 100644 --- a/components/frontend/src/services/ports/types.ts +++ b/components/frontend/src/services/ports/types.ts @@ -13,6 +13,7 @@ export type { WorkspaceItem, GitMergeStatus, GitStatus } from '@/services/api/wo export type { ProjectKey, CreateKeyRequest, CreateKeyResponse } from '@/services/api/keys' export type { Secret, SecretList, SecretsConfig } from '@/services/api/secrets' export type { OOTBWorkflow, WorkflowMetadataResponse, WorkflowCommand, WorkflowAgent, WorkflowConfig } from '@/services/api/workflows' +export type { WorkflowSourcesConfig } from '@/types/workflow' export type { RunnerType, RunnerTypeAuth } from '@/services/api/runner-types' export type { FeatureToggle } from '@/services/api/feature-flags-admin' export type { LDAPUser, LDAPGroup } from '@/services/api/ldap' diff --git a/components/frontend/src/services/ports/workflows.ts b/components/frontend/src/services/ports/workflows.ts index 3f9bc053e..791be18d9 100644 --- a/components/frontend/src/services/ports/workflows.ts +++ b/components/frontend/src/services/ports/workflows.ts @@ -1,6 +1,8 @@ -import type { OOTBWorkflow, WorkflowMetadataResponse } from './types' +import type { OOTBWorkflow, WorkflowMetadataResponse, WorkflowSourcesConfig } from './types' export type WorkflowsPort = { listOOTBWorkflows: (projectName?: string) => Promise getWorkflowMetadata: (projectName: string, sessionName: string) => Promise + getWorkflowSources: (projectName: string) => Promise + updateWorkflowSources: (projectName: string, config: WorkflowSourcesConfig) => Promise } diff --git a/components/frontend/src/services/queries/use-workflows.ts b/components/frontend/src/services/queries/use-workflows.ts index e15d6b8ba..fba8b4456 100755 --- a/components/frontend/src/services/queries/use-workflows.ts +++ b/components/frontend/src/services/queries/use-workflows.ts @@ -1,6 +1,7 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { workflowsAdapter } from '../adapters/workflows'; import type { WorkflowsPort } from '../ports/workflows'; +import type { WorkflowSourcesConfig } from '@/types/workflow'; import { BACKEND_VERSION } from './query-keys'; export const workflowKeys = { @@ -8,6 +9,7 @@ export const workflowKeys = { ootb: (projectName?: string) => [...workflowKeys.all, 'ootb', projectName] as const, metadata: (projectName: string, sessionName: string) => [...workflowKeys.all, 'metadata', projectName, sessionName] as const, + sources: (projectName: string) => [...workflowKeys.all, 'sources', projectName] as const, }; export function useOOTBWorkflows(projectName?: string, port: WorkflowsPort = workflowsAdapter) { @@ -35,3 +37,24 @@ export function useWorkflowMetadata( staleTime: 60 * 1000, }); } + +export function useWorkflowSources(projectName: string, port: WorkflowsPort = workflowsAdapter) { + return useQuery({ + queryKey: workflowKeys.sources(projectName), + queryFn: () => port.getWorkflowSources(projectName), + enabled: !!projectName, + staleTime: 5 * 60 * 1000, + }); +} + +export function useUpdateWorkflowSources(projectName: string, port: WorkflowsPort = workflowsAdapter) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (config: WorkflowSourcesConfig) => + port.updateWorkflowSources(projectName, config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workflowKeys.sources(projectName) }); + queryClient.invalidateQueries({ queryKey: workflowKeys.ootb(projectName) }); + }, + }); +} diff --git a/components/frontend/src/types/workflow.ts b/components/frontend/src/types/workflow.ts index 98274f876..2b5739e0a 100644 --- a/components/frontend/src/types/workflow.ts +++ b/components/frontend/src/types/workflow.ts @@ -44,3 +44,20 @@ export type WorkflowSelection = { branch: string; path?: string; }; + +/** + * A custom workflow source — a Git repository containing workflow definitions. + */ +export type WorkflowSource = { + name: string; + gitUrl: string; + branch?: string; + path?: string; +}; + +/** + * Configuration containing the list of custom workflow sources for a project. + */ +export type WorkflowSourcesConfig = { + sources: WorkflowSource[]; +}; From 0d40923e42e567103d1f3ebd601de93ced35587d Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Mon, 11 May 2026 18:12:36 +0000 Subject: [PATCH 2/3] feat: add custom workflow sources backend API and CRD extension - Extend ProjectSettings CRD with workflowSources array field - Add GET/PUT /api/projects/:projectName/workflow-sources endpoints - Extend ListOOTBWorkflows to aggregate workflows from custom sources - Add per-source caching with 5-min TTL - Tag workflows with source name for frontend grouping Addresses: ambient-code/platform#1549 --- .../handlers/project_workflow_sources.go | 149 ++++++++++++++ components/backend/handlers/sessions.go | 194 +++++++++++++++++- components/backend/routes.go | 4 + .../base/crds/projectsettings-crd.yaml | 21 ++ 4 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 components/backend/handlers/project_workflow_sources.go diff --git a/components/backend/handlers/project_workflow_sources.go b/components/backend/handlers/project_workflow_sources.go new file mode 100644 index 000000000..57e25f144 --- /dev/null +++ b/components/backend/handlers/project_workflow_sources.go @@ -0,0 +1,149 @@ +package handlers + +import ( + "context" + "log" + "net/http" + + "github.com/gin-gonic/gin" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WorkflowSource represents a custom workflow source repository. +type WorkflowSource struct { + Name string `json:"name"` + GitURL string `json:"gitUrl"` + Branch string `json:"branch,omitempty"` + Path string `json:"path,omitempty"` +} + +// WorkflowSourcesConfig is the request/response envelope for workflow sources. +type WorkflowSourcesConfig struct { + Sources []WorkflowSource `json:"sources"` +} + +// GetProjectWorkflowSources returns the custom workflow sources from the ProjectSettings CR. +// GET /api/projects/:projectName/workflow-sources +func GetProjectWorkflowSources(c *gin.Context) { + project := c.GetString("project") + _, k8sDyn := GetK8sClientsForRequest(c) + if k8sDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + gvr := GetProjectSettingsResource() + ps, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), "projectsettings", v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + // No project settings yet, return empty sources + c.JSON(http.StatusOK, WorkflowSourcesConfig{Sources: []WorkflowSource{}}) + return + } + log.Printf("Failed to get project settings for %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"}) + return + } + + spec, ok := ps.Object["spec"].(map[string]interface{}) + if !ok { + c.JSON(http.StatusOK, WorkflowSourcesConfig{Sources: []WorkflowSource{}}) + return + } + + rawSources, ok := spec["workflowSources"].([]interface{}) + if !ok { + c.JSON(http.StatusOK, WorkflowSourcesConfig{Sources: []WorkflowSource{}}) + return + } + + sources := make([]WorkflowSource, 0, len(rawSources)) + for _, raw := range rawSources { + srcMap, ok := raw.(map[string]interface{}) + if !ok { + continue + } + src := WorkflowSource{} + if name, ok := srcMap["name"].(string); ok { + src.Name = name + } + if gitURL, ok := srcMap["gitUrl"].(string); ok { + src.GitURL = gitURL + } + if branch, ok := srcMap["branch"].(string); ok { + src.Branch = branch + } + if path, ok := srcMap["path"].(string); ok { + src.Path = path + } + sources = append(sources, src) + } + + c.JSON(http.StatusOK, WorkflowSourcesConfig{Sources: sources}) +} + +// UpdateProjectWorkflowSources updates the custom workflow sources in the ProjectSettings CR. +// PUT /api/projects/:projectName/workflow-sources +func UpdateProjectWorkflowSources(c *gin.Context) { + project := c.GetString("project") + _, k8sDyn := GetK8sClientsForRequest(c) + if k8sDyn == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + var req WorkflowSourcesConfig + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + gvr := GetProjectSettingsResource() + ps, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), "projectsettings", v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Project settings not found"}) + return + } + log.Printf("Failed to get project settings for %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"}) + return + } + + spec, ok := ps.Object["spec"].(map[string]interface{}) + if !ok { + spec = map[string]interface{}{} + ps.Object["spec"] = spec + } + + // Build the workflowSources array + if len(req.Sources) > 0 { + sourcesArr := make([]interface{}, len(req.Sources)) + for i, src := range req.Sources { + srcMap := map[string]interface{}{ + "name": src.Name, + "gitUrl": src.GitURL, + } + if src.Branch != "" { + srcMap["branch"] = src.Branch + } + if src.Path != "" { + srcMap["path"] = src.Path + } + sourcesArr[i] = srcMap + } + spec["workflowSources"] = sourcesArr + } else { + delete(spec, "workflowSources") + } + + _, err = k8sDyn.Resource(gvr).Namespace(project).Update(context.TODO(), ps, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update project settings workflow sources for %s: %v", project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update workflow sources configuration"}) + return + } + + c.JSON(http.StatusOK, req) +} diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index f49613ee5..ac1386d3f 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -64,6 +64,10 @@ type ootbWorkflowsCache struct { var ( ootbCache = &ootbWorkflowsCache{} ootbCacheTTL = 5 * time.Minute // Cache OOTB workflows for 5 minutes + + // customSourceCaches provides per-source caching for custom workflow sources. + // Key: "gitUrl|branch|path" -> *ootbWorkflowsCache + customSourceCaches sync.Map ) // allowedSdkOptionKeys defines the SDK options that users are allowed to configure. @@ -2549,6 +2553,7 @@ type OOTBWorkflow struct { Branch string `json:"branch"` Path string `json:"path,omitempty"` Enabled bool `json:"enabled"` + Source string `json:"source,omitempty"` } // ListOOTBWorkflows returns the list of out-of-the-box workflows dynamically discovered from GitHub @@ -2575,12 +2580,21 @@ func ListOOTBWorkflows(c *gin.Context) { // Build cache key from repo configuration cacheKey := fmt.Sprintf("%s|%s|%s", ootbRepo, ootbBranch, ootbWorkflowsPath) - // Check cache first (read lock) + // Read project query param early - needed for both cache-hit and cache-miss paths + project := c.Query("project") // Optional query parameter + + // Check OOTB cache first (read lock) ootbCache.mu.RLock() if ootbCache.cacheKey == cacheKey && time.Since(ootbCache.cachedAt) < ootbCacheTTL && len(ootbCache.workflows) > 0 { - workflows := ootbCache.workflows + workflows := make([]OOTBWorkflow, len(ootbCache.workflows)) + copy(workflows, ootbCache.workflows) ootbCache.mu.RUnlock() - log.Printf("ListOOTBWorkflows: returning %d cached workflows (age: %v)", len(workflows), time.Since(ootbCache.cachedAt).Round(time.Second)) + log.Printf("ListOOTBWorkflows: returning %d cached OOTB workflows (age: %v)", len(workflows), time.Since(ootbCache.cachedAt).Round(time.Second)) + // Append custom source workflows if project is specified + if project != "" { + customWorkflows := fetchCustomSourceWorkflows(c, project, "") + workflows = append(workflows, customWorkflows...) + } c.JSON(http.StatusOK, gin.H{"workflows": workflows}) return } @@ -2590,7 +2604,6 @@ func ListOOTBWorkflows(c *gin.Context) { // Try to get user's GitHub token (best effort - not required) // This gives better rate limits (5000/hr vs 60/hr) and supports private repos token := "" - project := c.Query("project") // Optional query parameter if project != "" { usrID, _ := c.Get("userID") k8sClt, sessDyn := GetK8sClientsForRequest(c) @@ -2689,6 +2702,7 @@ func ListOOTBWorkflows(c *gin.Context) { Branch: ootbBranch, Path: fmt.Sprintf("%s/%s", ootbWorkflowsPath, entryName), Enabled: true, + Source: "ootb", }) } @@ -2700,9 +2714,181 @@ func ListOOTBWorkflows(c *gin.Context) { ootbCache.mu.Unlock() log.Printf("ListOOTBWorkflows: discovered %d workflows from %s (cached for %v)", len(workflows), ootbRepo, ootbCacheTTL) + + // Append workflows from custom sources if project is specified + if project != "" { + customWorkflows := fetchCustomSourceWorkflows(c, project, token) + workflows = append(workflows, customWorkflows...) + } + c.JSON(http.StatusOK, gin.H{"workflows": workflows}) } +// fetchCustomSourceWorkflows reads custom workflow sources from the ProjectSettings CR +// and discovers workflows from each source's GitHub repository. +// Failures on individual sources are logged but do not block other sources. +func fetchCustomSourceWorkflows(c *gin.Context, project, token string) []OOTBWorkflow { + _, k8sDyn := GetK8sClientsForRequest(c) + if k8sDyn == nil { + return nil + } + + gvr := GetProjectSettingsResource() + ps, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), "projectsettings", v1.GetOptions{}) + if err != nil { + if !errors.IsNotFound(err) { + log.Printf("fetchCustomSourceWorkflows: failed to get project settings for %s: %v", project, err) + } + return nil + } + + spec, ok := ps.Object["spec"].(map[string]interface{}) + if !ok { + return nil + } + + rawSources, ok := spec["workflowSources"].([]interface{}) + if !ok || len(rawSources) == 0 { + return nil + } + + var allWorkflows []OOTBWorkflow + + for srcIdx, raw := range rawSources { + srcMap, ok := raw.(map[string]interface{}) + if !ok { + continue + } + + srcName, _ := srcMap["name"].(string) + srcGitURL, _ := srcMap["gitUrl"].(string) + if srcGitURL == "" { + log.Printf("fetchCustomSourceWorkflows: skipping source %d with empty gitUrl in project %s", srcIdx, project) + continue + } + + srcBranch, _ := srcMap["branch"].(string) + if srcBranch == "" { + srcBranch = "main" + } + + srcPath, _ := srcMap["path"].(string) + if srcPath == "" { + srcPath = "workflows" + } + + workflows := fetchWorkflowsFromSource(c, srcIdx, srcName, srcGitURL, srcBranch, srcPath, token) + allWorkflows = append(allWorkflows, workflows...) + } + + return allWorkflows +} + +// fetchWorkflowsFromSource discovers workflows from a single custom source repository. +// Uses per-source caching with 5-min TTL via customSourceCaches. +func fetchWorkflowsFromSource(c *gin.Context, srcIdx int, srcName, srcGitURL, srcBranch, srcPath, token string) []OOTBWorkflow { + cacheKey := fmt.Sprintf("%s|%s|%s", srcGitURL, srcBranch, srcPath) + + // Check per-source cache + if cached, ok := customSourceCaches.Load(cacheKey); ok { + entry := cached.(*ootbWorkflowsCache) + entry.mu.RLock() + if time.Since(entry.cachedAt) < ootbCacheTTL && len(entry.workflows) > 0 { + workflows := make([]OOTBWorkflow, len(entry.workflows)) + copy(workflows, entry.workflows) + entry.mu.RUnlock() + log.Printf("fetchWorkflowsFromSource: returning %d cached workflows for source %q (age: %v)", len(workflows), srcName, time.Since(entry.cachedAt).Round(time.Second)) + return workflows + } + entry.mu.RUnlock() + } + + // Parse the source git URL + owner, repoName, err := git.ParseGitHubURL(srcGitURL) + if err != nil { + log.Printf("fetchWorkflowsFromSource: invalid git URL %q for source %q: %v", srcGitURL, srcName, err) + return nil + } + + // List workflow directories from the source + entries, err := fetchGitHubDirectoryListing(c.Request.Context(), owner, repoName, srcBranch, srcPath, token) + if err != nil { + log.Printf("fetchWorkflowsFromSource: failed to list directory for source %q (%s): %v", srcName, srcGitURL, err) + // Return stale cache if available + if cached, ok := customSourceCaches.Load(cacheKey); ok { + entry := cached.(*ootbWorkflowsCache) + entry.mu.RLock() + if len(entry.workflows) > 0 { + workflows := make([]OOTBWorkflow, len(entry.workflows)) + copy(workflows, entry.workflows) + entry.mu.RUnlock() + log.Printf("fetchWorkflowsFromSource: returning stale cached workflows for source %q due to error", srcName) + return workflows + } + entry.mu.RUnlock() + } + return nil + } + + // Scan each subdirectory for ambient.json + var workflows []OOTBWorkflow + for _, entry := range entries { + entryType, _ := entry["type"].(string) + entryName, _ := entry["name"].(string) + if entryType != "dir" { + continue + } + + // Try to fetch ambient.json from this workflow directory + ambientPath := fmt.Sprintf("%s/%s/.ambient/ambient.json", srcPath, entryName) + ambientData, fetchErr := fetchGitHubFileContent(c.Request.Context(), owner, repoName, srcBranch, ambientPath, token) + + var ambientConfig struct { + Name string `json:"name"` + Description string `json:"description"` + Enabled *bool `json:"enabled,omitempty"` + } + if fetchErr == nil { + if parseErr := json.Unmarshal(ambientData, &ambientConfig); parseErr != nil { + log.Printf("fetchWorkflowsFromSource: failed to parse ambient.json for %s/%s: %v", srcName, entryName, parseErr) + } + } + + // Skip workflows explicitly disabled in ambient.json + if ambientConfig.Enabled != nil && !*ambientConfig.Enabled { + log.Printf("fetchWorkflowsFromSource: skipping disabled workflow %s/%s", srcName, entryName) + continue + } + + workflowName := ambientConfig.Name + if workflowName == "" { + workflowName = strings.ReplaceAll(entryName, "-", " ") + workflowName = strings.Title(workflowName) + } + + workflows = append(workflows, OOTBWorkflow{ + ID: fmt.Sprintf("%d-%s", srcIdx, entryName), + Name: workflowName, + Description: ambientConfig.Description, + GitURL: srcGitURL, + Branch: srcBranch, + Path: fmt.Sprintf("%s/%s", srcPath, entryName), + Enabled: true, + Source: srcName, + }) + } + + // Update per-source cache + newEntry := &ootbWorkflowsCache{} + newEntry.workflows = workflows + newEntry.cachedAt = time.Now() + newEntry.cacheKey = cacheKey + customSourceCaches.Store(cacheKey, newEntry) + + log.Printf("fetchWorkflowsFromSource: discovered %d workflows from source %q (%s, cached for %v)", len(workflows), srcName, srcGitURL, ootbCacheTTL) + return workflows +} + func DeleteSession(c *gin.Context) { project := c.GetString("project") sessionName := c.Param("sessionName") diff --git a/components/backend/routes.go b/components/backend/routes.go index d920d27c4..1aa62ee65 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -129,6 +129,10 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/mcp-servers", handlers.GetProjectMCPServers) projectGroup.PUT("/mcp-servers", handlers.UpdateProjectMCPServers) + // Project-level workflow source configuration + projectGroup.GET("/workflow-sources", handlers.GetProjectWorkflowSources) + projectGroup.PUT("/workflow-sources", handlers.UpdateProjectWorkflowSources) + projectGroup.GET("/secrets", handlers.ListNamespaceSecrets) projectGroup.GET("/runner-secrets", handlers.ListRunnerSecrets) projectGroup.PUT("/runner-secrets", handlers.UpdateRunnerSecrets) diff --git a/components/manifests/base/crds/projectsettings-crd.yaml b/components/manifests/base/crds/projectsettings-crd.yaml index 854aa256b..57386503a 100755 --- a/components/manifests/base/crds/projectsettings-crd.yaml +++ b/components/manifests/base/crds/projectsettings-crd.yaml @@ -82,6 +82,27 @@ spec: description: "List of default MCP server names to disable for all sessions in this project" items: type: string + workflowSources: + type: array + description: "Custom workflow source repositories. Workflows from these repos appear alongside OOTB workflows in session creation." + items: + type: object + required: + - name + - gitUrl + properties: + name: + type: string + description: "Display name for this workflow source" + gitUrl: + type: string + description: "Git repository URL containing workflow definitions" + branch: + type: string + description: "Branch to read workflows from (defaults to main)" + path: + type: string + description: "Subdirectory containing workflows (defaults to workflows)" status: type: object properties: From 361c3510b067a5cec4fb3d36a74473eed1960f19 Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Mon, 11 May 2026 18:16:30 +0000 Subject: [PATCH 3/3] fix: add namespace-scoped cache keys and input validation for workflow sources - Include project namespace in custom source cache keys to prevent cross-tenant workflow leakage in multi-tenant deployments - Add server-side validation: max 20 sources, required name/gitUrl, git URL format check (https://, http://, git@) Co-Authored-By: Claude Opus 4.6 --- .../handlers/project_workflow_sources.go | 22 +++++++++++++++++++ components/backend/handlers/sessions.go | 6 ++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/components/backend/handlers/project_workflow_sources.go b/components/backend/handlers/project_workflow_sources.go index 57e25f144..fe32510db 100644 --- a/components/backend/handlers/project_workflow_sources.go +++ b/components/backend/handlers/project_workflow_sources.go @@ -2,8 +2,10 @@ package handlers import ( "context" + "fmt" "log" "net/http" + "strings" "github.com/gin-gonic/gin" "k8s.io/apimachinery/pkg/api/errors" @@ -99,6 +101,26 @@ func UpdateProjectWorkflowSources(c *gin.Context) { return } + const maxWorkflowSources = 20 + if len(req.Sources) > maxWorkflowSources { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Maximum %d workflow sources allowed", maxWorkflowSources)}) + return + } + for i, src := range req.Sources { + if strings.TrimSpace(src.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Source %d: name is required", i+1)}) + return + } + if strings.TrimSpace(src.GitURL) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Source %d: gitUrl is required", i+1)}) + return + } + if !strings.HasPrefix(src.GitURL, "https://") && !strings.HasPrefix(src.GitURL, "http://") && !strings.HasPrefix(src.GitURL, "git@") { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Source %d: gitUrl must be a valid Git URL (https://, http://, or git@)", i+1)}) + return + } + } + gvr := GetProjectSettingsResource() ps, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), "projectsettings", v1.GetOptions{}) if err != nil { diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index ac1386d3f..4f5d6988d 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -2777,7 +2777,7 @@ func fetchCustomSourceWorkflows(c *gin.Context, project, token string) []OOTBWor srcPath = "workflows" } - workflows := fetchWorkflowsFromSource(c, srcIdx, srcName, srcGitURL, srcBranch, srcPath, token) + workflows := fetchWorkflowsFromSource(c, project, srcIdx, srcName, srcGitURL, srcBranch, srcPath, token) allWorkflows = append(allWorkflows, workflows...) } @@ -2786,8 +2786,8 @@ func fetchCustomSourceWorkflows(c *gin.Context, project, token string) []OOTBWor // fetchWorkflowsFromSource discovers workflows from a single custom source repository. // Uses per-source caching with 5-min TTL via customSourceCaches. -func fetchWorkflowsFromSource(c *gin.Context, srcIdx int, srcName, srcGitURL, srcBranch, srcPath, token string) []OOTBWorkflow { - cacheKey := fmt.Sprintf("%s|%s|%s", srcGitURL, srcBranch, srcPath) +func fetchWorkflowsFromSource(c *gin.Context, project string, srcIdx int, srcName, srcGitURL, srcBranch, srcPath, token string) []OOTBWorkflow { + cacheKey := fmt.Sprintf("%s|%s|%s|%s", project, srcGitURL, srcBranch, srcPath) // Check per-source cache if cached, ok := customSourceCaches.Load(cacheKey); ok {