diff --git a/assets/src/components/ai/agent-runs/AIAgentRuntimesSelector.tsx b/assets/src/components/ai/agent-runs/AIAgentRuntimesSelector.tsx index e50f3a897a..c7107d8716 100644 --- a/assets/src/components/ai/agent-runs/AIAgentRuntimesSelector.tsx +++ b/assets/src/components/ai/agent-runs/AIAgentRuntimesSelector.tsx @@ -1,4 +1,5 @@ import { + Chip, CloseIcon, ListBoxFooterPlus, ListBoxItem, @@ -56,17 +57,22 @@ export function AIAgentRuntimesSelector({ if (data && !selectedRuntimeId) setRuntimeToDefault() }, [data, selectedRuntimeId]) return ( -
+
setWorkbenchId(key ? `${key}` : null)} - triggerButton={ - - ) : undefined - } - > - {loading ? ( - - ) : ( - (selectedWorkbench?.name ?? 'Select workbench') - )} - - } - > - {workbenches.map((workbench) => { - const ProviderIcon = - runtimeToIcon[workbench.agentRuntime?.type ?? AgentRuntimeType.Custom] - - return ( - - } - /> - ) - })} - - ) -} - const PopoverSC = styled.div(({ theme }) => ({ display: 'flex', flexDirection: 'column', @@ -393,14 +264,13 @@ const PopoverSC = styled.div(({ theme }) => ({ boxShadow: theme.boxShadows.moderate, })) -const PromptInputBoxSC = styled(Card)(({ theme }) => ({ +const PromptPreviewBoxSC = styled(Card)(({ theme }) => ({ flex: '1 1 auto', - minHeight: 0, + minHeight: 132, + maxHeight: 241, overflowY: 'auto', padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, backgroundColor: theme.colors['fill-two'], border: theme.borders.input, - '&:focus-within': { - border: theme.borders['outline-focused'], - }, + color: theme.colors['text-light'], })) diff --git a/assets/src/components/cost-management/details/recommendations/CreateRecommendationPrModal.tsx b/assets/src/components/cost-management/details/recommendations/CreateRecommendationPrModal.tsx index 24d659ec95..1176f27e4d 100644 --- a/assets/src/components/cost-management/details/recommendations/CreateRecommendationPrModal.tsx +++ b/assets/src/components/cost-management/details/recommendations/CreateRecommendationPrModal.tsx @@ -1,10 +1,12 @@ import { + Button, CheckIcon, Flex, GearTrainIcon, GitCommitIcon, GitPullIcon, Modal, + ReturnIcon, Stepper, StepperSteps, WorkbenchIcon, @@ -14,13 +16,11 @@ import { ComponentProps, useState } from 'react' import { ClusterScalingRecommendationFragment, PrAutomationFragment, - WorkbenchJobFragment, useApplyScalingRecommendationMutation, } from 'generated/graphql' import { SendToWorkbenchForm } from 'components/ai/insights/SendInsightToWorkbench' import { GqlError } from 'components/utils/Alert' -import { WorkbenchStartedJobPanel } from 'components/workbenches/common/WorkbenchStartedJobPanel' import { useWorkbenchOptions } from 'components/workbenches/useWorkbenchOptions' import { PrStepKey } from 'components/self-service/pr/automations/CreatePrModal' @@ -105,11 +105,9 @@ function CreateRecommendationPrModalBase({ const [currentStep, setCurrentStep] = useState( startWithWorkbench ? 'workbench' : 'selectType' ) - const [workbenchJob, setWorkbenchJob] = useState( - null - ) - const [workbenchPrompt, setWorkbenchPrompt] = useState(() => - buildScalingRecommendationWorkbenchPrompt(cluster, recommendation) + const workbenchPrompt = buildScalingRecommendationWorkbenchPrompt( + cluster, + recommendation ) const { hasWorkbenches } = useWorkbenchOptions() @@ -192,21 +190,34 @@ function CreateRecommendationPrModalBase({ : `Pull request configuration for ${selectedPrAutomation?.name}` } actions={ - currentStep !== 'workbench' && ( - void, - allowSubmit, - successPr, - loading: createPrLoading, - onClose, - hasConfiguration, - configIsValid, - isScalingRec: true, - pageData, + currentStep === 'workbench' && !startWithWorkbench ? ( + + ) : ( + currentStep !== 'workbench' && ( + void, + allowSubmit, + successPr, + loading: createPrLoading, + onClose, + hasConfiguration, + configIsValid, + isScalingRec: true, + pageData, + }} + /> + ) ) } > @@ -214,7 +225,7 @@ function CreateRecommendationPrModalBase({ direction="column" gap="large" overflow="hidden" - maxHeight={400} + maxHeight={currentStep === 'workbench' ? 560 : 400} > {currentStep !== 'success' && currentStep !== 'workbench' && ( @@ -266,21 +277,12 @@ function CreateRecommendationPrModalBase({ {currentStep === 'preview' && ( )} - {currentStep === 'workbench' && - (workbenchJob ? ( - - ) : ( - - ))} + {currentStep === 'workbench' && ( + + )} {createPrError && } diff --git a/assets/src/components/flows/flow/FlowWorkbenches.tsx b/assets/src/components/flows/flow/FlowWorkbenches.tsx index 4f5d04124f..181dfb4555 100644 --- a/assets/src/components/flows/flow/FlowWorkbenches.tsx +++ b/assets/src/components/flows/flow/FlowWorkbenches.tsx @@ -99,7 +99,7 @@ export function FlowWorkbenches() { flowId={flow?.id} workbenchLoading={workbenchesLoading && !workbenchesData} disabled={!workbenches.length} - placeholder="Send a job to your flow workbenches. Use / for skills and @ to mention services in this flow" + placeholder="Send a job to a workbench. Use / for skills and @ to mention services in this flow" wrapperStyles={{ maxWidth: 'none' }} /> Workbench Jobs diff --git a/assets/src/components/layout/Header.tsx b/assets/src/components/layout/Header.tsx index 6f4e63faf7..01b7c3b1f5 100644 --- a/assets/src/components/layout/Header.tsx +++ b/assets/src/components/layout/Header.tsx @@ -9,6 +9,7 @@ import NotificationsLauncher from '../notifications/NotificationsLauncher' import { ChatbotLauncher } from 'components/ai/chatbot/Chatbot' import DemoBanner from './DemoBanner' import { HeaderProjectSelect } from './HeaderProjectSelect' +import { HeaderWorkbenchSelect } from './HeaderWorkbenchSelect' import { ProfileMenu } from './ProfileMenu' const HeaderSC = styled.div(({ theme }) => ({ @@ -33,6 +34,7 @@ export default function Header() { + diff --git a/assets/src/components/layout/HeaderWorkbenchSelect.tsx b/assets/src/components/layout/HeaderWorkbenchSelect.tsx new file mode 100644 index 0000000000..b9dca0ce92 --- /dev/null +++ b/assets/src/components/layout/HeaderWorkbenchSelect.tsx @@ -0,0 +1,79 @@ +import { SelectButton, WorkbenchIcon } from '@pluralsh/design-system' +import { + borderShimmerStyles, + useBorderShimmer, +} from 'components/utils/borderShimmer' +import { + HEADER_WORKBENCH_SELECTOR_WIDTH, + WorkbenchSelector, +} from 'components/workbenches/WorkbenchSelector' +import { useWorkbenchOptions } from 'components/workbenches/useWorkbenchOptions' +import { useNavigate, useParams } from 'react-router-dom' +import { + getWorkbenchAbsPath, + WORKBENCH_PARAM_ID, +} from 'routes/workbenchesRoutesConsts' +import styled from 'styled-components' + +export function HeaderWorkbenchSelect() { + const navigate = useNavigate() + const params = useParams() + const { workbenches, hasWorkbenches, loading } = useWorkbenchOptions() + const showAnimation = useBorderShimmer({ + enabled: !loading && hasWorkbenches, + }) + const routeWorkbenchId = params[WORKBENCH_PARAM_ID] + const selectedWorkbenchId = workbenches.some( + (workbench) => workbench.id === routeWorkbenchId + ) + ? (routeWorkbenchId ?? null) + : null + + if (loading || !hasWorkbenches) return null + + return ( + + { + if (id) navigate(getWorkbenchAbsPath(id)) + }} + workbenches={workbenches} + loading={false} + width={HEADER_WORKBENCH_SELECTOR_WIDTH} + maxHeight={360} + triggerButton={ + } + > + Workbenches + + } + /> + + ) +} + +const HeaderWorkbenchSelectSC = styled.div({ + flexShrink: 0, + width: 'fit-content', +}) + +const HeaderWorkbenchSelectButtonSC = styled(SelectButton)<{ + $showAnimation: boolean +}>(({ theme, $showAnimation }) => ({ + width: 'auto', + flexShrink: 0, + '.leftContent': { marginRight: theme.spacing.xsmall }, + ...borderShimmerStyles({ + theme, + showAnimation: $showAnimation, + fillColor: + theme.mode === 'light' + ? theme.colors['fill-zero'] + : theme.colors['fill-one'], + }), +})) diff --git a/assets/src/components/layout/useSubheaderBackButton.tsx b/assets/src/components/layout/useSubheaderBackButton.tsx index cc0f88dd0f..3219d62b44 100644 --- a/assets/src/components/layout/useSubheaderBackButton.tsx +++ b/assets/src/components/layout/useSubheaderBackButton.tsx @@ -6,7 +6,7 @@ import { AI_AGENT_RUN_BACK_SOURCE_WORKBENCH, AI_AGENT_RUN_BACK_TO_PARAM, } from 'routes/aiRoutesConsts' -import { WORKBENCHES_ABS_PATH } from 'routes/workbenchesRoutesConsts' +import { WORKBENCH_LAUNCH_BACK_SOURCE } from 'routes/workbenchesRoutesConsts' import { useSearchParams } from 'react-router-dom' type SubheaderBackButton = { @@ -18,6 +18,7 @@ type SubheaderBackButton = { function getSourceIcon(source: Nullable) { switch (source) { case AI_AGENT_RUN_BACK_SOURCE_WORKBENCH: + case WORKBENCH_LAUNCH_BACK_SOURCE: return default: return undefined @@ -29,15 +30,17 @@ export function useSubheaderBackButton(): Nullable { const source = searchParams.get(AI_AGENT_RUN_BACK_SOURCE_PARAM) const backTo = searchParams.get(AI_AGENT_RUN_BACK_TO_PARAM) - if ( - source !== AI_AGENT_RUN_BACK_SOURCE_WORKBENCH || - !backTo?.startsWith(`${WORKBENCHES_ABS_PATH}/`) - ) - return null + if (!isInternalReturnPath(backTo)) return null return { icon: getSourceIcon(source), to: backTo, - label: searchParams.get(AI_AGENT_RUN_BACK_LABEL_PARAM) || 'Workbench', + label: + searchParams.get(AI_AGENT_RUN_BACK_LABEL_PARAM) || + (source === AI_AGENT_RUN_BACK_SOURCE_WORKBENCH ? 'Workbench' : 'Back'), } } + +function isInternalReturnPath(path: Nullable): path is string { + return !!path && path.startsWith('/') && !path.startsWith('//') +} diff --git a/assets/src/components/security/vulnerabilities/VulnDetailExpanded.tsx b/assets/src/components/security/vulnerabilities/VulnDetailExpanded.tsx index a03ad7430a..b87bac586a 100644 --- a/assets/src/components/security/vulnerabilities/VulnDetailExpanded.tsx +++ b/assets/src/components/security/vulnerabilities/VulnDetailExpanded.tsx @@ -1,15 +1,8 @@ -import { - AiSparkleFilledIcon, - Button, - Chip, - ChipProps, - Flex, -} from '@pluralsh/design-system' +import { Chip, ChipProps, Flex } from '@pluralsh/design-system' import styled, { useTheme } from 'styled-components' import { Row } from '@tanstack/react-table' import { Overline } from 'components/cd/utils/PermissionsModal' -import { StretchedFlex } from 'components/utils/StretchedFlex' import { StackedText } from 'components/utils/table/StackedText' import { Body2BoldP } from 'components/utils/typography/Text' import { @@ -20,10 +13,8 @@ import { export function VulnDetailExpanded({ row, - onFixVulnerability, }: { row: Row - onFixVulnerability: (vuln: VulnerabilityFragment) => void }) { const { original: v } = row @@ -32,25 +23,14 @@ export function VulnDetailExpanded({ return ( - - - - + ( - null - ) return ( - {!!workbenchJob ? ( - - ) : ( - - )} + ) } - -function VulnFixForm({ - flowId, - prompt, - setPrompt, - setWorkbenchJob, -}: { - flowId?: Nullable - prompt: string - setPrompt: (prompt: string) => void - setWorkbenchJob: (job: WorkbenchJobFragment) => void -}) { - const [workbenchId, setWorkbenchId] = useState>(null) - const { workbenches, loading } = useWorkbenchOptions(flowId) - - useEffect(() => { - setWorkbenchId((current) => { - if (!workbenches.length) return null - if (workbenches.some((workbench) => workbench.id === current)) - return current - return workbenches[0]?.id ?? null - }) - }, [workbenches]) - - const [createWorkbenchJob, { loading: mutationLoading, error }] = - useCreateWorkbenchJobMutation({ - onCompleted: ({ createWorkbenchJob }) => - createWorkbenchJob && setWorkbenchJob(createWorkbenchJob), - refetchQueries: ['WorkbenchJobs', 'RecentWorkbenchJobs'], - awaitRefetchQueries: true, - }) - - const canSubmit = - !!workbenchId && !!prompt.trim() && !mutationLoading && !loading - const promptInputRef = useRef(null) - - return ( - <> - {error && } - - - - - - - - - - - - ) -} - -function useWorkbenchOptions(flowId?: Nullable) { - const { data: flowData, loading: flowLoading } = useFlowWorkbenchesQuery({ - variables: { id: flowId ?? '' }, - skip: !flowId, - }) - const { data: allWorkbenchesData, loading: allWorkbenchesLoading } = - useWorkbenchesQuery({ - skip: !!flowId, - }) - - const workbenches = useMemo(() => { - if (flowId) return (flowData?.flow?.workbenches ?? []).filter(isNonNullable) - - return mapExistingNodes(allWorkbenchesData?.workbenches) - }, [allWorkbenchesData?.workbenches, flowData?.flow?.workbenches, flowId]) - - return { - workbenches, - loading: flowId ? flowLoading && !flowData : allWorkbenchesLoading, - } -} - -function WorkbenchSelector({ - workbenchId, - setWorkbenchId, - workbenches, - loading, -}: { - workbenchId: Nullable - setWorkbenchId: (id: Nullable) => void - workbenches: WorkbenchTinyFragment[] - loading: boolean -}) { - const [isOpen, setIsOpen] = useState(false) - const selectedWorkbench = workbenches.find( - (workbench) => workbench.id === workbenchId - ) - const SelectedIcon = selectedWorkbench - ? runtimeToIcon[ - selectedWorkbench.agentRuntime?.type ?? AgentRuntimeType.Custom - ] - : null - - return ( - - ) -} - -const PromptInputBoxSC = styled(Card)(({ theme }) => ({ - padding: `${theme.spacing.small}px ${theme.spacing.medium}px`, - '&:focus-within': { - border: theme.borders['outline-focused'], - }, -})) diff --git a/assets/src/components/security/vulnerabilities/VulnReportDetails.tsx b/assets/src/components/security/vulnerabilities/VulnReportDetails.tsx index 6ea38c47b9..85e93e0c43 100644 --- a/assets/src/components/security/vulnerabilities/VulnReportDetails.tsx +++ b/assets/src/components/security/vulnerabilities/VulnReportDetails.tsx @@ -171,12 +171,7 @@ export function VulnerabilityReportDetails() { columns={columns} loading={loading && !data} getRowCanExpand={() => true} - renderExpanded={({ row }) => ( - openFix([vuln])} - /> - )} + renderExpanded={({ row }) => } onRowClick={(_, row) => row.getToggleExpandedHandler()()} emptyStateProps={{ message: 'No vulnerabilities found.' }} expandedBgColor="fill-zero" diff --git a/assets/src/components/settings/ai/agent-runtimes/AIAgentRuntimes.tsx b/assets/src/components/settings/ai/agent-runtimes/AIAgentRuntimes.tsx index b3aecdb1e7..1ab7ee4e52 100644 --- a/assets/src/components/settings/ai/agent-runtimes/AIAgentRuntimes.tsx +++ b/assets/src/components/settings/ai/agent-runtimes/AIAgentRuntimes.tsx @@ -81,6 +81,11 @@ const columns = [ ) }, }), + columnHelper.accessor((runtime) => runtime.model?.model, { + id: 'model', + header: 'Model', + cell: ({ getValue }) => getValue() || '—', + }), columnHelper.accessor((runtime) => runtime.aiProxy, { id: 'aiProxy', header: 'AI Proxy', diff --git a/assets/src/components/utils/borderShimmer.ts b/assets/src/components/utils/borderShimmer.ts new file mode 100644 index 0000000000..78d8c2cf3a --- /dev/null +++ b/assets/src/components/utils/borderShimmer.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' +import { CSSObject, DefaultTheme } from 'styled-components' + +const ANIMATION_SPEED_S = 4 +// run every 20 minutes for 6 seconds +const ANIMATION_ON_MS = 6_000 +const ANIMATION_PERIOD_MS = 20 * 60 * 1000 + +export function useBorderShimmer({ + enabled = true, + onMs = ANIMATION_ON_MS, + periodMs = ANIMATION_PERIOD_MS, +}: { + enabled?: boolean + onMs?: number + periodMs?: number +} = {}) { + const [on, setOn] = useState(false) + useEffect(() => { + if (!enabled) return + + let timeoutId: NodeJS.Timeout + const trigger = () => { + if (timeoutId) clearTimeout(timeoutId) + setOn(true) + timeoutId = setTimeout(() => setOn(false), onMs) + } + const startId = setTimeout(trigger, 0) + const intervalId = setInterval(trigger, periodMs + onMs) + return () => { + clearTimeout(startId) + clearInterval(intervalId) + clearTimeout(timeoutId) + } + }, [enabled, onMs, periodMs]) + return on +} + +export function borderShimmerStyles({ + theme, + showAnimation, + fillColor, +}: { + theme: DefaultTheme + showAnimation: boolean + fillColor: string +}): CSSObject { + return { + overflow: 'visible', + '&, &:hover, &:focus, &:focus-visible': { + '@property --border-angle-1': { + syntax: "''", + inherits: 'true', + initialValue: '0deg', + }, + '@property --border-angle-2': { + syntax: "''", + inherits: 'true', + initialValue: '180deg', + }, + '--border-angle-1': '0deg', + '--border-angle-2': '180deg', + ...(showAnimation + ? { + border: '1px solid transparent', + backgroundColor: 'transparent', + } + : {}), + backgroundImage: ` + linear-gradient(${fillColor}, ${fillColor}), + conic-gradient( + from var(--border-angle-1) at 25% 30%, + transparent, + ${theme.colors['border-outline-focused']} 12%, + transparent 32%, + transparent + ), + conic-gradient( + from var(--border-angle-2) at 75% 60%, + transparent, + ${theme.colors['border-input']} 12%, + transparent 60%, + transparent + ) + `, + backgroundClip: 'padding-box, border-box, border-box', + backgroundOrigin: 'border-box', + animation: `rotateBorderShimmerA ${ANIMATION_SPEED_S}s linear infinite, rotateBorderShimmerB ${ANIMATION_SPEED_S * 1.5}s linear infinite`, + animationPlayState: showAnimation ? 'running' : 'paused', + '@keyframes rotateBorderShimmerA': { + to: { '--border-angle-1': '360deg' }, + }, + '@keyframes rotateBorderShimmerB': { + to: { '--border-angle-2': '-360deg' }, + }, + }, + } +} diff --git a/assets/src/components/workbenches/WorkbenchSelector.tsx b/assets/src/components/workbenches/WorkbenchSelector.tsx new file mode 100644 index 0000000000..12e93c50af --- /dev/null +++ b/assets/src/components/workbenches/WorkbenchSelector.tsx @@ -0,0 +1,178 @@ +import { + Flex, + ListBoxItem, + Select, + SelectButton, +} from '@pluralsh/design-system' +import { runtimeToIcon } from 'components/settings/ai/agent-runtimes/AIAgentRuntimeIcon' +import { MetadataIcons } from 'components/utils/MetadataIcons' +import { RectangleSkeleton } from 'components/utils/SkeletonLoaders' +import { TRUNCATE } from 'components/utils/truncate' +import { Body2P, CaptionP } from 'components/utils/typography/Text' +import { WorkbenchToolIcon } from 'components/workbenches/tools/workbenchToolsUtils' +import { AgentRuntimeType, WorkbenchTinyFragment } from 'generated/graphql' +import { ReactElement, ReactNode, useState } from 'react' +import { isNonNullable } from 'utils/isNonNullable' + +export const WORKBENCH_SELECTOR_WIDTH = 536 +export const HEADER_WORKBENCH_SELECTOR_WIDTH = 429 +const MAX_VISIBLE_TOOLS = 5 + +export function WorkbenchSelector({ + workbenchId, + setWorkbenchId, + workbenches, + loading, + width = WORKBENCH_SELECTOR_WIDTH, + maxHeight, + placeholder = 'Select workbench', + showSelectedInTrigger = true, + triggerButton, +}: { + workbenchId: Nullable + setWorkbenchId: (id: Nullable) => void + workbenches: WorkbenchTinyFragment[] + loading: boolean + width?: string | number + maxHeight?: string | number + placeholder?: ReactNode + showSelectedInTrigger?: boolean + triggerButton?: ReactElement +}) { + const [isOpen, setIsOpen] = useState(false) + const selectedWorkbench = workbenches.find( + (workbench) => workbench.id === workbenchId + ) + const triggerShowsSelected = showSelectedInTrigger && selectedWorkbench + + return ( + + ) +} + +function WorkbenchOptionLabel({ + workbench, +}: { + workbench: WorkbenchTinyFragment +}) { + const ProviderIcon = + runtimeToIcon[workbench.agentRuntime?.type ?? AgentRuntimeType.Custom] + + return ( + + + + {workbench.name} + + {workbench.description && ( + + {workbench.description} + + )} + + ) +} + +function WorkbenchToolIcons({ + workbench, +}: { + workbench: WorkbenchTinyFragment +}) { + const tools = workbench.tools?.filter(isNonNullable) ?? [] + if (!tools.length) return null + + return ( + ({ + id: tool.id, + label: tool.name, + icon: ( + + ), + }))} + /> + ) +} diff --git a/assets/src/components/workbenches/workbench/WorkbenchJobCreateInput.tsx b/assets/src/components/workbenches/workbench/WorkbenchJobCreateInput.tsx index 93d513cb61..c3b90db94b 100644 --- a/assets/src/components/workbenches/workbench/WorkbenchJobCreateInput.tsx +++ b/assets/src/components/workbenches/workbench/WorkbenchJobCreateInput.tsx @@ -32,10 +32,11 @@ import groupBy from 'lodash/groupBy' import isEmpty from 'lodash/isEmpty' import type { ComponentProps } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { getWorkbenchJobAbsPath, getWorkbenchSavedPromptCreateAbsPath, + WorkbenchLaunchRouteState, } from 'routes/workbenchesRoutesConsts' import styled, { useTheme } from 'styled-components' import { mapExistingNodes } from 'utils/graphql' @@ -77,9 +78,13 @@ export function WorkbenchJobCreateInput({ wrapperStyles?: ComponentProps['wrapperStyles'] }) { const navigate = useNavigate() + const location = useLocation() const inputRef = useAutofocusRef() - const [prompt, setPrompt] = useState('') - const [promptSyncKey, setPromptSyncKey] = useState(0) + const navPrompt = (location.state as Nullable) + ?.prompt + const prevNavPromptRef = useRef(navPrompt) + const [prompt, setPrompt] = useState(navPrompt ?? '') + const [promptSyncKey, setPromptSyncKey] = useState(navPrompt ? 1 : 0) const [promptModes, setPromptModes] = useState(null) const [selectedModelState, setSelectedModelState] = useState<{ @@ -128,6 +133,13 @@ export function WorkbenchJobCreateInput({ if (promptSyncKey > 0) inputRef.current?.focus() }, [inputRef, promptSyncKey]) + useEffect(() => { + if (!navPrompt || navPrompt === prevNavPromptRef.current) return + prevNavPromptRef.current = navPrompt + setPrompt(navPrompt) + setPromptSyncKey((key) => key + 1) + }, [navPrompt]) + const [createWorkbenchJob, { loading, error }] = useCreateWorkbenchJobMutation({ onCompleted: ({ createWorkbenchJob }) => { @@ -139,12 +151,13 @@ export function WorkbenchJobCreateInput({ return } if (workbenchId) - navigate( - getWorkbenchJobAbsPath({ + navigate({ + pathname: getWorkbenchJobAbsPath({ workbenchId, jobId: createWorkbenchJob.id, - }) - ) + }), + search: location.search, + }) }, refetchQueries: ['WorkbenchJobs', 'RecentWorkbenchJobs'], awaitRefetchQueries: true, diff --git a/assets/src/components/workbenches/workbench/create-edit/KnowledgeSubStep.tsx b/assets/src/components/workbenches/workbench/create-edit/KnowledgeSubStep.tsx new file mode 100644 index 0000000000..48847ebbd6 --- /dev/null +++ b/assets/src/components/workbenches/workbench/create-edit/KnowledgeSubStep.tsx @@ -0,0 +1,512 @@ +import { + Button, + Card, + Chip, + CodeEditor, + EmptyState, + Flex, + FormField, + IconFrame, + Input2, + PencilIcon, + TrashCanIcon, +} from '@pluralsh/design-system' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTheme } from 'styled-components' + +import { StackedText } from 'components/utils/table/StackedText' +import { CaptionP } from 'components/utils/typography/Text' +import { fromNow } from 'utils/datetime' +import { isNonNullable } from 'utils/isNonNullable' + +import { createFormUpdater, WorkbenchFormStepProps } from './WorkbenchFormSteps' +import { + useWorkbenchFormCardRightContent, + useWorkbenchFormFooterActions, + WorkbenchFormKnowledge, +} from './WorkbenchCreateOrEdit' + +type KnowledgeFormStep = 'metadata' | 'contents' +const KNOWLEDGE_FORM_STEPS: { id: KnowledgeFormStep; label: string }[] = [ + { id: 'metadata', label: 'Add metadata' }, + { id: 'contents', label: 'Add content' }, +] +const DUPLICATE_KNOWLEDGE_NAME_ERROR = + 'A knowledge entry with this name already exists.' + +const normalizeKnowledgeName = (name: Nullable) => + (name ?? '').trim().toLowerCase() + +const validateKnowledgeName = ({ + draftName, + existingNames, + editingName, +}: { + draftName: Nullable + existingNames: Nullable[] + editingName: Nullable +}): Nullable => { + const normalizedDraftName = normalizeKnowledgeName(draftName) + if (!normalizedDraftName) return null + + const normalizedEditingName = normalizeKnowledgeName(editingName) + const hasDuplicateName = existingNames.some((name) => { + const normalizedExistingName = normalizeKnowledgeName(name) + return ( + normalizedExistingName === normalizedDraftName && + normalizedExistingName !== normalizedEditingName + ) + }) + + return hasDuplicateName ? DUPLICATE_KNOWLEDGE_NAME_ERROR : null +} + +const knowledgeUsageLabel = (entry: WorkbenchFormKnowledge) => { + const usages = entry.usages ?? 0 + const usageText = `${usages} use${usages === 1 ? '' : 's'}` + const lastUsed = entry.lastUsedAt + ? `last used ${fromNow(entry.lastUsedAt)}` + : 'never used' + return [entry.description, `${usageText} · ${lastUsed}`] + .filter(Boolean) + .join(' · ') +} + +export function KnowledgeSubStep({ + formState, + setFormState, +}: WorkbenchFormStepProps) { + const theme = useTheme() + const update = createFormUpdater(setFormState) + const entries = formState.workbenchKnowledge + const existingNames = useMemo( + () => entries.map((entry) => entry.name), + [entries] + ) + const [editingId, setEditingId] = useState(null) + + const editingEntry = useMemo( + () => + !editingId + ? null + : (entries.find((entry) => entry.id === editingId) ?? null), + [editingId, entries] + ) + + const handleEdit = (id: string) => setEditingId(id) + + const handleDelete = (id: string) => + update((d) => { + d.workbenchKnowledge = (d.workbenchKnowledge ?? []).filter( + (entry) => entry.id !== id + ) + }) + + const handleSave = (draft: WorkbenchFormKnowledge): Nullable => { + const canSave = !!draft.knowledge.trim() && !!draft.name.trim() + if (!canSave) return 'Knowledge name and contents are required.' + + const error = validateKnowledgeName({ + draftName: draft.name, + existingNames, + editingName: editingEntry?.name, + }) + if (error) return error + + const normalizedDraft: WorkbenchFormKnowledge = { + ...draft, + name: draft.name.trim(), + description: draft.description?.trim() || null, + knowledge: draft.knowledge, + labels: draft.labels + .filter(isNonNullable) + .map((label) => label.trim()) + .filter(Boolean), + } + update((d) => { + const list = [...(d.workbenchKnowledge ?? [])] + const idx = list.findIndex((entry) => entry.id === draft.id) + if (idx >= 0) list[idx] = normalizedDraft + d.workbenchKnowledge = list + }) + setEditingId(null) + return null + } + + const handleCancel = () => setEditingId(null) + + if (editingId !== null && editingEntry) { + return ( + + ) + } + + return ( + + + Plural maintains a knowledge base automatically across multiple runs of + this workbench. Entries can go stale; edit or delete them to keep facts + accurate and make room for new ones. + + + {entries.length === 0 ? ( + + + + ) : ( + + {entries.map((entry, idx) => ( + handleEdit(entry.id)} + onDelete={() => handleDelete(entry.id)} + /> + ))} + + )} + + + ) +} + +function KnowledgeRow({ + entry, + isLast, + onEdit, + onDelete, +}: { + entry: WorkbenchFormKnowledge + isLast: boolean + onEdit: () => void + onDelete: () => void +}) { + const theme = useTheme() + return ( +
+ + + } + onClick={onEdit} + /> + } + onClick={onDelete} + /> + +
+ ) +} + +function KnowledgeForm({ + initialEntry, + existingNames, + onSave, + onCancel, +}: { + initialEntry: WorkbenchFormKnowledge + existingNames: Nullable[] + onSave: (entry: WorkbenchFormKnowledge) => Nullable + onCancel: () => void +}) { + const [draft, setDraft] = useState(initialEntry) + const [labelDraft, setLabelDraft] = useState('') + const [saveError, setSaveError] = useState>(null) + const [currentStep, setCurrentStep] = useState('metadata') + const { setFooterActions } = useWorkbenchFormFooterActions() + const { setRightContent } = useWorkbenchFormCardRightContent() + const validationError = useMemo( + () => + validateKnowledgeName({ + draftName: draft.name, + existingNames, + editingName: initialEntry.name, + }), + [draft.name, existingNames, initialEntry.name] + ) + const canContinue = !!draft.name.trim() && !validationError + const canSave = canContinue && !!draft.knowledge.trim() + const updateDraft = (next: WorkbenchFormKnowledge) => { + setSaveError(null) + setDraft(next) + } + + const onSaveRef = useRef(onSave) + const onCancelRef = useRef(onCancel) + const draftRef = useRef(draft) + useEffect(() => { + onSaveRef.current = onSave + onCancelRef.current = onCancel + draftRef.current = draft + }, [draft, onCancel, onSave]) + + useEffect(() => { + setFooterActions( + <> + + {currentStep === 'metadata' ? ( + + ) : ( + + )} + + ) + + return () => setFooterActions(null) + }, [canContinue, canSave, currentStep, setFooterActions]) + + useEffect(() => { + setRightContent( + + ) + + return () => setRightContent(null) + }, [canContinue, currentStep, setRightContent]) + + const addLabel = (raw: string) => { + const label = raw.trim() + if (!label) return + if ( + draft.labels.some( + (existing) => existing.toLowerCase() === label.toLowerCase() + ) + ) { + setLabelDraft('') + return + } + updateDraft({ ...draft, labels: [...draft.labels, label] }) + setLabelDraft('') + } + + return ( + + {currentStep === 'metadata' ? ( + <> + + updateDraft({ ...draft, name: e.target.value })} + /> + + + + updateDraft({ ...draft, description: e.target.value || null }) + } + /> + + + + setLabelDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key !== 'Enter') return + e.preventDefault() + addLabel(labelDraft) + }} + /> + {draft.labels.length > 0 && ( + + {draft.labels.map((label) => ( + + updateDraft({ + ...draft, + labels: draft.labels.filter( + (current) => current !== label + ), + }) + } + > + {label} + + ))} + + )} + + + + ) : ( + + + updateDraft({ ...draft, knowledge: value ?? '' }) + } + height={350} + options={{ minimap: { enabled: false } }} + /> + + )} + {!!saveError && {saveError}} + + ) +} + +function KnowledgeFormSteps({ + activeStep, + canGoToContent, + onStepSelect, +}: { + activeStep: KnowledgeFormStep + canGoToContent: boolean + onStepSelect: (step: KnowledgeFormStep) => void +}) { + const theme = useTheme() + + return ( + + {KNOWLEDGE_FORM_STEPS.map(({ id, label }, idx) => { + const isActive = id === activeStep + const isClickable = id === 'metadata' || canGoToContent + + return ( + + ) + })} + + ) +} diff --git a/assets/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx b/assets/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx index a90312cf85..432f1fae4d 100644 --- a/assets/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx +++ b/assets/src/components/workbenches/workbench/create-edit/WorkbenchCreateOrEdit.tsx @@ -16,10 +16,13 @@ import { getWorkbenchesBreadcrumbs } from 'components/workbenches/Workbenches' import { PolicyBindingFragment, useCreateWorkbenchMutation, + useDeleteWorkbenchKnowledgeMutation, + useUpdateWorkbenchKnowledgeMutation, useUpdateWorkbenchMutation, useWorkbenchQuery, WorkbenchAttributes, WorkbenchFragment, + WorkbenchKnowledgeAttributes, WorkbenchSkillAttributes, WorkbenchSkillSubagent, } from 'generated/graphql' @@ -81,6 +84,16 @@ export function useWorkbenchFormCardRightContent() { // requires every key from WorkbenchAttributes to be present. readBindings/writeBindings // use FormBinding[] so BindingInput can show chips (user email / group name). +export type WorkbenchFormKnowledge = { + id: string + name: string + description?: string | null + knowledge: string + labels: string[] + usages?: number | null + lastUsedAt?: string | null +} + export type WorkbenchFormState = Omit< Required, 'readBindings' | 'writeBindings' | 'projectId' | 'systemPrompt' @@ -88,6 +101,7 @@ export type WorkbenchFormState = Omit< readBindings: PolicyBindingFragment[] writeBindings: PolicyBindingFragment[] workbenchSkills: WorkbenchSkillAttributes[] + workbenchKnowledge: WorkbenchFormKnowledge[] } export function WorkbenchCreateOrEdit({ mode }: { mode: 'create' | 'edit' }) { @@ -225,15 +239,63 @@ function WorkbenchForm({ refetchQueries: ['Workbenches'], awaitRefetchQueries: true, }) - const mutationLoading = createLoading || updateLoading - const mutationError = createError || updateError + const [updateKnowledge, { loading: updateKnowledgeLoading }] = + useUpdateWorkbenchKnowledgeMutation() + const [deleteKnowledge, { loading: deleteKnowledgeLoading }] = + useDeleteWorkbenchKnowledgeMutation() + const [knowledgeError, setKnowledgeError] = useState(null) + const mutationLoading = + createLoading || + updateLoading || + updateKnowledgeLoading || + deleteKnowledgeLoading + const mutationError = createError || updateError || knowledgeError - const onSave = () => { + const persistKnowledgeChanges = async () => { + const initialById = new Map( + initialFormState.workbenchKnowledge.map((entry) => [entry.id, entry]) + ) + const currentIds = new Set( + formState.workbenchKnowledge.map((entry) => entry.id) + ) + const deletions = [...initialById.keys()].filter( + (id) => !currentIds.has(id) + ) + const updates = formState.workbenchKnowledge.filter((entry) => { + const initial = initialById.get(entry.id) + return ( + !!initial && knowledgeSignature(entry) !== knowledgeSignature(initial) + ) + }) + + await Promise.all([ + ...deletions.map((id) => deleteKnowledge({ variables: { id } })), + ...updates.map((entry) => + updateKnowledge({ + variables: { + id: entry.id, + attributes: knowledgeToAttributes(entry), + }, + }) + ), + ]) + } + + const onSave = async () => { const attributes = formStateToAttributes(formState) if (isCreateMode) { createWorkbench({ variables: { attributes } }) return } + try { + setKnowledgeError(null) + await persistKnowledgeChanges() + } catch (error) { + setKnowledgeError( + error instanceof Error ? error : new Error(String(error)) + ) + return + } updateWorkbench({ variables: { id: workbenchId ?? '', attributes }, }) @@ -444,9 +506,31 @@ const validateForm = (formState: WorkbenchFormState) => validateStep(label as WorkbenchStepLabel, formState) ) +function knowledgeToAttributes( + entry: WorkbenchFormKnowledge +): WorkbenchKnowledgeAttributes { + return { + name: entry.name, + description: entry.description ?? null, + knowledge: entry.knowledge, + labels: entry.labels, + } +} + +function knowledgeSignature(entry: WorkbenchFormKnowledge) { + return JSON.stringify(knowledgeToAttributes(entry)) +} + function formStateToAttributes(state: WorkbenchFormState): WorkbenchAttributes { - const { name, readBindings, writeBindings, modes, workbenchSkills, ...rest } = - state + const { + name, + readBindings, + writeBindings, + modes, + workbenchSkills, + workbenchKnowledge: _workbenchKnowledge, + ...rest + } = state return { ...deepOmitFalsy(rest), @@ -498,6 +582,7 @@ function sanitizeInitialForm({ modes, budget, workbenchSkills, + workbenchKnowledge, tools, readBindings, writeBindings, @@ -522,6 +607,19 @@ function sanitizeInitialForm({ [], })) + const resolvedWorkbenchKnowledge = (workbenchKnowledge?.edges ?? []) + .map((edge) => edge?.node) + .filter(isNonNullable) + .map((entry) => ({ + id: entry.id, + name: entry.name ?? '', + description: entry.description ?? null, + knowledge: entry.knowledge ?? '', + labels: (entry.labels ?? []).filter(isNonNullable), + usages: entry.usages ?? 0, + lastUsedAt: entry.lastUsedAt ?? null, + })) + return { name, description, @@ -561,5 +659,6 @@ function sanitizeInitialForm({ }, ]) ?? [], workbenchSkills: resolvedWorkbenchSkills, + workbenchKnowledge: resolvedWorkbenchKnowledge, } } diff --git a/assets/src/components/workbenches/workbench/create-edit/WorkbenchFormSteps.tsx b/assets/src/components/workbenches/workbench/create-edit/WorkbenchFormSteps.tsx index 6cd6560fa3..15de2d846d 100644 --- a/assets/src/components/workbenches/workbench/create-edit/WorkbenchFormSteps.tsx +++ b/assets/src/components/workbenches/workbench/create-edit/WorkbenchFormSteps.tsx @@ -83,6 +83,7 @@ import { } from '../../tools/workbenchToolsUtils' import { WorkbenchesConfiguredToolMetadata } from '../../WorkbenchesConfiguredToolMetadata' import { WorkbenchModesForm } from '../WorkbenchPromptModeSelector/WorkbenchModesForm' +import { KnowledgeSubStep } from './KnowledgeSubStep' import { PluralSkillsSubStep } from './PluralSkillsSubStep' import { useWorkbenchFormCardTabs, @@ -294,9 +295,9 @@ export function WorkbenchSkillsConfigStep({ setFormState, }: WorkbenchFormStepProps) { const { setTabs } = useWorkbenchFormCardTabs() - const [subTab, setSubTab] = useState<'git-skills' | 'plural-skills'>( - 'plural-skills' - ) + const [subTab, setSubTab] = useState< + 'git-skills' | 'plural-skills' | 'knowledge' + >('plural-skills') useEffect(() => { setTabs( @@ -313,17 +314,36 @@ export function WorkbenchSkillsConfigStep({ > Git skills + setSubTab('knowledge')} + > + Knowledge + ) return () => setTabs(null) }, [setTabs, subTab]) - return subTab === 'git-skills' ? ( - - ) : ( + if (subTab === 'git-skills') { + return ( + + ) + } + + if (subTab === 'knowledge') { + return ( + + ) + } + + return ( ; id: Scalars['ID']['output']; insertedAt?: Maybe; + /** default model override for runs on this runtime */ + model?: Maybe; /** the name of this runtime */ name: Scalars['String']['output']; pendingRuns?: Maybe; @@ -741,6 +743,8 @@ export type AgentRuntimeAttributes = { createBindings?: InputMaybe>>; /** whether this is the default runtime for coding agents */ default?: InputMaybe; + /** default model override for runs on this runtime */ + model?: InputMaybe; /** the name of this runtime */ name: Scalars['String']['input']; /** the name of the scm connection to use for this runtime */ @@ -4383,6 +4387,8 @@ export type Flow = { id: Scalars['ID']['output']; insertedAt?: Maybe; issues?: Maybe; + /** the maximum number of preview environments allowed for this flow (1-25, default 10) */ + maxPreviews?: Maybe; metadata?: Maybe; name: Scalars['String']['output']; pipelines?: Maybe; @@ -4489,6 +4495,8 @@ export type FlowAttributes = { /** workbenches associated with this flow */ flowWorkbenches?: InputMaybe>>; icon?: InputMaybe; + /** the maximum number of preview environments allowed for this flow (1-25, default 10) */ + maxPreviews?: InputMaybe; metadata?: InputMaybe; name: Scalars['String']['input']; projectId?: InputMaybe; @@ -8679,6 +8687,8 @@ export type PreviewEnvironmentInstance = { __typename?: 'PreviewEnvironmentInstance'; id: Scalars['ID']['output']; insertedAt?: Maybe; + /** when this preview environment instance expires */ + previewExpiresAt?: Maybe; pullRequest?: Maybe; service?: Maybe; template?: Maybe; @@ -8706,6 +8716,8 @@ export type PreviewEnvironmentTemplate = { id: Scalars['ID']['output']; insertedAt?: Maybe; name: Scalars['String']['output']; + /** how long preview environments should live, in seconds */ + previewTtl?: Maybe; referenceService?: Maybe; template?: Maybe; updatedAt?: Maybe; @@ -8720,6 +8732,8 @@ export type PreviewEnvironmentTemplateAttributes = { flowId: Scalars['ID']['input']; /** the name of the preview environment template */ name: Scalars['String']['input']; + /** how long preview environments should live, as a kubernetes duration (e.g. 1d, 5s) */ + previewTtl?: InputMaybe; /** the service that will be cloned to create the preview environment */ referenceServiceId: Scalars['ID']['input']; /** a set of service configuration overrides to use while cloning */ @@ -18299,7 +18313,7 @@ export type AgentRunTinyFragment = { __typename?: 'AgentRun', id: string, status export type AgentRunFragment = { __typename?: 'AgentRun', id: string, status: AgentRunStatus, mode: AgentRunMode, babysit?: boolean | null, approval?: boolean | null, approvedAt?: string | null, prompt: string, shared?: boolean | null, error?: string | null, repository: string, branch?: string | null, headBranch?: string | null, insertedAt?: string | null, updatedAt?: string | null, messages?: Array<{ __typename?: 'AgentMessage', id: string, seq: number, role: AiRole, message: string, insertedAt?: string | null, cost?: { __typename?: 'AgentMessageCost', total: number, tokens?: { __typename?: 'AgentMessageTokens', input?: number | null, output?: number | null, reasoning?: number | null } | null } | null, metadata?: { __typename?: 'AgentMessageMetadata', startedAt?: string | null, completedAt?: string | null, reasoning?: { __typename?: 'AgentMessageReasoning', text?: string | null, start?: number | null, end?: number | null } | null, file?: { __typename?: 'AgentMessageFile', name?: string | null, text?: string | null, start?: number | null, end?: number | null } | null, tool?: { __typename?: 'AgentMessageTool', name?: string | null, state?: AgentMessageToolState | null, input?: string | null, output?: string | null } | null } | null } | null> | null, analysis?: { __typename?: 'AgentAnalysis', summary: string, analysis: string, bullets?: Array | null } | null, runtime?: { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType } | null, pullRequests?: Array<{ __typename?: 'PullRequest', id: string, url: string, title?: string | null, creator?: string | null, status?: PrStatus | null, insertedAt?: string | null, updatedAt?: string | null } | null> | null, podReference?: { __typename?: 'AgentPodReference', name: string, namespace: string } | null, usage?: { __typename?: 'AgentRunUsage', totalCost?: number | null, totalTokens?: number | null } | null, workbenchJob?: { __typename?: 'WorkbenchJob', id: string, workbench?: { __typename?: 'Workbench', id: string, name: string } | null } | null, upload?: { __typename?: 'AgentRunUpload', id: string, session?: string | null, patch?: string | null } | null, todos?: Array<{ __typename?: 'AgentTodo', title: string, description: string, done?: boolean | null } | null> | null }; -export type AgentRuntimeFragment = { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null }; +export type AgentRuntimeFragment = { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', model?: string | null, provider?: AiProvider | null } | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null }; export type AgentRuntimeReposFragment = { __typename?: 'AgentRuntime', id: string, allowedRepositories?: Array | null }; @@ -18357,14 +18371,14 @@ export type AgentRuntimesQueryVariables = Exact<{ }>; -export type AgentRuntimesQuery = { __typename?: 'RootQueryType', agentRuntimes?: { __typename?: 'AgentRuntimeConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'AgentRuntimeEdge', node?: { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null } | null> | null } | null }; +export type AgentRuntimesQuery = { __typename?: 'RootQueryType', agentRuntimes?: { __typename?: 'AgentRuntimeConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, endCursor?: string | null, hasPreviousPage: boolean, startCursor?: string | null }, edges?: Array<{ __typename?: 'AgentRuntimeEdge', node?: { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', model?: string | null, provider?: AiProvider | null } | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null } | null> | null } | null }; export type AgentRuntimeQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type AgentRuntimeQuery = { __typename?: 'RootQueryType', agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; +export type AgentRuntimeQuery = { __typename?: 'RootQueryType', agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', model?: string | null, provider?: AiProvider | null } | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; export type AgentRuntimeReposQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -18426,7 +18440,7 @@ export type UpsertAgentRuntimeMutationVariables = Exact<{ }>; -export type UpsertAgentRuntimeMutation = { __typename?: 'RootMutationType', upsertAgentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; +export type UpsertAgentRuntimeMutation = { __typename?: 'RootMutationType', upsertAgentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, type: AgentRuntimeType, aiProxy?: boolean | null, default?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', model?: string | null, provider?: AiProvider | null } | null, cluster?: { __typename?: 'Cluster', self?: boolean | null, virtual?: boolean | null, id: string, name: string, handle?: string | null, distro?: ClusterDistro | null, upgradePlan?: { __typename?: 'ClusterUpgradePlan', compatibilities?: boolean | null, deprecations?: boolean | null, incompatibilities?: boolean | null } | null, provider?: { __typename?: 'ClusterProvider', name: string, cloud: string } | null } | null, createBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null } | null }; export type ShareAgentRunMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -21935,7 +21949,7 @@ export type IssueWebhookTinyFragment = { __typename?: 'IssueWebhook', id: string export type WorkbenchWebhookTinyFragment = { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null }; -export type WorkbenchFragment = { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null }; +export type WorkbenchFragment = { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, workbenchKnowledge?: { __typename?: 'WorkbenchKnowledgeConnection', edges?: Array<{ __typename?: 'WorkbenchKnowledgeEdge', node?: { __typename?: 'WorkbenchKnowledge', id: string, name?: string | null, description?: string | null, knowledge?: string | null, labels?: Array | null, usages?: number | null, lastUsedAt?: string | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null }; export type WorkbenchToolTinyFragment = { __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null }; @@ -22053,7 +22067,7 @@ export type WorkbenchQueryVariables = Exact<{ }>; -export type WorkbenchQuery = { __typename?: 'RootQueryType', workbench?: { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null } | null }; +export type WorkbenchQuery = { __typename?: 'RootQueryType', workbench?: { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, workbenchKnowledge?: { __typename?: 'WorkbenchKnowledgeConnection', edges?: Array<{ __typename?: 'WorkbenchKnowledgeEdge', node?: { __typename?: 'WorkbenchKnowledge', id: string, name?: string | null, description?: string | null, knowledge?: string | null, labels?: Array | null, usages?: number | null, lastUsedAt?: string | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null } | null }; export type WorkbenchAccessibleUserFragment = { __typename?: 'User', id: string, name: string, email: string, profile?: string | null }; @@ -22369,7 +22383,7 @@ export type CreateWorkbenchMutationVariables = Exact<{ }>; -export type CreateWorkbenchMutation = { __typename?: 'RootMutationType', createWorkbench?: { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null } | null }; +export type CreateWorkbenchMutation = { __typename?: 'RootMutationType', createWorkbench?: { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, workbenchKnowledge?: { __typename?: 'WorkbenchKnowledgeConnection', edges?: Array<{ __typename?: 'WorkbenchKnowledgeEdge', node?: { __typename?: 'WorkbenchKnowledge', id: string, name?: string | null, description?: string | null, knowledge?: string | null, labels?: Array | null, usages?: number | null, lastUsedAt?: string | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null } | null }; export type UpdateWorkbenchMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -22377,7 +22391,22 @@ export type UpdateWorkbenchMutationVariables = Exact<{ }>; -export type UpdateWorkbenchMutation = { __typename?: 'RootMutationType', updateWorkbench?: { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null } | null }; +export type UpdateWorkbenchMutation = { __typename?: 'RootMutationType', updateWorkbench?: { __typename?: 'Workbench', systemPrompt?: string | null, id: string, name: string, description?: string | null, agentRuntime?: { __typename?: 'AgentRuntime', id: string, name: string, allowedRepositories?: Array | null, type: AgentRuntimeType } | null, repository?: { __typename?: 'GitRepository', id: string } | null, configuration?: { __typename?: 'WorkbenchConfiguration', infrastructure?: { __typename?: 'WorkbenchInfrastructure', services?: boolean | null, stacks?: boolean | null, kubernetes?: boolean | null, podLogs?: boolean | null, vulnerabilities?: boolean | null, sentinels?: boolean | null } | null, observability?: { __typename?: 'WorkbenchObservability', logs?: boolean | null, metrics?: boolean | null } | null, coding?: { __typename?: 'WorkbenchCoding', mode?: AgentRunMode | null, repositories?: Array | null, enableBabysitting?: boolean | null } | null } | null, modes?: { __typename?: 'WorkbenchJobModes', plan?: boolean | null, verification?: boolean | null, model?: { __typename?: 'WorkbenchJobModel', provider?: AiProvider | null, model?: string | null } | null, coding?: { __typename?: 'WorkbenchJobCodingModes', approval?: boolean | null, babysit?: boolean | null } | null, budget?: { __typename?: 'WorkbenchJobBudget', cost?: number | null, tokens?: number | null } | null, kubernetes?: { __typename?: 'WorkbenchJobKubernetesModes', update?: boolean | null, delete?: boolean | null, exec?: boolean | null, excludeNamespaces?: Array | null, requireNamespaces?: Array | null } | null } | null, budget?: { __typename?: 'WorkbenchBudget', enabled?: boolean | null, maximum?: number | null, minFree?: number | null, unit?: WorkbenchBudgetUnit | null, last?: number | null, lastUpdated?: string | null } | null, skills?: { __typename?: 'WorkbenchSkills', files?: Array | null, ref?: { __typename?: 'GitRef', ref: string, folder: string } | null } | null, workbenchSkills?: { __typename?: 'WorkbenchSkillConnection', edges?: Array<{ __typename?: 'WorkbenchSkillEdge', node?: { __typename?: 'WorkbenchSkill', id: string, name?: string | null, description?: string | null, contents?: string | null, subagents?: Array | null } | null } | null> | null } | null, workbenchKnowledge?: { __typename?: 'WorkbenchKnowledgeConnection', edges?: Array<{ __typename?: 'WorkbenchKnowledgeEdge', node?: { __typename?: 'WorkbenchKnowledge', id: string, name?: string | null, description?: string | null, knowledge?: string | null, labels?: Array | null, usages?: number | null, lastUsedAt?: string | null } | null } | null> | null } | null, tools?: Array<{ __typename?: 'WorkbenchTool', id: string, name: string, tool: WorkbenchToolType, categories?: Array | null, approval?: boolean | null, scmConnection?: { __typename?: 'ScmConnection', id: string, name: string, type: ScmType } | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, configuration?: { __typename?: 'WorkbenchToolConfiguration', http?: { __typename?: 'WorkbenchToolHttpConfiguration', url?: string | null, method?: string | null, body?: string | null, inputSchema?: Record | null, headers?: Array<{ __typename?: 'WorkbenchToolHttpHeader', name?: string | null, value?: string | null } | null> | null } | null, datadog?: { __typename?: 'WorkbenchToolDatadogConnection', site?: string | null } | null, elastic?: { __typename?: 'WorkbenchToolElasticConnection', index: string, url: string, username: string } | null, opensearch?: { __typename?: 'WorkbenchToolOpensearchConnection', host: string, index: string, awsAccessKeyId?: string | null, awsRegion?: string | null, assumeRoleArn?: string | null, usePodIdentity?: boolean | null } | null, loki?: { __typename?: 'WorkbenchToolLokiConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, prometheus?: { __typename?: 'WorkbenchToolPrometheusConnection', url?: string | null, username?: string | null, tenantId?: string | null, awsSigv4?: boolean | null, awsAccessKeyId?: string | null, awsRegion?: string | null } | null, tempo?: { __typename?: 'WorkbenchToolTempoConnection', url?: string | null, username?: string | null, tenantId?: string | null } | null, jaeger?: { __typename?: 'WorkbenchToolJaegerConnection', url?: string | null, username?: string | null } | null, atlassian?: { __typename?: 'WorkbenchToolAtlassianConnection', email?: string | null, url: string } | null, linear?: { __typename?: 'WorkbenchToolLinearConnection', url: string } | null, slack?: { __typename?: 'WorkbenchToolSlackConnection', url: string } | null, pagerduty?: { __typename?: 'WorkbenchToolPagerdutyConnection', url: string } | null, teams?: { __typename?: 'WorkbenchToolTeamsConnection', clientId?: string | null, tenantId?: string | null } | null, splunk?: { __typename?: 'WorkbenchToolSplunkConnection', url?: string | null, username?: string | null } | null, cloudwatch?: { __typename?: 'WorkbenchToolCloudwatchConnection', logGroupNames?: Array | null, region?: string | null, roleArn?: string | null, roleSessionName?: string | null } | null, azure?: { __typename?: 'WorkbenchToolAzureConnection', subscriptionId?: string | null, tenantId?: string | null, clientId?: string | null, prometheusUrl?: string | null } | null, dynatrace?: { __typename?: 'WorkbenchToolDynatraceConnection', url?: string | null } | null, sentry?: { __typename?: 'WorkbenchToolSentryConnection', url?: string | null } | null, github?: { __typename?: 'WorkbenchToolGithubConnection', url: string, toolset?: string | null, appId?: string | null, installationId?: string | null } | null, gitlab?: { __typename?: 'WorkbenchToolGitlabConnection', url?: string | null } | null, bitbucket?: { __typename?: 'WorkbenchToolBitbucketConnection', url?: string | null } | null, bitbucketDatacenter?: { __typename?: 'WorkbenchToolBitbucketDatacenterConnection', url?: string | null } | null, azureDevops?: { __typename?: 'WorkbenchToolAzureDevopsConnection', url?: string | null } | null, lambda?: { __typename?: 'WorkbenchToolLambdaConnection', lambdaArn?: string | null, description?: string | null, inputSchema?: Record | null } | null, cloudRun?: { __typename?: 'WorkbenchToolCloudRunConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, azureFunction?: { __typename?: 'WorkbenchToolAzureFunctionConnection', identifier?: string | null, description?: string | null, inputSchema?: Record | null } | null, docker?: { __typename?: 'WorkbenchToolDockerConnection', url?: string | null, provider?: HelmAuthProvider | null, proxy?: { __typename?: 'HttpProxyConfiguration', url: string, noproxy?: string | null } | null } | null } | null, cloudConnection?: { __typename?: 'CloudConnection', id: string, name: string, provider: Provider } | null, mcpServer?: { __typename?: 'McpServer', id: string, name: string, url: string } | null } | null> | null, readBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, writeBindings?: Array<{ __typename?: 'PolicyBinding', id?: string | null, user?: { __typename?: 'User', id: string, name: string, email: string } | null, group?: { __typename?: 'Group', id: string, name: string } | null } | null> | null, botUser?: { __typename?: 'User', id: string, name: string, email: string, profile?: string | null } | null, webhooks?: { __typename?: 'WorkbenchWebhookConnection', edges?: Array<{ __typename?: 'WorkbenchWebhookEdge', node?: { __typename?: 'WorkbenchWebhook', id: string, name?: string | null, priority?: number | null, webhook?: { __typename?: 'ObservabilityWebhook', id: string, type: ObservabilityWebhookType } | null, issueWebhook?: { __typename?: 'IssueWebhook', id: string, provider: IssueWebhookProvider } | null } | null } | null> | null } | null } | null }; + +export type UpdateWorkbenchKnowledgeMutationVariables = Exact<{ + id: Scalars['ID']['input']; + attributes: WorkbenchKnowledgeAttributes; +}>; + + +export type UpdateWorkbenchKnowledgeMutation = { __typename?: 'RootMutationType', updateWorkbenchKnowledge?: { __typename?: 'WorkbenchKnowledge', id: string, name?: string | null, description?: string | null, knowledge?: string | null, labels?: Array | null, usages?: number | null, lastUsedAt?: string | null } | null }; + +export type DeleteWorkbenchKnowledgeMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DeleteWorkbenchKnowledgeMutation = { __typename?: 'RootMutationType', deleteWorkbenchKnowledge?: { __typename?: 'WorkbenchKnowledge', id: string } | null }; export type CreateWorkbenchEvalMutationVariables = Exact<{ workbenchId: Scalars['ID']['input']; @@ -22853,6 +22882,10 @@ export const AgentRuntimeFragmentDoc = gql` name type aiProxy + model { + model + provider + } cluster { ...ClusterTiny } @@ -27972,6 +28005,19 @@ export const WorkbenchFragmentDoc = gql` } } } + workbenchKnowledge(first: 50) { + edges { + node { + id + name + description + knowledge + labels + usages + lastUsedAt + } + } + } tools { ...WorkbenchTool } @@ -47090,6 +47136,79 @@ export function useUpdateWorkbenchMutation(baseOptions?: Apollo.MutationHookOpti export type UpdateWorkbenchMutationHookResult = ReturnType; export type UpdateWorkbenchMutationResult = Apollo.MutationResult; export type UpdateWorkbenchMutationOptions = Apollo.BaseMutationOptions; +export const UpdateWorkbenchKnowledgeDocument = gql` + mutation UpdateWorkbenchKnowledge($id: ID!, $attributes: WorkbenchKnowledgeAttributes!) { + updateWorkbenchKnowledge(id: $id, attributes: $attributes) { + id + name + description + knowledge + labels + usages + lastUsedAt + } +} + `; +export type UpdateWorkbenchKnowledgeMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateWorkbenchKnowledgeMutation__ + * + * To run a mutation, you first call `useUpdateWorkbenchKnowledgeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateWorkbenchKnowledgeMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateWorkbenchKnowledgeMutation, { data, loading, error }] = useUpdateWorkbenchKnowledgeMutation({ + * variables: { + * id: // value for 'id' + * attributes: // value for 'attributes' + * }, + * }); + */ +export function useUpdateWorkbenchKnowledgeMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateWorkbenchKnowledgeDocument, options); + } +export type UpdateWorkbenchKnowledgeMutationHookResult = ReturnType; +export type UpdateWorkbenchKnowledgeMutationResult = Apollo.MutationResult; +export type UpdateWorkbenchKnowledgeMutationOptions = Apollo.BaseMutationOptions; +export const DeleteWorkbenchKnowledgeDocument = gql` + mutation DeleteWorkbenchKnowledge($id: ID!) { + deleteWorkbenchKnowledge(id: $id) { + id + } +} + `; +export type DeleteWorkbenchKnowledgeMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteWorkbenchKnowledgeMutation__ + * + * To run a mutation, you first call `useDeleteWorkbenchKnowledgeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteWorkbenchKnowledgeMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteWorkbenchKnowledgeMutation, { data, loading, error }] = useDeleteWorkbenchKnowledgeMutation({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useDeleteWorkbenchKnowledgeMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteWorkbenchKnowledgeDocument, options); + } +export type DeleteWorkbenchKnowledgeMutationHookResult = ReturnType; +export type DeleteWorkbenchKnowledgeMutationResult = Apollo.MutationResult; +export type DeleteWorkbenchKnowledgeMutationOptions = Apollo.BaseMutationOptions; export const CreateWorkbenchEvalDocument = gql` mutation CreateWorkbenchEval($workbenchId: ID!, $attributes: WorkbenchEvalAttributes!) { createWorkbenchEval(workbenchId: $workbenchId, attributes: $attributes) { @@ -48850,6 +48969,8 @@ export const namedOperations = { RejectWorkbenchJobActivity: 'RejectWorkbenchJobActivity', CreateWorkbench: 'CreateWorkbench', UpdateWorkbench: 'UpdateWorkbench', + UpdateWorkbenchKnowledge: 'UpdateWorkbenchKnowledge', + DeleteWorkbenchKnowledge: 'DeleteWorkbenchKnowledge', CreateWorkbenchEval: 'CreateWorkbenchEval', UpdateWorkbenchEval: 'UpdateWorkbenchEval', DeleteWorkbenchEval: 'DeleteWorkbenchEval', diff --git a/assets/src/generated/persisted-queries/client.json b/assets/src/generated/persisted-queries/client.json index 86b0917bc9..0819791a8f 100644 --- a/assets/src/generated/persisted-queries/client.json +++ b/assets/src/generated/persisted-queries/client.json @@ -22,15 +22,15 @@ "name": "AgentRunPod", "body": "query AgentRunPod($id: ID!) {\n agentRun(id: $id) {\n id\n prompt\n pod {\n ...Pod\n __typename\n }\n __typename\n }\n}\n\nfragment Pod on Pod {\n metadata {\n ...Metadata\n __typename\n }\n status {\n phase\n podIp\n reason\n containerStatuses {\n ...ContainerStatus\n __typename\n }\n initContainerStatuses {\n ...ContainerStatus\n __typename\n }\n conditions {\n lastProbeTime\n lastTransitionTime\n message\n reason\n status\n type\n __typename\n }\n __typename\n }\n spec {\n nodeName\n serviceAccountName\n containers {\n ...Container\n __typename\n }\n initContainers {\n ...Container\n __typename\n }\n __typename\n }\n raw\n __typename\n}\n\nfragment Metadata on Metadata {\n uid\n name\n namespace\n labels {\n name\n value\n __typename\n }\n annotations {\n name\n value\n __typename\n }\n creationTimestamp\n __typename\n}\n\nfragment ContainerStatus on ContainerStatus {\n restartCount\n ready\n name\n state {\n running {\n startedAt\n __typename\n }\n terminated {\n exitCode\n message\n reason\n __typename\n }\n waiting {\n message\n reason\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment Container on Container {\n name\n image\n ports {\n containerPort\n protocol\n __typename\n }\n resources {\n ...Resources\n __typename\n }\n __typename\n}\n\nfragment Resources on Resources {\n limits {\n ...ResourceSpec\n __typename\n }\n requests {\n ...ResourceSpec\n __typename\n }\n __typename\n}\n\nfragment ResourceSpec on ResourceSpec {\n cpu\n memory\n __typename\n}" }, - "sha256:1e3d1a189391140f3a632ec43e1ffd813aa9a413af209501bfde812f7c328940": { + "sha256:0a18cac7a821510f77cb723f573934163b44a9ef4defd2f373fb6142be467b8f": { "type": "query", "name": "AgentRuntimes", - "body": "query AgentRuntimes($after: String, $first: Int = 100) {\n agentRuntimes(after: $after, first: $first) {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...AgentRuntime\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment AgentRuntime on AgentRuntime {\n id\n name\n type\n aiProxy\n cluster {\n ...ClusterTiny\n __typename\n }\n default\n createBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment ClusterTiny on Cluster {\n ...ClusterMinimal\n self\n upgradePlan {\n compatibilities\n deprecations\n incompatibilities\n __typename\n }\n virtual\n __typename\n}\n\nfragment ClusterMinimal on Cluster {\n id\n name\n handle\n provider {\n name\n cloud\n __typename\n }\n distro\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "query AgentRuntimes($after: String, $first: Int = 100) {\n agentRuntimes(after: $after, first: $first) {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...AgentRuntime\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment AgentRuntime on AgentRuntime {\n id\n name\n type\n aiProxy\n model {\n model\n provider\n __typename\n }\n cluster {\n ...ClusterTiny\n __typename\n }\n default\n createBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment ClusterTiny on Cluster {\n ...ClusterMinimal\n self\n upgradePlan {\n compatibilities\n deprecations\n incompatibilities\n __typename\n }\n virtual\n __typename\n}\n\nfragment ClusterMinimal on Cluster {\n id\n name\n handle\n provider {\n name\n cloud\n __typename\n }\n distro\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, - "sha256:dca06f780a9f5907079254880eafbe6b79cc464a17035a9391729a857cf47a40": { + "sha256:ab9f0a2f959ed7d8eecc6c39f39b5d87bfe3e40d465c1dd83d7cf4fcd1a06541": { "type": "query", "name": "AgentRuntime", - "body": "query AgentRuntime($id: ID!) {\n agentRuntime(id: $id) {\n ...AgentRuntime\n __typename\n }\n}\n\nfragment AgentRuntime on AgentRuntime {\n id\n name\n type\n aiProxy\n cluster {\n ...ClusterTiny\n __typename\n }\n default\n createBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment ClusterTiny on Cluster {\n ...ClusterMinimal\n self\n upgradePlan {\n compatibilities\n deprecations\n incompatibilities\n __typename\n }\n virtual\n __typename\n}\n\nfragment ClusterMinimal on Cluster {\n id\n name\n handle\n provider {\n name\n cloud\n __typename\n }\n distro\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "query AgentRuntime($id: ID!) {\n agentRuntime(id: $id) {\n ...AgentRuntime\n __typename\n }\n}\n\nfragment AgentRuntime on AgentRuntime {\n id\n name\n type\n aiProxy\n model {\n model\n provider\n __typename\n }\n cluster {\n ...ClusterTiny\n __typename\n }\n default\n createBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment ClusterTiny on Cluster {\n ...ClusterMinimal\n self\n upgradePlan {\n compatibilities\n deprecations\n incompatibilities\n __typename\n }\n virtual\n __typename\n}\n\nfragment ClusterMinimal on Cluster {\n id\n name\n handle\n provider {\n name\n cloud\n __typename\n }\n distro\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, "sha256:c3828326291fea906271e608edc61b408b2f0d6105526a8f3511dd63576eca7f": { "type": "query", @@ -67,10 +67,10 @@ "name": "ApproveAgentRun", "body": "mutation ApproveAgentRun($id: ID!) {\n approveAgentRun(id: $id) {\n ...AgentRunTiny\n __typename\n }\n}\n\nfragment AgentRunTiny on AgentRun {\n id\n status\n mode\n babysit\n approval\n approvedAt\n prompt\n shared\n error\n runtime {\n id\n name\n type\n __typename\n }\n repository\n branch\n headBranch\n pullRequests {\n ...PullRequestBasic\n __typename\n }\n podReference {\n name\n namespace\n __typename\n }\n usage {\n totalCost\n totalTokens\n __typename\n }\n workbenchJob {\n id\n workbench {\n id\n name\n __typename\n }\n __typename\n }\n upload {\n id\n session\n patch\n __typename\n }\n todos {\n ...AgentTodo\n __typename\n }\n insertedAt\n updatedAt\n __typename\n}\n\nfragment PullRequestBasic on PullRequest {\n id\n url\n title\n creator\n status\n insertedAt\n updatedAt\n __typename\n}\n\nfragment AgentTodo on AgentTodo {\n title\n description\n done\n __typename\n}" }, - "sha256:b411f7b4699e0fe182eb77f9cb87870dd7933e0aa5f20643ed35edd123d01586": { + "sha256:1ee5cc9605c763469f45e0b47a6232a1cd5fd932151687b2efdf3767496bb61f": { "type": "mutation", "name": "UpsertAgentRuntime", - "body": "mutation UpsertAgentRuntime($attributes: AgentRuntimeAttributes!) {\n upsertAgentRuntime(attributes: $attributes) {\n ...AgentRuntime\n __typename\n }\n}\n\nfragment AgentRuntime on AgentRuntime {\n id\n name\n type\n aiProxy\n cluster {\n ...ClusterTiny\n __typename\n }\n default\n createBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment ClusterTiny on Cluster {\n ...ClusterMinimal\n self\n upgradePlan {\n compatibilities\n deprecations\n incompatibilities\n __typename\n }\n virtual\n __typename\n}\n\nfragment ClusterMinimal on Cluster {\n id\n name\n handle\n provider {\n name\n cloud\n __typename\n }\n distro\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "mutation UpsertAgentRuntime($attributes: AgentRuntimeAttributes!) {\n upsertAgentRuntime(attributes: $attributes) {\n ...AgentRuntime\n __typename\n }\n}\n\nfragment AgentRuntime on AgentRuntime {\n id\n name\n type\n aiProxy\n model {\n model\n provider\n __typename\n }\n cluster {\n ...ClusterTiny\n __typename\n }\n default\n createBindings {\n ...PolicyBinding\n __typename\n }\n __typename\n}\n\nfragment ClusterTiny on Cluster {\n ...ClusterMinimal\n self\n upgradePlan {\n compatibilities\n deprecations\n incompatibilities\n __typename\n }\n virtual\n __typename\n}\n\nfragment ClusterMinimal on Cluster {\n id\n name\n handle\n provider {\n name\n cloud\n __typename\n }\n distro\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, "sha256:2731abdae8ce650f8e2222cafedea48135c4782e154114a3e50b82d8d58e7b5e": { "type": "mutation", @@ -1897,10 +1897,10 @@ "name": "WorkbenchesAlerts", "body": "query WorkbenchesAlerts($first: Int = 100, $after: String) {\n workbenchAlerts(first: $first, after: $after) {\n pageInfo {\n ...PageInfo\n __typename\n }\n edges {\n node {\n ...Alert\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment PageInfo on PageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n __typename\n}\n\nfragment Alert on Alert {\n id\n title\n message\n type\n severity\n state\n fingerprint\n url\n annotations\n tags {\n id\n name\n value\n __typename\n }\n insight {\n ...AiInsight\n __typename\n }\n resolution {\n ...AlertResolution\n __typename\n }\n workbench {\n id\n __typename\n }\n workbenchJob {\n id\n status\n __typename\n }\n updatedAt\n __typename\n}\n\nfragment AiInsight on AiInsight {\n id\n text\n summary\n sha\n freshness\n updatedAt\n insertedAt\n error {\n message\n source\n __typename\n }\n ...AiInsightContext\n __typename\n}\n\nfragment AiInsightContext on AiInsight {\n evidence {\n ...AiInsightEvidence\n __typename\n }\n cluster {\n id\n name\n distro\n provider {\n cloud\n __typename\n }\n __typename\n }\n clusterInsightComponent {\n id\n group\n version\n kind\n name\n namespace\n cluster {\n ...ClusterMinimal\n __typename\n }\n __typename\n }\n service {\n id\n name\n cluster {\n ...ClusterMinimal\n __typename\n }\n __typename\n }\n serviceComponent {\n id\n group\n version\n kind\n name\n namespace\n service {\n id\n name\n cluster {\n ...ClusterMinimal\n __typename\n }\n __typename\n }\n __typename\n }\n stack {\n id\n name\n type\n __typename\n }\n stackRun {\n id\n message\n type\n stack {\n id\n name\n __typename\n }\n __typename\n }\n alert {\n id\n title\n message\n __typename\n }\n __typename\n}\n\nfragment AiInsightEvidence on AiInsightEvidence {\n id\n type\n logs {\n ...LogsEvidence\n __typename\n }\n pullRequest {\n ...PullRequestEvidence\n __typename\n }\n alert {\n ...AlertEvidence\n __typename\n }\n knowledge {\n ...KnowledgeEvidence\n __typename\n }\n insertedAt\n updatedAt\n __typename\n}\n\nfragment LogsEvidence on LogsEvidence {\n clusterId\n serviceId\n line\n lines {\n ...LogLine\n __typename\n }\n __typename\n}\n\nfragment LogLine on LogLine {\n facets {\n ...LogFacet\n __typename\n }\n log\n timestamp\n __typename\n}\n\nfragment LogFacet on LogFacet {\n key\n value\n __typename\n}\n\nfragment PullRequestEvidence on PullRequestEvidence {\n contents\n filename\n patch\n repo\n sha\n title\n url\n __typename\n}\n\nfragment AlertEvidence on AlertEvidence {\n alertId\n title\n resolution\n __typename\n}\n\nfragment KnowledgeEvidence on KnowledgeEvidence {\n name\n observations\n type\n __typename\n}\n\nfragment ClusterMinimal on Cluster {\n id\n name\n handle\n provider {\n name\n cloud\n __typename\n }\n distro\n __typename\n}\n\nfragment AlertResolution on AlertResolution {\n resolution\n __typename\n}" }, - "sha256:6ad805090dda2b5c3ac2c40de931f7d6d8ced4ca54da34bcce68f9d490f2faa6": { + "sha256:aa85f3b51fcfd0dd614b3da510bcc62fa542c5195426b6ecfe0cf0cd6c4b5486": { "type": "query", "name": "Workbench", - "body": "query Workbench($id: ID, $name: String) {\n workbench(id: $id, name: $name) {\n ...Workbench\n __typename\n }\n}\n\nfragment Workbench on Workbench {\n ...WorkbenchTiny\n systemPrompt\n agentRuntime {\n id\n name\n allowedRepositories\n __typename\n }\n repository {\n id\n __typename\n }\n configuration {\n infrastructure {\n services\n stacks\n kubernetes\n podLogs\n vulnerabilities\n sentinels\n __typename\n }\n observability {\n logs\n metrics\n __typename\n }\n coding {\n mode\n repositories\n enableBabysitting\n __typename\n }\n __typename\n }\n modes {\n ...WorkbenchJobModesFields\n __typename\n }\n budget {\n enabled\n maximum\n minFree\n unit\n last\n lastUpdated\n __typename\n }\n skills {\n ref {\n ref\n folder\n __typename\n }\n files\n __typename\n }\n workbenchSkills(first: 500) {\n edges {\n node {\n id\n name\n description\n contents\n subagents\n __typename\n }\n __typename\n }\n __typename\n }\n tools {\n ...WorkbenchTool\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n botUser {\n id\n name\n email\n profile\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTiny on Workbench {\n id\n name\n description\n agentRuntime {\n id\n name\n type\n __typename\n }\n tools {\n ...WorkbenchToolTiny\n __typename\n }\n webhooks(first: 50) {\n edges {\n node {\n ...WorkbenchWebhookTiny\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment WorkbenchWebhookTiny on WorkbenchWebhook {\n id\n name\n priority\n webhook {\n id\n type\n __typename\n }\n issueWebhook {\n ...IssueWebhookTiny\n __typename\n }\n __typename\n}\n\nfragment IssueWebhookTiny on IssueWebhook {\n id\n provider\n __typename\n}\n\nfragment WorkbenchJobModesFields on WorkbenchJobModes {\n plan\n verification\n model {\n provider\n model\n __typename\n }\n coding {\n approval\n babysit\n __typename\n }\n budget {\n cost\n tokens\n __typename\n }\n kubernetes {\n update\n delete\n exec\n excludeNamespaces\n requireNamespaces\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n configuration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "query Workbench($id: ID, $name: String) {\n workbench(id: $id, name: $name) {\n ...Workbench\n __typename\n }\n}\n\nfragment Workbench on Workbench {\n ...WorkbenchTiny\n systemPrompt\n agentRuntime {\n id\n name\n allowedRepositories\n __typename\n }\n repository {\n id\n __typename\n }\n configuration {\n infrastructure {\n services\n stacks\n kubernetes\n podLogs\n vulnerabilities\n sentinels\n __typename\n }\n observability {\n logs\n metrics\n __typename\n }\n coding {\n mode\n repositories\n enableBabysitting\n __typename\n }\n __typename\n }\n modes {\n ...WorkbenchJobModesFields\n __typename\n }\n budget {\n enabled\n maximum\n minFree\n unit\n last\n lastUpdated\n __typename\n }\n skills {\n ref {\n ref\n folder\n __typename\n }\n files\n __typename\n }\n workbenchSkills(first: 500) {\n edges {\n node {\n id\n name\n description\n contents\n subagents\n __typename\n }\n __typename\n }\n __typename\n }\n workbenchKnowledge(first: 50) {\n edges {\n node {\n id\n name\n description\n knowledge\n labels\n usages\n lastUsedAt\n __typename\n }\n __typename\n }\n __typename\n }\n tools {\n ...WorkbenchTool\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n botUser {\n id\n name\n email\n profile\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTiny on Workbench {\n id\n name\n description\n agentRuntime {\n id\n name\n type\n __typename\n }\n tools {\n ...WorkbenchToolTiny\n __typename\n }\n webhooks(first: 50) {\n edges {\n node {\n ...WorkbenchWebhookTiny\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment WorkbenchWebhookTiny on WorkbenchWebhook {\n id\n name\n priority\n webhook {\n id\n type\n __typename\n }\n issueWebhook {\n ...IssueWebhookTiny\n __typename\n }\n __typename\n}\n\nfragment IssueWebhookTiny on IssueWebhook {\n id\n provider\n __typename\n}\n\nfragment WorkbenchJobModesFields on WorkbenchJobModes {\n plan\n verification\n model {\n provider\n model\n __typename\n }\n coding {\n approval\n babysit\n __typename\n }\n budget {\n cost\n tokens\n __typename\n }\n kubernetes {\n update\n delete\n exec\n excludeNamespaces\n requireNamespaces\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n configuration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, "sha256:6c3686838d1372371451664e8d6b593929eeca76e340065ed6dda5691e325b56": { "type": "query", @@ -2092,15 +2092,25 @@ "name": "WorkbenchTool", "body": "query WorkbenchTool($id: ID, $name: String) {\n workbenchTool(id: $id, name: $name) {\n ...WorkbenchTool\n __typename\n }\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n configuration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, - "sha256:6a903fe07100b682d259f0c383631b96944e2ab6f8a0c0cc432ac959ac85686e": { + "sha256:8933b288b45256584ec8a4f7267089ba97815fefe3f071d68250ee0458ee64a1": { "type": "mutation", "name": "CreateWorkbench", - "body": "mutation CreateWorkbench($attributes: WorkbenchAttributes!) {\n createWorkbench(attributes: $attributes) {\n ...Workbench\n __typename\n }\n}\n\nfragment Workbench on Workbench {\n ...WorkbenchTiny\n systemPrompt\n agentRuntime {\n id\n name\n allowedRepositories\n __typename\n }\n repository {\n id\n __typename\n }\n configuration {\n infrastructure {\n services\n stacks\n kubernetes\n podLogs\n vulnerabilities\n sentinels\n __typename\n }\n observability {\n logs\n metrics\n __typename\n }\n coding {\n mode\n repositories\n enableBabysitting\n __typename\n }\n __typename\n }\n modes {\n ...WorkbenchJobModesFields\n __typename\n }\n budget {\n enabled\n maximum\n minFree\n unit\n last\n lastUpdated\n __typename\n }\n skills {\n ref {\n ref\n folder\n __typename\n }\n files\n __typename\n }\n workbenchSkills(first: 500) {\n edges {\n node {\n id\n name\n description\n contents\n subagents\n __typename\n }\n __typename\n }\n __typename\n }\n tools {\n ...WorkbenchTool\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n botUser {\n id\n name\n email\n profile\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTiny on Workbench {\n id\n name\n description\n agentRuntime {\n id\n name\n type\n __typename\n }\n tools {\n ...WorkbenchToolTiny\n __typename\n }\n webhooks(first: 50) {\n edges {\n node {\n ...WorkbenchWebhookTiny\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment WorkbenchWebhookTiny on WorkbenchWebhook {\n id\n name\n priority\n webhook {\n id\n type\n __typename\n }\n issueWebhook {\n ...IssueWebhookTiny\n __typename\n }\n __typename\n}\n\nfragment IssueWebhookTiny on IssueWebhook {\n id\n provider\n __typename\n}\n\nfragment WorkbenchJobModesFields on WorkbenchJobModes {\n plan\n verification\n model {\n provider\n model\n __typename\n }\n coding {\n approval\n babysit\n __typename\n }\n budget {\n cost\n tokens\n __typename\n }\n kubernetes {\n update\n delete\n exec\n excludeNamespaces\n requireNamespaces\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n configuration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "mutation CreateWorkbench($attributes: WorkbenchAttributes!) {\n createWorkbench(attributes: $attributes) {\n ...Workbench\n __typename\n }\n}\n\nfragment Workbench on Workbench {\n ...WorkbenchTiny\n systemPrompt\n agentRuntime {\n id\n name\n allowedRepositories\n __typename\n }\n repository {\n id\n __typename\n }\n configuration {\n infrastructure {\n services\n stacks\n kubernetes\n podLogs\n vulnerabilities\n sentinels\n __typename\n }\n observability {\n logs\n metrics\n __typename\n }\n coding {\n mode\n repositories\n enableBabysitting\n __typename\n }\n __typename\n }\n modes {\n ...WorkbenchJobModesFields\n __typename\n }\n budget {\n enabled\n maximum\n minFree\n unit\n last\n lastUpdated\n __typename\n }\n skills {\n ref {\n ref\n folder\n __typename\n }\n files\n __typename\n }\n workbenchSkills(first: 500) {\n edges {\n node {\n id\n name\n description\n contents\n subagents\n __typename\n }\n __typename\n }\n __typename\n }\n workbenchKnowledge(first: 50) {\n edges {\n node {\n id\n name\n description\n knowledge\n labels\n usages\n lastUsedAt\n __typename\n }\n __typename\n }\n __typename\n }\n tools {\n ...WorkbenchTool\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n botUser {\n id\n name\n email\n profile\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTiny on Workbench {\n id\n name\n description\n agentRuntime {\n id\n name\n type\n __typename\n }\n tools {\n ...WorkbenchToolTiny\n __typename\n }\n webhooks(first: 50) {\n edges {\n node {\n ...WorkbenchWebhookTiny\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment WorkbenchWebhookTiny on WorkbenchWebhook {\n id\n name\n priority\n webhook {\n id\n type\n __typename\n }\n issueWebhook {\n ...IssueWebhookTiny\n __typename\n }\n __typename\n}\n\nfragment IssueWebhookTiny on IssueWebhook {\n id\n provider\n __typename\n}\n\nfragment WorkbenchJobModesFields on WorkbenchJobModes {\n plan\n verification\n model {\n provider\n model\n __typename\n }\n coding {\n approval\n babysit\n __typename\n }\n budget {\n cost\n tokens\n __typename\n }\n kubernetes {\n update\n delete\n exec\n excludeNamespaces\n requireNamespaces\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n configuration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" }, - "sha256:f749730b7e62b8a628d3a80275c0135004f2ddc57f97869c2fe391c4445da46f": { + "sha256:da7c50e3f2590f9537bd4c6fea36e10f7b703101837e43b9be3e6f4733cd2d48": { "type": "mutation", "name": "UpdateWorkbench", - "body": "mutation UpdateWorkbench($id: ID!, $attributes: WorkbenchAttributes!) {\n updateWorkbench(id: $id, attributes: $attributes) {\n ...Workbench\n __typename\n }\n}\n\nfragment Workbench on Workbench {\n ...WorkbenchTiny\n systemPrompt\n agentRuntime {\n id\n name\n allowedRepositories\n __typename\n }\n repository {\n id\n __typename\n }\n configuration {\n infrastructure {\n services\n stacks\n kubernetes\n podLogs\n vulnerabilities\n sentinels\n __typename\n }\n observability {\n logs\n metrics\n __typename\n }\n coding {\n mode\n repositories\n enableBabysitting\n __typename\n }\n __typename\n }\n modes {\n ...WorkbenchJobModesFields\n __typename\n }\n budget {\n enabled\n maximum\n minFree\n unit\n last\n lastUpdated\n __typename\n }\n skills {\n ref {\n ref\n folder\n __typename\n }\n files\n __typename\n }\n workbenchSkills(first: 500) {\n edges {\n node {\n id\n name\n description\n contents\n subagents\n __typename\n }\n __typename\n }\n __typename\n }\n tools {\n ...WorkbenchTool\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n botUser {\n id\n name\n email\n profile\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTiny on Workbench {\n id\n name\n description\n agentRuntime {\n id\n name\n type\n __typename\n }\n tools {\n ...WorkbenchToolTiny\n __typename\n }\n webhooks(first: 50) {\n edges {\n node {\n ...WorkbenchWebhookTiny\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment WorkbenchWebhookTiny on WorkbenchWebhook {\n id\n name\n priority\n webhook {\n id\n type\n __typename\n }\n issueWebhook {\n ...IssueWebhookTiny\n __typename\n }\n __typename\n}\n\nfragment IssueWebhookTiny on IssueWebhook {\n id\n provider\n __typename\n}\n\nfragment WorkbenchJobModesFields on WorkbenchJobModes {\n plan\n verification\n model {\n provider\n model\n __typename\n }\n coding {\n approval\n babysit\n __typename\n }\n budget {\n cost\n tokens\n __typename\n }\n kubernetes {\n update\n delete\n exec\n excludeNamespaces\n requireNamespaces\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n configuration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + "body": "mutation UpdateWorkbench($id: ID!, $attributes: WorkbenchAttributes!) {\n updateWorkbench(id: $id, attributes: $attributes) {\n ...Workbench\n __typename\n }\n}\n\nfragment Workbench on Workbench {\n ...WorkbenchTiny\n systemPrompt\n agentRuntime {\n id\n name\n allowedRepositories\n __typename\n }\n repository {\n id\n __typename\n }\n configuration {\n infrastructure {\n services\n stacks\n kubernetes\n podLogs\n vulnerabilities\n sentinels\n __typename\n }\n observability {\n logs\n metrics\n __typename\n }\n coding {\n mode\n repositories\n enableBabysitting\n __typename\n }\n __typename\n }\n modes {\n ...WorkbenchJobModesFields\n __typename\n }\n budget {\n enabled\n maximum\n minFree\n unit\n last\n lastUpdated\n __typename\n }\n skills {\n ref {\n ref\n folder\n __typename\n }\n files\n __typename\n }\n workbenchSkills(first: 500) {\n edges {\n node {\n id\n name\n description\n contents\n subagents\n __typename\n }\n __typename\n }\n __typename\n }\n workbenchKnowledge(first: 50) {\n edges {\n node {\n id\n name\n description\n knowledge\n labels\n usages\n lastUsedAt\n __typename\n }\n __typename\n }\n __typename\n }\n tools {\n ...WorkbenchTool\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n botUser {\n id\n name\n email\n profile\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTiny on Workbench {\n id\n name\n description\n agentRuntime {\n id\n name\n type\n __typename\n }\n tools {\n ...WorkbenchToolTiny\n __typename\n }\n webhooks(first: 50) {\n edges {\n node {\n ...WorkbenchWebhookTiny\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment WorkbenchToolTiny on WorkbenchTool {\n id\n name\n tool\n categories\n approval\n cloudConnection {\n ...CloudConnectionTiny\n __typename\n }\n mcpServer {\n id\n name\n url\n __typename\n }\n __typename\n}\n\nfragment CloudConnectionTiny on CloudConnection {\n id\n name\n provider\n __typename\n}\n\nfragment WorkbenchWebhookTiny on WorkbenchWebhook {\n id\n name\n priority\n webhook {\n id\n type\n __typename\n }\n issueWebhook {\n ...IssueWebhookTiny\n __typename\n }\n __typename\n}\n\nfragment IssueWebhookTiny on IssueWebhook {\n id\n provider\n __typename\n}\n\nfragment WorkbenchJobModesFields on WorkbenchJobModes {\n plan\n verification\n model {\n provider\n model\n __typename\n }\n coding {\n approval\n babysit\n __typename\n }\n budget {\n cost\n tokens\n __typename\n }\n kubernetes {\n update\n delete\n exec\n excludeNamespaces\n requireNamespaces\n __typename\n }\n __typename\n}\n\nfragment WorkbenchTool on WorkbenchTool {\n ...WorkbenchToolTiny\n scmConnection {\n id\n name\n type\n __typename\n }\n readBindings {\n ...PolicyBinding\n __typename\n }\n writeBindings {\n ...PolicyBinding\n __typename\n }\n configuration {\n http {\n url\n method\n headers {\n name\n value\n __typename\n }\n body\n inputSchema\n __typename\n }\n datadog {\n site\n __typename\n }\n elastic {\n index\n url\n username\n __typename\n }\n opensearch {\n host\n index\n awsAccessKeyId\n awsRegion\n assumeRoleArn\n usePodIdentity\n __typename\n }\n loki {\n url\n username\n tenantId\n __typename\n }\n prometheus {\n url\n username\n tenantId\n awsSigv4\n awsAccessKeyId\n awsRegion\n __typename\n }\n tempo {\n url\n username\n tenantId\n __typename\n }\n jaeger {\n url\n username\n __typename\n }\n atlassian {\n email\n url\n __typename\n }\n linear {\n url\n __typename\n }\n slack {\n url\n __typename\n }\n pagerduty {\n url\n __typename\n }\n teams {\n clientId\n tenantId\n __typename\n }\n splunk {\n url\n username\n __typename\n }\n cloudwatch {\n logGroupNames\n region\n roleArn\n roleSessionName\n __typename\n }\n azure {\n subscriptionId\n tenantId\n clientId\n prometheusUrl\n __typename\n }\n dynatrace {\n url\n __typename\n }\n sentry {\n url\n __typename\n }\n github {\n url\n toolset\n appId\n installationId\n __typename\n }\n gitlab {\n url\n __typename\n }\n bitbucket {\n url\n __typename\n }\n bitbucketDatacenter {\n url\n __typename\n }\n azureDevops {\n url\n __typename\n }\n lambda {\n lambdaArn\n description\n inputSchema\n __typename\n }\n cloudRun {\n identifier\n description\n inputSchema\n __typename\n }\n azureFunction {\n identifier\n description\n inputSchema\n __typename\n }\n docker {\n url\n provider\n proxy {\n url\n noproxy\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment PolicyBinding on PolicyBinding {\n id\n user {\n id\n name\n email\n __typename\n }\n group {\n id\n name\n __typename\n }\n __typename\n}" + }, + "sha256:9d48f13216f613d1a0265a4c8453d9ac1dfe8a3f75e1875a63d591c76afee930": { + "type": "mutation", + "name": "UpdateWorkbenchKnowledge", + "body": "mutation UpdateWorkbenchKnowledge($id: ID!, $attributes: WorkbenchKnowledgeAttributes!) {\n updateWorkbenchKnowledge(id: $id, attributes: $attributes) {\n id\n name\n description\n knowledge\n labels\n usages\n lastUsedAt\n __typename\n }\n}" + }, + "sha256:c6c811af847229047f22c330d512afdd2cc0a4c1d78be4c4fd2714bb4e6ad9cc": { + "type": "mutation", + "name": "DeleteWorkbenchKnowledge", + "body": "mutation DeleteWorkbenchKnowledge($id: ID!) {\n deleteWorkbenchKnowledge(id: $id) {\n id\n __typename\n }\n}" }, "sha256:6bdd9295cb14dc91a238b8bb12b0b9b2781f2c6789194acd1364f8fe10bb3f6a": { "type": "mutation", diff --git a/assets/src/graph/ai/agent.graphql b/assets/src/graph/ai/agent.graphql index c79f56bb0b..0b94bc8497 100644 --- a/assets/src/graph/ai/agent.graphql +++ b/assets/src/graph/ai/agent.graphql @@ -61,6 +61,10 @@ fragment AgentRuntime on AgentRuntime { name type aiProxy + model { + model + provider + } cluster { ...ClusterTiny } diff --git a/assets/src/graph/workbench.graphql b/assets/src/graph/workbench.graphql index ef4401ef20..8118e2ddd9 100644 --- a/assets/src/graph/workbench.graphql +++ b/assets/src/graph/workbench.graphql @@ -135,6 +135,19 @@ fragment Workbench on Workbench { } } } + workbenchKnowledge(first: 50) { + edges { + node { + id + name + description + knowledge + labels + usages + lastUsedAt + } + } + } tools { ...WorkbenchTool } @@ -1469,6 +1482,27 @@ mutation UpdateWorkbench($id: ID!, $attributes: WorkbenchAttributes!) { } } +mutation UpdateWorkbenchKnowledge( + $id: ID! + $attributes: WorkbenchKnowledgeAttributes! +) { + updateWorkbenchKnowledge(id: $id, attributes: $attributes) { + id + name + description + knowledge + labels + usages + lastUsedAt + } +} + +mutation DeleteWorkbenchKnowledge($id: ID!) { + deleteWorkbenchKnowledge(id: $id) { + id + } +} + mutation CreateWorkbenchEval( $workbenchId: ID! $attributes: WorkbenchEvalAttributes! diff --git a/assets/src/routes/workbenchesRoutesConsts.test.ts b/assets/src/routes/workbenchesRoutesConsts.test.ts new file mode 100644 index 0000000000..8dca6e37db --- /dev/null +++ b/assets/src/routes/workbenchesRoutesConsts.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + AI_AGENT_RUN_BACK_LABEL_PARAM, + AI_AGENT_RUN_BACK_SOURCE_PARAM, + AI_AGENT_RUN_BACK_TO_PARAM, +} from 'routes/aiRoutesConsts' +import { + getWorkbenchLaunchAbsPath, + WORKBENCH_LAUNCH_BACK_SOURCE, +} from './workbenchesRoutesConsts' + +describe('getWorkbenchLaunchAbsPath', () => { + it('returns the workbench path when no return target is provided', () => { + expect(getWorkbenchLaunchAbsPath({ workbenchId: 'wb-1' })).toBe( + '/workbenches/wb-1' + ) + }) + + it('attaches return query params for the send-to-workbench flow', () => { + const path = getWorkbenchLaunchAbsPath({ + workbenchId: 'wb-1', + backTo: '/stacks/abc/insights', + backLabel: 'Insights', + }) + const [pathname, query] = path.split('?') + const params = new URLSearchParams(query) + + expect(pathname).toBe('/workbenches/wb-1') + expect(params.get(AI_AGENT_RUN_BACK_SOURCE_PARAM)).toBe( + WORKBENCH_LAUNCH_BACK_SOURCE + ) + expect(params.get(AI_AGENT_RUN_BACK_TO_PARAM)).toBe('/stacks/abc/insights') + expect(params.get(AI_AGENT_RUN_BACK_LABEL_PARAM)).toBe('Insights') + }) +}) diff --git a/assets/src/routes/workbenchesRoutesConsts.tsx b/assets/src/routes/workbenchesRoutesConsts.tsx index 369c715c3c..ab3a9256a7 100644 --- a/assets/src/routes/workbenchesRoutesConsts.tsx +++ b/assets/src/routes/workbenchesRoutesConsts.tsx @@ -1,3 +1,9 @@ +import { + AI_AGENT_RUN_BACK_LABEL_PARAM, + AI_AGENT_RUN_BACK_SOURCE_PARAM, + AI_AGENT_RUN_BACK_TO_PARAM, +} from './aiRoutesConsts' + export const WORKBENCHES_ABS_PATH = '/workbenches' export const WORKBENCHES_TOOLS_PARAM_ID = 'toolId' @@ -47,6 +53,33 @@ export const WORKBENCHES_CREATE_ABS_PATH = `${WORKBENCHES_ABS_PATH}/${WORKBENCHE export const getWorkbenchAbsPath = (workbenchId: Nullable) => `${WORKBENCHES_ABS_PATH}/${workbenchId ?? ''}` +export const WORKBENCH_LAUNCH_BACK_SOURCE = 'send-to-workbench' + +export type WorkbenchLaunchRouteState = { + prompt?: string +} + +export const getWorkbenchLaunchAbsPath = ({ + workbenchId, + backTo, + backLabel, +}: { + workbenchId: string + backTo?: string + backLabel?: string +}) => { + const path = getWorkbenchAbsPath(workbenchId) + if (!backTo) return path + + const params = new URLSearchParams({ + [AI_AGENT_RUN_BACK_SOURCE_PARAM]: WORKBENCH_LAUNCH_BACK_SOURCE, + [AI_AGENT_RUN_BACK_TO_PARAM]: backTo, + ...(backLabel ? { [AI_AGENT_RUN_BACK_LABEL_PARAM]: backLabel } : {}), + }) + + return `${path}?${params}` +} + export const getWorkbenchCronSchedulesAbsPath = ( workbenchId: Nullable ) => diff --git a/charts/console-rapid/charts/controller-0.0.207.tgz b/charts/console-rapid/charts/controller-0.0.207.tgz index 07c9a78f60..581c025800 100644 Binary files a/charts/console-rapid/charts/controller-0.0.207.tgz and b/charts/console-rapid/charts/controller-0.0.207.tgz differ diff --git a/charts/console/charts/controller-0.0.207.tgz b/charts/console/charts/controller-0.0.207.tgz index 13a808962f..3de244f2e1 100644 Binary files a/charts/console/charts/controller-0.0.207.tgz and b/charts/console/charts/controller-0.0.207.tgz differ diff --git a/charts/controller/crds/deployments.plural.sh_clusters.yaml b/charts/controller/crds/deployments.plural.sh_clusters.yaml index 4be5f0a78d..1cffe4bc52 100644 --- a/charts/controller/crds/deployments.plural.sh_clusters.yaml +++ b/charts/controller/crds/deployments.plural.sh_clusters.yaml @@ -206,6 +206,13 @@ spec: This has to be specified to adopt the existing cluster. example: myclusterhandle type: string + mergeTags: + description: |- + MergeTags, when true, merges tags specified on this resource with the existing + tags on the tracked Console cluster instead of replacing them. Spec tags overlay + existing tags (the CR wins on key conflicts). Only applies when this Cluster is + tracking an existing Console cluster (read-only mode). Defaults to false. + type: boolean metadata: description: |- Metadata contains arbitrary JSON metadata for storing cluster-specific configuration. diff --git a/charts/controller/crds/deployments.plural.sh_flows.yaml b/charts/controller/crds/deployments.plural.sh_flows.yaml index f1aa996072..2baa031e16 100644 --- a/charts/controller/crds/deployments.plural.sh_flows.yaml +++ b/charts/controller/crds/deployments.plural.sh_flows.yaml @@ -121,6 +121,14 @@ spec: description: Icon specifies an optional image icon for the flow to apply branding or improve identification. type: string + maxPreviews: + description: |- + MaxPreviews is the maximum number of preview environments allowed for this flow. + Must be between 1 and 25. Defaults to 10 if omitted. + format: int64 + maximum: 25 + minimum: 1 + type: integer metadata: description: |- Metadata contains arbitrary JSON metadata for the flow. diff --git a/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml b/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml index 2481dad19d..5f29a021ec 100644 --- a/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml +++ b/charts/controller/crds/deployments.plural.sh_previewenvironmenttemplates.yaml @@ -110,6 +110,11 @@ spec: Name specifies the name for this preview environment template. If not provided, the name from the resource metadata will be used. type: string + previewTtl: + description: |- + PreviewTTL specifies how long preview environments created from this template should live, + as a Kubernetes duration (e.g. 1d, 12h, 30m). If omitted, the Console default is used. + type: string reconciliation: description: |- Reconciliation settings for this resource. diff --git a/config/prod.exs b/config/prod.exs index 70403c6604..5b76f1ed6c 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -83,6 +83,7 @@ config :console, Console.Cron.Scheduler, {"30 1 * * *", {Console.Cron.Jobs, :prune_notifications, []}}, {"45 1 * * *", {Console.Cron.Jobs, :prune_audits, []}}, {"0 2 * * *", {Console.Deployments.Cron, :prune_alerts, []}}, + {"1 0 * * *", {Console.Deployments.Cron, :prune_preview_environments, []}}, {"15 2 * * *", {Console.AI.Cron, :trim, []}}, {"30 2 * * *", {Console.AI.Cron, :trim_threads, []}}, {"45 2 * * *", {Console.AI.Cron, :trim_mcp_logs, []}}, @@ -92,7 +93,7 @@ config :console, Console.Cron.Scheduler, {"0 8 * * *", {Console.AI.Cron, :vectorize_stacks, []}}, {"0 9 * * *", {Console.AI.Cron, :vectorize_workbench_jobs, []}}, # {"0 10 * * *", {Console.AI.Cron, :workbench_job_knowledge_backfill, []}}, - {"*/15 * * * *", {Console.AI.Cron, :workbench_job_eval, []}}, + {"*/15 * * * *", {Console.AI.Cron, :workbench_job_eval, []}}, {"45 2 * * *", {Console.Cost.Cron, :history, []}}, {"0 3 * * *", {Console.Cost.Cron, :prune, []}}, {"15 3 * * *", {Console.AI.Cron, :trim_sentinel_runs, []}}, diff --git a/go/client/models_gen.go b/go/client/models_gen.go index 08dd116039..f95c735931 100644 --- a/go/client/models_gen.go +++ b/go/client/models_gen.go @@ -600,6 +600,8 @@ type AgentRuntime struct { AllowedRepositories []*string `json:"allowedRepositories,omitempty"` // default interval in seconds between babysit checks for runs on this runtime BabysitInterval *int64 `json:"babysitInterval,omitempty"` + // default model override for runs on this runtime + Model *WorkbenchJobModel `json:"model,omitempty"` // the cluster this runtime is running on Cluster *Cluster `json:"cluster,omitempty"` // the policy for creating runs on this runtime @@ -628,6 +630,8 @@ type AgentRuntimeAttributes struct { BabysitInterval *int64 `json:"babysitInterval,omitempty"` // the name of the scm connection to use for this runtime ScmConnection *string `json:"scmConnection,omitempty"` + // default model override for runs on this runtime + Model *WorkbenchJobModelAttributes `json:"model,omitempty"` } type AgentRuntimeConnection struct { @@ -3632,6 +3636,8 @@ type Flow struct { Metadata map[string]any `json:"metadata,omitempty"` // the git https urls of the application code repositories used in this flow Repositories []*string `json:"repositories,omitempty"` + // the maximum number of preview environments allowed for this flow (1-25, default 10) + MaxPreviews *int64 `json:"maxPreviews,omitempty"` // the agent runtime for this flow AgentRuntime *AgentRuntime `json:"agentRuntime,omitempty"` // servers that are bound to this flow @@ -3664,8 +3670,10 @@ type FlowAttributes struct { ProjectID *string `json:"projectId,omitempty"` Metadata *string `json:"metadata,omitempty"` // the agent runtime for this flow - AgentRuntimeID *string `json:"agentRuntimeId,omitempty"` - Repositories []*string `json:"repositories,omitempty"` + AgentRuntimeID *string `json:"agentRuntimeId,omitempty"` + Repositories []*string `json:"repositories,omitempty"` + // the maximum number of preview environments allowed for this flow (1-25, default 10) + MaxPreviews *int64 `json:"maxPreviews,omitempty"` ReadBindings []*PolicyBindingAttributes `json:"readBindings,omitempty"` WriteBindings []*PolicyBindingAttributes `json:"writeBindings,omitempty"` ServerAssociations []*McpServerAssociationAttributes `json:"serverAssociations,omitempty"` @@ -7196,12 +7204,14 @@ type PrVendorSpecAttributes struct { // An instance of a preview environment template type PreviewEnvironmentInstance struct { - ID string `json:"id"` - Service *ServiceDeployment `json:"service,omitempty"` - PullRequest *PullRequest `json:"pullRequest,omitempty"` - Template *PreviewEnvironmentTemplate `json:"template,omitempty"` - InsertedAt *string `json:"insertedAt,omitempty"` - UpdatedAt *string `json:"updatedAt,omitempty"` + ID string `json:"id"` + // when this preview environment instance expires + PreviewExpiresAt *string `json:"previewExpiresAt,omitempty"` + Service *ServiceDeployment `json:"service,omitempty"` + PullRequest *PullRequest `json:"pullRequest,omitempty"` + Template *PreviewEnvironmentTemplate `json:"template,omitempty"` + InsertedAt *string `json:"insertedAt,omitempty"` + UpdatedAt *string `json:"updatedAt,omitempty"` } type PreviewEnvironmentInstanceConnection struct { @@ -7216,9 +7226,11 @@ type PreviewEnvironmentInstanceEdge struct { // A template for generating preview environments type PreviewEnvironmentTemplate struct { - ID string `json:"id"` - Name string `json:"name"` - CommentTemplate *string `json:"commentTemplate,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + CommentTemplate *string `json:"commentTemplate,omitempty"` + // how long preview environments should live, in seconds + PreviewTTL *int64 `json:"previewTtl,omitempty"` Flow *Flow `json:"flow,omitempty"` ReferenceService *ServiceDeployment `json:"referenceService,omitempty"` Template *ServiceTemplate `json:"template,omitempty"` @@ -7240,6 +7252,8 @@ type PreviewEnvironmentTemplateAttributes struct { Template ServiceTemplateAttributes `json:"template"` // an scm connection id to use for PR preview comment generation ConnectionID *string `json:"connectionId,omitempty"` + // how long preview environments should live, as a kubernetes duration (e.g. 1d, 5s) + PreviewTTL *string `json:"previewTtl,omitempty"` } type PreviewEnvironmentTemplateConnection struct { @@ -9284,6 +9298,8 @@ type StackRun struct { Approval *bool `json:"approval,omitempty"` // the commit message Message *string `json:"message,omitempty"` + // the committer email of the commit that spawned this run + Committer *string `json:"committer,omitempty"` // when this run was approved ApprovedAt *string `json:"approvedAt,omitempty"` // the subdirectory you want to run the stack's commands w/in diff --git a/go/controller/api/v1alpha1/cluster_types.go b/go/controller/api/v1alpha1/cluster_types.go index 3900bdd8b1..71871cdf53 100644 --- a/go/controller/api/v1alpha1/cluster_types.go +++ b/go/controller/api/v1alpha1/cluster_types.go @@ -63,17 +63,10 @@ func (c *Cluster) SetCondition(condition metav1.Condition) { meta.SetStatusCondition(&c.Status.Conditions, condition) } -func (c *Cluster) TagUpdateAttributes() console.ClusterUpdateAttributes { - var tags []*console.TagAttributes - if len(c.Spec.Tags) > 0 { - for k, v := range c.Spec.Tags { - tags = append(tags, &console.TagAttributes{ - Name: k, - Value: v, - }) - } - slices.SortFunc(tags, func(a, b *console.TagAttributes) int { return strings.Compare(a.Name, b.Name) }) - } +// TagUpdateAttributes builds cluster update attributes from this resource. +// When MergeTags is true, existing Console tags are retained and overlaid with +// tags specified on the CR. Otherwise the CR tags replace the existing set. +func (c *Cluster) TagUpdateAttributes(existing []*console.ClusterTags) console.ClusterUpdateAttributes { var metadata *string if c.Spec.Metadata != nil { metadata = lo.ToPtr(string(c.Spec.Metadata.Raw)) @@ -81,11 +74,43 @@ func (c *Cluster) TagUpdateAttributes() console.ClusterUpdateAttributes { return console.ClusterUpdateAttributes{ Handle: c.Spec.Handle, - Tags: tags, + Tags: mergeClusterTags(existing, c.Spec.Tags, c.Spec.MergeTags), Metadata: metadata, } } +// mergeClusterTags converts spec tags to GraphQL tag attributes. +// When merge is true, existing tags are used as the base and spec tags overlay them +// (spec wins on key conflicts). When merge is false, only spec tags are used. +func mergeClusterTags(existing []*console.ClusterTags, specTags map[string]string, merge bool) []*console.TagAttributes { + tagMap := make(map[string]string) + if merge { + for _, tag := range existing { + if tag == nil { + continue + } + tagMap[tag.Name] = tag.Value + } + } + for k, v := range specTags { + tagMap[k] = v + } + + if len(tagMap) == 0 { + return nil + } + + tags := make([]*console.TagAttributes, 0, len(tagMap)) + for k, v := range tagMap { + tags = append(tags, &console.TagAttributes{ + Name: k, + Value: v, + }) + } + slices.SortFunc(tags, func(a, b *console.TagAttributes) int { return strings.Compare(a.Name, b.Name) }) + return tags +} + // ClusterSpec defines the desired state of a Cluster. // Configures cluster properties including cloud provider settings, node pools, and access controls // for continuous deployment workflows across the Plural fleet management architecture. @@ -146,6 +171,13 @@ type ClusterSpec struct { // +kubebuilder:validation:Optional Tags map[string]string `json:"tags,omitempty"` + // MergeTags, when true, merges tags specified on this resource with the existing + // tags on the tracked Console cluster instead of replacing them. Spec tags overlay + // existing tags (the CR wins on key conflicts). Only applies when this Cluster is + // tracking an existing Console cluster (read-only mode). Defaults to false. + // +kubebuilder:validation:Optional + MergeTags bool `json:"mergeTags,omitempty"` + // Metadata contains arbitrary JSON metadata for storing cluster-specific configuration. // Used for custom cluster properties and integration with external systems. // +kubebuilder:validation:Optional diff --git a/go/controller/api/v1alpha1/cluster_types_test.go b/go/controller/api/v1alpha1/cluster_types_test.go new file mode 100644 index 0000000000..81623d4217 --- /dev/null +++ b/go/controller/api/v1alpha1/cluster_types_test.go @@ -0,0 +1,144 @@ +package v1alpha1 + +import ( + "testing" + + console "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTagUpdateAttributesMergeTags(t *testing.T) { + existing := []*console.ClusterTags{ + {Name: "env", Value: "prod"}, + {Name: "team", Value: "platform"}, + } + + tests := []struct { + name string + cluster Cluster + existing []*console.ClusterTags + want []*console.TagAttributes + }{ + { + name: "merge disabled uses only spec tags", + cluster: Cluster{ + Spec: ClusterSpec{ + Handle: lo.ToPtr("cluster"), + MergeTags: false, + Tags: map[string]string{"region": "us-east"}, + }, + }, + existing: existing, + want: []*console.TagAttributes{ + {Name: "region", Value: "us-east"}, + }, + }, + { + name: "merge enabled overlays spec tags onto existing tags", + cluster: Cluster{ + Spec: ClusterSpec{ + Handle: lo.ToPtr("cluster"), + MergeTags: true, + Tags: map[string]string{"team": "infra", "region": "us-east"}, + }, + }, + existing: existing, + want: []*console.TagAttributes{ + {Name: "env", Value: "prod"}, + {Name: "region", Value: "us-east"}, + {Name: "team", Value: "infra"}, + }, + }, + { + name: "merge enabled with empty spec tags preserves existing tags", + cluster: Cluster{ + Spec: ClusterSpec{ + Handle: lo.ToPtr("cluster"), + MergeTags: true, + }, + }, + existing: existing, + want: []*console.TagAttributes{ + {Name: "env", Value: "prod"}, + {Name: "team", Value: "platform"}, + }, + }, + { + name: "merge enabled with nil existing uses spec tags", + cluster: Cluster{ + Spec: ClusterSpec{ + Handle: lo.ToPtr("cluster"), + MergeTags: true, + Tags: map[string]string{"region": "us-east"}, + }, + }, + existing: nil, + want: []*console.TagAttributes{ + {Name: "region", Value: "us-east"}, + }, + }, + { + name: "merge enabled skips nil existing entries", + cluster: Cluster{ + Spec: ClusterSpec{ + Handle: lo.ToPtr("cluster"), + MergeTags: true, + Tags: map[string]string{"region": "us-east"}, + }, + }, + existing: []*console.ClusterTags{ + nil, + {Name: "env", Value: "prod"}, + }, + want: []*console.TagAttributes{ + {Name: "env", Value: "prod"}, + {Name: "region", Value: "us-east"}, + }, + }, + { + name: "no tags returns nil", + cluster: Cluster{ + Spec: ClusterSpec{ + Handle: lo.ToPtr("cluster"), + MergeTags: true, + }, + }, + existing: nil, + want: nil, + }, + { + name: "merge disabled with empty spec tags does not send existing tags", + cluster: Cluster{ + Spec: ClusterSpec{ + Handle: lo.ToPtr("cluster"), + MergeTags: false, + }, + }, + existing: existing, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cluster.TagUpdateAttributes(tt.existing) + assert.Equal(t, tt.cluster.Spec.Handle, got.Handle) + require.Equal(t, tt.want, got.Tags) + }) + } +} + +func TestMergeClusterTagsSortsByName(t *testing.T) { + tags := mergeClusterTags(nil, map[string]string{ + "z-last": "1", + "a-first": "2", + "m-mid": "3", + }, false) + + require.Len(t, tags, 3) + assert.Equal(t, "a-first", tags[0].Name) + assert.Equal(t, "m-mid", tags[1].Name) + assert.Equal(t, "z-last", tags[2].Name) +} diff --git a/go/controller/api/v1alpha1/flow_types.go b/go/controller/api/v1alpha1/flow_types.go index ac29b462a1..13d40d51b2 100644 --- a/go/controller/api/v1alpha1/flow_types.go +++ b/go/controller/api/v1alpha1/flow_types.go @@ -113,6 +113,13 @@ type FlowSpec struct { // +kubebuilder:validation:Optional AgentRuntime *AgentRuntimeRef `json:"agentRuntime,omitempty"` + // MaxPreviews is the maximum number of preview environments allowed for this flow. + // Must be between 1 and 25. Defaults to 10 if omitted. + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=25 + MaxPreviews *int64 `json:"maxPreviews,omitempty"` + // Reconciliation settings for this resource. // Controls drift detection and reconciliation intervals. // +kubebuilder:validation:Optional diff --git a/go/controller/api/v1alpha1/previewenvironmenttemplate_types.go b/go/controller/api/v1alpha1/previewenvironmenttemplate_types.go index 2d413d69b6..7f084fb7e6 100644 --- a/go/controller/api/v1alpha1/previewenvironmenttemplate_types.go +++ b/go/controller/api/v1alpha1/previewenvironmenttemplate_types.go @@ -103,6 +103,11 @@ type PreviewEnvironmentTemplateSpec struct { // +kubebuilder:validation:Optional CommentTemplate *string `json:"commentTemplate,omitempty"` + // PreviewTTL specifies how long preview environments created from this template should live, + // as a Kubernetes duration (e.g. 1d, 12h, 30m). If omitted, the Console default is used. + // +kubebuilder:validation:Optional + PreviewTTL *string `json:"previewTtl,omitempty"` + // ScmConnectionRef references the source control management connection to use for PR operations. // This connection is used to post comments on pull requests with preview environment information // and to trigger environment creation based on PR events. diff --git a/go/controller/api/v1alpha1/zz_generated.deepcopy.go b/go/controller/api/v1alpha1/zz_generated.deepcopy.go index 421296042f..6d1a6bfbca 100644 --- a/go/controller/api/v1alpha1/zz_generated.deepcopy.go +++ b/go/controller/api/v1alpha1/zz_generated.deepcopy.go @@ -2917,6 +2917,11 @@ func (in *FlowSpec) DeepCopyInto(out *FlowSpec) { *out = new(AgentRuntimeRef) **out = **in } + if in.MaxPreviews != nil { + in, out := &in.MaxPreviews, &out.MaxPreviews + *out = new(int64) + **out = **in + } if in.Reconciliation != nil { in, out := &in.Reconciliation, &out.Reconciliation *out = new(Reconciliation) @@ -7570,6 +7575,11 @@ func (in *PreviewEnvironmentTemplateSpec) DeepCopyInto(out *PreviewEnvironmentTe *out = new(string) **out = **in } + if in.PreviewTTL != nil { + in, out := &in.PreviewTTL, &out.PreviewTTL + *out = new(string) + **out = **in + } if in.ScmConnectionRef != nil { in, out := &in.ScmConnectionRef, &out.ScmConnectionRef *out = new(v1.ObjectReference) diff --git a/go/controller/config/crd/bases/deployments.plural.sh_clusters.yaml b/go/controller/config/crd/bases/deployments.plural.sh_clusters.yaml index 4be5f0a78d..1cffe4bc52 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_clusters.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_clusters.yaml @@ -206,6 +206,13 @@ spec: This has to be specified to adopt the existing cluster. example: myclusterhandle type: string + mergeTags: + description: |- + MergeTags, when true, merges tags specified on this resource with the existing + tags on the tracked Console cluster instead of replacing them. Spec tags overlay + existing tags (the CR wins on key conflicts). Only applies when this Cluster is + tracking an existing Console cluster (read-only mode). Defaults to false. + type: boolean metadata: description: |- Metadata contains arbitrary JSON metadata for storing cluster-specific configuration. diff --git a/go/controller/config/crd/bases/deployments.plural.sh_flows.yaml b/go/controller/config/crd/bases/deployments.plural.sh_flows.yaml index f1aa996072..2baa031e16 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_flows.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_flows.yaml @@ -121,6 +121,14 @@ spec: description: Icon specifies an optional image icon for the flow to apply branding or improve identification. type: string + maxPreviews: + description: |- + MaxPreviews is the maximum number of preview environments allowed for this flow. + Must be between 1 and 25. Defaults to 10 if omitted. + format: int64 + maximum: 25 + minimum: 1 + type: integer metadata: description: |- Metadata contains arbitrary JSON metadata for the flow. diff --git a/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml b/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml index 2481dad19d..5f29a021ec 100644 --- a/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml +++ b/go/controller/config/crd/bases/deployments.plural.sh_previewenvironmenttemplates.yaml @@ -110,6 +110,11 @@ spec: Name specifies the name for this preview environment template. If not provided, the name from the resource metadata will be used. type: string + previewTtl: + description: |- + PreviewTTL specifies how long preview environments created from this template should live, + as a Kubernetes duration (e.g. 1d, 12h, 30m). If omitted, the Console default is used. + type: string reconciliation: description: |- Reconciliation settings for this resource. diff --git a/go/controller/config/samples/deployments_v1alpha1_flow.yaml b/go/controller/config/samples/deployments_v1alpha1_flow.yaml index 31959fab17..57caa0649e 100644 --- a/go/controller/config/samples/deployments_v1alpha1_flow.yaml +++ b/go/controller/config/samples/deployments_v1alpha1_flow.yaml @@ -11,6 +11,7 @@ metadata: spec: name: test description: "test flow" + maxPreviews: 10 bindings: read: - userEmail: marcin@plural.sh diff --git a/go/controller/config/samples/deployments_v1alpha1_previewenvironmenttemplate.yaml b/go/controller/config/samples/deployments_v1alpha1_previewenvironmenttemplate.yaml index addc288d02..159ba63805 100644 --- a/go/controller/config/samples/deployments_v1alpha1_previewenvironmenttemplate.yaml +++ b/go/controller/config/samples/deployments_v1alpha1_previewenvironmenttemplate.yaml @@ -65,6 +65,7 @@ metadata: spec: template: namespace: default + previewTtl: 7d flowRef: name: flow-template namespace: default diff --git a/go/controller/docs/api.md b/go/controller/docs/api.md index ba89aeef47..ae75c3e2b9 100644 --- a/go/controller/docs/api.md +++ b/go/controller/docs/api.md @@ -940,6 +940,7 @@ _Appears in:_ | `cloud` _string_ | Cloud specifies the cloud provider to use for this cluster.
Determines the infrastructure platform where the cluster will be provisioned and managed.
For BYOK clusters, this field is set to "byok" and no cloud provider is required.
Deprecated.
Do not use. | | Enum: [aws azure gcp byok]
Optional: \{\}
Type: string
| | `protect` _boolean_ | Protect prevents accidental deletion of this cluster.
When enabled, the cluster cannot be deleted through the Console UI or API.
Deprecated.
Do not use. | | Optional: \{\}
| | `tags` _object (keys:string, values:string)_ | Tags are key-value pairs used to categorize and filter clusters in fleet management.
Used for organizing clusters by environment, team, or other operational criteria. | | Optional: \{\}
| +| `mergeTags` _boolean_ | MergeTags, when true, merges tags specified on this resource with the existing
tags on the tracked Console cluster instead of replacing them. Spec tags overlay
existing tags (the CR wins on key conflicts). Only applies when this Cluster is
tracking an existing Console cluster (read-only mode). Defaults to false. | | Optional: \{\}
| | `metadata` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#rawextension-runtime-pkg)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | Optional: \{\}
| | `bindings` _[Bindings](#bindings)_ | Bindings contain read and write access policies for this cluster.
Controls which users and groups can view or manage this cluster through RBAC. | | Optional: \{\}
| | `cloudSettings` _[ClusterCloudSettings](#clustercloudsettings)_ | CloudSettings contains cloud provider-specific configuration for this cluster.
Deprecated.
Do not use. | | Optional: \{\}
| @@ -1653,6 +1654,7 @@ _Appears in:_ | `workbenchAssociations` _[FlowWorkbenchAssociation](#flowworkbenchassociation) array_ | WorkbenchAssociations contains a list of workbenches you wish to associate with this flow. | | Optional: \{\}
| | `metadata` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#rawextension-runtime-pkg)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | Optional: \{\}
| | `agentRuntime` _[AgentRuntimeRef](#agentruntimeref)_ | AgentRuntime references the agent runtime to use for this flow by cluster handle and runtime name.
The controller resolves this to an agent runtime ID when syncing to the Console API. | | Optional: \{\}
| +| `maxPreviews` _integer_ | MaxPreviews is the maximum number of preview environments allowed for this flow.
Must be between 1 and 25. Defaults to 10 if omitted. | | Maximum: 25
Minimum: 1
Optional: \{\}
| | `reconciliation` _[Reconciliation](#reconciliation)_ | Reconciliation settings for this resource.
Controls drift detection and reconciliation intervals. | | Optional: \{\}
| @@ -4170,6 +4172,7 @@ _Appears in:_ | --- | --- | --- | --- | | `name` _string_ | Name specifies the name for this preview environment template.
If not provided, the name from the resource metadata will be used. | | Optional: \{\}
| | `commentTemplate` _string_ | CommentTemplate provides a liquid template for generating custom PR comments.
This template can include dynamic information about the preview environment such as
URLs, deployment status, or custom instructions for reviewers. Variables from the
service template and environment can be interpolated into the comment. | | Optional: \{\}
| +| `previewTtl` _string_ | PreviewTTL specifies how long preview environments created from this template should live,
as a Kubernetes duration (e.g. 1d, 12h, 30m). If omitted, the Console default is used. | | Optional: \{\}
| | `scmConnectionRef` _[ObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectreference-v1-core)_ | ScmConnectionRef references the source control management connection to use for PR operations.
This connection is used to post comments on pull requests with preview environment information
and to trigger environment creation based on PR events. | | Optional: \{\}
| | `referenceServiceRef` _[ObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectreference-v1-core)_ | ReferenceServiceRef specifies the existing service deployment to use as a template.
This service will be cloned and customized according to the Template configuration
to create preview environments. The referenced service should be a stable, working
deployment that represents the base configuration for preview environments. | | Required: \{\}
| | `flowRef` _[ObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectreference-v1-core)_ | FlowRef references the flow that owns and manages this preview environment template.
The flow defines the overall workflow and permissions for creating and managing
preview environments based on this template. | | Required: \{\}
| diff --git a/go/controller/internal/controller/cluster_controller.go b/go/controller/internal/controller/cluster_controller.go index f4a2e38239..241796700b 100644 --- a/go/controller/internal/controller/cluster_controller.go +++ b/go/controller/internal/controller/cluster_controller.go @@ -132,7 +132,7 @@ func (r *ClusterReconciler) handleExisting(cluster *v1alpha1.Cluster) (ctrl.Resu } // Calculate SHA to detect changes that should be applied in the Console API. - attrs, err := r.Attributes(cluster) + attrs, err := r.Attributes(cluster, apiCluster) if err != nil { return common.HandleRequeue(nil, err, cluster.SetCondition) } @@ -166,8 +166,12 @@ func (r *ClusterReconciler) handleExisting(cluster *v1alpha1.Cluster) (ctrl.Resu return cluster.Spec.Reconciliation.Requeue(), nil } -func (r *ClusterReconciler) Attributes(cluster *v1alpha1.Cluster) (*console.ClusterUpdateAttributes, error) { - tagAttributes := cluster.TagUpdateAttributes() +func (r *ClusterReconciler) Attributes(cluster *v1alpha1.Cluster, apiCluster *console.ClusterFragment) (*console.ClusterUpdateAttributes, error) { + var existingTags []*console.ClusterTags + if apiCluster != nil { + existingTags = apiCluster.Tags + } + tagAttributes := cluster.TagUpdateAttributes(existingTags) var readBindings, writeBindings []*console.PolicyBindingAttributes var err error diff --git a/go/controller/internal/controller/cluster_controller_attributes_test.go b/go/controller/internal/controller/cluster_controller_attributes_test.go new file mode 100644 index 0000000000..f1f5e98534 --- /dev/null +++ b/go/controller/internal/controller/cluster_controller_attributes_test.go @@ -0,0 +1,101 @@ +package controller_test + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/controller/api/v1alpha1" + "github.com/pluralsh/console/go/controller/internal/controller" +) + +func TestClusterReconcilerAttributesMergeTags(t *testing.T) { + existing := []*gqlclient.ClusterTags{ + {Name: "env", Value: "prod"}, + {Name: "team", Value: "platform"}, + } + apiCluster := &gqlclient.ClusterFragment{ + ID: "cluster-id", + Tags: existing, + } + reconciler := &controller.ClusterReconciler{} + + t.Run("merges existing tags with spec tags when mergeTags is true", func(t *testing.T) { + cluster := &v1alpha1.Cluster{ + Spec: v1alpha1.ClusterSpec{ + Handle: lo.ToPtr("tracked"), + MergeTags: true, + Tags: map[string]string{ + "team": "infra", + "region": "us-east", + }, + }, + } + + attrs, err := reconciler.Attributes(cluster, apiCluster) + require.NoError(t, err) + require.NotNil(t, attrs) + assert.Equal(t, map[string]string{ + "env": "prod", + "team": "infra", + "region": "us-east", + }, tagMap(attrs.Tags)) + }) + + t.Run("replaces tags when mergeTags is false", func(t *testing.T) { + cluster := &v1alpha1.Cluster{ + Spec: v1alpha1.ClusterSpec{ + Handle: lo.ToPtr("tracked"), + Tags: map[string]string{ + "region": "us-east", + }, + }, + } + + attrs, err := reconciler.Attributes(cluster, apiCluster) + require.NoError(t, err) + require.NotNil(t, attrs) + assert.Equal(t, map[string]string{ + "region": "us-east", + }, tagMap(attrs.Tags)) + }) + + t.Run("preserves existing tags when mergeTags is true and spec tags are empty", func(t *testing.T) { + cluster := &v1alpha1.Cluster{ + Spec: v1alpha1.ClusterSpec{ + Handle: lo.ToPtr("tracked"), + MergeTags: true, + }, + } + + attrs, err := reconciler.Attributes(cluster, apiCluster) + require.NoError(t, err) + require.NotNil(t, attrs) + assert.Equal(t, map[string]string{ + "env": "prod", + "team": "platform", + }, tagMap(attrs.Tags)) + }) + + t.Run("uses spec tags when mergeTags is true but api cluster is nil", func(t *testing.T) { + cluster := &v1alpha1.Cluster{ + Spec: v1alpha1.ClusterSpec{ + Handle: lo.ToPtr("tracked"), + MergeTags: true, + Tags: map[string]string{ + "region": "us-east", + }, + }, + } + + attrs, err := reconciler.Attributes(cluster, nil) + require.NoError(t, err) + require.NotNil(t, attrs) + assert.Equal(t, map[string]string{ + "region": "us-east", + }, tagMap(attrs.Tags)) + }) +} diff --git a/go/controller/internal/controller/cluster_controller_test.go b/go/controller/internal/controller/cluster_controller_test.go index f946713c1b..5991f9cccc 100644 --- a/go/controller/internal/controller/cluster_controller_test.go +++ b/go/controller/internal/controller/cluster_controller_test.go @@ -226,3 +226,189 @@ var _ = Describe("Cluster Controller", Ordered, func() { }) }) }) + +func tagMap(tags []*gqlclient.TagAttributes) map[string]string { + result := map[string]string{} + for _, tag := range tags { + if tag == nil { + continue + } + result[tag.Name] = tag.Value + } + return result +} + +var _ = Describe("Cluster Controller mergeTags", Ordered, func() { + Context("when reconciling a tracked cluster", func() { + const ( + mergeTagsClusterName = "merge-tags-cluster" + mergeTagsClusterConsoleID = "merge-tags-cluster-console-id" + replaceTagsClusterName = "replace-tags-cluster" + replaceTagsClusterConsoleID = "replace-tags-cluster-console-id" + preserveTagsClusterName = "preserve-tags-cluster" + preserveTagsClusterConsoleID = "preserve-tags-cluster-console-id" + ) + + ctx := context.Background() + mergeTagsNamespacedName := types.NamespacedName{Name: mergeTagsClusterName, Namespace: namespace} + replaceTagsNamespacedName := types.NamespacedName{Name: replaceTagsClusterName, Namespace: namespace} + preserveTagsNamespacedName := types.NamespacedName{Name: preserveTagsClusterName, Namespace: namespace} + + existingAPITags := []*gqlclient.ClusterTags{ + {Name: "env", Value: "prod"}, + {Name: "team", Value: "platform"}, + } + + BeforeAll(func() { + By("Creating cluster that merges tags with the tracked Console cluster") + Expect(common.MaybeCreate(k8sClient, &v1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: mergeTagsClusterName, + Namespace: namespace, + }, + Spec: v1alpha1.ClusterSpec{ + Handle: lo.ToPtr(mergeTagsClusterName), + Cloud: "byok", + MergeTags: true, + Tags: map[string]string{ + "team": "infra", + "region": "us-east", + }, + }, + }, nil)).To(Succeed()) + + By("Creating cluster that replaces tags on the tracked Console cluster") + Expect(common.MaybeCreate(k8sClient, &v1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: replaceTagsClusterName, + Namespace: namespace, + }, + Spec: v1alpha1.ClusterSpec{ + Handle: lo.ToPtr(replaceTagsClusterName), + Cloud: "byok", + Tags: map[string]string{ + "region": "us-east", + }, + }, + }, nil)).To(Succeed()) + + By("Creating cluster that preserves existing Console tags when the CR specifies none") + Expect(common.MaybeCreate(k8sClient, &v1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: preserveTagsClusterName, + Namespace: namespace, + }, + Spec: v1alpha1.ClusterSpec{ + Handle: lo.ToPtr(preserveTagsClusterName), + Cloud: "byok", + MergeTags: true, + }, + }, nil)).To(Succeed()) + }) + + AfterAll(func() { + By("Cleanup merge and replace tag clusters") + for _, name := range []types.NamespacedName{mergeTagsNamespacedName, replaceTagsNamespacedName, preserveTagsNamespacedName} { + cluster := &v1alpha1.Cluster{} + Expect(k8sClient.Get(ctx, name, cluster)).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, cluster)).To(Succeed()) + } + }) + + It("should merge existing Console tags with CR tags when mergeTags is true", func() { + var captured gqlclient.ClusterUpdateAttributes + fakeConsoleClient := mocks.NewConsoleClientMock(mocks.TestingT) + fakeConsoleClient.On("UseCredentials", mock.Anything, mock.Anything).Return("", nil) + fakeConsoleClient.On("GetClusterByHandle", mock.AnythingOfType("*string")).Return(&gqlclient.ClusterFragment{ + ID: mergeTagsClusterConsoleID, + CurrentVersion: lo.ToPtr("1.24.11"), + Tags: existingAPITags, + }, nil) + fakeConsoleClient.On("UpdateCluster", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + captured = args.Get(1).(gqlclient.ClusterUpdateAttributes) + }).Return(nil, nil) + + controllerReconciler := &controller.ClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConsoleClient: fakeConsoleClient, + CredentialsCache: credentials.FakeNamespaceCredentialsCache(k8sClient), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: mergeTagsNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(tagMap(captured.Tags)).To(Equal(map[string]string{ + "env": "prod", + "team": "infra", + "region": "us-east", + })) + + cluster := &v1alpha1.Cluster{} + Expect(k8sClient.Get(ctx, mergeTagsNamespacedName, cluster)).NotTo(HaveOccurred()) + Expect(cluster.Status.ID).To(Equal(lo.ToPtr(mergeTagsClusterConsoleID))) + Expect(cluster.Status.ReadOnly).To(BeTrue()) + fakeConsoleClient.AssertCalled(GinkgoT(), "UpdateCluster", mergeTagsClusterConsoleID, mock.Anything) + }) + + It("should replace tags when mergeTags is false", func() { + var captured gqlclient.ClusterUpdateAttributes + fakeConsoleClient := mocks.NewConsoleClientMock(mocks.TestingT) + fakeConsoleClient.On("UseCredentials", mock.Anything, mock.Anything).Return("", nil) + fakeConsoleClient.On("GetClusterByHandle", mock.AnythingOfType("*string")).Return(&gqlclient.ClusterFragment{ + ID: replaceTagsClusterConsoleID, + CurrentVersion: lo.ToPtr("1.24.11"), + Tags: existingAPITags, + }, nil) + fakeConsoleClient.On("UpdateCluster", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + captured = args.Get(1).(gqlclient.ClusterUpdateAttributes) + }).Return(nil, nil) + + controllerReconciler := &controller.ClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConsoleClient: fakeConsoleClient, + CredentialsCache: credentials.FakeNamespaceCredentialsCache(k8sClient), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: replaceTagsNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(tagMap(captured.Tags)).To(Equal(map[string]string{ + "region": "us-east", + })) + + cluster := &v1alpha1.Cluster{} + Expect(k8sClient.Get(ctx, replaceTagsNamespacedName, cluster)).NotTo(HaveOccurred()) + Expect(cluster.Status.ID).To(Equal(lo.ToPtr(replaceTagsClusterConsoleID))) + Expect(cluster.Status.ReadOnly).To(BeTrue()) + fakeConsoleClient.AssertCalled(GinkgoT(), "UpdateCluster", replaceTagsClusterConsoleID, mock.Anything) + }) + + It("should preserve existing Console tags when mergeTags is true and the CR has no tags", func() { + var captured gqlclient.ClusterUpdateAttributes + fakeConsoleClient := mocks.NewConsoleClientMock(mocks.TestingT) + fakeConsoleClient.On("UseCredentials", mock.Anything, mock.Anything).Return("", nil) + fakeConsoleClient.On("GetClusterByHandle", mock.AnythingOfType("*string")).Return(&gqlclient.ClusterFragment{ + ID: preserveTagsClusterConsoleID, + CurrentVersion: lo.ToPtr("1.24.11"), + Tags: existingAPITags, + }, nil) + fakeConsoleClient.On("UpdateCluster", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + captured = args.Get(1).(gqlclient.ClusterUpdateAttributes) + }).Return(nil, nil) + + controllerReconciler := &controller.ClusterReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConsoleClient: fakeConsoleClient, + CredentialsCache: credentials.FakeNamespaceCredentialsCache(k8sClient), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: preserveTagsNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + Expect(tagMap(captured.Tags)).To(Equal(map[string]string{ + "env": "prod", + "team": "platform", + })) + }) + }) +}) diff --git a/go/controller/internal/controller/flow_controller.go b/go/controller/internal/controller/flow_controller.go index 6669bd27a9..8a26a43c45 100644 --- a/go/controller/internal/controller/flow_controller.go +++ b/go/controller/internal/controller/flow_controller.go @@ -159,6 +159,7 @@ func (r *FlowReconciler) Attributes( FlowWorkbenches: workbenchAssociations, Repositories: lo.ToSlicePtr(flow.Spec.Repositories), AgentRuntimeID: agentRuntimeID, + MaxPreviews: flow.Spec.MaxPreviews, } if flow.Spec.Metadata != nil && len(flow.Spec.Metadata.Raw) > 0 { diff --git a/go/controller/internal/controller/flow_controller_test.go b/go/controller/internal/controller/flow_controller_test.go index f8b65f26b3..f22a38700a 100644 --- a/go/controller/internal/controller/flow_controller_test.go +++ b/go/controller/internal/controller/flow_controller_test.go @@ -217,6 +217,33 @@ var _ = Describe("Flow Controller", Ordered, func() { Expect(err).NotTo(HaveOccurred()) }) + It("should include maxPreviews in flow attributes", func() { + Expect(common.MaybePatchObject(k8sClient, &v1alpha1.Flow{ + ObjectMeta: metav1.ObjectMeta{Name: flowName, Namespace: namespace}, + }, func(p *v1alpha1.Flow) { + p.Spec.MaxPreviews = lo.ToPtr(int64(5)) + })).To(Succeed()) + + flowFragment := &gqlclient.FlowFragment{ID: id} + fakeConsoleClient := mocks.NewConsoleClientMock(mocks.TestingT) + fakeConsoleClient.On("UseCredentials", mock.Anything, mock.Anything).Return("", nil) + fakeConsoleClient.On("GetFlow", mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.NewNotFound(schema.GroupResource{}, id)) + fakeConsoleClient. + On("UpsertFlow", mock.Anything, mock.MatchedBy(func(attrs gqlclient.FlowAttributes) bool { + return attrs.MaxPreviews != nil && *attrs.MaxPreviews == 5 + })). + Return(flowFragment, nil) + + reconciler := &controller.FlowReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConsoleClient: fakeConsoleClient, + } + + _, err := reconciler.Process(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + }) + It("should successfully reconcile the resource", func() { By("Delete resource") Expect(common.MaybePatch(k8sClient, &v1alpha1.Flow{ diff --git a/go/controller/internal/controller/previewenvironmenttemplate_controller.go b/go/controller/internal/controller/previewenvironmenttemplate_controller.go index 505d2e70e8..625c50e56b 100644 --- a/go/controller/internal/controller/previewenvironmenttemplate_controller.go +++ b/go/controller/internal/controller/previewenvironmenttemplate_controller.go @@ -153,6 +153,7 @@ func getAttributes(ctx context.Context, kubeClient client.Client, template v1alp attr := &console.PreviewEnvironmentTemplateAttributes{ Name: template.ConsoleName(), CommentTemplate: template.Spec.CommentTemplate, + PreviewTTL: template.Spec.PreviewTTL, } sta, err := common.ServiceTemplateAttributes(ctx, kubeClient, template.Namespace, &template.Spec.Template, nil) if err != nil { diff --git a/go/controller/internal/controller/previewenvironmenttemplate_controller_test.go b/go/controller/internal/controller/previewenvironmenttemplate_controller_test.go index d7092acec1..9d510f6100 100644 --- a/go/controller/internal/controller/previewenvironmenttemplate_controller_test.go +++ b/go/controller/internal/controller/previewenvironmenttemplate_controller_test.go @@ -153,6 +153,31 @@ var _ = Describe("PreviewEnvironmentTemplate Controller", Ordered, func() { Expect(common.SanitizeStatusConditions(f.Status)).To(Equal(common.SanitizeStatusConditions(test.expectedStatus))) }) + It("should include previewTtl in preview environment template attributes", func() { + Expect(common.MaybePatchObject(k8sClient, &v1alpha1.PreviewEnvironmentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + }, func(p *v1alpha1.PreviewEnvironmentTemplate) { + p.Spec.PreviewTTL = lo.ToPtr("1d") + })).To(Succeed()) + + fragment := &gqlclient.PreviewEnvironmentTemplateFragment{ID: id} + fakeConsoleClient := mocks.NewConsoleClientMock(mocks.TestingT) + fakeConsoleClient. + On("UpsertPreviewEnvironmentTemplate", mock.Anything, mock.MatchedBy(func(attrs gqlclient.PreviewEnvironmentTemplateAttributes) bool { + return attrs.PreviewTTL != nil && *attrs.PreviewTTL == "1d" + })). + Return(fragment, nil) + + reconciler := &controller.PreviewEnvironmentTemplateReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + ConsoleClient: fakeConsoleClient, + } + + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + }) + It("should successfully reconcile the resource", func() { By("Delete resource") Expect(common.MaybePatch(k8sClient, &v1alpha1.PreviewEnvironmentTemplate{ diff --git a/go/deployment-operator/api/v1alpha1/agentruntime_model.go b/go/deployment-operator/api/v1alpha1/agentruntime_model.go new file mode 100644 index 0000000000..99c1c6bb49 --- /dev/null +++ b/go/deployment-operator/api/v1alpha1/agentruntime_model.go @@ -0,0 +1,114 @@ +package v1alpha1 + +import ( + "strings" + + console "github.com/pluralsh/console/go/client" + "github.com/samber/lo" +) + +// modelAttributes infers Console model attribution from the runtime's configured +// model. This is only possible when aiProxy is enabled, because that is the only +// path that fully routes LLM calls through Plural. +// +// Provider and slug come from `{provider}/{slug}` when present. A bare model id +// uses the same default provider mapping as the AI proxy harness. +func (in *AgentRuntime) modelAttributes() *console.WorkbenchJobModelAttributes { + if !in.IsAiProxyEnabled() { + return nil + } + + model := strings.TrimSpace(in.configuredModel()) + if model == "" { + return nil + } + + providerSlug, modelSlug := splitConfiguredModel(model) + if modelSlug == "" { + return nil + } + if providerSlug == "" { + providerSlug = defaultModelProvider(in.Spec.Type) + } + if providerSlug == "" { + return nil + } + + provider, ok := aiProviderFromSlug(providerSlug) + if !ok { + return nil + } + + return &console.WorkbenchJobModelAttributes{ + Provider: provider, + Model: modelSlug, + } +} + +func (in *AgentRuntime) configuredModel() string { + if in == nil || in.Spec.Config == nil { + return "" + } + + switch in.Spec.Type { + case console.AgentRuntimeTypeClaude: + return lo.FromPtr(lo.FromPtr(in.Spec.Config.Claude).Model) + case console.AgentRuntimeTypeOpencode: + return lo.FromPtr(lo.FromPtr(in.Spec.Config.OpenCode).Model) + case console.AgentRuntimeTypeGemini: + return lo.FromPtr(lo.FromPtr(in.Spec.Config.Gemini).Model) + case console.AgentRuntimeTypeCodex: + return lo.FromPtr(lo.FromPtr(in.Spec.Config.Codex).Model) + case console.AgentRuntimeTypePi: + return lo.FromPtr(lo.FromPtr(in.Spec.Config.Pi).Model) + default: + return "" + } +} + +func splitConfiguredModel(model string) (provider, slug string) { + provider, slug, found := strings.Cut(strings.TrimSpace(model), "/") + if !found { + return "", strings.TrimSpace(model) + } + + return strings.TrimSpace(provider), strings.TrimSpace(slug) +} + +// defaultModelProvider matches pkg/agentrun-harness/model.ProxyProvider. +func defaultModelProvider(runtimeType console.AgentRuntimeType) string { + switch runtimeType { + case console.AgentRuntimeTypeClaude: + return "anthropic" + case console.AgentRuntimeTypeCodex, console.AgentRuntimeTypeOpencode, console.AgentRuntimeTypePi: + return "openai" + case console.AgentRuntimeTypeGemini: + return "vertex" + default: + return "" + } +} + +func aiProviderFromSlug(slug string) (console.AiProvider, bool) { + normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(slug), "-", "_")) + switch normalized { + case "openai": + return console.AiProviderOpenai, true + case "anthropic": + return console.AiProviderAnthropic, true + case "ollama": + return console.AiProviderOllama, true + case "azure": + return console.AiProviderAzure, true + case "bedrock": + return console.AiProviderBedrock, true + case "vertex": + return console.AiProviderVertex, true + case "openai_compatible": + return console.AiProviderOpenaiCompatible, true + case "xai": + return console.AiProviderXai, true + default: + return "", false + } +} diff --git a/go/deployment-operator/api/v1alpha1/agentruntime_model_test.go b/go/deployment-operator/api/v1alpha1/agentruntime_model_test.go new file mode 100644 index 0000000000..1aa6880de8 --- /dev/null +++ b/go/deployment-operator/api/v1alpha1/agentruntime_model_test.go @@ -0,0 +1,227 @@ +package v1alpha1 + +import ( + "encoding/json" + "testing" + + console "github.com/pluralsh/console/go/client" + proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" + "github.com/samber/lo" +) + +func TestAgentRuntimeAttributesModel(t *testing.T) { + tests := []struct { + name string + runtime *AgentRuntime + want *console.WorkbenchJobModelAttributes + }{ + { + name: "omits model when aiProxy is disabled", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeClaude, false, &AgentRuntimeConfig{ + Claude: &ClaudeConfig{Model: lo.ToPtr("claude-sonnet-4-5")}, + }), + want: nil, + }, + { + name: "omits model when none is configured", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeCodex, true, &AgentRuntimeConfig{ + Codex: &CodexConfig{}, + }), + want: nil, + }, + { + name: "parses provider/slug syntax", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeCodex, true, &AgentRuntimeConfig{ + Codex: &CodexConfig{Model: lo.ToPtr("anthropic/claude-sonnet-4-5")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderAnthropic, + Model: "claude-sonnet-4-5", + }, + }, + { + name: "uses claude default provider for a bare model", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeClaude, true, &AgentRuntimeConfig{ + Claude: &ClaudeConfig{Model: lo.ToPtr("claude-sonnet-4-5")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderAnthropic, + Model: "claude-sonnet-4-5", + }, + }, + { + name: "uses openai default provider for codex", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeCodex, true, &AgentRuntimeConfig{ + Codex: &CodexConfig{Model: lo.ToPtr("gpt-5.4")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderOpenai, + Model: "gpt-5.4", + }, + }, + { + name: "uses openai default provider for opencode", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeOpencode, true, &AgentRuntimeConfig{ + OpenCode: &OpenCodeConfig{Model: lo.ToPtr("gpt-5.4")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderOpenai, + Model: "gpt-5.4", + }, + }, + { + name: "uses openai default provider for pi", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypePi, true, &AgentRuntimeConfig{ + Pi: &PiConfig{Model: lo.ToPtr("gpt-5.4")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderOpenai, + Model: "gpt-5.4", + }, + }, + { + name: "uses vertex default provider for gemini", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeGemini, true, &AgentRuntimeConfig{ + Gemini: &GeminiConfig{Model: lo.ToPtr("gemini-2.5-pro")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderVertex, + Model: "gemini-2.5-pro", + }, + }, + { + name: "maps openai-compatible provider slug", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeOpencode, true, &AgentRuntimeConfig{ + OpenCode: &OpenCodeConfig{Model: lo.ToPtr("openai-compatible/custom-model")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderOpenaiCompatible, + Model: "custom-model", + }, + }, + { + name: "keeps slug path after the provider prefix", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeGemini, true, &AgentRuntimeConfig{ + Gemini: &GeminiConfig{Model: lo.ToPtr("vertex/publishers/google/models/gemini-2.5-pro")}, + }), + want: &console.WorkbenchJobModelAttributes{ + Provider: console.AiProviderVertex, + Model: "publishers/google/models/gemini-2.5-pro", + }, + }, + { + name: "omits model for custom runtime without a provider prefix", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeCustom, true, &AgentRuntimeConfig{ + OpenCode: &OpenCodeConfig{Model: lo.ToPtr("gpt-5.4")}, + }), + want: nil, + }, + { + name: "omits model for an unknown provider prefix", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeCodex, true, &AgentRuntimeConfig{ + Codex: &CodexConfig{Model: lo.ToPtr("unknown/gpt-5.4")}, + }), + want: nil, + }, + { + name: "omits model when the type-specific config is missing", + runtime: agentRuntimeWithModel(console.AgentRuntimeTypeClaude, true, &AgentRuntimeConfig{ + Codex: &CodexConfig{Model: lo.ToPtr("gpt-5.4")}, + }), + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.runtime.Attributes().Model + if tt.want == nil { + if got != nil { + t.Fatalf("Model = %+v, want nil", got) + } + return + } + if got == nil { + t.Fatal("Model is nil") + } + if got.Provider != tt.want.Provider || got.Model != tt.want.Model { + t.Fatalf("Model = {%s %s}, want {%s %s}", got.Provider, got.Model, tt.want.Provider, tt.want.Model) + } + }) + } +} + +func TestDefaultModelProviderMatchesProxyProvider(t *testing.T) { + types := []console.AgentRuntimeType{ + console.AgentRuntimeTypeClaude, + console.AgentRuntimeTypeCodex, + console.AgentRuntimeTypeOpencode, + console.AgentRuntimeTypePi, + console.AgentRuntimeTypeGemini, + console.AgentRuntimeTypeCustom, + } + for _, runtimeType := range types { + if got, want := defaultModelProvider(runtimeType), proxymodel.ProxyProvider(runtimeType); got != want { + t.Fatalf("defaultModelProvider(%s) = %q, want %q", runtimeType, got, want) + } + } +} + +func TestAgentRuntimeDiffHashesConsoleAttributes(t *testing.T) { + hasher := func(v any) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(data), nil + } + runtime := agentRuntimeWithModel(console.AgentRuntimeTypeCodex, true, &AgentRuntimeConfig{ + Codex: &CodexConfig{Model: lo.ToPtr("gpt-5.4")}, + }) + runtime.Spec.Dind = lo.ToPtr(true) + + changed, sha, err := runtime.Diff(hasher) + if err != nil { + t.Fatalf("Diff() error = %v", err) + } + if !changed { + t.Fatal("expected first Diff to report a change") + } + + runtime.Status.SHA = &sha + changed, _, err = runtime.Diff(hasher) + if err != nil { + t.Fatalf("Diff() error = %v", err) + } + if changed { + t.Fatal("expected unchanged attributes to skip upsert") + } + + runtime.Spec.Dind = lo.ToPtr(false) + changed, _, err = runtime.Diff(hasher) + if err != nil { + t.Fatalf("Diff() error = %v", err) + } + if changed { + t.Fatal("expected spec fields that are not sent to Console to skip upsert") + } + + runtime.Spec.Config.Codex.Model = lo.ToPtr("gpt-5") + changed, _, err = runtime.Diff(hasher) + if err != nil { + t.Fatalf("Diff() error = %v", err) + } + if !changed { + t.Fatal("expected model change to trigger upsert") + } +} + +func agentRuntimeWithModel(runtimeType console.AgentRuntimeType, aiProxy bool, config *AgentRuntimeConfig) *AgentRuntime { + return &AgentRuntime{ + Spec: AgentRuntimeSpec{ + Type: runtimeType, + AiProxy: lo.ToPtr(aiProxy), + Config: config, + }, + } +} diff --git a/go/deployment-operator/api/v1alpha1/agentruntime_types.go b/go/deployment-operator/api/v1alpha1/agentruntime_types.go index 4632baa5ed..3b86c00d41 100644 --- a/go/deployment-operator/api/v1alpha1/agentruntime_types.go +++ b/go/deployment-operator/api/v1alpha1/agentruntime_types.go @@ -117,6 +117,60 @@ type AgentRuntimeSpec struct { // ExaConnection enables Exa web search and content retrieval tools on the Plural MCP server. ExaConnection *ExaConnection `json:"exaConnection,omitempty"` + + // MCPServers are additional remote MCP servers made available to coding agents + // on this runtime. Servers are expected to already be deployed and reachable + // at the given URL. Built-in servers named "plural" and "codebase-memory-mcp" + // are reserved and cannot be overridden. + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=name + MCPServers []MCPServer `json:"mcpServers,omitempty"` +} + +// MCPServer is a remote MCP server exposed to agent runtimes. +// +// +kubebuilder:validation:XValidation:rule="self.name != 'plural' && self.name != 'codebase-memory-mcp'",message="mcpServers name cannot collide with built-in servers plural or codebase-memory-mcp" +type MCPServer struct { + // Name is the MCP server identifier used by the coding agent. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // URL is the remote streamable HTTP MCP endpoint. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + URL string `json:"url"` + + // AllowedTools is an optional allowlist of tool names from this server. + // When omitted or empty, all tools advertised by the server are exposed. + // +kubebuilder:validation:Optional + AllowedTools []string `json:"allowedTools,omitempty"` + + // Headers are HTTP headers sent with requests to this MCP server. + // Each header must set exactly one of value or valueFrom. + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=name + Headers []MCPServerHeader `json:"headers,omitempty"` +} + +// MCPServerHeader is an HTTP header for a remote MCP server. +// +// +kubebuilder:validation:XValidation:rule="has(self.value) != has(self.valueFrom)",message="exactly one of value or valueFrom must be set" +type MCPServerHeader struct { + // Name is the HTTP header name. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Value is a literal header value. + // +kubebuilder:validation:Optional + Value *string `json:"value,omitempty"` + + // ValueFrom sources the header value the same way as a pod env var. + // +kubebuilder:validation:Optional + ValueFrom *corev1.EnvVarSource `json:"valueFrom,omitempty"` } type ExaConnection struct { @@ -824,7 +878,7 @@ type AgentRuntimeBindings struct { } func (in *AgentRuntime) Diff(hasher Hasher) (changed bool, sha string, err error) { - currentSha, err := hasher(in.Spec) + currentSha, err := hasher(in.Attributes()) if err != nil { return false, "", err } @@ -881,6 +935,9 @@ func (in *AgentRuntime) Attributes() console.AgentRuntimeAttributes { if in.Spec.ScmConnection != nil && len(*in.Spec.ScmConnection) > 0 { attrs.ScmConnection = in.Spec.ScmConnection } + if model := in.modelAttributes(); model != nil { + attrs.Model = model + } return attrs } diff --git a/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go b/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go index 1bf67b1b7c..af300b63d5 100644 --- a/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -617,6 +617,13 @@ func (in *AgentRuntimeSpec) DeepCopyInto(out *AgentRuntimeSpec) { *out = new(ExaConnection) (*in).DeepCopyInto(*out) } + if in.MCPServers != nil { + in, out := &in.MCPServers, &out.MCPServers + *out = make([]MCPServer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRuntimeSpec. @@ -1066,81 +1073,6 @@ func (in *CodexConfigRaw) DeepCopy() *CodexConfigRaw { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PiConfig) DeepCopyInto(out *PiConfig) { - *out = *in - if in.APIKeySecretRef != nil { - in, out := &in.APIKeySecretRef, &out.APIKeySecretRef - *out = new(v1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } - if in.Provider != nil { - in, out := &in.Provider, &out.Provider - *out = new(string) - **out = **in - } - if in.Model != nil { - in, out := &in.Model, &out.Model - *out = new(string) - **out = **in - } - if in.Endpoint != nil { - in, out := &in.Endpoint, &out.Endpoint - *out = new(string) - **out = **in - } - if in.Timeout != nil { - in, out := &in.Timeout, &out.Timeout - *out = new(metav1.Duration) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PiConfig. -func (in *PiConfig) DeepCopy() *PiConfig { - if in == nil { - return nil - } - out := new(PiConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PiConfigRaw) DeepCopyInto(out *PiConfigRaw) { - *out = *in - if in.Provider != nil { - in, out := &in.Provider, &out.Provider - *out = new(string) - **out = **in - } - if in.Model != nil { - in, out := &in.Model, &out.Model - *out = new(string) - **out = **in - } - if in.Endpoint != nil { - in, out := &in.Endpoint, &out.Endpoint - *out = new(string) - **out = **in - } - if in.Timeout != nil { - in, out := &in.Timeout, &out.Timeout - *out = new(metav1.Duration) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PiConfigRaw. -func (in *PiConfigRaw) DeepCopy() *PiConfigRaw { - if in == nil { - return nil - } - out := new(PiConfigRaw) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CustomHealth) DeepCopyInto(out *CustomHealth) { *out = *in @@ -1696,6 +1628,58 @@ func (in *KubecostExtractorSpec) DeepCopy() *KubecostExtractorSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServer) DeepCopyInto(out *MCPServer) { + *out = *in + if in.AllowedTools != nil { + in, out := &in.AllowedTools, &out.AllowedTools + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = make([]MCPServerHeader, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServer. +func (in *MCPServer) DeepCopy() *MCPServer { + if in == nil { + return nil + } + out := new(MCPServer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerHeader) DeepCopyInto(out *MCPServerHeader) { + *out = *in + if in.Value != nil { + in, out := &in.Value, &out.Value + *out = new(string) + **out = **in + } + if in.ValueFrom != nil { + in, out := &in.ValueFrom, &out.ValueFrom + *out = new(v1.EnvVarSource) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerHeader. +func (in *MCPServerHeader) DeepCopy() *MCPServerHeader { + if in == nil { + return nil + } + out := new(MCPServerHeader) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MetricsAggregate) DeepCopyInto(out *MetricsAggregate) { *out = *in @@ -1886,6 +1870,81 @@ func (in *OpenCodeOpenAICompatibleConfig) DeepCopy() *OpenCodeOpenAICompatibleCo return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PiConfig) DeepCopyInto(out *PiConfig) { + *out = *in + if in.APIKeySecretRef != nil { + in, out := &in.APIKeySecretRef, &out.APIKeySecretRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } + if in.Provider != nil { + in, out := &in.Provider, &out.Provider + *out = new(string) + **out = **in + } + if in.Model != nil { + in, out := &in.Model, &out.Model + *out = new(string) + **out = **in + } + if in.Endpoint != nil { + in, out := &in.Endpoint, &out.Endpoint + *out = new(string) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(metav1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PiConfig. +func (in *PiConfig) DeepCopy() *PiConfig { + if in == nil { + return nil + } + out := new(PiConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PiConfigRaw) DeepCopyInto(out *PiConfigRaw) { + *out = *in + if in.Provider != nil { + in, out := &in.Provider, &out.Provider + *out = new(string) + **out = **in + } + if in.Model != nil { + in, out := &in.Model, &out.Model + *out = new(string) + **out = **in + } + if in.Endpoint != nil { + in, out := &in.Endpoint, &out.Endpoint + *out = new(string) + **out = **in + } + if in.Timeout != nil { + in, out := &in.Timeout, &out.Timeout + *out = new(metav1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PiConfigRaw. +func (in *PiConfigRaw) DeepCopy() *PiConfigRaw { + if in == nil { + return nil + } + out := new(PiConfigRaw) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PipelineGate) DeepCopyInto(out *PipelineGate) { *out = *in diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentconfigurations.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentconfigurations.yaml index 2cb5844ea1..0fa492eb03 100644 --- a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentconfigurations.yaml +++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentconfigurations.yaml @@ -55,7 +55,8 @@ spec: Set to "0s" to disable compatibility uploads. type: string componentShaCacheTTL: - description: ComponentShaCacheTTL specifies how long component SHA cache entries remain valid. + description: ComponentShaCacheTTL specifies how long duplicate component + updates are cached. type: string disableWebsocket: description: |- diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml index 0476010126..bff2e3ba57 100644 --- a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml +++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml @@ -44,6 +44,10 @@ spec: spec: description: AgentRunSpec defines the desired state of AgentRun properties: + branch: + description: Branch is the repository branch the agent should operate + on. If omitted, the repository default branch is used. + type: string flowId: description: FlowID is the flow this agent run is associated with (optional) @@ -51,11 +55,13 @@ spec: language: description: |- Language is the programming language used in the agent run. + Deprecated: No longer used for image selection. Enable dind on the AgentRuntime instead. type: string languageVersion: description: |- LanguageVersion is the version of the language to use, if you wish to specify. + Deprecated: No longer used for image selection. Enable dind on the AgentRuntime instead. type: string mode: @@ -68,10 +74,6 @@ spec: description: Repository is the git repository the agent will work with type: string - branch: - description: Branch is the repository branch the agent should operate - on. If omitted, the repository default branch is used. - type: string runtimeRef: properties: name: diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml index df9e2665d3..6a2ad26abb 100644 --- a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml +++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml @@ -44,6 +44,11 @@ spec: spec: description: AgentRuntimeSpec defines the desired state of AgentRuntime properties: + agentTTL: + description: AgentTTL configures the maximum lifetime for agent run + pods on this runtime. When not provided, a default TTL of 12 hours + will be used. + type: string aiProxy: description: |- AiProxy routes LLM requests through the Console AI proxy (/ext/ai) using the deploy token, @@ -60,12 +65,6 @@ spec: - GEMINI: vertex/{model} - CUSTOM: no automatic prefix; use provider/name explicitly type: boolean - streamingProxy: - description: |- - StreamingProxy routes OpenAI-compatible LLM requests through the in-pod mcpserver - streaming proxy before they reach the Console AI proxy (/ext/ai). Only valid when aiProxy - is enabled. Applies to CODEX and OPENCODE runtimes. - type: boolean allowedRepositories: description: AllowedRepositories the git repositories allowed to be used with this runtime. @@ -78,16 +77,6 @@ spec: babysitting actions (e.g. restarting unhealthy runtimes). When not provided, a default interval of 1 minute will be used. type: string - agentTTL: - description: AgentTTL configures the maximum lifetime for agent run - pods on this runtime. When not provided, a default TTL of 12 hours - will be used. - type: string - scmConnection: - description: |- - ScmConnection is the name of an ScmConnection in Console to use for git operations on agent runs using this runtime. - This should match the name of an existing ScmConnection resource or connection created in the Plural UI. - type: string bindings: description: Bindings define the creation permissions for this agent runtime. @@ -1273,7 +1262,6 @@ spec: procMount denotes the type of proc mount to use for the containers. The default value is Default which uses the container runtime defaults for readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. type: string readOnlyRootFilesystem: @@ -1813,39 +1801,6 @@ spec: description: Timeout bounds a single codex run invocation. type: string type: object - pi: - description: Pi config for Pi coding-agent CLI runtime. - properties: - apiKeySecretRef: - description: APIKeySecretRef references an API key. Optional with aiProxy enabled. - properties: - key: - description: The key of the secret to select from. Must be a valid secret key. - type: string - name: - default: "" - description: Name of the referent. - type: string - optional: - description: Specify whether the Secret or its key must be defined. - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - endpoint: - description: Endpoint overrides the OpenAI-compatible provider base URL. - type: string - model: - description: Model is the model id to use. - type: string - provider: - description: Provider is Pi's provider id. Defaults to openai. - type: string - timeout: - description: Timeout bounds a single Pi invocation. - type: string - type: object gemini: description: Config for Gemini CLI runtime. properties: @@ -1997,6 +1952,48 @@ spec: type: object x-kubernetes-map-type: atomic type: object + pi: + description: Pi config for Pi coding-agent CLI runtime. + properties: + apiKeySecretRef: + description: APIKeySecretRef references an API key. Optional + with aiProxy enabled. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + endpoint: + description: Endpoint overrides the OpenAI-compatible provider + base URL. + type: string + model: + description: Model is the model id to use. + type: string + provider: + description: Provider is Pi's provider id. Defaults to openai. + type: string + timeout: + description: Timeout bounds a single Pi invocation. + type: string + type: object type: object default: description: Default indicates whether this is the default agent runtime @@ -2012,11 +2009,12 @@ spec: tools on the Plural MCP server. properties: apiKeySecretRef: - description: SecretKeySelector selects a key of a Secret. + description: ApiKeySecretRef references a Secret containing the + Exa API key. properties: key: - description: The key of the secret to select from. Must - be a valid secret key. + description: The key of the secret to select from. Must be + a valid secret key. type: string name: default: "" @@ -2028,20 +2026,20 @@ spec: More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string optional: - description: Specify whether the Secret or its key must - be defined + description: Specify whether the Secret or its key must be + defined type: boolean required: - key type: object x-kubernetes-map-type: atomic + proxyUrl: + description: ProxyURL is an HTTP proxy URL used for Exa API requests. + type: string url: description: URL is the Exa API base URL. Defaults to https://api.exa.ai when unset. type: string - proxyUrl: - description: ProxyURL is an HTTP proxy URL used for Exa API requests. - type: string type: object git: description: Git configure commit signing on agent run. When provided, @@ -2075,6 +2073,199 @@ spec: type: object x-kubernetes-map-type: atomic type: object + mcpServers: + description: |- + MCPServers are additional remote MCP servers made available to coding agents + on this runtime. Servers are expected to already be deployed and reachable + at the given URL. Built-in servers named "plural" and "codebase-memory-mcp" + are reserved and cannot be overridden. + items: + description: MCPServer is a remote MCP server exposed to agent runtimes. + properties: + allowedTools: + description: |- + AllowedTools is an optional allowlist of tool names from this server. + When omitted or empty, all tools advertised by the server are exposed. + items: + type: string + type: array + headers: + description: |- + Headers are HTTP headers sent with requests to this MCP server. + Each header must set exactly one of value or valueFrom. + items: + description: MCPServerHeader is an HTTP header for a remote + MCP server. + properties: + name: + description: Name is the HTTP header name. + minLength: 1 + type: string + value: + description: Value is a literal header value. + type: string + valueFrom: + description: ValueFrom sources the header value the same + way as a pod env var. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the + specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the + exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + x-kubernetes-validations: + - message: exactly one of value or valueFrom must be set + rule: has(self.value) != has(self.valueFrom) + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + name: + description: Name is the MCP server identifier used by the coding + agent. + minLength: 1 + type: string + url: + description: URL is the remote streamable HTTP MCP endpoint. + minLength: 1 + type: string + required: + - name + - url + type: object + x-kubernetes-validations: + - message: mcpServers name cannot collide with built-in servers + plural or codebase-memory-mcp + rule: self.name != 'plural' && self.name != 'codebase-memory-mcp' + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map memory: description: |- Memory enables team-shared codebase-memory persistence for this agent runtime. @@ -2088,6 +2279,17 @@ spec: Name of this AgentRuntime. If not provided, the name from AgentRuntime.ObjectMeta will be used. type: string + scmConnection: + description: |- + ScmConnection is the name of an ScmConnection in Console to use for git operations on agent runs using this runtime. + This should match the name of an existing ScmConnection resource or connection created in the Plural UI. + type: string + streamingProxy: + description: |- + StreamingProxy routes OpenAI-compatible LLM requests through the in-pod mcpserver + sse conversion proxy before they reach the Console AI proxy (/ext/ai). Only valid when aiProxy + is enabled. Applies to CODEX and OPENCODE runtimes. + type: boolean targetNamespace: type: string template: @@ -4154,7 +4356,6 @@ spec: procMount denotes the type of proc mount to use for the containers. The default value is Default which uses the container runtime defaults for readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. type: string readOnlyRootFilesystem: @@ -5725,7 +5926,6 @@ spec: procMount denotes the type of proc mount to use for the containers. The default value is Default which uses the container runtime defaults for readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. type: string readOnlyRootFilesystem: @@ -6209,7 +6409,6 @@ spec: When set to false, a new userns is created for the pod. Setting false is useful for mitigating container breakout vulnerabilities even allowing users to run their containers as root without actually having root privileges on the host. - This field is alpha-level and is only honored by servers that enable the UserNamespacesSupport feature. type: boolean hostname: description: |- @@ -7379,7 +7578,6 @@ spec: procMount denotes the type of proc mount to use for the containers. The default value is Default which uses the container runtime defaults for readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. type: string readOnlyRootFilesystem: @@ -7948,6 +8146,14 @@ spec: It adds a name to it that uniquely identifies the ResourceClaim inside the Pod. Containers that need access to the ResourceClaim reference it with this name. + + When the DRAWorkloadResourceClaims feature gate is enabled and this Pod + belongs to a PodGroup, a PodResourceClaim is matched to a + PodGroupResourceClaim if all of their fields are equal (Name, + ResourceClaimName, and ResourceClaimTemplateName). A matched claim references + a single ResourceClaim shared across all Pods in the PodGroup, reserved for + the PodGroup in ResourceClaimStatus.ReservedFor rather than for individual + Pods. properties: name: description: |- @@ -7973,6 +8179,16 @@ spec: generated component, will be used to form a unique name for the ResourceClaim, which will be recorded in pod.status.resourceClaimStatuses. + When the DRAWorkloadResourceClaims feature gate is enabled and the pod + belongs to a PodGroup that defines a PodGroupResourceClaim with the same + Name and ResourceClaimTemplateName, this PodResourceClaim resolves to the + ResourceClaim generated for the PodGroup. All pods in the group that + define an equivalent PodResourceClaim matching the + PodGroupResourceClaim's Name and ResourceClaimTemplateName share the same + generated ResourceClaim. ResourceClaims generated for a PodGroup are + owned by the PodGroup and their lifecycles are tied to the PodGroup + instead of any individual pod. + This field is immutable and no changes will be made to the corresponding ResourceClaim by the control plane after creating the ResourceClaim. @@ -8098,6 +8314,28 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + schedulingGroup: + description: |- + SchedulingGroup provides a reference to the immediate scheduling runtime + grouping object that this Pod belongs to. + This field is used by the scheduler to identify the group and apply the + correct group scheduling policies. The association with a group also + impacts other lifecycle aspects of a Pod that are relevant in a wider context + of scheduling like preemption, resource attachment, etc. If not specified, + the Pod is treated as a single unit in all of these aspects. + The group object referenced by this field may not exist at the time the + Pod is created. + This field is immutable, but a group object with the same name may be + recreated with different policies. Doing this during pod scheduling + may result in the placement not conforming to the expected policies. + properties: + podGroupName: + description: |- + PodGroupName specifies the name of the standalone PodGroup object + that represents the runtime instance of this group. + Must be a DNS subdomain. + type: string + type: object securityContext: description: |- SecurityContext holds pod-level security attributes and common container settings. @@ -9526,7 +9764,7 @@ spec: A failure to resolve or pull the image during pod startup will block containers from starting and may add significant latency. Failures will be retried using normal volume backoff and will be reported on the pod reason and message. The types of objects that may be mounted by this volume are defined by the container runtime implementation on a host machine and at minimum must include all valid types supported by the container image field. The OCI object gets mounted in a single directory (spec.containers[*].volumeMounts.mountPath) by merging the manifest layers in the same way as for container images. - The volume will be mounted read-only (ro) and non-executable files (noexec). + The volume will be mounted read-only (ro). Sub path mounts for containers are not supported (spec.containers[*].volumeMounts.subpath) before 1.33. The field spec.securityContext.fsGroupChangePolicy has no effect on this volume type. properties: @@ -9698,8 +9936,7 @@ spec: description: |- portworxVolume represents a portworx volume attached and mounted on kubelets host machine. Deprecated: PortworxVolume is deprecated. All operations for the in-tree portworxVolume type - are redirected to the pxd.portworx.com CSI driver when the CSIMigrationPortworx feature-gate - is on. + are redirected to the pxd.portworx.com CSI driver. properties: fsType: description: |- @@ -10522,42 +10759,6 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - workloadRef: - description: |- - WorkloadRef provides a reference to the Workload object that this Pod belongs to. - This field is used by the scheduler to identify the PodGroup and apply the - correct group scheduling policies. The Workload object referenced - by this field may not exist at the time the Pod is created. - This field is immutable, but a Workload object with the same name - may be recreated with different policies. Doing this during pod scheduling - may result in the placement not conforming to the expected policies. - properties: - name: - description: |- - Name defines the name of the Workload object this Pod belongs to. - Workload must be in the same namespace as the Pod. - If it doesn't match any existing Workload, the Pod will remain unschedulable - until a Workload object is created and observed by the kube-scheduler. - It must be a DNS subdomain. - type: string - podGroup: - description: |- - PodGroup is the name of the PodGroup within the Workload that this Pod - belongs to. If it doesn't match any existing PodGroup within the Workload, - the Pod will remain unschedulable until the Workload object is recreated - and observed by the kube-scheduler. It must be a DNS label. - type: string - podGroupReplicaKey: - description: |- - PodGroupReplicaKey specifies the replica key of the PodGroup to which this - Pod belongs. It is used to distinguish pods belonging to different replicas - of the same pod group. The pod group policy is applied separately to each replica. - When set, it must be a DNS label. - type: string - required: - - name - - podGroup - type: object required: - containers type: object @@ -10580,7 +10781,8 @@ spec: type: object x-kubernetes-validations: - message: streamingProxy requires aiProxy to be enabled - rule: '!has(self.streamingProxy) || !self.streamingProxy || (has(self.aiProxy) && self.aiProxy)' + rule: '!has(self.streamingProxy) || !self.streamingProxy || (has(self.aiProxy) + && self.aiProxy)' status: properties: conditions: diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml index fe7adf2948..743638f56e 100644 --- a/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml +++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml @@ -2498,7 +2498,6 @@ spec: procMount denotes the type of proc mount to use for the containers. The default value is Default which uses the container runtime defaults for readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. type: string readOnlyRootFilesystem: @@ -4103,7 +4102,6 @@ spec: procMount denotes the type of proc mount to use for the containers. The default value is Default which uses the container runtime defaults for readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. type: string readOnlyRootFilesystem: @@ -4595,7 +4593,6 @@ spec: When set to false, a new userns is created for the pod. Setting false is useful for mitigating container breakout vulnerabilities even allowing users to run their containers as root without actually having root privileges on the host. - This field is alpha-level and is only honored by servers that enable the UserNamespacesSupport feature. type: boolean hostname: description: |- @@ -5788,7 +5785,6 @@ spec: procMount denotes the type of proc mount to use for the containers. The default value is Default which uses the container runtime defaults for readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. type: string readOnlyRootFilesystem: @@ -6365,6 +6361,14 @@ spec: It adds a name to it that uniquely identifies the ResourceClaim inside the Pod. Containers that need access to the ResourceClaim reference it with this name. + + When the DRAWorkloadResourceClaims feature gate is enabled and this Pod + belongs to a PodGroup, a PodResourceClaim is matched to a + PodGroupResourceClaim if all of their fields are equal (Name, + ResourceClaimName, and ResourceClaimTemplateName). A matched claim references + a single ResourceClaim shared across all Pods in the PodGroup, reserved for + the PodGroup in ResourceClaimStatus.ReservedFor rather than for individual + Pods. properties: name: description: |- @@ -6390,6 +6394,16 @@ spec: generated component, will be used to form a unique name for the ResourceClaim, which will be recorded in pod.status.resourceClaimStatuses. + When the DRAWorkloadResourceClaims feature gate is enabled and the pod + belongs to a PodGroup that defines a PodGroupResourceClaim with the same + Name and ResourceClaimTemplateName, this PodResourceClaim resolves to the + ResourceClaim generated for the PodGroup. All pods in the group that + define an equivalent PodResourceClaim matching the + PodGroupResourceClaim's Name and ResourceClaimTemplateName share the same + generated ResourceClaim. ResourceClaims generated for a PodGroup are + owned by the PodGroup and their lifecycles are tied to the PodGroup + instead of any individual pod. + This field is immutable and no changes will be made to the corresponding ResourceClaim by the control plane after creating the ResourceClaim. @@ -6516,6 +6530,28 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + schedulingGroup: + description: |- + SchedulingGroup provides a reference to the immediate scheduling runtime + grouping object that this Pod belongs to. + This field is used by the scheduler to identify the group and apply the + correct group scheduling policies. The association with a group also + impacts other lifecycle aspects of a Pod that are relevant in a wider context + of scheduling like preemption, resource attachment, etc. If not specified, + the Pod is treated as a single unit in all of these aspects. + The group object referenced by this field may not exist at the time the + Pod is created. + This field is immutable, but a group object with the same name may be + recreated with different policies. Doing this during pod scheduling + may result in the placement not conforming to the expected policies. + properties: + podGroupName: + description: |- + PodGroupName specifies the name of the standalone PodGroup object + that represents the runtime instance of this group. + Must be a DNS subdomain. + type: string + type: object securityContext: description: |- SecurityContext holds pod-level security attributes and common container settings. @@ -7960,7 +7996,7 @@ spec: A failure to resolve or pull the image during pod startup will block containers from starting and may add significant latency. Failures will be retried using normal volume backoff and will be reported on the pod reason and message. The types of objects that may be mounted by this volume are defined by the container runtime implementation on a host machine and at minimum must include all valid types supported by the container image field. The OCI object gets mounted in a single directory (spec.containers[*].volumeMounts.mountPath) by merging the manifest layers in the same way as for container images. - The volume will be mounted read-only (ro) and non-executable files (noexec). + The volume will be mounted read-only (ro). Sub path mounts for containers are not supported (spec.containers[*].volumeMounts.subpath) before 1.33. The field spec.securityContext.fsGroupChangePolicy has no effect on this volume type. properties: @@ -8134,8 +8170,7 @@ spec: description: |- portworxVolume represents a portworx volume attached and mounted on kubelets host machine. Deprecated: PortworxVolume is deprecated. All operations for the in-tree portworxVolume type - are redirected to the pxd.portworx.com CSI driver when the CSIMigrationPortworx feature-gate - is on. + are redirected to the pxd.portworx.com CSI driver. properties: fsType: description: |- @@ -8982,42 +9017,6 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map - workloadRef: - description: |- - WorkloadRef provides a reference to the Workload object that this Pod belongs to. - This field is used by the scheduler to identify the PodGroup and apply the - correct group scheduling policies. The Workload object referenced - by this field may not exist at the time the Pod is created. - This field is immutable, but a Workload object with the same name - may be recreated with different policies. Doing this during pod scheduling - may result in the placement not conforming to the expected policies. - properties: - name: - description: |- - Name defines the name of the Workload object this Pod belongs to. - Workload must be in the same namespace as the Pod. - If it doesn't match any existing Workload, the Pod will remain unschedulable - until a Workload object is created and observed by the kube-scheduler. - It must be a DNS subdomain. - type: string - podGroup: - description: |- - PodGroup is the name of the PodGroup within the Workload that this Pod - belongs to. If it doesn't match any existing PodGroup within the Workload, - the Pod will remain unschedulable until the Workload object is recreated - and observed by the kube-scheduler. It must be a DNS label. - type: string - podGroupReplicaKey: - description: |- - PodGroupReplicaKey specifies the replica key of the PodGroup to which this - Pod belongs. It is used to distinguish pods belonging to different replicas - of the same pod group. The pod group policy is applied separately to each replica. - When set, it must be a DNS label. - type: string - required: - - name - - podGroup - type: object required: - containers type: object diff --git a/go/deployment-operator/config/samples/agentRuntime.yaml b/go/deployment-operator/config/samples/agentRuntime.yaml index b4119edd11..dad4df699f 100644 --- a/go/deployment-operator/config/samples/agentRuntime.yaml +++ b/go/deployment-operator/config/samples/agentRuntime.yaml @@ -19,6 +19,17 @@ spec: args: - --v=3 dind: true + mcpServers: + - name: linear + url: https://mcp.linear.app/mcp + allowedTools: + - list_issues + headers: + - name: Authorization + valueFrom: + secretKeyRef: + name: linear-mcp + key: token --- apiVersion: v1 kind: Namespace diff --git a/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile index c6411c9f36..b69144e646 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile @@ -79,14 +79,30 @@ ARG CODEBASE_MEMORY_MCP_VERSION=0.8.1 ARG DOCKER_COMPOSE_VERSION=5.5.0-1~debian.13~trixie ARG PODMAN_STATIC_CONFIG_REVISION=a14f4b3ee9751ea232ef10b72e4923869ea8c3d7 -RUN apt update && apt install -y \ - ca-certificates \ - curl \ - gnupg \ - git \ - jq \ - make \ - tar +# DHI's apt index is occasionally truncated (a few KB instead of several MB). +# Apt then selects Debian git, which cannot install against DHI's rebuilt perl-base. +RUN set -eux; \ + export DEBIAN_FRONTEND=noninteractive; \ + for attempt in 1 2 3 4 5; do \ + rm -rf /var/lib/apt/lists/*; \ + if apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gnupg \ + git \ + jq \ + make \ + tar; then \ + break; \ + fi; \ + if [ "${attempt}" -eq 5 ]; then \ + echo "apt-get install failed after ${attempt} attempts" >&2; \ + exit 1; \ + fi; \ + sleep "${attempt}"; \ + done; \ + command -v git >/dev/null; \ + rm -rf /var/lib/apt/lists/* RUN set -eux; \ portable=""; \ @@ -113,14 +129,24 @@ RUN install -m 0755 -d /etc/apt/keyrings && \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/debian trixie stable" | \ tee /etc/apt/sources.list.d/docker.list > /dev/null && \ - apt update && \ - apt install -y docker-ce-cli "docker-compose-plugin=${DOCKER_COMPOSE_VERSION}" ripgrep && \ + for attempt in 1 2 3 4 5; do \ + if apt-get update && apt-get install -y --no-install-recommends \ + docker-ce-cli "docker-compose-plugin=${DOCKER_COMPOSE_VERSION}" ripgrep; then \ + break; \ + fi; \ + if [ "${attempt}" -eq 5 ]; then \ + echo "apt-get install docker-ce-cli failed after ${attempt} attempts" >&2; \ + exit 1; \ + fi; \ + rm -rf /var/lib/apt/lists/*; \ + sleep "${attempt}"; \ + done; \ ln -s /usr/libexec/docker/cli-plugins/docker-compose /usr/bin/docker-compose && \ rm -rf /var/lib/apt/lists/* # Install the Nix binary-cache Podman engine and rootless user mapping helpers RUN set -eux; \ - for attempt in 1 2 3; do \ + for attempt in 1 2 3 4 5; do \ if apt-get update && apt-get install -y --no-install-recommends uidmap; then \ break; \ fi; \ diff --git a/go/deployment-operator/docs/api.md b/go/deployment-operator/docs/api.md index 2367eb56e9..a3dc9a3093 100644 --- a/go/deployment-operator/docs/api.md +++ b/go/deployment-operator/docs/api.md @@ -282,6 +282,7 @@ _Appears in:_ | `agentTTL` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#duration-v1-meta)_ | AgentTTL configures the maximum lifetime for agent run pods on this runtime. When not provided, a default TTL of 12 hours will be used. | | Optional: \{\}
| | `scmConnection` _string_ | ScmConnection is the name of an ScmConnection in Console to use for git operations on agent runs using this runtime.
This should match the name of an existing ScmConnection resource or connection created in the Plural UI. | | Optional: \{\}
| | `exaConnection` _[ExaConnection](#exaconnection)_ | ExaConnection enables Exa web search and content retrieval tools on the Plural MCP server. | | | +| `mcpServers` _[MCPServer](#mcpserver) array_ | MCPServers are additional remote MCP servers made available to coding agents
on this runtime. Servers are expected to already be deployed and reachable
at the given URL. Built-in servers named "plural" and "codebase-memory-mcp"
are reserved and cannot be overridden. | | Optional: \{\}
| #### Binding @@ -851,6 +852,43 @@ _Appears in:_ | `recommendationsSettings` _[RecommendationsSettings](#recommendationssettings)_ | | | Optional: \{\}
| +#### MCPServer + + + +MCPServer is a remote MCP server exposed to agent runtimes. + + + +_Appears in:_ +- [AgentRuntimeSpec](#agentruntimespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the MCP server identifier used by the coding agent. | | MinLength: 1
Required: \{\}
| +| `url` _string_ | URL is the remote streamable HTTP MCP endpoint. | | MinLength: 1
Required: \{\}
| +| `allowedTools` _string array_ | AllowedTools is an optional allowlist of tool names from this server.
When omitted or empty, all tools advertised by the server are exposed. | | Optional: \{\}
| +| `headers` _[MCPServerHeader](#mcpserverheader) array_ | Headers are HTTP headers sent with requests to this MCP server.
Each header must set exactly one of value or valueFrom. | | Optional: \{\}
| + + +#### MCPServerHeader + + + +MCPServerHeader is an HTTP header for a remote MCP server. + + + +_Appears in:_ +- [MCPServer](#mcpserver) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the HTTP header name. | | MinLength: 1
Required: \{\}
| +| `value` _string_ | Value is a literal header value. | | Optional: \{\}
| +| `valueFrom` _[EnvVarSource](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#envvarsource-v1-core)_ | ValueFrom sources the header value the same way as a pod env var. | | Optional: \{\}
| + + #### MetricsAggregate diff --git a/go/deployment-operator/internal/controller/agentrun_controller.go b/go/deployment-operator/internal/controller/agentrun_controller.go index ec8a94bf11..60d8b81326 100644 --- a/go/deployment-operator/internal/controller/agentrun_controller.go +++ b/go/deployment-operator/internal/controller/agentrun_controller.go @@ -28,6 +28,7 @@ import ( "github.com/pluralsh/console/go/deployment-operator/api/v1alpha1" "github.com/pluralsh/console/go/deployment-operator/internal/utils" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" pluralclient "github.com/pluralsh/console/go/deployment-operator/pkg/client" ) @@ -78,6 +79,7 @@ const ( EnvExaConnection = "PLRL_EXA_CONNECTION" EnvMcpExcludeTools = "PLRL_EXCLUDE_TOOLS" EnvStreamingProxy = "PLRL_STREAMING_PROXY" + EnvMCPServers = "PLRL_MCP_SERVERS" ) var ( @@ -473,6 +475,11 @@ func (r *AgentRunReconciler) reconcilePodSecret(ctx context.Context, run *v1alph return nil, fmt.Errorf("failed to resolve git signing key: %w", err) } + mcpServers, err := r.resolveMCPServers(ctx, run.Namespace, runtime.Spec.MCPServers) + if err != nil { + return nil, fmt.Errorf("failed to resolve mcp servers: %w", err) + } + var exaConnection *v1alpha1.ExaConnectionRaw if runtime.Spec.ExaConnection != nil { var err error @@ -490,7 +497,7 @@ func (r *AgentRunReconciler) reconcilePodSecret(ctx context.Context, run *v1alph secret = &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: run.Name, Namespace: run.Namespace}, - StringData: r.getSecretData(run, config, runtime.Spec.Type, signingKey, exaConnection), + StringData: r.getSecretData(run, config, runtime.Spec.Type, signingKey, exaConnection, mcpServers), } logger.V(2).Info("creating secret", "namespace", secret.Namespace, "name", secret.Name) @@ -503,7 +510,7 @@ func (r *AgentRunReconciler) reconcilePodSecret(ctx context.Context, run *v1alph if !r.hasSecretData(secret.Data, run) { logger.V(2).Info("updating secret", "namespace", secret.Namespace, "name", secret.Name) - secret.StringData = r.getSecretData(run, config, runtime.Spec.Type, signingKey, exaConnection) + secret.StringData = r.getSecretData(run, config, runtime.Spec.Type, signingKey, exaConnection, mcpServers) if err := r.Update(ctx, secret); err != nil { logger.Error(err, "unable to update secret") return nil, err @@ -514,11 +521,7 @@ func (r *AgentRunReconciler) reconcilePodSecret(ctx context.Context, run *v1alph } func (r *AgentRunReconciler) getExaConnection(ctx context.Context, namespace string, config v1alpha1.ExaConnection) (*v1alpha1.ExaConnectionRaw, error) { - return config.ToExaConnectionRaw(func(selector corev1.SecretKeySelector) (*corev1.Secret, error) { - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: selector.Name}, secret) - return secret, err - }) + return config.ToExaConnectionRaw(secretGetter(ctx, r.configurationFetcher(namespace))) } func (r *AgentRunReconciler) getAgentRuntimeConfig(ctx context.Context, namespace string, config *v1alpha1.AgentRuntimeConfig, aiProxy bool) (*v1alpha1.AgentRuntimeConfigRaw, error) { @@ -526,11 +529,7 @@ func (r *AgentRunReconciler) getAgentRuntimeConfig(ctx context.Context, namespac return nil, nil } - return config.ToAgentRuntimeConfigRaw(func(selector corev1.SecretKeySelector) (*corev1.Secret, error) { - secret := &corev1.Secret{} - err := r.Get(ctx, client.ObjectKey{Namespace: namespace, Name: selector.Name}, secret) - return secret, err - }, aiProxy) + return config.ToAgentRuntimeConfigRaw(secretGetter(ctx, r.configurationFetcher(namespace)), aiProxy) } // resolveSigningKey fetches the signing key value from the secret referenced in runtime.Spec.Git.SigningKeyRef. @@ -541,27 +540,24 @@ func (r *AgentRunReconciler) resolveSigningKey(ctx context.Context, runtime *v1a return nil, nil } - ref := runtime.Spec.Git.SigningKeyRef - s := &corev1.Secret{} - if err := r.Get(ctx, client.ObjectKey{Namespace: runtime.Spec.TargetNamespace, Name: ref.Name}, s); err != nil { - return nil, fmt.Errorf("failed to get git signing key secret %q: %w", ref.Name, err) - } - - value, ok := s.Data[ref.Key] - if !ok { - return nil, fmt.Errorf("key %q not found in secret %q", ref.Key, ref.Name) + value, err := configurationSecretKey(ctx, r.configurationFetcher(runtime.Spec.TargetNamespace), *runtime.Spec.Git.SigningKeyRef) + if err != nil { + return nil, fmt.Errorf("failed to resolve git signing key: %w", err) } - - return value, nil + return []byte(value), nil } -func (r *AgentRunReconciler) getSecretData(run *v1alpha1.AgentRun, config *v1alpha1.AgentRuntimeConfigRaw, runtimeType console.AgentRuntimeType, signingKey []byte, exaConnection *v1alpha1.ExaConnectionRaw) map[string]string { +func (r *AgentRunReconciler) getSecretData(run *v1alpha1.AgentRun, config *v1alpha1.AgentRuntimeConfigRaw, runtimeType console.AgentRuntimeType, signingKey []byte, exaConnection *v1alpha1.ExaConnectionRaw, mcpServers []mcp.Server) map[string]string { result := map[string]string{ EnvConsoleURL: r.ConsoleURL, EnvDeployToken: r.DeployToken, EnvAgentRunID: run.Status.GetID(), } + if payload := mcpServersPayload(mcpServers); payload != "" { + result[EnvMCPServers] = payload + } + if len(signingKey) > 0 { result[gitSigningKeySecretKey] = string(signingKey) } diff --git a/go/deployment-operator/internal/controller/agentrun_controller_test.go b/go/deployment-operator/internal/controller/agentrun_controller_test.go index cdc6bba060..10290975d5 100644 --- a/go/deployment-operator/internal/controller/agentrun_controller_test.go +++ b/go/deployment-operator/internal/controller/agentrun_controller_test.go @@ -7,6 +7,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" pkgcommon "github.com/pluralsh/console/go/deployment-operator/pkg/common" "github.com/samber/lo" "github.com/stretchr/testify/mock" @@ -718,7 +719,7 @@ var _ = Describe("AgentRun Controller", Ordered, func() { run := &v1alpha1.AgentRun{} run.Status.ID = lo.ToPtr("run-456") - data := reconciler.getSecretData(run, nil, console.AgentRuntimeTypeClaude, nil, nil) + data := reconciler.getSecretData(run, nil, console.AgentRuntimeTypeClaude, nil, nil, nil) Expect(data).Should(HaveLen(3)) Expect(data[EnvConsoleURL]).Should(Equal("https://console.test.com")) Expect(data[EnvDeployToken]).Should(Equal("test-token-123")) @@ -774,7 +775,7 @@ var _ = Describe("AgentRun Controller", Ordered, func() { }, } - data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeClaude, nil, nil) + data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeClaude, nil, nil, nil) Expect(data[EnvClaudeModel]).Should(Equal("claude-3-opus")) Expect(data[EnvClaudeToken]).Should(Equal("claude-api-key")) Expect(data[EnvClaudeArgs]).Should(ContainSubstring("--verbose")) @@ -798,7 +799,7 @@ var _ = Describe("AgentRun Controller", Ordered, func() { }, } - data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeOpencode, nil, nil) + data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeOpencode, nil, nil, nil) Expect(data[EnvOpenCodeProvider]).Should(Equal("openai")) Expect(data[EnvOpenCodeEndpoint]).Should(Equal("https://api.openai.com")) Expect(data[EnvOpenCodeModel]).Should(Equal("gpt-4")) @@ -823,7 +824,7 @@ var _ = Describe("AgentRun Controller", Ordered, func() { }, } - data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeOpencode, nil, nil) + data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeOpencode, nil, nil, nil) Expect(data[EnvOpenCodeOpenAICompatible]).Should(Equal("true")) Expect(data[EnvOpenCodeProvider]).Should(Equal("openai-compatible")) Expect(data[EnvOpenCodeEndpoint]).Should(Equal("https://litellm.example/v1")) @@ -844,7 +845,7 @@ var _ = Describe("AgentRun Controller", Ordered, func() { }, } - data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeGemini, nil, nil) + data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeGemini, nil, nil, nil) Expect(data[EnvGeminiModel]).Should(Equal("gemini-pro")) Expect(data[EnvGeminiAPIKey]).Should(Equal("gemini-api-key")) }) @@ -867,12 +868,32 @@ var _ = Describe("AgentRun Controller", Ordered, func() { }, } - data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeCodex, nil, nil) + data := reconciler.getSecretData(run, config, console.AgentRuntimeTypeCodex, nil, nil, nil) Expect(data[EnvCodexModel]).Should(Equal("gpt-5.4")) Expect(data[EnvCodexAPIKey]).Should(Equal("codex-api-key")) Expect(data[EnvCodexMethod]).Should(Equal("CHAT")) Expect(data[EnvCodexEndpoint]).Should(Equal("https://litellm.example/v1")) }) + + It("should include extra MCP servers in secret data", func() { + reconciler := &AgentRunReconciler{ + ConsoleURL: "https://console.test.com", + DeployToken: "test-token-123", + } + run := &v1alpha1.AgentRun{} + run.Status.ID = lo.ToPtr("run-123") + + data := reconciler.getSecretData(run, nil, console.AgentRuntimeTypeCodex, nil, nil, []mcp.Server{{ + Name: "linear", + URL: "https://mcp.linear.app/mcp", + Headers: map[string]string{ + "Authorization": "Bearer token", + }, + }}) + Expect(data[EnvMCPServers]).Should(ContainSubstring("linear")) + Expect(data[EnvMCPServers]).Should(ContainSubstring("https://mcp.linear.app/mcp")) + Expect(data[EnvMCPServers]).Should(ContainSubstring("Bearer token")) + }) }) Context("Timeout helpers", func() { diff --git a/go/deployment-operator/internal/controller/agentrun_pod.go b/go/deployment-operator/internal/controller/agentrun_pod.go index f5d2707f2a..5451a031d1 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod.go +++ b/go/deployment-operator/internal/controller/agentrun_pod.go @@ -384,9 +384,9 @@ func enableAgentBootstrap(run *v1alpha1.AgentRun, runtime *v1alpha1.AgentRuntime pod.Spec.InitContainers[index].Image = defaultImage } - pod.Spec.InitContainers[index].SecurityContext = ensureDefaultContainerSecurityContext(pod.Spec.InitContainers[index].SecurityContext) + pod.Spec.InitContainers[index].SecurityContext = ensureAgentBootstrapSecurityContext(pod.Spec.InitContainers[index].SecurityContext) pod.Spec.InitContainers[index].EnvFrom = getDefaultContainerEnvFrom(run.Name) - pod.Spec.InitContainers[index].Env = ensureMCPServerEnvVars(pod.Spec.InitContainers[index].Env, run, runtime) + pod.Spec.InitContainers[index].Env = ensureAgentBootstrapEnvVars(pod.Spec.InitContainers[index].Env, run, runtime) pod.Spec.InitContainers[index].VolumeMounts = ensureMCPServerVolumeMounts(pod.Spec.InitContainers[index].VolumeMounts, runtime) if len(pod.Spec.InitContainers[index].Command) == 0 { @@ -401,15 +401,47 @@ func getAgentBootstrapContainer(run *v1alpha1.AgentRun, runtime *v1alpha1.AgentR return corev1.Container{ Name: agentBootstrapContainerName, Image: image, - SecurityContext: ensureDefaultContainerSecurityContext(nil), + SecurityContext: ensureAgentBootstrapSecurityContext(nil), EnvFrom: getDefaultContainerEnvFrom(run.Name), - Env: getMCPServerEnvVars(run, runtime), + Env: getAgentBootstrapEnvVars(run, runtime), Command: []string{"/agent-bootstrap"}, Args: []string{"--working-dir", common.AgentRunSharedWorkDir}, VolumeMounts: ensureMCPServerVolumeMounts(nil, runtime), } } +// ensureAgentBootstrapSecurityContext pins a restricted container profile. +// Bootstrap only writes the clone and git metadata into the shared emptyDir +// (/plural/shared) and uses the /tmp emptyDir for git's global config, so a +// read-only root filesystem is safe. +func ensureAgentBootstrapSecurityContext(sc *corev1.SecurityContext) *corev1.SecurityContext { + if sc != nil { + return sc + } + + sc = ensureDefaultContainerSecurityContext(nil) + sc.ReadOnlyRootFilesystem = lo.ToPtr(true) + sc.Capabilities = &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + } + return sc +} + +func getAgentBootstrapEnvVars(run *v1alpha1.AgentRun, runtime *v1alpha1.AgentRuntime) []corev1.EnvVar { + return ensureAgentBootstrapEnvVars(getMCPServerEnvVars(run, runtime), run, runtime) +} + +func ensureAgentBootstrapEnvVars(existing []corev1.EnvVar, run *v1alpha1.AgentRun, runtime *v1alpha1.AgentRuntime) []corev1.EnvVar { + existing = ensureMCPServerEnvVars(existing, run, runtime) + for _, env := range existing { + if env.Name == "HOME" { + return existing + } + } + // git config --global writes $HOME/.gitconfig; keep that on the writable /tmp volume. + return append(existing, corev1.EnvVar{Name: "HOME", Value: defaultTmpVolumePath}) +} + func enableMCPServer(run *v1alpha1.AgentRun, runtime *v1alpha1.AgentRuntime, pod *corev1.Pod) { defaultImage := getPodDefaultContainerImage(pod, run, runtime) index := algorithms.Index(pod.Spec.InitContainers, func(container corev1.Container) bool { diff --git a/go/deployment-operator/internal/controller/agentrun_pod_test.go b/go/deployment-operator/internal/controller/agentrun_pod_test.go index 25e79370cd..ee2ab14e82 100644 --- a/go/deployment-operator/internal/controller/agentrun_pod_test.go +++ b/go/deployment-operator/internal/controller/agentrun_pod_test.go @@ -413,6 +413,59 @@ func TestBuildAgentRunPod_IncludesMCPServerSidecar(t *testing.T) { assert.Equal(t, []string{"/agent-bootstrap"}, bootstrap.Command) assert.Contains(t, bootstrap.Args, "--working-dir") assert.Contains(t, bootstrap.Args, common.AgentRunSharedWorkDir) + assertAgentBootstrapRestrictedSecurityContext(t, *bootstrap) + assert.Contains(t, bootstrap.Env, corev1.EnvVar{Name: "HOME", Value: defaultTmpVolumePath}) + + defaultC := requireContainer(t, pod.Spec.Containers, defaultContainer) + if assert.NotNil(t, defaultC.SecurityContext) && assert.NotNil(t, defaultC.SecurityContext.ReadOnlyRootFilesystem) { + assert.False(t, *defaultC.SecurityContext.ReadOnlyRootFilesystem) + } +} + +func TestBuildAgentRunPod_PreservesCustomAgentBootstrapSecurityContext(t *testing.T) { + customSC := &corev1.SecurityContext{ + ReadOnlyRootFilesystem: lo.ToPtr(false), + RunAsUser: lo.ToPtr(int64(1000)), + } + run := &v1alpha1.AgentRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-run", + Namespace: "default", + }, + Spec: v1alpha1.AgentRunSpec{ + RuntimeRef: v1alpha1.AgentRuntimeReference{Name: "test-runtime"}, + Prompt: "test prompt", + Repository: "https://github.com/test/repo", + Mode: console.AgentRunModeAnalyze, + }, + Status: v1alpha1.AgentRunStatus{ + Status: v1alpha1.Status{ID: lo.ToPtr("test-run-id")}, + }, + } + runtime := &v1alpha1.AgentRuntime{ + ObjectMeta: metav1.ObjectMeta{Name: "test-runtime"}, + Spec: v1alpha1.AgentRuntimeSpec{ + Type: console.AgentRuntimeTypeClaude, + TargetNamespace: "default", + Template: &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{{ + Name: agentBootstrapContainerName, + SecurityContext: customSC, + }}, + }, + }, + }, + } + + pod := buildAgentRunPod(run, runtime) + bootstrap := requireContainer(t, pod.Spec.InitContainers, agentBootstrapContainerName) + if assert.NotNil(t, bootstrap.SecurityContext) { + assert.Equal(t, customSC.RunAsUser, bootstrap.SecurityContext.RunAsUser) + if assert.NotNil(t, bootstrap.SecurityContext.ReadOnlyRootFilesystem) { + assert.False(t, *bootstrap.SecurityContext.ReadOnlyRootFilesystem) + } + } } func TestGetAgentRunPodCompletion(t *testing.T) { @@ -532,6 +585,26 @@ func TestGetAgentRunPodCompletion(t *testing.T) { } } +func assertAgentBootstrapRestrictedSecurityContext(t *testing.T, bootstrap corev1.Container) { + t.Helper() + if !assert.NotNil(t, bootstrap.SecurityContext) { + return + } + sc := bootstrap.SecurityContext + if assert.NotNil(t, sc.ReadOnlyRootFilesystem) { + assert.True(t, *sc.ReadOnlyRootFilesystem) + } + if assert.NotNil(t, sc.AllowPrivilegeEscalation) { + assert.False(t, *sc.AllowPrivilegeEscalation) + } + if assert.NotNil(t, sc.RunAsNonRoot) { + assert.True(t, *sc.RunAsNonRoot) + } + if assert.NotNil(t, sc.Capabilities) { + assert.Equal(t, []corev1.Capability{"ALL"}, sc.Capabilities.Drop) + } +} + func requireContainer(t *testing.T, containers []corev1.Container, name string) corev1.Container { t.Helper() for _, container := range containers { diff --git a/go/deployment-operator/internal/controller/configuration_fetcher.go b/go/deployment-operator/internal/controller/configuration_fetcher.go new file mode 100644 index 0000000000..c334a0e1ca --- /dev/null +++ b/go/deployment-operator/internal/controller/configuration_fetcher.go @@ -0,0 +1,77 @@ +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ConfigurationFetcher loads Secrets and ConfigMaps referenced by AgentRuntime configuration. +type ConfigurationFetcher interface { + GetSecret(ctx context.Context, selector corev1.SecretKeySelector) (*corev1.Secret, error) + GetConfigMap(ctx context.Context, selector corev1.ConfigMapKeySelector) (*corev1.ConfigMap, error) +} + +type kubeConfigurationFetcher struct { + client client.Client + namespace string +} + +func (r *AgentRunReconciler) configurationFetcher(namespace string) ConfigurationFetcher { + return &kubeConfigurationFetcher{client: r.Client, namespace: namespace} +} + +func (f *kubeConfigurationFetcher) GetSecret(ctx context.Context, selector corev1.SecretKeySelector) (*corev1.Secret, error) { + secret := &corev1.Secret{} + err := f.client.Get(ctx, client.ObjectKey{Namespace: f.namespace, Name: selector.Name}, secret) + return secret, err +} + +func (f *kubeConfigurationFetcher) GetConfigMap(ctx context.Context, selector corev1.ConfigMapKeySelector) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{} + err := f.client.Get(ctx, client.ObjectKey{Namespace: f.namespace, Name: selector.Name}, cm) + return cm, err +} + +func secretGetter(ctx context.Context, fetcher ConfigurationFetcher) func(corev1.SecretKeySelector) (*corev1.Secret, error) { + return func(selector corev1.SecretKeySelector) (*corev1.Secret, error) { + return fetcher.GetSecret(ctx, selector) + } +} + +func configurationSecretKey(ctx context.Context, fetcher ConfigurationFetcher, selector corev1.SecretKeySelector) (string, error) { + secret, err := fetcher.GetSecret(ctx, selector) + if err != nil { + return "", err + } + value, exists := secret.Data[selector.Key] + if !exists { + return "", fmt.Errorf("secret %s does not contain key %s", selector.Name, selector.Key) + } + return string(value), nil +} + +func configurationConfigMapKey(ctx context.Context, fetcher ConfigurationFetcher, selector corev1.ConfigMapKeySelector) (string, error) { + cm, err := fetcher.GetConfigMap(ctx, selector) + if err != nil { + return "", err + } + value, exists := cm.Data[selector.Key] + if !exists { + return "", fmt.Errorf("configmap %s does not contain key %s", selector.Name, selector.Key) + } + return value, nil +} + +func envVarValue(ctx context.Context, fetcher ConfigurationFetcher, src *corev1.EnvVarSource) (string, error) { + switch { + case src.SecretKeyRef != nil: + return configurationSecretKey(ctx, fetcher, *src.SecretKeyRef) + case src.ConfigMapKeyRef != nil: + return configurationConfigMapKey(ctx, fetcher, *src.ConfigMapKeyRef) + default: + return "", fmt.Errorf("valueFrom must set secretKeyRef or configMapKeyRef") + } +} diff --git a/go/deployment-operator/internal/controller/mcp.go b/go/deployment-operator/internal/controller/mcp.go new file mode 100644 index 0000000000..b76ddc55d3 --- /dev/null +++ b/go/deployment-operator/internal/controller/mcp.go @@ -0,0 +1,76 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pluralsh/console/go/deployment-operator/api/v1alpha1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" +) + +func mcpServersPayload(servers []mcp.Server) string { + if len(servers) == 0 { + return "" + } + + data, err := json.Marshal(servers) + if err != nil { + return "" + } + return string(data) +} + +func (r *AgentRunReconciler) resolveMCPServers(ctx context.Context, namespace string, servers []v1alpha1.MCPServer) ([]mcp.Server, error) { + return resolveMCPServers(ctx, servers, r.configurationFetcher(namespace)) +} + +func resolveMCPServers(ctx context.Context, servers []v1alpha1.MCPServer, fetcher ConfigurationFetcher) ([]mcp.Server, error) { + if len(servers) == 0 { + return nil, nil + } + + out := make([]mcp.Server, 0, len(servers)) + for _, server := range servers { + item := mcp.Server{ + Name: server.Name, + URL: server.URL, + AllowedTools: server.AllowedTools, + } + if len(server.Headers) == 0 { + out = append(out, item) + continue + } + + headers, err := resolveMCPHeaders(ctx, server, fetcher) + if err != nil { + return nil, err + } + item.Headers = headers + out = append(out, item) + } + return out, nil +} + +func resolveMCPHeaders(ctx context.Context, server v1alpha1.MCPServer, fetcher ConfigurationFetcher) (map[string]string, error) { + headers := make(map[string]string, len(server.Headers)) + for _, header := range server.Headers { + if header.Value != nil { + headers[header.Name] = *header.Value + continue + } + if header.ValueFrom == nil { + continue + } + + value, err := envVarValue(ctx, fetcher, header.ValueFrom) + if err != nil { + return nil, fmt.Errorf("mcp server %q header %q: %w", server.Name, header.Name, err) + } + headers[header.Name] = value + } + if len(headers) == 0 { + return nil, nil + } + return headers, nil +} diff --git a/go/deployment-operator/internal/controller/mcp_test.go b/go/deployment-operator/internal/controller/mcp_test.go new file mode 100644 index 0000000000..78e1ebfb90 --- /dev/null +++ b/go/deployment-operator/internal/controller/mcp_test.go @@ -0,0 +1,159 @@ +package controller + +import ( + "context" + "encoding/json" + "testing" + + "github.com/samber/lo" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/pluralsh/console/go/deployment-operator/api/v1alpha1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" +) + +type fakeConfigurationFetcher struct { + secrets map[string]*corev1.Secret + configMaps map[string]*corev1.ConfigMap +} + +func (f *fakeConfigurationFetcher) GetSecret(_ context.Context, selector corev1.SecretKeySelector) (*corev1.Secret, error) { + if secret, ok := f.secrets[selector.Name]; ok { + return secret, nil + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, selector.Name) +} + +func (f *fakeConfigurationFetcher) GetConfigMap(_ context.Context, selector corev1.ConfigMapKeySelector) (*corev1.ConfigMap, error) { + if cm, ok := f.configMaps[selector.Name]; ok { + return cm, nil + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, selector.Name) +} + +var _ ConfigurationFetcher = (*fakeConfigurationFetcher)(nil) + +func TestResolveMCPServers_LiteralAndSecretAndConfigMap(t *testing.T) { + servers := []v1alpha1.MCPServer{{ + Name: "linear", + URL: "https://mcp.linear.app/mcp", + AllowedTools: []string{"list_issues"}, + Headers: []v1alpha1.MCPServerHeader{{ + Name: "Authorization", + Value: lo.ToPtr("Bearer token"), + }, { + Name: "X-Api-Key", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "linear"}, + Key: "api-key", + }, + }, + }, { + Name: "X-Org", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "linear"}, + Key: "org", + }, + }, + }}, + }} + + fetcher := &fakeConfigurationFetcher{ + secrets: map[string]*corev1.Secret{ + "linear": {Data: map[string][]byte{"api-key": []byte("secret-key")}}, + }, + configMaps: map[string]*corev1.ConfigMap{ + "linear": {Data: map[string]string{"org": "acme"}}, + }, + } + + resolved, err := resolveMCPServers(context.Background(), servers, fetcher) + if err != nil { + t.Fatalf("resolveMCPServers() error = %v", err) + } + + payload := mcpServersPayload(resolved) + var decoded []mcp.Server + if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if len(decoded) != 1 || decoded[0].Name != "linear" { + t.Fatalf("payload = %#v", decoded) + } + if decoded[0].Headers["Authorization"] != "Bearer token" { + t.Fatalf("literal header = %#v", decoded[0].Headers) + } + if decoded[0].Headers["X-Api-Key"] != "secret-key" { + t.Fatalf("secret header = %#v", decoded[0].Headers) + } + if decoded[0].Headers["X-Org"] != "acme" { + t.Fatalf("configmap header = %#v", decoded[0].Headers) + } +} + +func TestResolveMCPServers_MissingSecret(t *testing.T) { + servers := []v1alpha1.MCPServer{{ + Name: "linear", + URL: "https://mcp.linear.app/mcp", + Headers: []v1alpha1.MCPServerHeader{{ + Name: "Authorization", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "missing"}, + Key: "token", + }, + }, + }}, + }} + + _, err := resolveMCPServers(context.Background(), servers, &fakeConfigurationFetcher{}) + if err == nil { + t.Fatal("expected missing secret to fail") + } +} + +func TestResolveMCPServers_RejectsFieldRef(t *testing.T) { + servers := []v1alpha1.MCPServer{{ + Name: "linear", + URL: "https://mcp.linear.app/mcp", + Headers: []v1alpha1.MCPServerHeader{{ + Name: "PodName", + ValueFrom: &corev1.EnvVarSource{ + FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}, + }, + }}, + }} + + _, err := resolveMCPServers(context.Background(), servers, &fakeConfigurationFetcher{}) + if err == nil { + t.Fatal("expected fieldRef to be rejected") + } +} + +func TestResolveMCPServers_Empty(t *testing.T) { + resolved, err := resolveMCPServers(context.Background(), nil, &fakeConfigurationFetcher{}) + if err != nil || resolved != nil { + t.Fatalf("resolveMCPServers(nil) = %#v, %v", resolved, err) + } +} + +func TestEnvVarValue_MissingKey(t *testing.T) { + fetcher := &fakeConfigurationFetcher{ + secrets: map[string]*corev1.Secret{ + "linear": {Data: map[string][]byte{"other": []byte("x")}}, + }, + } + _, err := envVarValue(context.Background(), fetcher, &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: "linear"}, + Key: "token", + }, + }) + if err == nil { + t.Fatal("expected missing key to fail") + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/mcp/servers.go b/go/deployment-operator/pkg/agentrun-harness/mcp/servers.go new file mode 100644 index 0000000000..2f3ac2cc2c --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/mcp/servers.go @@ -0,0 +1,62 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "k8s.io/klog/v2" + + "github.com/pluralsh/console/go/deployment-operator/pkg/common" +) + +const ( + // EnvServers is the JSON payload describing extra remote MCP servers for an agent run. + EnvServers = "PLRL_MCP_SERVERS" +) + +// Server is the harness-side description of a remote MCP server. +// Header values are resolved by the agent-run controller before the pod starts. +type Server struct { + Name string `json:"name"` + URL string `json:"url"` + AllowedTools []string `json:"allowedTools,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +// HasAllowedTools reports whether this server should expose an explicit tool allowlist. +func (s Server) HasAllowedTools() bool { + return len(s.AllowedTools) > 0 +} + +// Load reads extra MCP servers from EnvServers. +func Load() ([]Server, error) { + raw := os.Getenv(EnvServers) + if strings.TrimSpace(raw) == "" { + return nil, nil + } + + var servers []Server + if err := json.Unmarshal([]byte(raw), &servers); err != nil { + return nil, fmt.Errorf("parse %s: %w", EnvServers, err) + } + + out := make([]Server, 0, len(servers)) + for _, server := range servers { + if server.Name == "" || server.URL == "" { + klog.InfoS("skipping mcp server with empty name or url", "name", server.Name, "url", server.URL) + continue + } + if reservedName(server.Name) { + klog.InfoS("skipping mcp server that collides with a built-in server", "name", server.Name) + continue + } + out = append(out, server) + } + return out, nil +} + +func reservedName(name string) bool { + return name == "plural" || name == common.CodebaseMemoryMCPServerName +} diff --git a/go/deployment-operator/pkg/agentrun-harness/mcp/servers_test.go b/go/deployment-operator/pkg/agentrun-harness/mcp/servers_test.go new file mode 100644 index 0000000000..10aff28005 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/mcp/servers_test.go @@ -0,0 +1,56 @@ +package mcp + +import ( + "encoding/json" + "os" + "testing" +) + +func TestLoadIncludesHeadersAndSkipsReserved(t *testing.T) { + t.Setenv(EnvServers, mustJSON(t, []Server{{ + Name: "linear", + URL: "https://mcp.linear.app/mcp", + AllowedTools: []string{"list_issues"}, + Headers: map[string]string{"Authorization": "Bearer secret"}, + }, { + Name: "plural", + URL: "https://example.invalid/mcp", + }})) + + servers, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(servers) != 1 { + t.Fatalf("Load() returned %d servers, want 1", len(servers)) + } + if servers[0].Name != "linear" { + t.Fatalf("name = %q", servers[0].Name) + } + if !servers[0].HasAllowedTools() { + t.Fatal("expected allowed tools") + } + if servers[0].Headers["Authorization"] != "Bearer secret" { + t.Fatalf("headers = %#v", servers[0].Headers) + } +} + +func TestLoadEmpty(t *testing.T) { + os.Unsetenv(EnvServers) + servers, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if servers != nil { + t.Fatalf("Load() = %#v, want nil", servers) + } +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go index ac8f8b46d0..0b42710f9a 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go @@ -1,6 +1,11 @@ package claude -import "encoding/json" +import ( + "encoding/json" + "fmt" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" +) const ( mcpUpdateAnalysis = "mcp__plural__updateAgentRunAnalysis" @@ -59,6 +64,40 @@ func appendTools(base, extra []string) []string { return append(append([]string(nil), base...), extra...) } +func externalMCPAllowTools(servers []mcp.Server) []string { + var tools []string + for _, server := range servers { + if server.HasAllowedTools() { + for _, tool := range server.AllowedTools { + tools = append(tools, fmt.Sprintf("mcp__%s__%s", server.Name, tool)) + } + continue + } + tools = append(tools, fmt.Sprintf("mcp__%s__*", server.Name)) + } + return tools +} + +func agentWithMCPTools(agentJSON string, extra []string) string { + if len(extra) == 0 { + return agentJSON + } + + payload := map[string]agentDef{} + if err := json.Unmarshal([]byte(agentJSON), &payload); err != nil { + return agentJSON + } + for name, def := range payload { + def.Tools = appendTools(def.Tools, extra) + payload[name] = def + } + out, err := json.Marshal(payload) + if err != nil { + return agentJSON + } + return string(out) +} + var ( analysisAgent = agentJSON("analysis", agentDef{ Description: "Analyze code for potential issues, vulnerabilities and improvements. Use PROACTIVELY.", diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents_test.go new file mode 100644 index 0000000000..70b619f3b0 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents_test.go @@ -0,0 +1,41 @@ +package claude + +import ( + "encoding/json" + "testing" + + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" +) + +func TestExternalMCPAllowTools(t *testing.T) { + all := externalMCPAllowTools([]mcp.Server{{Name: "linear", URL: "https://mcp.linear.app/mcp"}}) + if len(all) != 1 || all[0] != "mcp__linear__*" { + t.Fatalf("wildcard tools = %#v", all) + } + + filtered := externalMCPAllowTools([]mcp.Server{{ + Name: "linear", + URL: "https://mcp.linear.app/mcp", + AllowedTools: []string{"list_issues", "create_issue"}, + }}) + if len(filtered) != 2 || filtered[0] != "mcp__linear__list_issues" || filtered[1] != "mcp__linear__create_issue" { + t.Fatalf("allowlisted tools = %#v", filtered) + } +} + +func TestAgentWithMCPTools(t *testing.T) { + out := agentWithMCPTools(analysisAgent, []string{"mcp__linear__*"}) + payload := map[string]agentDef{} + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatal(err) + } + found := false + for _, tool := range payload["analysis"].Tools { + if tool == "mcp__linear__*" { + found = true + } + } + if !found { + t.Fatalf("analysis tools = %#v", payload["analysis"].Tools) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go index a066d88b97..ac652ba133 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go @@ -13,6 +13,7 @@ import ( console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/usage" "github.com/pluralsh/console/go/deployment-operator/pkg/common" @@ -59,7 +60,7 @@ func (in *Claude) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool // promptFile is the absolute path to the rendered system prompt file. promptFile := path.Join(in.Config.WorkDir, ".claude", "prompts", v1.SystemPromptFile) - agent := babysitAgent + agent := in.agentJSON(babysitAgent) args := claudeRunArgs(in.Config.RepositoryDir, promptFile, agent, in.model, bCtx.Prompt, in.sessionID) @@ -118,9 +119,9 @@ func (in *Claude) FollowUpRun(ctx context.Context, followUpPrompt string) error ) promptFile := path.Join(in.Config.WorkDir, ".claude", "prompts", v1.SystemPromptFile) - agent := analysisAgent + agent := in.agentJSON(analysisAgent) if in.Config.Run.Mode == console.AgentRunModeWrite { - agent = autonomousAgent + agent = in.agentJSON(autonomousAgent) } args := claudeRunArgs(in.Config.RepositoryDir, promptFile, agent, in.model, followUpPrompt, in.sessionID) @@ -168,9 +169,9 @@ func (in *Claude) FollowUpRun(ctx context.Context, followUpPrompt string) error func (in *Claude) start(ctx context.Context, options ...exec.Option) { promptFile := path.Join(in.Config.WorkDir, ".claude", "prompts", v1.SystemPromptFile) - agent := analysisAgent + agent := in.agentJSON(analysisAgent) if in.Config.Run.Mode == console.AgentRunModeWrite { - agent = autonomousAgent + agent = in.agentJSON(autonomousAgent) } args := claudeRunArgs(in.Config.RepositoryDir, promptFile, agent, in.model, in.Config.Run.Prompt, "") @@ -236,6 +237,10 @@ func (in *Claude) ConfigureBabysitRun() error { } settings := NewSettingsBuilder(in.model) + external, err := mcp.Load() + if err != nil { + return err + } settings.AllowTools( "Read", "Write", @@ -245,7 +250,7 @@ func (in *Claude) ConfigureBabysitRun() error { "WebFetch", PluralMCPToolsWildcard, CodebaseMemoryMCPToolsWildcard, - ) + ).AllowTools(externalMCPAllowTools(external)...) defaultTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashTimeout.Milliseconds()) maxTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashMaxTimeout.Milliseconds()) settings.WithEnv("BASH_DEFAULT_TIMEOUT_MS", defaultTimeout) @@ -263,15 +268,27 @@ func (in *Claude) Configure(consoleURL, consoleToken string) error { return err } - mcp := NewMCPConfigBuilder() - mcp. + mcpCfg := NewMCPConfigBuilder() + mcpCfg. AddURLServer("plural", common.AgentMCPServerURL). Done(). AddServer(common.CodebaseMemoryMCPServerName, common.CodebaseMemoryMCPCommand). Env(common.CodebaseMemoryCacheEnv, common.CodebaseMemoryCacheDir). Done() - if err := mcp.WriteToFile(filepath.Join(in.Config.WorkDir, ".mcp.json")); err != nil { + external, err := mcp.Load() + if err != nil { + return err + } + for _, server := range external { + builder := mcpCfg.AddURLServer(server.Name, server.URL) + for name, value := range server.Headers { + builder.Header(name, value) + } + builder.Done() + } + + if err := mcpCfg.WriteToFile(filepath.Join(in.Config.WorkDir, ".mcp.json")); err != nil { return err } @@ -300,7 +317,7 @@ func (in *Claude) Configure(consoleURL, consoleToken string) error { "WebFetch", PluralMCPToolsWildcard, CodebaseMemoryMCPToolsWildcard, - ).DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") + ).AllowTools(externalMCPAllowTools(external)...).DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") } else { settings.AllowTools( "Read", @@ -311,7 +328,7 @@ func (in *Claude) Configure(consoleURL, consoleToken string) error { "WebFetch", PluralMCPToolsWildcard, CodebaseMemoryMCPToolsWildcard, - ) + ).AllowTools(externalMCPAllowTools(external)...) } defaultTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashTimeout.Milliseconds()) @@ -323,6 +340,15 @@ func (in *Claude) Configure(consoleURL, consoleToken string) error { return settings.WriteToFile(filepath.Join(in.configPath(), "settings.local.json")) } +func (in *Claude) agentJSON(agent string) string { + servers, err := mcp.Load() + if err != nil { + klog.ErrorS(err, "failed to load external mcp servers for claude agents") + return agent + } + return agentWithMCPTools(agent, externalMCPAllowTools(servers)) +} + func (in *Claude) configPath() string { return path.Join(in.Config.WorkDir, ".claude") } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go index cf74ef36ae..c2f1ea2236 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go @@ -12,6 +12,7 @@ import ( console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/dind" + mcpcfg "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" "github.com/pluralsh/console/go/deployment-operator/pkg/common" "github.com/pluralsh/console/go/deployment-operator/pkg/log" @@ -169,6 +170,23 @@ func (in *Codex) writeCodexConfig() error { TrustPolicy: "always", }} + external, err := mcpcfg.Load() + if err != nil { + return err + } + for _, server := range external { + input := MCPInput{ + Name: server.Name, + URL: server.URL, + HTTPHeaders: server.Headers, + TrustPolicy: "always", + } + if server.HasAllowedTools() { + input.EnabledTools = server.AllowedTools + } + mcps = append(mcps, input) + } + switch in.Config.Run.Mode { case console.AgentRunModeAnalyze: agents = []AgentInput{{ diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go index dee4decc5e..3dfc7f70b2 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates.go @@ -57,15 +57,17 @@ func BuildCodexConfig(repositoryDir string, agents []AgentInput, mcps []MCPInput // Add MCP servers for _, m := range mcps { cfg.MCPServers[m.Name] = &MCPServer{ - Type: m.Type, - URL: m.URL, - Command: m.Command, - Args: m.Args, - Env: m.Env, - Headers: m.Headers, - EnabledTools: m.EnabledTools, - DisabledTools: m.DisabledTools, - TrustPolicy: m.TrustPolicy, + Type: m.Type, + URL: m.URL, + Command: m.Command, + Args: m.Args, + Env: m.Env, + Headers: m.Headers, + HTTPHeaders: m.HTTPHeaders, + EnvHTTPHeaders: m.EnvHTTPHeaders, + EnabledTools: m.EnabledTools, + DisabledTools: m.DisabledTools, + TrustPolicy: m.TrustPolicy, } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go index 992c6858a0..03a7f2710a 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_templates_test.go @@ -77,6 +77,42 @@ func TestBuildCodexConfig_CodebaseMemoryMCPServer(t *testing.T) { } } +func TestBuildCodexConfig_ExternalHTTPServer(t *testing.T) { + cfg, err := BuildCodexConfig("/repo", []AgentInput{{ + Name: autonomousProfile, + SandboxMode: sandboxModeHarness, + Model: string(ModelGPT54), + }}, []MCPInput{{ + Name: "linear", + URL: "https://mcp.linear.app/mcp", + HTTPHeaders: map[string]string{ + "Authorization": "Bearer token", + }, + EnabledTools: []string{"list_issues"}, + TrustPolicy: "always", + }}, nil) + if err != nil { + t.Fatalf("BuildCodexConfig() failed: %v", err) + } + + server := cfg.MCPServers["linear"] + if server == nil { + t.Fatal("expected linear MCP server") + } + if server.URL != "https://mcp.linear.app/mcp" { + t.Fatalf("url = %q", server.URL) + } + if server.HTTPHeaders["Authorization"] != "Bearer token" { + t.Fatalf("http_headers = %#v", server.HTTPHeaders) + } + if len(server.EnabledTools) != 1 || server.EnabledTools[0] != "list_issues" { + t.Fatalf("enabled_tools = %#v", server.EnabledTools) + } + if server.TrustPolicy != "always" { + t.Fatalf("trust_policy = %q", server.TrustPolicy) + } +} + func TestCodexExecArgs(t *testing.T) { repositoryDir := dind.RepositoryDir() args := codexExecArgs(repositoryDir, autonomousProfile, "run tests", "") diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go index d0fc4679fd..78074172d9 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go @@ -193,16 +193,18 @@ type Project struct { } type MCPInput struct { - Name string - Type string // Transport type: "stdio", "sse" or "http" - URL string - Command string - Args []string - Env map[string]string - Headers map[string]string // HTTP request headers, used for "http" transport - EnabledTools []string - DisabledTools []string - TrustPolicy string // e.g. "always" to auto-approve tool calls in exec mode + Name string + Type string // Transport type: "stdio", "sse" or "http" + URL string + Command string + Args []string + Env map[string]string + Headers map[string]string // HTTP request headers, used for "http" transport + HTTPHeaders map[string]string // Codex streamable HTTP headers (`http_headers`) + EnvHTTPHeaders map[string]string // Header name -> env var name (`env_http_headers`) + EnabledTools []string + DisabledTools []string + TrustPolicy string // e.g. "always" to auto-approve tool calls in exec mode } // ModelProviderInput is the user-facing input for registering a custom model provider. @@ -254,15 +256,17 @@ type Profile struct { } type MCPServer struct { - Type string `toml:"type,omitempty"` // Transport type: "stdio", "sse" or "http" - URL string `toml:"url,omitempty"` // For remote MCP (sse/http) - Command string `toml:"command,omitempty"` // For local MCP (stdio) - Args []string `toml:"args,omitempty"` - Env map[string]string `toml:"env,omitempty"` - Headers map[string]string `toml:"headers,omitempty"` // HTTP request headers for "http" transport - EnabledTools []string `toml:"enabled_tools,omitempty"` - DisabledTools []string `toml:"disabled_tools,omitempty"` - TrustPolicy string `toml:"trust_policy,omitempty"` // e.g. "always" to auto-approve tool calls in exec mode + Type string `toml:"type,omitempty"` // Transport type: "stdio", "sse" or "http" + URL string `toml:"url,omitempty"` // For remote MCP (sse/http) + Command string `toml:"command,omitempty"` // For local MCP (stdio) + Args []string `toml:"args,omitempty"` + Env map[string]string `toml:"env,omitempty"` + Headers map[string]string `toml:"headers,omitempty"` // HTTP request headers for "http" transport + HTTPHeaders map[string]string `toml:"http_headers,omitempty"` // Codex streamable HTTP headers + EnvHTTPHeaders map[string]string `toml:"env_http_headers,omitempty"` // Header values read from the process environment + EnabledTools []string `toml:"enabled_tools,omitempty"` + DisabledTools []string `toml:"disabled_tools,omitempty"` + TrustPolicy string `toml:"trust_policy,omitempty"` // e.g. "always" to auto-approve tool calls in exec mode } type CodexConfig struct { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go index a3b42fdab9..33f68a882c 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings.go @@ -2,10 +2,12 @@ package gemini import ( _ "embed" + "encoding/json" "strings" "text/template" console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" ) //go:embed templates/settings.json.gotmpl @@ -30,6 +32,52 @@ func settings(input *ConfigTemplateInput) (fileName, content string, err error) out := new(strings.Builder) err = tmpl.Execute(out, input) + if err != nil { + return SettingsFileName, "", err + } + + content, err = injectExternalMCPServers(out.String()) + return SettingsFileName, content, err +} + +func injectExternalMCPServers(content string) (string, error) { + servers, err := mcp.Load() + if err != nil { + return "", err + } + if len(servers) == 0 { + return content, nil + } - return SettingsFileName, out.String(), err + var cfg map[string]any + if err := json.Unmarshal([]byte(content), &cfg); err != nil { + return "", err + } + + mcpServers, _ := cfg["mcpServers"].(map[string]any) + if mcpServers == nil { + mcpServers = map[string]any{} + cfg["mcpServers"] = mcpServers + } + + for _, server := range servers { + entry := map[string]any{ + "httpUrl": server.URL, + "trust": true, + "description": "External MCP server " + server.Name, + } + if len(server.Headers) > 0 { + entry["headers"] = server.Headers + } + if server.HasAllowedTools() { + entry["includeTools"] = server.AllowedTools + } + mcpServers[server.Name] = entry + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + return string(data), nil } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go index 054e2cdaf9..4a59654d88 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/settings_test.go @@ -5,6 +5,7 @@ import ( "testing" console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" "github.com/pluralsh/console/go/deployment-operator/pkg/common" ) @@ -122,3 +123,35 @@ func TestSettingsTemplate_GenerateAndVerifyContents(t *testing.T) { } }) } + +func TestSettingsTemplate_ExternalMCPServer(t *testing.T) { + t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp","allowedTools":["list_issues"],"headers":{"Authorization":"Bearer secret"}}]`) + + input := &ConfigTemplateInput{ + Model: ModelGemini31FlashLite, + RepositoryDir: "/repo", + AgentRunID: "run-123", + AgentRunMode: console.AgentRunModeWrite, + } + _, content, err := settings(input) + if err != nil { + t.Fatalf("settings() failed: %v", err) + } + + var out map[string]any + if err := json.Unmarshal([]byte(content), &out); err != nil { + t.Fatalf("generated content is not valid JSON: %v", err) + } + linear := out["mcpServers"].(map[string]any)["linear"].(map[string]any) + if linear["httpUrl"] != "https://mcp.linear.app/mcp" { + t.Fatalf("httpUrl = %v", linear["httpUrl"]) + } + headers := linear["headers"].(map[string]any) + if headers["Authorization"] != "Bearer secret" { + t.Fatalf("headers = %#v", headers) + } + includeTools := linear["includeTools"].([]any) + if len(includeTools) != 1 || includeTools[0] != "list_issues" { + t.Fatalf("includeTools = %#v", includeTools) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates.go index 6076202496..244291ea2d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates.go @@ -2,10 +2,12 @@ package opencode import ( _ "embed" + "encoding/json" "strings" "text/template" console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" ) //go:embed templates/opencode.json.gotmpl @@ -59,6 +61,77 @@ func configTemplate(input *ConfigTemplateInput) (fileName, content string, err e out := new(strings.Builder) err = tmpl.Execute(out, input) + if err != nil { + return ConfigFileName, "", err + } + + content, err = injectExternalMCPServers(out.String()) + return ConfigFileName, content, err +} + +func injectExternalMCPServers(content string) (string, error) { + servers, err := mcp.Load() + if err != nil { + return "", err + } + if len(servers) == 0 { + return content, nil + } + + var cfg map[string]any + if err := json.Unmarshal([]byte(content), &cfg); err != nil { + return "", err + } - return ConfigFileName, out.String(), err + mcpSection, _ := cfg["mcp"].(map[string]any) + if mcpSection == nil { + mcpSection = map[string]any{} + cfg["mcp"] = mcpSection + } + agentSection, _ := cfg["agent"].(map[string]any) + + for _, server := range servers { + entry := map[string]any{ + "type": "remote", + "url": server.URL, + "enabled": true, + "oauth": false, + } + if len(server.Headers) > 0 { + entry["headers"] = server.Headers + } + mcpSection[server.Name] = entry + + for _, agentName := range []string{"analysis", "autonomous"} { + agent, _ := agentSection[agentName].(map[string]any) + if agent == nil { + continue + } + tools, _ := agent["tools"].(map[string]any) + if tools == nil { + tools = map[string]any{} + agent["tools"] = tools + } + for _, key := range openCodeToolKeys(server) { + tools[key] = true + } + } + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} + +func openCodeToolKeys(server mcp.Server) []string { + if !server.HasAllowedTools() { + return []string{server.Name + "*"} + } + keys := make([]string, 0, len(server.AllowedTools)) + for _, tool := range server.AllowedTools { + keys = append(keys, server.Name+"_"+tool) + } + return keys } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go index d665dd763d..adbf1c59f7 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go @@ -7,6 +7,7 @@ import ( console "github.com/pluralsh/console/go/client" agentrunv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" "github.com/pluralsh/console/go/deployment-operator/pkg/common" ) @@ -77,15 +78,58 @@ func TestConfigTemplate_PluralMcpServer(t *testing.T) { }) } -func TestConfigTemplate_DisablesLocalStateFeatures(t *testing.T) { +func TestConfigTemplate_ExternalMCPServer(t *testing.T) { + t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp","allowedTools":["list_issues"],"headers":{"Authorization":"Bearer secret"}}]`) + out := renderJSON(t, baseInput(console.AgentRunModeWrite)) + mcpSection := out["mcp"].(map[string]any) + linear := mcpSection["linear"].(map[string]any) + if linear["type"] != "remote" { + t.Fatalf("type = %v", linear["type"]) + } + if linear["url"] != "https://mcp.linear.app/mcp" { + t.Fatalf("url = %v", linear["url"]) + } + headers := linear["headers"].(map[string]any) + if headers["Authorization"] != "Bearer secret" { + t.Fatalf("headers = %#v", headers) + } + if linear["oauth"] != false { + t.Fatalf("oauth = %v", linear["oauth"]) + } - if out["autoupdate"] != false { - t.Fatalf("expected autoupdate=false, got %v", out["autoupdate"]) + tools := out["agent"].(map[string]any)["analysis"].(map[string]any)["tools"].(map[string]any) + if tools["linear_list_issues"] != true { + t.Fatalf("analysis tools = %#v", tools) } - if out["snapshot"] != false { - t.Fatalf("expected snapshot=false, got %v", out["snapshot"]) +} + +func TestConfigTemplate_ExternalMCPServerAllTools(t *testing.T) { + t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp"}]`) + + out := renderJSON(t, baseInput(console.AgentRunModeWrite)) + mcpSection := out["mcp"].(map[string]any) + linear := mcpSection["linear"].(map[string]any) + if linear["oauth"] != false { + t.Fatalf("oauth = %v", linear["oauth"]) } + tools := out["agent"].(map[string]any)["analysis"].(map[string]any)["tools"].(map[string]any) + if tools["linear*"] != true { + t.Fatalf("analysis tools = %#v", tools) + } +} + +func TestConfigTemplate_DisablesLocalStateFeatures(t *testing.T) { + t.Run("disables autoupdate and snapshot", func(t *testing.T) { + out := renderJSON(t, baseInput(console.AgentRunModeWrite)) + + if out["autoupdate"] != false { + t.Fatalf("expected autoupdate=false, got %v", out["autoupdate"]) + } + if out["snapshot"] != false { + t.Fatalf("expected snapshot=false, got %v", out["snapshot"]) + } + }) } func TestConfigTemplate_AllowsSkillLoading(t *testing.T) { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go index 42454ea48a..2e49035a92 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi.go @@ -10,6 +10,7 @@ import ( console "github.com/pluralsh/console/go/client" "github.com/pluralsh/console/go/deployment-operator/internal/helpers" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" proxymodel "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/model" "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/artifacts" v1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" @@ -244,6 +245,9 @@ func (in *Pi) writeConfig() error { }, }, } + if err := addExternalMCPServers(mcp["mcpServers"].(map[string]any)); err != nil { + return err + } mcpData, err := json.Marshal(mcp) if err != nil { return fmt.Errorf("marshal pi mcp config: %w", err) @@ -254,6 +258,29 @@ func (in *Pi) writeConfig() error { return nil } +func addExternalMCPServers(servers map[string]any) error { + external, err := mcp.Load() + if err != nil { + return fmt.Errorf("load external mcp servers: %w", err) + } + for _, server := range external { + entry := map[string]any{ + "url": server.URL, + } + if len(server.Headers) > 0 { + entry["headers"] = server.Headers + } + if server.HasAllowedTools() { + entry["directTools"] = server.AllowedTools + entry["includeTools"] = server.AllowedTools + } else { + entry["directTools"] = true + } + servers[server.Name] = entry + } + return nil +} + func (in *Pi) UploadArtifacts(ctx context.Context) (*artifacts.UploadArtifacts, error) { return in.BuildUploadArtifacts(ctx, artifacts.BuildArtifactsOptions{ Provider: "pi", diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go index 4352319f56..1e6609f4ea 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/pi/pi_test.go @@ -6,9 +6,31 @@ import ( "testing" console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/mcp" toolv1 "github.com/pluralsh/console/go/deployment-operator/pkg/agentrun-harness/tool/v1" ) +func TestAddExternalMCPServers(t *testing.T) { + t.Setenv(mcp.EnvServers, `[{"name":"linear","url":"https://mcp.linear.app/mcp","allowedTools":["list_issues"],"headers":{"Authorization":"Bearer secret"}}]`) + + servers := map[string]any{} + if err := addExternalMCPServers(servers); err != nil { + t.Fatalf("addExternalMCPServers() error = %v", err) + } + linear := servers["linear"].(map[string]any) + if linear["url"] != "https://mcp.linear.app/mcp" { + t.Fatalf("url = %v", linear["url"]) + } + headers := linear["headers"].(map[string]string) + if headers["Authorization"] != "Bearer secret" { + t.Fatalf("headers = %#v", headers) + } + directTools := linear["directTools"].([]string) + if len(directTools) != 1 || directTools[0] != "list_issues" { + t.Fatalf("directTools = %#v", directTools) + } +} + func TestArgsIncludesJSONModeSessionAndMCPConfig(t *testing.T) { tool := &Pi{ DefaultTool: toolv1.DefaultTool{Config: toolv1.Config{WorkDir: "/work"}}, diff --git a/go/deployment-operator/test/mixed/kustomize/liquid/dev/kustomization.yaml b/go/deployment-operator/test/mixed/kustomize/liquid/dev/kustomization.yaml index 2b01f31ff2..ef2c014f06 100644 --- a/go/deployment-operator/test/mixed/kustomize/liquid/dev/kustomization.yaml +++ b/go/deployment-operator/test/mixed/kustomize/liquid/dev/kustomization.yaml @@ -11,7 +11,7 @@ nameSuffix: -dev configMapGenerator: - literals: - username=demo-user - name: nginx + name: secretGenerator: - literals: diff --git a/go/deployment-operator/test/mixed/raw/pod.yaml b/go/deployment-operator/test/mixed/raw/pod.yaml index efe5bd384b..2086ffbe04 100644 --- a/go/deployment-operator/test/mixed/raw/pod.yaml +++ b/go/deployment-operator/test/mixed/raw/pod.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: Pod metadata: - name: nginx + name: spec: containers: - name: nginx diff --git a/lib/console/ai/mcp/tool.ex b/lib/console/ai/mcp/tool.ex index 0de9847b96..9cdceeb1f1 100644 --- a/lib/console/ai/mcp/tool.ex +++ b/lib/console/ai/mcp/tool.ex @@ -1,11 +1,12 @@ defmodule Console.AI.MCP.Tool do defstruct [:name, :description, :input_schema] - def new(args) do + def new(args) when is_map(args) do %__MODULE__{ name: args["name"], description: args["description"], input_schema: args["inputSchema"] } end + def new(_), do: nil end diff --git a/lib/console/ai/model_selection.ex b/lib/console/ai/model_selection.ex index fe02fad5c5..5444fee7d5 100644 --- a/lib/console/ai/model_selection.ex +++ b/lib/console/ai/model_selection.ex @@ -1,5 +1,12 @@ defmodule Console.AI.ModelSelection do - alias Console.Schema.{AIUsage, DeploymentSettings, WorkbenchJob} + alias Console.Schema.{ + AIUsage, + AgentRun, + AgentRuntime, + DeploymentSettings, + Workbench, + WorkbenchJob + } @tokens_per_million 1_000_000 @@ -9,10 +16,12 @@ defmodule Console.AI.ModelSelection do A workbench job's model override takes precedence over the configured tool model. """ @spec tool_model(WorkbenchJob.t(), DeploymentSettings.t() | nil) :: %{provider: atom, model: binary} | nil - def tool_model(%WorkbenchJob{modes: %{model: %{provider: provider, model: model}}}, _settings) - when is_atom(provider) and is_binary(model), - do: %{provider: provider, model: model} - def tool_model(%WorkbenchJob{}, %DeploymentSettings{ai: %{provider: provider} = ai}) do + def tool_model(%WorkbenchJob{modes: %{model: model}} = job, settings) do + model_info(model) || settings_tool_model(job, settings) + end + def tool_model(%WorkbenchJob{} = job, settings), do: settings_tool_model(job, settings) + + defp settings_tool_model(%WorkbenchJob{}, %DeploymentSettings{ai: %{provider: provider} = ai}) do provider = ai.tool_provider || provider with config when is_map(config) <- Map.get(ai, provider), @@ -22,7 +31,25 @@ defmodule Console.AI.ModelSelection do _ -> nil end end - def tool_model(%WorkbenchJob{}, _), do: nil + defp settings_tool_model(_, _), do: nil + + @doc """ + Resolves the provider and model used by an agent runtime. + + Coding-agent runs report token counts but often omit cost. This is the model those + tokens should be attributed against when a price sheet is configured. + """ + @spec runtime_model(term) :: %{provider: atom, model: binary} | nil + def runtime_model(%AgentRun{runtime: runtime}), do: runtime_model(runtime) + def runtime_model(%WorkbenchJob{workbench: workbench}), do: runtime_model(workbench) + def runtime_model(%Workbench{agent_runtime: runtime}), do: runtime_model(runtime) + def runtime_model(%AgentRuntime{model: model}), do: model_info(model) + def runtime_model(_), do: nil + + defp model_info(%{provider: provider, model: model}) + when is_atom(provider) and is_binary(model), + do: %{provider: provider, model: model} + defp model_info(_), do: nil @doc """ Finds the configured price sheet for a provider and model. diff --git a/lib/console/ai/tools/agent/base.ex b/lib/console/ai/tools/agent/base.ex index 77b543cf06..1d4f08cd46 100644 --- a/lib/console/ai/tools/agent/base.ex +++ b/lib/console/ai/tools/agent/base.ex @@ -61,31 +61,31 @@ defmodule Console.AI.Tools.Agent.Base do defp maybe_preload(session, true), do: Repo.preload(session, @preloads, force: true) defp maybe_preload(session, _), do: session - def to_pb(%CloudConnection{provider: :aws} = connection) do + def to_pb(%CloudConnection{provider: :aws, configuration: %{aws: %{} = aws}} = connection) do %Connection{ provider: "#{connection.provider}", - credentials: {:aws, to_pb(connection.configuration.aws)}, + credentials: {:aws, to_pb(aws)}, } end - def to_pb(%CloudConnection{provider: :gcp} = connection) do + def to_pb(%CloudConnection{provider: :gcp, configuration: %{gcp: %{} = gcp}} = connection) do %Connection{ provider: "#{connection.provider}", - credentials: {:gcp, to_pb(connection.configuration.gcp)}, + credentials: {:gcp, to_pb(gcp)}, } end - def to_pb(%CloudConnection{provider: :azure} = connection) do + def to_pb(%CloudConnection{provider: :azure, configuration: %{azure: %{} = azure}} = connection) do %Connection{ provider: "#{connection.provider}", - credentials: {:azure, to_pb(connection.configuration.azure)}, + credentials: {:azure, to_pb(azure)}, } end - def to_pb(%CloudConnection{provider: :vsphere} = connection) do + def to_pb(%CloudConnection{provider: :vsphere, configuration: %{vsphere: %{} = vsphere}} = connection) do %Connection{ provider: "#{connection.provider}", - credentials: {:vsphere, to_pb(connection.configuration.vsphere)}, + credentials: {:vsphere, to_pb(vsphere)}, } end diff --git a/lib/console/ai/tools/workbench/infrastructure/cloud_schemas.ex b/lib/console/ai/tools/workbench/infrastructure/cloud_schemas.ex index 3e683b9f82..e7df922c33 100644 --- a/lib/console/ai/tools/workbench/infrastructure/cloud_schemas.ex +++ b/lib/console/ai/tools/workbench/infrastructure/cloud_schemas.ex @@ -32,19 +32,24 @@ defmodule Console.AI.Tools.Workbench.Infrastructure.CloudSchemas do def json_schema(_), do: @json_schema def name(%__MODULE__{tool: %{name: name}}), do: "cloud_schemas_#{name}" + def name(_), do: "cloud_schemas" def description(%__MODULE__{tool: %WorkbenchTool{cloud_connection: %CloudConnection{provider: provider}}}), do: "Shows the schemas for an exact list of tables in a #{provider} cloud account. Use after cloud_tables to inspect only the tables needed for a SQL query." + def description(_), + do: "Shows the schemas for an exact list of tables in a cloud account. Use after cloud_tables to inspect only the tables needed for a SQL query." def implement(%__MODULE__{tool: %WorkbenchTool{cloud_connection: %CloudConnection{} = connection}, tables: tables}) do - with {:ok, client} <- Client.connect(), - input = %SchemasInput{connection: to_pb(connection), tables: tables}, + with %{} = pb <- to_pb(connection) || {:error, "cloud connection is missing provider credentials"}, + {:ok, client} <- Client.connect(), + input = %SchemasInput{connection: pb, tables: tables}, {:ok, output} <- Stub.schemas(client, input, Client.cloud_query_rpc_opts()) do - format_schema(results: output.result) + format_schema(results: output.result || []) |> String.trim() |> then(& {:ok, &1}) end end + def implement(_), do: {:error, "cloud schemas tool is missing a cloud connection"} EEx.function_from_file( :defp, diff --git a/lib/console/ai/tools/workbench/infrastructure/cloud_tables.ex b/lib/console/ai/tools/workbench/infrastructure/cloud_tables.ex index 06ec037cf7..d078dc4cbe 100644 --- a/lib/console/ai/tools/workbench/infrastructure/cloud_tables.ex +++ b/lib/console/ai/tools/workbench/infrastructure/cloud_tables.ex @@ -19,14 +19,19 @@ defmodule Console.AI.Tools.Workbench.Infrastructure.CloudTables do def json_schema(_), do: @json_schema def name(%__MODULE__{tool: %{name: name}}), do: "cloud_tables_#{name}" + def name(_), do: "cloud_tables" def description(%__MODULE__{tool: %WorkbenchTool{cloud_connection: %CloudConnection{provider: provider}}}), do: "Shows the available tables for querying a #{provider} cloud account using sql. Can also fuzzy search for certain tables using the table parameter to save tokens." + def description(_), + do: "Shows the available tables for querying a cloud account using sql. Can also fuzzy search for certain tables using the table parameter to save tokens." def implement(%__MODULE__{tool: %WorkbenchTool{cloud_connection: %CloudConnection{} = connection}, table: table}) do - with {:ok, client} <- Client.connect(), - input = %TablesInput{connection: to_pb(connection), table: table}, + with %{} = pb <- to_pb(connection) || {:error, "cloud connection is missing provider credentials"}, + {:ok, client} <- Client.connect(), + input = %TablesInput{connection: pb, table: table}, {:ok, output} <- Stub.tables(client, input) do Protobuf.JSON.encode(output) end end + def implement(_), do: {:error, "cloud tables tool is missing a cloud connection"} end diff --git a/lib/console/ai/tools/workbench/infrastructure/cluster.ex b/lib/console/ai/tools/workbench/infrastructure/cluster.ex index 0ab2715ae2..98b4e4b797 100644 --- a/lib/console/ai/tools/workbench/infrastructure/cluster.ex +++ b/lib/console/ai/tools/workbench/infrastructure/cluster.ex @@ -59,7 +59,7 @@ defmodule Console.AI.Tools.Workbench.Infrastructure.Cluster do end def simplified_upgrade_plan(_), do: nil - defp simplify_addon(%{current: curr, fix: fix} = addon) do + defp simplify_addon(%{current: %{} = curr, fix: fix} = addon) do %{ current: Map.take(curr, [:version, :summary]) |> Map.put(:name, curr.addon && curr.addon.name) @@ -70,7 +70,7 @@ defmodule Console.AI.Tools.Workbench.Infrastructure.Cluster do end defp simplify_addon(_), do: nil - defp simplify_cloud_addon(%{current: curr, fix: fix} = addon) do + defp simplify_cloud_addon(%{current: %{} = curr, fix: fix} = addon) do %{ current: Map.take(curr, [:version, :summary]) |> Map.put(:addon_details, Map.drop(curr, [:addon])), diff --git a/lib/console/ai/tools/workbench/infrastructure/raw_cloud_query.ex b/lib/console/ai/tools/workbench/infrastructure/raw_cloud_query.ex index c46efeb527..df95c4a602 100644 --- a/lib/console/ai/tools/workbench/infrastructure/raw_cloud_query.ex +++ b/lib/console/ai/tools/workbench/infrastructure/raw_cloud_query.ex @@ -18,15 +18,20 @@ defmodule Console.AI.Tools.Workbench.Infrastructure.RawCloudQuery do end def json_schema(_), do: @json_schema - def name(%__MODULE__{tool: %WorkbenchTool{name: name}}), do: "cloud_query_#{name}" + def name(%__MODULE__{tool: %{name: name}}), do: "cloud_query_#{name}" + def name(_), do: "cloud_query" def description(%__MODULE__{tool: %WorkbenchTool{cloud_connection: %CloudConnection{provider: provider}}}), do: "Performs a postgresql-compatible sql query against the #{provider} cloud account. You *must* use the cloud schema tool to discover the schema of the sql database first before calling this so it uses the proper tables and columns." + def description(_), + do: "Performs a postgresql-compatible sql query against a cloud account. You *must* use the cloud schema tool to discover the schema of the sql database first before calling this so it uses the proper tables and columns." def implement(%__MODULE__{query: query, tool: %WorkbenchTool{cloud_connection: %CloudConnection{} = connection}}) do - with {:ok, client} <- Client.connect(), - input = %QueryInput{query: query, connection: to_pb(connection)}, + with %{} = pb <- to_pb(connection) || {:error, "cloud connection is missing provider credentials"}, + {:ok, client} <- Client.connect(), + input = %QueryInput{query: query, connection: pb}, {:ok, %QueryResult{result: result}} <- Stub.query(client, input, Client.cloud_query_rpc_opts()), do: JSON.decode(result) end + def implement(_), do: {:error, "cloud query tool is missing a cloud connection"} end diff --git a/lib/console/ai/tools/workbench/integration/slack/tools.ex b/lib/console/ai/tools/workbench/integration/slack/tools.ex new file mode 100644 index 0000000000..82492b282a --- /dev/null +++ b/lib/console/ai/tools/workbench/integration/slack/tools.ex @@ -0,0 +1,20 @@ +defmodule Console.AI.Tools.Workbench.Integration.Slack.Tools do + @moduledoc false + + alias Console.Schema.WorkbenchTool + + @modules [ + Console.AI.Tools.Workbench.Integration.Slack.ListChannels, + Console.AI.Tools.Workbench.Integration.Slack.ListMessages, + Console.AI.Tools.Workbench.Integration.Slack.ListUserGroups, + Console.AI.Tools.Workbench.Integration.Slack.FindChannelByName, + Console.AI.Tools.Workbench.Integration.Slack.InviteToChannel, + Console.AI.Tools.Workbench.Integration.Slack.CreateChannel, + Console.AI.Tools.Workbench.Integration.Slack.PostMessage, + Console.AI.Tools.Workbench.Integration.Slack.EditMessage, + Console.AI.Tools.Workbench.Integration.Slack.ReactToMessage + ] + + @spec expand(WorkbenchTool.t()) :: [struct()] + def expand(%WorkbenchTool{} = tool), do: Enum.map(@modules, &struct(&1, tool: tool)) +end diff --git a/lib/console/ai/tools/workbench/knowledge.ex b/lib/console/ai/tools/workbench/knowledge.ex new file mode 100644 index 0000000000..869438503e --- /dev/null +++ b/lib/console/ai/tools/workbench/knowledge.ex @@ -0,0 +1,29 @@ +defmodule Console.AI.Tools.Workbench.Knowledge do + use Console.AI.Tools.Workbench.Base + alias Console.Schema.WorkbenchJob + alias Console.Deployments.Workbenches + + embedded_schema do + field :job, :map, virtual: true + field :name, :string + end + + @json_schema Console.priv_file!("tools/workbench/read_knowledge.json") |> Jason.decode!() + + def name(_), do: "workbench_knowledge" + def json_schema(_), do: @json_schema + def description(_), do: "Get the full contents of a specific workbench knowledge entry by name. Use the workbench_list_knowledge tool to list entries first. Reading an entry records a usage." + + def changeset(model, attrs) do + model + |> cast(attrs, [:name]) + |> validate_required([:name]) + end + + def implement(%__MODULE__{job: %WorkbenchJob{workbench_id: id}, name: name}) do + with {:ok, knowledge} <- Workbenches.knowledge_used(id, name) do + Map.take(knowledge, [:id, :name, :description, :knowledge, :labels, :usages, :last_used_at]) + |> Jason.encode() + end + end +end diff --git a/lib/console/ai/tools/workbench/knowledge_delete.ex b/lib/console/ai/tools/workbench/knowledge_delete.ex new file mode 100644 index 0000000000..75b83cb3e5 --- /dev/null +++ b/lib/console/ai/tools/workbench/knowledge_delete.ex @@ -0,0 +1,28 @@ +defmodule Console.AI.Tools.Workbench.KnowledgeDelete do + use Console.AI.Tools.Workbench.Base + alias Console.Schema.WorkbenchJob + alias Console.Deployments.Workbenches + + embedded_schema do + field :job, :map, virtual: true + field :knowledge_id, :string + end + + @json_schema Console.priv_file!("tools/workbench/knowledge_delete.json") |> Jason.decode!() + + def name(_), do: "workbench_knowledge_delete" + def json_schema(_), do: @json_schema + def description(_), do: "Delete a workbench knowledge entry by id. Prefer deleting less used entries (low usages and older last_used_at) when making room for new knowledge. Use workbench_list_knowledge to inspect usage data first." + + def changeset(model, attrs) do + model + |> cast(attrs, [:knowledge_id]) + |> validate_required([:knowledge_id]) + end + + def implement(%__MODULE__{job: %WorkbenchJob{} = job, knowledge_id: knowledge_id}) do + with {:ok, knowledge} <- Workbenches.delete_workbench_knowledge(knowledge_id, job) do + {:ok, "Deleted knowledge #{knowledge.name} (#{knowledge.id})"} + end + end +end diff --git a/lib/console/ai/tools/workbench/knowledge_upsert.ex b/lib/console/ai/tools/workbench/knowledge_upsert.ex new file mode 100644 index 0000000000..7bac914c01 --- /dev/null +++ b/lib/console/ai/tools/workbench/knowledge_upsert.ex @@ -0,0 +1,42 @@ +defmodule Console.AI.Tools.Workbench.KnowledgeUpsert do + use Console.AI.Tools.Workbench.Base + alias Console.Schema.WorkbenchJob + alias Console.Deployments.Workbenches + + embedded_schema do + field :job, :map, virtual: true + field :name, :string + field :description, :string + field :knowledge, :string + field :labels, {:array, :string} + end + + @json_schema Console.priv_file!("tools/workbench/knowledge_upsert.json") |> Jason.decode!() + + def name(_), do: "workbench_knowledge_upsert" + def json_schema(_), do: @json_schema + def description(_), do: "Create or update a workbench knowledge entry by name. At most 10 knowledge entries can exist on a workbench; creating a new entry will fail if that limit is already reached. Prefer updating an existing entry when the information belongs with it." + + def changeset(model, attrs) do + model + |> cast(attrs, [:name, :description, :knowledge, :labels]) + |> validate_required([:name, :knowledge]) + end + + def implement(%__MODULE__{job: %WorkbenchJob{workbench_id: id}} = model) do + attrs = + %{ + name: model.name, + description: model.description, + knowledge: model.knowledge, + labels: model.labels + } + |> Enum.reject(fn {_, v} -> is_nil(v) end) + |> Map.new() + + with {:ok, knowledge} <- Workbenches.upsert_workbench_knowledge(attrs, id) do + Map.take(knowledge, [:id, :name, :description, :knowledge, :labels, :usages, :last_used_at]) + |> Jason.encode() + end + end +end diff --git a/lib/console/ai/tools/workbench/knowledge_used.ex b/lib/console/ai/tools/workbench/knowledge_used.ex new file mode 100644 index 0000000000..7c63a4955d --- /dev/null +++ b/lib/console/ai/tools/workbench/knowledge_used.ex @@ -0,0 +1,29 @@ +defmodule Console.AI.Tools.Workbench.KnowledgeUsed do + use Console.AI.Tools.Workbench.Base + alias Console.Schema.WorkbenchJob + alias Console.Deployments.Workbenches + + embedded_schema do + field :job, :map, virtual: true + field :name, :string + end + + @json_schema Console.priv_file!("tools/workbench/knowledge_used.json") |> Jason.decode!() + + def name(_), do: "workbench_knowledge_used" + def json_schema(_), do: @json_schema + def description(_), do: "Record that a knowledge entry was used for this workbench job. Call this when you apply existing knowledge, including if you already have its contents. Use workbench_list_knowledge to find names. Reading via workbench_knowledge also records usage." + + def changeset(model, attrs) do + model + |> cast(attrs, [:name]) + |> validate_required([:name]) + end + + def implement(%__MODULE__{job: %WorkbenchJob{workbench_id: id}, name: name}) do + with {:ok, knowledge} <- Workbenches.knowledge_used(id, name) do + Map.take(knowledge, [:id, :name, :usages, :last_used_at]) + |> Jason.encode() + end + end +end diff --git a/lib/console/ai/tools/workbench/list_knowledge.ex b/lib/console/ai/tools/workbench/list_knowledge.ex new file mode 100644 index 0000000000..773da9490b --- /dev/null +++ b/lib/console/ai/tools/workbench/list_knowledge.ex @@ -0,0 +1,26 @@ +defmodule Console.AI.Tools.Workbench.ListKnowledge do + use Console.AI.Tools.Workbench.Base + alias Console.Schema.WorkbenchJob + alias Console.Deployments.Workbenches + + embedded_schema do + field :job, :map, virtual: true + end + + @json_schema Console.priv_file!("tools/empty.json") |> Jason.decode!() + + def name(_), do: "workbench_list_knowledge" + def json_schema(_), do: @json_schema + def description(_), do: "Get the knowledge entries available on this workbench. This only lists names, descriptions, labels, and usage data (usages, last_used_at); call the workbench_knowledge tool to get the full contents of a specific entry." + + def changeset(model, attrs) do + model + |> cast(attrs, []) + end + + def implement(%__MODULE__{job: %WorkbenchJob{workbench_id: id}}) do + Workbenches.list_workbench_knowledge(id) + |> Enum.map(&Map.take(&1, [:id, :name, :description, :labels, :usages, :last_used_at])) + |> Jason.encode() + end +end diff --git a/lib/console/ai/workbench/engine.ex b/lib/console/ai/workbench/engine.ex index fe823f7280..9e6603a372 100644 --- a/lib/console/ai/workbench/engine.ex +++ b/lib/console/ai/workbench/engine.ex @@ -9,7 +9,7 @@ defmodule Console.AI.Workbench.Engine do message history to the memory engine to inform the next iteration of the loop. 3. A complete tool is used to mark the conclusion of the job. """ - import Console.AI.Workbench.Subagents.Base, only: [drop_empty: 1, log_error: 2] + import Console.AI.Workbench.Subagents.Base, only: [drop_empty: 1, log_error: 2, skill_knowledge_tools: 2] import Console.AI.Agents.Base, only: [publish_absinthe: 2] import Console.AI.Workbench.Environment, only: [engine_opts: 1] import Console.Schema.WorkbenchJobActivity, only: [is_action: 1] @@ -25,15 +25,16 @@ defmodule Console.AI.Workbench.Engine do Supervisor, Heartbeat, Canvas, - Activity + Activity, + Tools } alias Console.AI.Tools.Workbench.{ Codemode, Complete, Subagents, Subagent, - Skills, - Skill, + KnowledgeUpsert, + KnowledgeDelete, Notes, FetchNotes, SkillBackfill, @@ -327,9 +328,9 @@ defmodule Console.AI.Workbench.Engine do categories = Environment.categories(job) skills = Environment.with_builtins(skills) |> Environment.subagent_skills(:orchestrator) - [ - %Skills{skills: skills}, - %Skill{skills: skills}, + skill_knowledge_tools(job, skills) ++ [ + %KnowledgeUpsert{job: job}, + %KnowledgeDelete{job: job}, %Subagents{bench: job.workbench, subagents: subagents, categories: categories}, %Subagent{subagents: subagents}, %FetchNotes{job: job}, @@ -349,7 +350,7 @@ defmodule Console.AI.Workbench.Engine do defp type_tools(_), do: [CanvasTool] defp function_tools(%Environment{job: job, functions: [_ | _] = funcs}), - do: Enum.map(funcs, & %FunctionCall{tool: &1, job: job}) + do: Tools.function_tools(funcs, job) defp function_tools(_), do: [] defp kube_tools(%WorkbenchJob{modes: %{kubernetes: %{update: u, delete: d, exec: e}}} = job) do diff --git a/lib/console/ai/workbench/environment.ex b/lib/console/ai/workbench/environment.ex index 690183ee57..610e452032 100644 --- a/lib/console/ai/workbench/environment.ex +++ b/lib/console/ai/workbench/environment.ex @@ -9,7 +9,7 @@ defmodule Console.AI.Workbench.Environment do alias Console.{AI.ModelSelection, Deployments.Settings} alias Console.AI.Tool alias Console.Deployments.Workbenches - alias Console.AI.Workbench.{Skill, Skills.Builtins, Heartbeat} + alias Console.AI.Workbench.{Tools, Skill, Skills.Builtins, Heartbeat} @type t :: %__MODULE__{ user: User.t, @@ -18,14 +18,15 @@ defmodule Console.AI.Workbench.Environment do functions: [WorkbenchTool.t], skills: %{binary => Skill.t}, activities: [WorkbenchJobActivity.t], - policies: [Tool.Policy.t] + policies: [Tool.Policy.t], + tool_index: %{binary => {struct, WorkbenchTool.t}} } defmodule Actions, do: defstruct [:functions, :kubernetes] defguardp is_map_or_list(m) when is_map(m) or is_list(m) - defstruct [:job, :tools, :skills, :user, functions: [], activities: [], policies: [], verifiable: false] + defstruct [:job, :tools, :skills, :user, functions: [], activities: [], policies: [], verifiable: false, tool_index: %{}] def new(%WorkbenchJob{} = job, tools, skills) when is_map_or_list(tools) and is_map_or_list(skills) do {functions, tools} = Enum.split_with(to_l(tools), fn @@ -34,7 +35,7 @@ defmodule Console.AI.Workbench.Environment do _ -> false end) - %__MODULE__{ + env = %__MODULE__{ user: job.user, job: job, tools: to_map(tools), @@ -42,48 +43,43 @@ defmodule Console.AI.Workbench.Environment do skills: to_map(skills), policies: policies(job) } - |> save() - end - def engine_opts(%__MODULE__{job: job, policies: policies}) do - settings = Settings.cached() - - case ModelSelection.tool_model(job, settings) do - %{model: model, provider: provider} -> - price_sheet = ModelSelection.price_sheet(settings, provider, model) - - [ - model: model, - provider: provider, - policies: policies, - usage_callback: &Heartbeat.usage_callback(job, provider, model, price_sheet, &1) - ] - - _ -> - [ - policies: policies, - usage_callback: &Heartbeat.usage_callback(job, &1) - ] - end + index = Tools.index(env) + save(%{env | tool_index: index}) end + def engine_opts(%__MODULE__{job: job, policies: policies}), + do: Keyword.merge(engine_opts(job), [policies: policies]) + def engine_opts(%WorkbenchJob{} = job) do - settings = Settings.cached() + model_opts(job, ModelSelection.tool_model(job, Settings.cached())) + end - case ModelSelection.tool_model(job, settings) do - %{model: model, provider: provider} -> - price_sheet = ModelSelection.price_sheet(settings, provider, model) + @doc """ + Records tokens reported by a coding-agent run against this workbench job. - [ - model: model, - provider: provider, - usage_callback: &Heartbeat.usage_callback(job, provider, model, price_sheet, &1) - ] + Prices against the agent runtime model when it is known, otherwise records + the raw token counts without cost backfill. + """ + def runtime_usage_callback(%WorkbenchJob{} = job, run, usage) do + usage_callback(job, ModelSelection.runtime_model(run) || ModelSelection.runtime_model(job), usage) + end - _ -> - [usage_callback: &Heartbeat.usage_callback(job, &1)] - end + def usage_callback(%WorkbenchJob{} = job, %{model: model, provider: provider}, usage) do + price_sheet = ModelSelection.price_sheet(Settings.cached(), provider, model) + Heartbeat.usage_callback(job, provider, model, price_sheet, usage) + end + def usage_callback(%WorkbenchJob{} = job, _, usage), + do: Heartbeat.usage_callback(job, usage) + + defp model_opts(job, %{model: model, provider: provider} = info) do + [ + model: model, + provider: provider, + usage_callback: &usage_callback(job, info, &1) + ] end + defp model_opts(job, _), do: [usage_callback: &usage_callback(job, nil, &1)] defp policies(%WorkbenchJob{workbench_id: id}) when is_binary(id) do Workbenches.get_workbench_policies(id) diff --git a/lib/console/ai/workbench/knowledge/backfill.ex b/lib/console/ai/workbench/knowledge/backfill.ex index a9ef8072a1..4de0cc173e 100644 --- a/lib/console/ai/workbench/knowledge/backfill.ex +++ b/lib/console/ai/workbench/knowledge/backfill.ex @@ -4,7 +4,7 @@ defmodule Console.AI.Workbench.Knowledge.Backfill do alias Console.Deployments.Workbenches alias Console.Repo alias Console.AI.Workbench.Skills, as: SkillsUtils - alias Console.AI.Tools.Workbench.{Skills, Skill, SkillUpdate, SkillIgnore, SkillCreate} + alias Console.AI.Tools.Workbench.{SkillUpdate, SkillIgnore, SkillCreate} require EEx @@ -56,9 +56,7 @@ defmodule Console.AI.Workbench.Knowledge.Backfill do defp terminal?(%SkillIgnore{}), do: true defp tools(job, skills) do - [ - %Skills{skills: skills}, - %Skill{skills: skills}, + skill_knowledge_tools(job, skills) ++ [ %SkillUpdate{skills: skills, job: job}, %SkillCreate{job: job}, SkillIgnore diff --git a/lib/console/ai/workbench/mcp.ex b/lib/console/ai/workbench/mcp.ex index a227db15ff..f7d1ec3b25 100644 --- a/lib/console/ai/workbench/mcp.ex +++ b/lib/console/ai/workbench/mcp.ex @@ -25,7 +25,10 @@ defmodule Console.AI.Workbench.MCP do |> Enum.flat_map(fn tool -> case list_tools(tool, j) do {:ok, mcp_tools} -> - Enum.map(mcp_tools, & %MCPTool{tool: tool, mcp_tool: &1, job: j}) + Enum.flat_map(mcp_tools, fn + %Tool{} = mcp_tool -> [%MCPTool{tool: tool, mcp_tool: mcp_tool, job: j}] + _ -> [] + end) _ -> [] end end) @@ -37,8 +40,11 @@ defmodule Console.AI.Workbench.MCP do |> Anubis.Client.list_tools() end) |> case do - {:ok, %Anubis.MCP.Response{result: %{"tools" => found}}} -> - {:ok, Enum.map(found, &Tool.new/1)} + {:ok, %Anubis.MCP.Response{result: %{"tools" => found}}} when is_list(found) -> + {:ok, Enum.flat_map(found, fn + tool when is_map(tool) -> List.wrap(Tool.new(tool)) + _ -> [] + end)} err -> {:error, "failed to list tools: #{inspect(err)}"} end end diff --git a/lib/console/ai/workbench/subagents/base.ex b/lib/console/ai/workbench/subagents/base.ex index 4b7262d9df..902496a432 100644 --- a/lib/console/ai/workbench/subagents/base.ex +++ b/lib/console/ai/workbench/subagents/base.ex @@ -2,9 +2,10 @@ defmodule Console.AI.Workbench.Subagents.Base do import Console.AI.Agents.Base, only: [publish_absinthe: 2] alias Console.Repo alias Console.AI.{Stream, VectorStore} - alias Console.AI.Workbench.Activity + alias Console.AI.Workbench.{Activity, Environment, Tools} alias Console.Deployments.Workbenches - alias Console.Schema.{AgentRun, WorkbenchJobThought, WorkbenchJob, WorkbenchJobActivity} + alias Console.Schema.{AgentRun, WorkbenchJobThought, WorkbenchJob, WorkbenchJobActivity, WorkbenchTool} + alias Console.AI.Tools.Workbench.{Skills, Skill, ListKnowledge, Knowledge, KnowledgeUsed} require Logger defmacro __using__(_) do @@ -49,12 +50,12 @@ defmodule Console.AI.Workbench.Subagents.Base do ) end - def callback(%WorkbenchJobActivity{id: id, workbench_job_id: job_id}, {kind, content}) + def callback(%WorkbenchJobActivity{id: id, workbench_job_id: job_id}, _, {kind, content}) when kind in [:content, :assistant] and is_binary(content), do: publish_absinthe(%{activity_id: id, text: content}, workbench_job_progress: "workbench_jobs:#{job_id}:progress") - def callback(%WorkbenchJobActivity{id: id, workbench_job_id: job_id} = activity, {:tool, content, %{name: name, arguments: args} = tool}) + def callback(%WorkbenchJobActivity{id: id, workbench_job_id: job_id} = activity, %Environment{} = environment, {:tool, content, %{name: name, arguments: args} = tool}) when is_binary(content) do - save_thought(activity, content, tool) + save_thought(activity, environment, content, tool) publish_absinthe(%{ activity_id: id, tool: name, @@ -62,7 +63,7 @@ defmodule Console.AI.Workbench.Subagents.Base do text: content }, workbench_job_progress: "workbench_jobs:#{job_id}:progress") end - def callback(_, _), do: :ok + def callback(_, _, _), do: :ok def last_message(messages, mapper) when is_function(mapper, 1) do Enum.reverse(messages) @@ -77,7 +78,9 @@ defmodule Console.AI.Workbench.Subagents.Base do def poll_run(%AgentRun{} = run), do: Activity.await_run(run) def save_thought( - %WorkbenchJobActivity{id: activity_id} = activity, content, + %WorkbenchJobActivity{id: activity_id} = activity, + %Environment{} = environment, + content, %{name: name, arguments: args, attributes: %{} = attributes} ) when is_binary(content) and is_binary(activity_id) do %WorkbenchJobThought{activity_id: activity_id, activity: activity} @@ -85,12 +88,21 @@ defmodule Console.AI.Workbench.Subagents.Base do content: content, attributes: attributes, tool_name: name, - tool_args: args + tool_args: if(is_map(args), do: args), + tool_id: thought_tool_id(environment, name) }) |> Repo.insert() |> Workbenches.notify(:create) end - def save_thought(_, _, _), do: :ok + def save_thought(_, _, _, _), do: :ok + + defp thought_tool_id(%Environment{tool_index: index}, name) when is_binary(name) do + case Tools.get(index || %{}, name) do + {_, %WorkbenchTool{id: id}} -> id + _ -> nil + end + end + defp thought_tool_id(_, _), do: nil def log_error({:error, error}, context) do Logger.error("#{context}: #{inspect(error)}") @@ -98,4 +110,21 @@ defmodule Console.AI.Workbench.Subagents.Base do end def log_error(pass, _), do: pass + @doc """ + Read-only skill and knowledge tools shared by the orchestrator and every subagent. + Includes listing/reading skills and knowledge, plus recording knowledge usage. + """ + def skill_knowledge_tools(%WorkbenchJob{} = job, skills) do + [ + %Skills{skills: skills}, + %Skill{skills: skills}, + %ListKnowledge{job: job}, + %Knowledge{job: job}, + %KnowledgeUsed{job: job} + ] + end + + def skill_knowledge_pre_enable do + [%Skills{}, %Skill{}, %ListKnowledge{}, %Knowledge{}, %KnowledgeUsed{}] + end end diff --git a/lib/console/ai/workbench/subagents/canvas.ex b/lib/console/ai/workbench/subagents/canvas.ex index ff4947fdb7..767de7f343 100644 --- a/lib/console/ai/workbench/subagents/canvas.ex +++ b/lib/console/ai/workbench/subagents/canvas.ex @@ -3,8 +3,6 @@ defmodule Console.AI.Workbench.Subagents.Canvas do alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity} alias Console.AI.Tools.Workbench.{ Result, - Skills, - Skill, Scratchpad, History } @@ -26,7 +24,7 @@ defmodule Console.AI.Workbench.Subagents.Canvas do @spec run(WorkbenchJobActivity.t(), WorkbenchJob.t(), Environment.t()) :: binary def run(%WorkbenchJobActivity{prompt: prompt} = activity, %WorkbenchJob{} = job, %Environment{} = environment) do tools(environment) - |> MemoryEngine.new(20, engine_opts(environment) ++ [system_prompt: String.trim(system_prompt(prompt: WorkbenchJob.objective(job))), acc: %{}, callback: &callback(activity, &1)]) + |> MemoryEngine.new(20, engine_opts(environment) ++ [system_prompt: String.trim(system_prompt(prompt: WorkbenchJob.objective(job))), acc: %{}, callback: &callback(activity, environment, &1)]) |> MemoryEngine.reduce([{:user, prompt}], &reducer/2) |> case do {:ok, output} -> output @@ -43,9 +41,7 @@ defmodule Console.AI.Workbench.Subagents.Canvas do defp tools(%Environment{skills: skills, job: job, activities: activities} = env) do skills = Environment.subagent_skills(skills, :canvas) - [ - %Skills{skills: skills}, - %Skill{skills: skills}, + skill_knowledge_tools(job, skills) ++ [ Scratchpad, Result, Canvas, diff --git a/lib/console/ai/workbench/subagents/coding.ex b/lib/console/ai/workbench/subagents/coding.ex index 691a6004f8..e15c0ed283 100644 --- a/lib/console/ai/workbench/subagents/coding.ex +++ b/lib/console/ai/workbench/subagents/coding.ex @@ -7,9 +7,7 @@ defmodule Console.AI.Workbench.Subagents.Coding do AIUsage } alias Console.AI.Tools.Workbench.{ - Skills, History, - Skill, Scratchpad, CodingAgent, Result, @@ -27,7 +25,7 @@ defmodule Console.AI.Workbench.Subagents.Coding do engine_opts(environment) ++ [ system_prompt: String.trim(system_prompt(prompt: WorkbenchJob.objective(job))), acc: %{}, - callback: &callback(activity, &1), + callback: &callback(activity, environment, &1), continue_msg: cont_msg() ] ) @@ -84,11 +82,12 @@ defmodule Console.AI.Workbench.Subagents.Coding do end end - defp preload_run({result, %AgentRun{} = run}), do: {result, Repo.preload(run, [:pull_requests])} + defp preload_run({result, %AgentRun{} = run}), + do: {result, Repo.preload(run, [:pull_requests, :runtime])} - defp record_usage({result, %AgentRun{usage: %AIUsage{} = usage}} = pass, job) when result in [:failed, :success] do - callback = Environment.engine_opts(job) |> Keyword.fetch!(:usage_callback) - callback.(AIUsage.to_map(usage)) + defp record_usage({result, %AgentRun{usage: %AIUsage{} = usage} = run} = pass, job) + when result in [:failed, :success] do + Environment.runtime_usage_callback(job, run, AIUsage.to_map(usage)) pass end defp record_usage(result, _), do: result @@ -102,8 +101,7 @@ defmodule Console.AI.Workbench.Subagents.Coding do [ %CodingAgent{activity: activity, workbench: job.workbench, job: job, skills: skills}, %PullRequests{job: job}, - %Skills{skills: skills}, - %Skill{skills: skills}, + ] ++ skill_knowledge_tools(job, skills) ++ [ Scratchpad, %History{job: job, activities: activities}, Result diff --git a/lib/console/ai/workbench/subagents/history.ex b/lib/console/ai/workbench/subagents/history.ex index b2cdd67735..f5702d9138 100644 --- a/lib/console/ai/workbench/subagents/history.ex +++ b/lib/console/ai/workbench/subagents/history.ex @@ -1,7 +1,7 @@ defmodule Console.AI.Workbench.Subagents.History do use Console.AI.Workbench.Subagents.Base alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity} - alias Console.AI.Tools.Workbench.{Result, Skills, Skill, Search, Scratchpad} + alias Console.AI.Tools.Workbench.{Result, Search, Scratchpad} alias Console.AI.Workbench.{Environment} import Console.AI.Workbench.Environment, only: [engine_opts: 1] @@ -13,7 +13,7 @@ defmodule Console.AI.Workbench.Subagents.History do engine_opts(environment) ++ [ system_prompt: &String.trim(system_prompt(prompt: WorkbenchJob.objective(job), engine: &1)), acc: %{}, - callback: &callback(activity, &1), + callback: &callback(activity, environment, &1), continue_msg: cont_msg() ] ) @@ -37,9 +37,7 @@ defmodule Console.AI.Workbench.Subagents.History do defp tools(%Environment{skills: skills}, job) do job = Repo.preload(job, [referenced_job: [activities: :thoughts]]) skills = Environment.subagent_skills(skills, :memory) - [ - %Skills{skills: skills}, - %Skill{skills: skills}, + skill_knowledge_tools(job, skills) ++ [ Scratchpad, %Search{activities: job.referenced_job.activities}, Result diff --git a/lib/console/ai/workbench/subagents/infrastructure.ex b/lib/console/ai/workbench/subagents/infrastructure.ex index 0ee69059cc..36702fdc38 100644 --- a/lib/console/ai/workbench/subagents/infrastructure.ex +++ b/lib/console/ai/workbench/subagents/infrastructure.ex @@ -1,11 +1,9 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do use Console.AI.Workbench.Subagents.Base - alias Console.Schema.{WorkbenchJob, WorkbenchTool, WorkbenchJobActivity, Workbench, User} + alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity, Workbench, User} alias Console.AI.Tools.Workbench.{ SummarizeComponent, Result, - Skills, - Skill, Scratchpad, History, Codemode, @@ -19,16 +17,14 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do Infrastructure.ServiceInspect, Infrastructure.StackList, Infrastructure.StackInspect, - Infrastructure.CloudSchemas, Infrastructure.RawCloudQuery, - Infrastructure.CloudTables, Infrastructure.PodLogs, Infrastructure.Vulns, Infrastructure.Manifests, Infrastructure.StateSearch } alias Console.AI.Tools.Agent.{ServiceComponent, Stack} - alias Console.AI.Workbench.{Environment, FileCache} + alias Console.AI.Workbench.{Environment, FileCache, Tools} import Console.AI.Workbench.Environment, only: [engine_opts: 1] require EEx @@ -39,12 +35,12 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do MemoryEngine.new(tools, 50, engine_opts(environment) ++ [ - system_prompt: &String.trim(system_prompt(prompt: objective, cloud_tools: has_cloud_tools?(environment.tools), engine: &1)), + system_prompt: &String.trim(system_prompt(prompt: objective, cloud_tools: has_cloud_tools?(environment), engine: &1)), acc: %{}, continue_msg: cont_msg(), tool_search: length(tools) > 10, - pre_enable: [Result, %Skills{} ,%Skill{}], - callback: &callback(activity, &1) + pre_enable: [Result | skill_knowledge_pre_enable()], + callback: &callback(activity, environment, &1) ] ) |> MemoryEngine.reduce([{:user, prompt}], &reducer/2) @@ -70,9 +66,7 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do core_tools(job, environment) |> Enum.concat(vuln_tools(bench, user)) |> Enum.concat(manifests_tools(bench, job, user, cache)) - |> Enum.concat([ - %Skills{skills: skills}, - %Skill{skills: skills}, + |> Enum.concat(skill_knowledge_tools(job, skills) ++ [ Scratchpad, %History{job: job, activities: activities}, Result @@ -84,27 +78,12 @@ defmodule Console.AI.Workbench.Subagents.Infrastructure do |> Enum.concat(stack_tools(bench, user)) |> Enum.concat(k8s_tools(bench, user)) |> Enum.concat(pod_logs_tools(bench, user)) - |> Enum.concat(cloud_tools(environment)) + |> Enum.concat(Tools.cloud_tools(environment.tools)) |> build_codemode(policies) end - defp cloud_tools(%Environment{tools: tools}) do - Enum.flat_map(tools, fn - {_, %WorkbenchTool{tool: :cloud} = tool} -> [ - %CloudSchemas{tool: tool}, - %RawCloudQuery{tool: tool}, - %CloudTables{tool: tool} - ] - _ -> [] - end) - end - - defp has_cloud_tools?(tools) do - Enum.any?(tools, fn - {_, %WorkbenchTool{tool: :cloud}} -> true - _ -> false - end) - end + defp has_cloud_tools?(%Environment{tools: tools}), do: Tools.cloud_tools(tools) != [] + defp has_cloud_tools?(tools), do: Tools.cloud_tools(tools) != [] defp svc_tools(%Workbench{configuration: %{infrastructure: %{services: true}}}, %WorkbenchJob{} = job, user) do if_vector_store_enabled(ServiceComponent) ++ [ diff --git a/lib/console/ai/workbench/subagents/integration.ex b/lib/console/ai/workbench/subagents/integration.ex index b3f387ccf8..c20511e1eb 100644 --- a/lib/console/ai/workbench/subagents/integration.ex +++ b/lib/console/ai/workbench/subagents/integration.ex @@ -1,17 +1,8 @@ defmodule Console.AI.Workbench.Subagents.Integration do use Console.AI.Workbench.Subagents.Base - alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity, WorkbenchTool} - alias Console.AI.Tools.Workbench.{Result, Skills, Skill, Http, Scratchpad} - alias Console.AI.Tools.Workbench.Integration.Slack.{CreateChannel, EditMessage, FindChannelByName, InviteToChannel, ListChannels, ListMessages, ListUserGroups, PostMessage, ReactToMessage} - alias Console.AI.Tools.Workbench.Integration.Github.Tools, as: GithubTools - alias Console.AI.Tools.Workbench.Integration.Gitlab.Tools, as: GitlabTools - alias Console.AI.Tools.Workbench.Integration.Bitbucket.Tools, as: BitbucketTools - alias Console.AI.Tools.Workbench.Integration.BitbucketDatacenter.Tools, as: BitbucketDatacenterTools - alias Console.AI.Tools.Workbench.Integration.AzureDevops.Tools, as: AzureDevopsTools - alias Console.AI.Tools.Workbench.Integration.Teams.Tools, as: TeamsTools - alias Console.AI.Tools.Workbench.Integration.Pagerduty.Tools, as: PagerdutyTools - alias Console.AI.Tools.Workbench.Integration.Docker.Tools, as: DockerTools - alias Console.AI.Workbench.{Environment, MCP} + alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity} + alias Console.AI.Tools.Workbench.{Result, Scratchpad} + alias Console.AI.Workbench.{Environment, MCP, Tools} import Console.AI.Workbench.Environment, only: [engine_opts: 1] require EEx @@ -24,8 +15,8 @@ defmodule Console.AI.Workbench.Subagents.Integration do system_prompt: &String.trim(system_prompt(prompt: WorkbenchJob.objective(job), engine: &1)), acc: %{}, tool_search: length(tools) > 10, - pre_enable: [Result, %Skills{} ,%Skill{}], - callback: &callback(activity, &1), + pre_enable: [Result | skill_knowledge_pre_enable()], + callback: &callback(activity, environment, &1), continue_msg: cont_msg() ] ) @@ -49,67 +40,15 @@ defmodule Console.AI.Workbench.Subagents.Integration do defp tools(%Environment{skills: skills, tools: tools, job: job}) do skills = Environment.subagent_skills(skills, :integration) - workbench_tools(tools) + Tools.integration_tools(tools) |> Enum.concat(MCP.expand_tools(Environment.subagent_tools(tools, :integration), job)) - |> Enum.concat([ - %Skills{skills: skills}, - %Skill{skills: skills}, + |> Enum.concat(skill_knowledge_tools(job, skills) ++ [ Scratchpad, Result ]) end - @allowed_tools ~w(http slack pagerduty github gitlab bitbucket bitbucket_datacenter teams azure_devops docker)a - - def scm_tools(tools) do - tools - |> tool_values() - |> Enum.filter(fn - %WorkbenchTool{categories: categories} when is_list(categories) -> :scm in categories - _ -> false - end) - |> expand_workbench_tools() - end - - defp workbench_tools(tools) do - tools - |> tool_values() - |> expand_workbench_tools() - end - - defp tool_values(tools) when is_map(tools), do: Map.values(tools) - defp tool_values(tools) when is_list(tools), do: tools - - defp expand_workbench_tools(tools) do - tools - |> Enum.filter(fn - %WorkbenchTool{tool: t} when t in @allowed_tools -> true - _ -> false - end) - |> Enum.flat_map(fn - %WorkbenchTool{tool: :http} = tool -> [%Http{tool: tool}] - %WorkbenchTool{tool: :slack} = tool -> - [ - %ListChannels{tool: tool}, - %ListMessages{tool: tool}, - %ListUserGroups{tool: tool}, - %FindChannelByName{tool: tool}, - %InviteToChannel{tool: tool}, - %CreateChannel{tool: tool}, - %PostMessage{tool: tool}, - %EditMessage{tool: tool}, - %ReactToMessage{tool: tool} - ] - %WorkbenchTool{tool: :github} = tool -> GithubTools.expand(tool) - %WorkbenchTool{tool: :gitlab} = tool -> GitlabTools.expand(tool) - %WorkbenchTool{tool: :bitbucket} = tool -> BitbucketTools.expand(tool) - %WorkbenchTool{tool: :bitbucket_datacenter} = tool -> BitbucketDatacenterTools.expand(tool) - %WorkbenchTool{tool: :azure_devops} = tool -> AzureDevopsTools.expand(tool) - %WorkbenchTool{tool: :teams} = tool -> TeamsTools.expand(tool) - %WorkbenchTool{tool: :pagerduty} = tool -> PagerdutyTools.expand(tool) - %WorkbenchTool{tool: :docker} = tool -> DockerTools.expand(tool) - end) - end + def scm_tools(tools), do: Tools.scm_tools(tools) EEx.function_from_file(:defp, :system_prompt, Console.priv_filename(["prompts", "workbench", "integration.md.eex"]), [:assigns]) end diff --git a/lib/console/ai/workbench/subagents/memory.ex b/lib/console/ai/workbench/subagents/memory.ex index 579beeaa20..fdbb3525ac 100644 --- a/lib/console/ai/workbench/subagents/memory.ex +++ b/lib/console/ai/workbench/subagents/memory.ex @@ -1,7 +1,7 @@ defmodule Console.AI.Workbench.Subagents.Memory do use Console.AI.Workbench.Subagents.Base alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity} - alias Console.AI.Tools.Workbench.{Result, Skills, Skill, Search, Scratchpad} + alias Console.AI.Tools.Workbench.{Result, Search, Scratchpad} alias Console.AI.Workbench.{Environment} import Console.AI.Workbench.Environment, only: [engine_opts: 1] @@ -13,7 +13,7 @@ defmodule Console.AI.Workbench.Subagents.Memory do engine_opts(environment) ++ [ system_prompt: &String.trim(system_prompt(prompt: WorkbenchJob.objective(job), engine: &1)), acc: %{}, - callback: &callback(activity, &1), + callback: &callback(activity, environment, &1), continue_msg: cont_msg() ] ) @@ -34,10 +34,8 @@ defmodule Console.AI.Workbench.Subagents.Memory do end end - defp tools(%Environment{skills: skills, activities: activities}) do - [ - %Skills{skills: Environment.subagent_skills(skills, :memory)}, - %Skill{skills: Environment.subagent_skills(skills, :memory)}, + defp tools(%Environment{skills: skills, activities: activities, job: job}) do + skill_knowledge_tools(job, Environment.subagent_skills(skills, :memory)) ++ [ Scratchpad, %Search{activities: activities}, Result diff --git a/lib/console/ai/workbench/subagents/observability.ex b/lib/console/ai/workbench/subagents/observability.ex index 2e9d33de40..d1c4c6ed44 100644 --- a/lib/console/ai/workbench/subagents/observability.ex +++ b/lib/console/ai/workbench/subagents/observability.ex @@ -1,10 +1,9 @@ defmodule Console.AI.Workbench.Subagents.Observability do use Console.AI.Workbench.Subagents.Base - alias Console.Schema.{Workbench, WorkbenchJob, WorkbenchJobActivity, WorkbenchTool, User} - alias Console.AI.Tools.Workbench.{ObservabilityResult, Skills, Skill, Codemode, History, Infrastructure.PodLogs, Scratchpad} - alias Console.AI.Tools.Workbench.Observability.{Metrics, MetricsSearch, MetricsLabelSearch, Logs, Traces, Plrl} - alias Console.AI.Tools.Workbench.Integration.Sentry.Tools, as: SentryTools - alias Console.AI.Workbench.{Environment, MCP} + alias Console.Schema.{Workbench, WorkbenchJob, WorkbenchJobActivity, User} + alias Console.AI.Tools.Workbench.{ObservabilityResult, Codemode, History, Infrastructure.PodLogs, Scratchpad} + alias Console.AI.Tools.Workbench.Observability.Plrl + alias Console.AI.Workbench.{Environment, MCP, Tools} import Console.AI.Workbench.Environment, only: [engine_opts: 1] require EEx @@ -16,9 +15,9 @@ defmodule Console.AI.Workbench.Subagents.Observability do engine_opts(environment) ++ [ system_prompt: &String.trim(system_prompt(prompt: WorkbenchJob.objective(job), engine: &1)), acc: %{}, - callback: &callback(activity, &1), + callback: &callback(activity, environment, &1), tool_search: length(tools) > 10, - pre_enable: [ObservabilityResult, %Skills{} ,%Skill{}], + pre_enable: [ObservabilityResult | skill_knowledge_pre_enable()], continue_msg: "looks like we aren't done, let's continue and if you're done just call observability_result to wrap up" ] ) @@ -46,9 +45,7 @@ defmodule Console.AI.Workbench.Subagents.Observability do core_tools(job, environment, user) |> Enum.concat(MCP.expand_tools(Environment.subagent_tools(tools, :observability), job)) |> Enum.concat(pod_logs_tools(job, user)) - |> Enum.concat([ - %Skills{skills: skills}, - %Skill{skills: skills}, + |> Enum.concat(skill_knowledge_tools(job, skills) ++ [ Scratchpad, ObservabilityResult, %Codemode{tools: []}, @@ -59,7 +56,7 @@ defmodule Console.AI.Workbench.Subagents.Observability do def core_tools(%WorkbenchJob{user: user} = job, %Environment{tools: tools}), do: core_tools(job, tools, user) def core_tools(%WorkbenchJob{} = job, %Environment{tools: tools}, user), do: core_tools(job, tools, user) def core_tools(%WorkbenchJob{} = job, tools, user) when is_list(tools) or is_map(tools) do - obs_tools(tools) + Tools.obs_tools(tools) |> Enum.concat(plrl_log_tools(job, user)) |> Enum.concat(plrl_metric_tools(job)) end @@ -81,27 +78,5 @@ defmodule Console.AI.Workbench.Subagents.Observability do do: [Plrl.Metrics, Plrl.MetricsSearch, Plrl.MetricsLabelSearch] defp plrl_metric_tools(_), do: [] - @allowed_tools MapSet.new(~w(metrics logs traces error_tracking)a) - - defp obs_tools(tools) do - Enum.map(tools, &elem(&1, 1)) - |> Enum.filter(fn - %WorkbenchTool{tool: t, categories: [_ | _] = categories} when t != :mcp -> - MapSet.subset?(MapSet.new(categories), @allowed_tools) - _ -> false - end) - |> Enum.flat_map(fn - %WorkbenchTool{tool: :sentry} = tool -> SentryTools.expand(tool) - %WorkbenchTool{categories: [_ | _] = categories} = tool -> - Enum.flat_map(categories, fn c -> to_tool(tool, c) end) - _ -> [] - end) - end - - defp to_tool(%WorkbenchTool{} = tool, :metrics), do: [%Metrics{tool: tool}, %MetricsSearch{tool: tool}, %MetricsLabelSearch{tool: tool}] - defp to_tool(%WorkbenchTool{} = tool, :logs), do: [%Logs{tool: tool}] - defp to_tool(%WorkbenchTool{} = tool, :traces), do: [%Traces{tool: tool}] - defp to_tool(_, _), do: [] - EEx.function_from_file(:defp, :system_prompt, Console.priv_filename(["prompts", "workbench", "observability.md.eex"]), [:assigns]) end diff --git a/lib/console/ai/workbench/subagents/plan.ex b/lib/console/ai/workbench/subagents/plan.ex index b76cb48c2c..2e167040da 100644 --- a/lib/console/ai/workbench/subagents/plan.ex +++ b/lib/console/ai/workbench/subagents/plan.ex @@ -2,7 +2,7 @@ defmodule Console.AI.Workbench.Subagents.Plan do use Console.AI.Workbench.Subagents.Base alias Console.Schema.WorkbenchJob alias Console.AI.Workbench.Environment - alias Console.AI.Tools.Workbench.{Skills, Skill, Plan, Subagents, Scratchpad} + alias Console.AI.Tools.Workbench.{Plan, Subagents, Scratchpad} import Console.AI.Workbench.Environment, only: [engine_opts: 1] @system Console.priv_file!("prompts/workbench/plan.md") @@ -35,9 +35,7 @@ defmodule Console.AI.Workbench.Subagents.Plan do defp tools(%WorkbenchJob{} = job, %Environment{skills: skills}) do skills = Environment.subagent_skills(skills, :plan) - [ - %Skills{skills: skills}, - %Skill{skills: skills}, + skill_knowledge_tools(job, skills) ++ [ Scratchpad, %Subagents{ subagents: Environment.subagents(job), diff --git a/lib/console/ai/workbench/subagents/search.ex b/lib/console/ai/workbench/subagents/search.ex index a0d4bee953..d149d0c222 100644 --- a/lib/console/ai/workbench/subagents/search.ex +++ b/lib/console/ai/workbench/subagents/search.ex @@ -1,7 +1,7 @@ defmodule Console.AI.Workbench.Subagents.Search do use Console.AI.Workbench.Subagents.Base alias Console.Schema.{WorkbenchJob, WorkbenchJobActivity} - alias Console.AI.Tools.Workbench.{Result, Skills, Skill, Scratchpad} + alias Console.AI.Tools.Workbench.{Result, Scratchpad} alias Console.AI.Workbench.{Environment, MCP} import Console.AI.Workbench.Environment, only: [engine_opts: 1] @@ -13,7 +13,7 @@ defmodule Console.AI.Workbench.Subagents.Search do engine_opts(environment) ++ [ system_prompt: String.trim(system_prompt(prompt: WorkbenchJob.objective(job))), acc: %{}, - callback: &callback(activity, &1), + callback: &callback(activity, environment, &1), continue_msg: cont_msg() ] ) @@ -38,9 +38,7 @@ defmodule Console.AI.Workbench.Subagents.Search do skills = Environment.subagent_skills(skills, :search) MCP.expand_tools(Environment.subagent_tools(tools, :search), job) - |> Enum.concat([ - %Skills{skills: skills}, - %Skill{skills: skills}, + |> Enum.concat(skill_knowledge_tools(job, skills) ++ [ Scratchpad, Result ]) diff --git a/lib/console/ai/workbench/subagents/skill.ex b/lib/console/ai/workbench/subagents/skill.ex index 2f0726d45f..01fa486d88 100644 --- a/lib/console/ai/workbench/subagents/skill.ex +++ b/lib/console/ai/workbench/subagents/skill.ex @@ -3,8 +3,6 @@ defmodule Console.AI.Workbench.Subagents.Skill do alias Console.Schema.{WorkbenchJob, WorkbenchSkill, WorkbenchJobActivity, PullRequest} alias Console.AI.Workbench.Environment alias Console.AI.Tools.Workbench.{ - Skills, - Skill, SkillUpdate, SkillCreate, SkillIgnore, @@ -34,7 +32,7 @@ defmodule Console.AI.Workbench.Subagents.Skill do system_prompt: String.trim(system_prompt(job: target_job)), continue_msg: cont_msg(), acc: %{}, - callback: &callback(activity, &1) + callback: &callback(activity, environment, &1) ] ) |> MemoryEngine.reduce([{:user, String.trim(eval_job_prompt(job: target_job))}, @skill_prompt], &reducer/2) @@ -68,9 +66,7 @@ defmodule Console.AI.Workbench.Subagents.Skill do defp target_job(job), do: job defp tools(target_job, %Environment{skills: skills}) do - [ - %Skills{skills: skills}, - %Skill{skills: skills}, + skill_knowledge_tools(target_job, skills) ++ [ Scratchpad, %SkillUpdate{skills: skills, job: target_job}, %SkillCreate{job: target_job}, diff --git a/lib/console/ai/workbench/subagents/verify.ex b/lib/console/ai/workbench/subagents/verify.ex index 00b1baad2f..4c21357cd9 100644 --- a/lib/console/ai/workbench/subagents/verify.ex +++ b/lib/console/ai/workbench/subagents/verify.ex @@ -3,7 +3,7 @@ defmodule Console.AI.Workbench.Subagents.Verify do alias Console.AI.Workbench.Subagents.{Infrastructure, Observability} alias Console.Deployments.Sentinels alias Console.Schema.{SentinelRun, WorkbenchJob, WorkbenchJobActivity} - alias Console.AI.Tools.Workbench.{Result, Skills, Skill, Scratchpad} + alias Console.AI.Tools.Workbench.{Result, Scratchpad} alias Console.AI.Tools.Workbench.Sentinel.{ FetchSentinelRun, FetchSentinelRunJob, @@ -21,8 +21,8 @@ defmodule Console.AI.Workbench.Subagents.Verify do engine_opts(environment) ++ [ system_prompt: String.trim(system_prompt(prompt: WorkbenchJob.objective(job))), acc: %{}, - callback: &callback(activity, &1), - pre_enable: [Result, %Skills{} ,%Skill{}], + callback: &callback(activity, environment, &1), + pre_enable: [Result | skill_knowledge_pre_enable()], continue_msg: cont_msg() ] ) @@ -54,9 +54,7 @@ defmodule Console.AI.Workbench.Subagents.Verify do Observability.core_tools(job, environment) |> Enum.concat(Infrastructure.core_tools(job, environment)) |> Enum.concat(sentinel_tools(job)) - |> Enum.concat([ - %Skills{skills: skills}, - %Skill{skills: skills}, + |> Enum.concat(skill_knowledge_tools(job, skills) ++ [ Scratchpad, Result ]) diff --git a/lib/console/ai/workbench/tools.ex b/lib/console/ai/workbench/tools.ex new file mode 100644 index 0000000000..c250dca1d0 --- /dev/null +++ b/lib/console/ai/workbench/tools.ex @@ -0,0 +1,196 @@ +defmodule Console.AI.Workbench.Tools do + @moduledoc """ + Constructs workbench-backed AI tools and indexes them by tool name. + + Agent loops can later recover `{module, workbench_tool}` from a tool call name + so thoughts and other records can reinfer the originating `WorkbenchTool`. + """ + alias Console.AI.Tool + alias Console.AI.Workbench.{Environment, MCP} + alias Console.AI.Tools.Workbench.{Http, FunctionCall} + alias Console.AI.Tools.Workbench.Observability.{ + Metrics, + MetricsSearch, + MetricsLabelSearch, + Logs, + Traces + } + alias Console.AI.Tools.Workbench.Infrastructure.{CloudSchemas, RawCloudQuery, CloudTables} + alias Console.AI.Tools.Workbench.Integration.{ + Github, + Gitlab, + Bitbucket, + BitbucketDatacenter, + AzureDevops, + Teams, + Pagerduty, + Docker, + Sentry, + Slack + } + alias Console.Repo + alias Console.Schema.{Workbench, WorkbenchJob, WorkbenchTool} + + @type entry :: {module, WorkbenchTool.t} + @type index :: %{binary => entry} + + @tool_preloads [:cloud_connection, :mcp_server, :scm_connection] + + @obs_categories MapSet.new(~w(metrics logs traces error_tracking)a) + @integration_tools ~w(http slack pagerduty github gitlab bitbucket bitbucket_datacenter teams azure_devops docker)a + + @doc """ + Maps each constructed tool name to `{module, workbench_tool}`. + + Accepts a workbench with tools preloaded. Pass a job (or an `Environment`) to + include MCP expansions, which require a live MCP client for that job. + """ + @spec index(Workbench.t | Environment.t | [WorkbenchTool.t] | map) :: index + def index(%Environment{tools: tools, functions: funcs, job: job}), + do: index(tool_values(tools) ++ List.wrap(funcs), job) + def index(%Workbench{tools: tools}), do: index(tools, nil) + def index(tools) when is_list(tools) or is_map(tools), do: index(tools, nil) + + @spec index(Workbench.t | [WorkbenchTool.t] | map, WorkbenchJob.t | nil) :: index + def index(%Workbench{tools: tools}, job), do: index(tools, job) + def index(tools, job) when is_list(tools) or is_map(tools) do + tools + |> preload() + |> expand(job) + |> Enum.flat_map(fn + %mod{tool: %WorkbenchTool{} = wt} = instance -> + [{Tool.name(instance), {mod, wt}}] + _ -> + [] + end) + |> Map.new() + end + + @doc "Looks up `{module, workbench_tool}` for a tool name against a workbench or prebuilt index." + @spec get(Workbench.t | Environment.t | index, binary) :: entry | nil + def get(%Workbench{} = workbench, name) when is_binary(name), do: get(index(workbench), name) + def get(%Environment{} = environment, name) when is_binary(name), do: get(index(environment), name) + def get(%{} = index, name) when is_binary(name), do: Map.get(index, name) + def get(_, _), do: nil + + @spec get(Workbench.t, WorkbenchJob.t, binary) :: entry | nil + def get(%Workbench{} = workbench, %WorkbenchJob{} = job, name) when is_binary(name), + do: get(index(workbench, job), name) + + @doc "Expands every workbench-backed AI tool, including MCP tools when a job is provided." + @spec expand(Workbench.t | Environment.t | [WorkbenchTool.t] | map, WorkbenchJob.t | nil) :: [struct] + def expand(source, job \\ nil) + def expand(%Environment{tools: tools, functions: funcs, job: job}, _), + do: expand(tool_values(tools) ++ List.wrap(funcs), job) + def expand(%Workbench{tools: tools}, job), do: expand(tools, job) + def expand(tools, job) do + tools = preload(tools) + + cloud_tools(tools) + |> Enum.concat(obs_tools(tools)) + |> Enum.concat(integration_tools(tools)) + |> Enum.concat(function_tools(tools, job)) + |> Enum.concat(mcp_tools(tools, job)) + end + + @doc "Cloud query tools (`CloudSchemas`, `RawCloudQuery`, `CloudTables`) for `:cloud` workbench tools." + @spec cloud_tools(Workbench.t | [WorkbenchTool.t] | map) :: [struct] + def cloud_tools(%Workbench{tools: tools}), do: cloud_tools(tools) + def cloud_tools(tools) do + Enum.flat_map(preload(tools), fn + %WorkbenchTool{tool: :cloud} = tool -> [ + %CloudSchemas{tool: tool}, + %RawCloudQuery{tool: tool}, + %CloudTables{tool: tool} + ] + _ -> [] + end) + end + + @doc "Observability tools for metrics, logs, traces, and error tracking workbench tools." + @spec obs_tools(Workbench.t | [WorkbenchTool.t] | map) :: [struct] + def obs_tools(%Workbench{tools: tools}), do: obs_tools(tools) + def obs_tools(tools) do + Enum.filter(tool_values(tools), fn + %WorkbenchTool{tool: t, categories: [_ | _] = categories} when t != :mcp -> + MapSet.subset?(MapSet.new(categories), @obs_categories) + _ -> false + end) + |> Enum.flat_map(fn + %WorkbenchTool{tool: :sentry} = tool -> Sentry.Tools.expand(tool) + %WorkbenchTool{categories: [_ | _] = categories} = tool -> + Enum.flat_map(categories, &obs_category_tools(tool, &1)) + _ -> [] + end) + end + + @doc "Integration tools (HTTP, Slack, SCM, chat, PagerDuty, Docker, etc.)." + @spec integration_tools(Workbench.t | [WorkbenchTool.t] | map) :: [struct] + def integration_tools(%Workbench{tools: tools}), do: integration_tools(tools) + def integration_tools(tools) do + Enum.reject(tool_values(tools), &function_tool?/1) + |> Enum.filter(fn + %WorkbenchTool{tool: t} when t in @integration_tools -> true + _ -> false + end) + |> Enum.flat_map(&expand_integration/1) + end + + @doc "SCM-category tools (GitHub, GitLab, Bitbucket, Azure DevOps)." + @spec scm_tools(Workbench.t | [WorkbenchTool.t] | map) :: [struct] + def scm_tools(%Workbench{tools: tools}), do: scm_tools(tools) + def scm_tools(tools) do + Enum.filter(tool_values(tools), fn + %WorkbenchTool{categories: categories} when is_list(categories) -> :scm in categories + _ -> false + end) + |> Enum.flat_map(&expand_integration/1) + end + + @doc "Function-call tools (lambda, cloud run, azure function, HTTP functions)." + @spec function_tools(Workbench.t | [WorkbenchTool.t] | map, WorkbenchJob.t | nil) :: [struct] + def function_tools(tools, job \\ nil) + def function_tools(%Workbench{tools: tools}, job), do: function_tools(tools, job) + def function_tools(tools, job) do + Enum.filter(tool_values(tools), &function_tool?/1) + |> Enum.map(& %FunctionCall{tool: &1, job: job}) + end + + @doc "Expands MCP-backed workbench tools (generic MCP, Linear, Atlassian, Exa) for a job." + @spec mcp_tools(Workbench.t | [WorkbenchTool.t] | map, WorkbenchJob.t | nil) :: [struct] + def mcp_tools(_, nil), do: [] + def mcp_tools(%Workbench{tools: tools}, %WorkbenchJob{} = job), do: mcp_tools(tools, job) + def mcp_tools(tools, %WorkbenchJob{} = job), do: MCP.expand_tools(tools, job) + def mcp_tools(_, _), do: [] + + defp obs_category_tools(%WorkbenchTool{} = tool, :metrics), + do: [%Metrics{tool: tool}, %MetricsSearch{tool: tool}, %MetricsLabelSearch{tool: tool}] + defp obs_category_tools(%WorkbenchTool{} = tool, :logs), do: [%Logs{tool: tool}] + defp obs_category_tools(%WorkbenchTool{} = tool, :traces), do: [%Traces{tool: tool}] + defp obs_category_tools(_, _), do: [] + + defp expand_integration(%WorkbenchTool{tool: :http} = tool), do: [%Http{tool: tool}] + defp expand_integration(%WorkbenchTool{tool: :slack} = tool), do: Slack.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :github} = tool), do: Github.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :gitlab} = tool), do: Gitlab.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :bitbucket} = tool), do: Bitbucket.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :bitbucket_datacenter} = tool), + do: BitbucketDatacenter.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :azure_devops} = tool), do: AzureDevops.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :teams} = tool), do: Teams.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :pagerduty} = tool), do: Pagerduty.Tools.expand(tool) + defp expand_integration(%WorkbenchTool{tool: :docker} = tool), do: Docker.Tools.expand(tool) + defp expand_integration(_), do: [] + + defp function_tool?(%WorkbenchTool{categories: [_ | _] = categories}), do: :function in categories + defp function_tool?(%WorkbenchTool{tool: :http, configuration: %{http: %{function: true}}}), do: true + defp function_tool?(_), do: false + + defp preload(tools), do: Repo.preload(tool_values(tools), @tool_preloads) + + defp tool_values(%Workbench{tools: tools}), do: tool_values(tools) + defp tool_values(nil), do: [] + defp tool_values(%Ecto.Association.NotLoaded{}), do: [] + defp tool_values(tools) when is_map(tools), do: Map.values(tools) + defp tool_values(tools) when is_list(tools), do: tools +end diff --git a/lib/console/deployments/cron.ex b/lib/console/deployments/cron.ex index 14d75864ae..0a15ff2c79 100644 --- a/lib/console/deployments/cron.ex +++ b/lib/console/deployments/cron.ex @@ -25,9 +25,11 @@ defmodule Console.Deployments.Cron do ClusterInsightComponent, ClusterUpgrade, WorkbenchJob, - PolicyEvaluation + PolicyEvaluation, + PreviewEnvironmentInstance } alias Console.Deployments.Pipelines.Discovery + alias Console.Deployments.Flows.Preview require Logger @@ -72,6 +74,20 @@ defmodule Console.Deployments.Cron do |> Repo.delete_all(timeout: 300_000) end + def prune_preview_environments() do + Logger.info "pruning expired preview environments" + PreviewEnvironmentInstance.expired() + |> PreviewEnvironmentInstance.active() + |> PreviewEnvironmentInstance.stream() + |> Repo.stream(method: :keyset) + |> Console.throttle(count: 100, pause: :timer.seconds(1)) + |> Stream.each(fn inst -> + Logger.info "pruning preview environment instance #{inst.id}" + Preview.delete_instance(inst) + end) + |> Stream.run() + end + def cache_warm(), do: Git.warm_helm_cache() def install_clusters() do diff --git a/lib/console/deployments/flows/preview.ex b/lib/console/deployments/flows/preview.ex index 43b6f95e51..a9cdcf3b9c 100644 --- a/lib/console/deployments/flows/preview.ex +++ b/lib/console/deployments/flows/preview.ex @@ -4,6 +4,7 @@ defmodule Console.Deployments.Flows.Preview do alias Console.Deployments.{Services, Git, Pr} alias Console.Services.Users alias Console.Schema.{ + Flow, PreviewEnvironmentInstance, PreviewEnvironmentTemplate, PullRequest, @@ -96,10 +97,12 @@ defmodule Console.Deployments.Flows.Preview do end end + def delete_instance(%PreviewEnvironmentInstance{service_id: id}) when is_binary(id), + do: Services.delete_service(id, bot()) def delete_instance(%PullRequest{preview: p, flow_id: fid} = pr) when is_binary(p) and is_binary(fid) do with %PreviewEnvironmentTemplate{} = template <- get_template(fid, p), %PreviewEnvironmentInstance{} = inst <- get_instance(template.id, pr.id) do - Services.delete_service(inst.service_id, bot()) + delete_instance(inst) end end def delete_instance(_), do: :ok @@ -108,20 +111,44 @@ defmodule Console.Deployments.Flows.Preview do %PreviewEnvironmentTemplate{reference_service: %Service{} = ref} = template, %PullRequest{} = pr ) do - with {:ok, attrs} <- build_attributes(pr, template), - {:ok, svc} <- Services.clone_service(attrs, ref.id, ref.cluster_id, bot()) do + start_transaction() + |> add_operation(:limit, fn _ -> enforce_max_previews(template) end) + |> add_operation(:attrs, fn _ -> build_attributes(pr, template) end) + |> add_operation(:svc, fn %{attrs: attrs} -> + Services.clone_service(attrs, ref.id, ref.cluster_id, bot()) + end) + |> add_operation(:inst, fn %{svc: svc} -> %PreviewEnvironmentInstance{} |> PreviewEnvironmentInstance.changeset(%{ - service_id: svc.id, - pull_request_id: pr.id, - template_id: template.id + service_id: svc.id, + pull_request_id: pr.id, + template_id: template.id, + preview_expires_at: expiry(template) }) |> Repo.insert() - |> notify(:create) - end + end) + |> execute(extract: :inst) + |> notify(:create) end defp create_instance(_, _), do: :ok + defp enforce_max_previews(%PreviewEnvironmentTemplate{} = template) do + %{flow: %Flow{max_previews: max, id: flow_id}} = Repo.preload(template, :flow) + + PreviewEnvironmentInstance.for_flow(flow_id) + |> PreviewEnvironmentInstance.active() + |> Repo.aggregate(:count, :id) + |> case do + count when is_integer(max) and count >= max -> + {:error, "this flow has reached its maximum of #{max} preview environments"} + count -> {:ok, count} + end + end + + defp expiry(%PreviewEnvironmentTemplate{preview_ttl: ttl}) when is_integer(ttl) and ttl > 0, + do: Timex.shift(Timex.now(), seconds: ttl) + defp expiry(_), do: nil + def update_instance( %PreviewEnvironmentInstance{template: %PreviewEnvironmentTemplate{} = tpl, service: %Service{} = svc} = inst, %PullRequest{} = pr diff --git a/lib/console/deployments/workbenches.ex b/lib/console/deployments/workbenches.ex index 9ea66ab375..0a16825c2f 100644 --- a/lib/console/deployments/workbenches.ex +++ b/lib/console/deployments/workbenches.ex @@ -59,6 +59,7 @@ defmodule Console.Deployments.Workbenches do @cache_adapter Console.conf(:cache_adapter) @ttl :timer.hours(6) + @max_knowledge 10 def get_workbench!(id), do: Repo.get!(Workbench, id) def get_workbench_with_lock!(id), do: Repo.get!(Workbench.with_lock(), id) @@ -88,6 +89,8 @@ defmodule Console.Deployments.Workbenches do def get_workbench_skill(id), do: Repo.get(WorkbenchSkill, id) def get_workbench_knowledge!(id), do: Repo.get!(WorkbenchKnowledge, id) def get_workbench_knowledge(id), do: Repo.get(WorkbenchKnowledge, id) + def get_workbench_knowledge(workbench_id, name), + do: Repo.get_by(WorkbenchKnowledge, workbench_id: workbench_id, name: name) def get_workbench_webhook!(id), do: Repo.get!(WorkbenchWebhook, id) def get_workbench_webhook(id), do: Repo.get(WorkbenchWebhook, id) @@ -466,15 +469,103 @@ defmodule Console.Deployments.Workbenches do end @doc """ - Deletes saved workbench knowledge. Requires write access to the workbench. + Deletes saved workbench knowledge. The user variant requires write access. + The tool variant scopes deletion to a workbench id or job (no authz). """ @spec delete_workbench_knowledge(binary, User.t()) :: knowledge_resp + @spec delete_workbench_knowledge(binary, binary | WorkbenchJob.t()) :: knowledge_resp def delete_workbench_knowledge(id, %User{} = user) do get_workbench_knowledge!(id) |> allow(user, :write) |> when_ok(:delete) |> notify(:delete, user) end + def delete_workbench_knowledge(id, %WorkbenchJob{workbench_id: workbench_id}), + do: delete_workbench_knowledge(id, workbench_id) + def delete_workbench_knowledge(id, workbench_id) when is_binary(id) and is_binary(workbench_id) do + case get_workbench_knowledge(id) do + %WorkbenchKnowledge{workbench_id: ^workbench_id} = knowledge -> Repo.delete(knowledge) + _ -> {:error, "knowledge not found"} + end + end + + @doc """ + Lists knowledge entries for a workbench. Intended for tool use (no authz). + """ + @spec list_workbench_knowledge(binary) :: [WorkbenchKnowledge.t] + def list_workbench_knowledge(workbench_id) do + WorkbenchKnowledge.for_workbench(workbench_id) + |> WorkbenchKnowledge.ordered() + |> Repo.all() + end + + @doc """ + Creates or updates workbench knowledge by name. Creating fails if the workbench + already has #{@max_knowledge} entries. Intended for tool use (no authz). + """ + @spec upsert_workbench_knowledge(map, binary) :: knowledge_resp + def upsert_workbench_knowledge(attrs, workbench_id) do + start_transaction() + |> add_operation(:lock, fn _ -> + {:ok, get_workbench_with_lock!(workbench_id)} + end) + |> add_operation(:knowledge, fn _ -> + name = knowledge_name(attrs) + case get_workbench_knowledge(workbench_id, name) do + %WorkbenchKnowledge{} = existing -> + existing + |> WorkbenchKnowledge.changeset(attrs) + |> Repo.update() + _ -> + insert_workbench_knowledge(attrs, workbench_id) + end + end) + |> execute(extract: :knowledge) + end + + @doc """ + Increments usages and stamps last_used_at for a knowledge entry. + Accepts a knowledge id, a knowledge struct, or a workbench id + name. + """ + @spec knowledge_used(binary | WorkbenchKnowledge.t) :: knowledge_resp + def knowledge_used(%WorkbenchKnowledge{id: id}), do: knowledge_used(id) + def knowledge_used(id) when is_binary(id) do + case get_workbench_knowledge(id) do + %WorkbenchKnowledge{} = knowledge -> bump_knowledge_usage(knowledge) + _ -> {:error, "knowledge not found"} + end + end + + @spec knowledge_used(binary, binary) :: knowledge_resp + def knowledge_used(workbench_id, name) when is_binary(workbench_id) and is_binary(name) do + case get_workbench_knowledge(workbench_id, name) do + %WorkbenchKnowledge{} = knowledge -> bump_knowledge_usage(knowledge) + _ -> {:error, "knowledge not found"} + end + end + + defp insert_workbench_knowledge(attrs, workbench_id) do + count = WorkbenchKnowledge.for_workbench(workbench_id) |> Repo.aggregate(:count, :id) + if count >= @max_knowledge do + {:error, "workbench already has #{@max_knowledge} knowledge entries, delete a less-used entry before creating another"} + else + %WorkbenchKnowledge{workbench_id: workbench_id} + |> WorkbenchKnowledge.changeset(attrs) + |> Repo.insert() + end + end + + defp bump_knowledge_usage(%WorkbenchKnowledge{} = knowledge) do + knowledge + |> WorkbenchKnowledge.changeset(%{ + usages: (knowledge.usages || 0) + 1, + last_used_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() + end + + defp knowledge_name(attrs) when is_map(attrs), + do: Map.get(attrs, :name) || Map.get(attrs, "name") @doc """ Creates a workbench eval configuration for a workbench. Requires write access to the workbench. diff --git a/lib/console/graphql/deployments/agent.ex b/lib/console/graphql/deployments/agent.ex index 66546632e5..ad039adb53 100644 --- a/lib/console/graphql/deployments/agent.ex +++ b/lib/console/graphql/deployments/agent.ex @@ -23,6 +23,7 @@ defmodule Console.GraphQl.Deployments.Agent do field :allowed_repositories, list_of(:string), description: "the git repositories allowed to be used with this runtime" field :babysit_interval, :integer, description: "default interval in seconds between babysit checks for runs on this runtime" field :scm_connection, :string, description: "the name of the scm connection to use for this runtime" + field :model, :workbench_job_model_attributes, description: "default model override for runs on this runtime" end input_object :agent_binding_attributes do @@ -168,6 +169,7 @@ defmodule Console.GraphQl.Deployments.Agent do field :default, :boolean, description: "whether this is the default runtime for coding agents" field :allowed_repositories, list_of(:string), description: "the git repositories allowed to be used with this runtime" field :babysit_interval, :integer, description: "default interval in seconds between babysit checks for runs on this runtime" + field :model, :workbench_job_model, description: "default model override for runs on this runtime" field :cluster, :cluster, resolve: dataloader(Deployments), description: "the cluster this runtime is running on" field :create_bindings, list_of(:policy_binding), resolve: dataloader(Deployments), description: "the policy for creating runs on this runtime" diff --git a/lib/console/graphql/deployments/flow.ex b/lib/console/graphql/deployments/flow.ex index d782c7c2e0..183ffd4a3b 100644 --- a/lib/console/graphql/deployments/flow.ex +++ b/lib/console/graphql/deployments/flow.ex @@ -13,6 +13,7 @@ defmodule Console.GraphQl.Deployments.Flow do field :metadata, :json field :agent_runtime_id, :id, description: "the agent runtime for this flow" field :repositories, list_of(:string) + field :max_previews, :integer, description: "the maximum number of preview environments allowed for this flow (1-25, default 10)" field :read_bindings, list_of(:policy_binding_attributes) field :write_bindings, list_of(:policy_binding_attributes) field :server_associations, list_of(:mcp_server_association_attributes) @@ -55,6 +56,7 @@ defmodule Console.GraphQl.Deployments.Flow do field :reference_service_id, non_null(:id), description: "the service that will be cloned to create the preview environment" field :template, non_null(:service_template_attributes), description: "a set of service configuration overrides to use while cloning" field :connection_id, :id, description: "an scm connection id to use for PR preview comment generation" + field :preview_ttl, :string, description: "how long preview environments should live, as a kubernetes duration (e.g. 1d, 5s)" end object :flow do @@ -64,6 +66,7 @@ defmodule Console.GraphQl.Deployments.Flow do field :icon, :string field :metadata, :map field :repositories, list_of(:string), description: "the git https urls of the application code repositories used in this flow" + field :max_previews, :integer, description: "the maximum number of preview environments allowed for this flow (1-25, default 10)" field :agent_runtime, :agent_runtime, resolve: dataloader(Deployments), description: "the agent runtime for this flow" @@ -181,6 +184,7 @@ defmodule Console.GraphQl.Deployments.Flow do field :id, non_null(:id) field :name, non_null(:string) field :comment_template, :string + field :preview_ttl, :integer, description: "how long preview environments should live, in seconds" field :flow, :flow, resolve: dataloader(Deployments) field :reference_service, :service_deployment, resolve: dataloader(Deployments) @@ -192,7 +196,8 @@ defmodule Console.GraphQl.Deployments.Flow do @desc "An instance of a preview environment template" object :preview_environment_instance do - field :id, non_null(:id) + field :id, non_null(:id) + field :preview_expires_at, :datetime, description: "when this preview environment instance expires" field :service, :service_deployment, resolve: dataloader(Deployments) field :pull_request, :pull_request, resolve: dataloader(Deployments) diff --git a/lib/console/openapi/ai/agent_runtime.ex b/lib/console/openapi/ai/agent_runtime.ex index 34bbe9d80c..8a4e95883f 100644 --- a/lib/console/openapi/ai/agent_runtime.ex +++ b/lib/console/openapi/ai/agent_runtime.ex @@ -27,6 +27,7 @@ defmodule Console.OpenAPI.AI.AgentRuntime do default: boolean(description: "Whether this is the default runtime for coding agents"), cluster_id: string(description: "ID of the cluster this runtime is deployed on"), allowed_repositories: array_of(string(), description: "The git repositories allowed to be used with this runtime"), + model: Console.OpenAPI.AI.WorkbenchJobModel, }) } end diff --git a/lib/console/schema/agent_runtime.ex b/lib/console/schema/agent_runtime.ex index 3a9b7af6fa..5bf9541c01 100644 --- a/lib/console/schema/agent_runtime.ex +++ b/lib/console/schema/agent_runtime.ex @@ -1,6 +1,6 @@ defmodule Console.Schema.AgentRuntime do use Piazza.Ecto.Schema - alias Console.Schema.{Cluster, PolicyBinding, ScmConnection} + alias Console.Schema.{Cluster, PolicyBinding, ScmConnection, WorkbenchJob.Modes} alias Console.Deployments.{Policies.Rbac, Pr.Git} defenum Type, claude: 0, opencode: 1, gemini: 3, custom: 4, codex: 5, pi: 6 @@ -14,6 +14,7 @@ defmodule Console.Schema.AgentRuntime do field :ai_proxy, :boolean, default: false field :babysit_interval, :integer + embeds_one :model, Modes.Model, on_replace: :update belongs_to :cluster, Cluster belongs_to :connection, ScmConnection @@ -67,6 +68,7 @@ defmodule Console.Schema.AgentRuntime do def changeset(model, attrs \\ %{}) do model |> cast(attrs, @valid) + |> cast_embed(:model, with: &Modes.model_changeset/2) |> unique_constraint(:default, message: "only one default runtime can be set at once") |> unique_constraint(:name, name: :agent_runtimes_cluster_id_name_uniq_index, message: "a runtime with this name already exists for this cluster") |> validate_length(:name, max: 255) diff --git a/lib/console/schema/base.ex b/lib/console/schema/base.ex index c6fbaac61a..b3e888eef1 100644 --- a/lib/console/schema/base.ex +++ b/lib/console/schema/base.ex @@ -71,6 +71,27 @@ defmodule Console.Schema.Base do end end + def duration_seconds(cs, field) do + case duration_value(cs, field) do + val when is_binary(val) -> + case parse_duration(val) do + {:ok, duration} -> put_change(cs, field, seconds(duration)) + {:error, _} -> add_error(cs, field, "invalid duration") + end + val when is_integer(val) -> + put_change(cs, field, val) + _ -> cs + end + end + + defp duration_value(cs, field) do + get_change(cs, field) || duration_param(cs, field) + end + + defp duration_param(%{params: params}, field) when is_map(params), + do: Map.get(params, Atom.to_string(field)) || Map.get(params, field) + defp duration_param(_, _), do: nil + def helm_url(cs, field) do validate_change(cs, field, fn ^field, "http" <> _ -> [] @@ -125,10 +146,24 @@ defmodule Console.Schema.Base do defp sanitize_text_value(value) when is_list(value), do: Enum.map(value, &sanitize_text_value/1) defp sanitize_text_value(value), do: value - def seconds(%Duration{hour: h, minute: m, second: s}), do: h * 3600 + m * 60 + s + def seconds(%Duration{week: w, day: d, hour: h, minute: m, second: s}), + do: (w * 7 + d) * 86_400 + h * 3_600 + m * 60 + s def parse_duration("P" <> _ = duration), do: Duration.from_iso8601(duration) - def parse_duration(duration), do: Duration.from_iso8601(String.upcase("PT#{duration}")) + def parse_duration(duration) when is_binary(duration) do + duration + |> String.upcase() + |> to_iso8601_duration() + |> Duration.from_iso8601() + end + + defp to_iso8601_duration(duration) do + case String.split(duration, "D", parts: 2) do + [days, ""] when days != "" -> "P#{days}D" + [days, rest] when days != "" and rest != "" -> "P#{days}DT#{rest}" + _ -> "PT#{duration}" + end + end def normalize_period(period) when period in ~w(day week month), do: period def normalize_period(period) when period in ~w(day week month)a, do: Atom.to_string(period) diff --git a/lib/console/schema/flow.ex b/lib/console/schema/flow.ex index 5fdff67c05..7bf0619ac9 100644 --- a/lib/console/schema/flow.ex +++ b/lib/console/schema/flow.ex @@ -16,6 +16,8 @@ defmodule Console.Schema.Flow do field :icon, :string field :repositories, {:array, :string} field :metadata, :map + field :max_previews, :integer, default: 10 + field :write_policy_id, :binary_id field :read_policy_id, :binary_id @@ -70,7 +72,7 @@ defmodule Console.Schema.Flow do def changeset(model, attrs \\ %{}) do model - |> cast(attrs, ~w(name description icon repositories project_id agent_runtime_id metadata)a) + |> cast(attrs, ~w(name description icon repositories project_id agent_runtime_id metadata max_previews)a) |> validate_length(:name, max: 255) |> cast_assoc(:server_associations) |> cast_assoc(:read_bindings) @@ -83,6 +85,7 @@ defmodule Console.Schema.Flow do |> foreign_key_constraint(:preview_environment_instances, name: :preview_environment, match: :prefix, message: "Cannot delete as there are preview environments using this flow still deployed") |> put_new_change(:write_policy_id, &Ecto.UUID.generate/0) |> put_new_change(:read_policy_id, &Ecto.UUID.generate/0) + |> validate_number(:max_previews, greater_than: 0, less_than_or_equal_to: 25) |> validate_required([:name, :project_id]) end diff --git a/lib/console/schema/preview_environment_instance.ex b/lib/console/schema/preview_environment_instance.ex index f78aba4f83..8b6870d6a5 100644 --- a/lib/console/schema/preview_environment_instance.ex +++ b/lib/console/schema/preview_environment_instance.ex @@ -8,6 +8,8 @@ defmodule Console.Schema.PreviewEnvironmentInstance do } schema "preview_environment_instances" do + field :preview_expires_at, :utc_datetime_usec + embeds_one :status, Status, on_replace: :update do field :comment_id, :string end @@ -37,6 +39,24 @@ defmodule Console.Schema.PreviewEnvironmentInstance do from(i in query, where: i.template_id == ^id) end + def active(query \\ __MODULE__) do + from(i in query, + join: s in assoc(i, :service), + where: is_nil(s.deleted_at) + ) + end + + def expired(query \\ __MODULE__) do + now = Timex.now() + from(i in query, + where: not is_nil(i.preview_expires_at) and i.preview_expires_at <= ^now + ) + end + + def stream(query \\ __MODULE__) do + from(i in query, order_by: [asc: :id]) + end + def ordered(query \\ __MODULE__, order \\ [desc: :inserted_at]) do from(i in query, order_by: ^order) end @@ -45,7 +65,7 @@ defmodule Console.Schema.PreviewEnvironmentInstance do from(i in query, preload: ^preloads) end - @valid ~w(template_id service_id pull_request_id)a + @valid ~w(template_id service_id pull_request_id preview_expires_at)a @required ~w(template_id service_id pull_request_id)a def changeset(instance, attrs) do diff --git a/lib/console/schema/preview_environment_template.ex b/lib/console/schema/preview_environment_template.ex index fe99594967..a3de9dce25 100644 --- a/lib/console/schema/preview_environment_template.ex +++ b/lib/console/schema/preview_environment_template.ex @@ -1,5 +1,5 @@ defmodule Console.Schema.PreviewEnvironmentTemplate do - use Piazza.Ecto.Schema + use Console.Schema.Base alias Console.Schema.{ Flow, Service, @@ -7,9 +7,12 @@ defmodule Console.Schema.PreviewEnvironmentTemplate do ScmConnection } + @week_in_seconds 60 * 60 * 24 * 7 + schema "preview_environment_templates" do field :name, :string field :comment_template, :string + field :preview_ttl, :integer, default: @week_in_seconds belongs_to :flow, Flow belongs_to :reference_service, Service @@ -34,9 +37,11 @@ defmodule Console.Schema.PreviewEnvironmentTemplate do template |> cast(attrs, @valid) |> cast_assoc(:template) + |> duration_seconds(:preview_ttl) |> validate_required(@required) |> validate_length(:name, max: 255) |> foreign_key_constraint(:id, name: :preview_environment, match: :prefix, message: "there is an active preview environment instance for this template") |> validate_format(:name, ~r/\A[a-zA-Z0-9-]+\z/) + |> validate_number(:preview_ttl, greater_than: 0) end end diff --git a/lib/console/schema/workbench_job.ex b/lib/console/schema/workbench_job.ex index e691b3d1e6..ac3f008f88 100644 --- a/lib/console/schema/workbench_job.ex +++ b/lib/console/schema/workbench_job.ex @@ -60,7 +60,7 @@ defmodule Console.Schema.WorkbenchJob do |> cast_embed(:kubernetes, with: &kubernetes_changeset/2) end - defp model_changeset(model, attrs) do + def model_changeset(model, attrs) do model |> cast(attrs, [:provider, :model]) |> validate_required([:provider, :model]) diff --git a/lib/console/schema/workbench_job_thought.ex b/lib/console/schema/workbench_job_thought.ex index 2ec4c021d3..1566dae705 100644 --- a/lib/console/schema/workbench_job_thought.ex +++ b/lib/console/schema/workbench_job_thought.ex @@ -1,6 +1,6 @@ defmodule Console.Schema.WorkbenchJobThought do use Console.Schema.Base - alias Console.Schema.WorkbenchJobActivity + alias Console.Schema.{WorkbenchJobActivity, WorkbenchTool} alias Console.Schema.WorkbenchJobActivity.WorkbenchJobResult.{Metric, Log, Trace} schema "workbench_job_thoughts" do @@ -14,6 +14,7 @@ defmodule Console.Schema.WorkbenchJobThought do embeds_many :traces, Trace, on_replace: :delete end + belongs_to :tool, WorkbenchTool belongs_to :activity, WorkbenchJobActivity timestamps() @@ -27,7 +28,7 @@ defmodule Console.Schema.WorkbenchJobThought do from(t in query, order_by: ^order) end - @valid ~w(content activity_id tool_name tool_args)a + @valid ~w(content activity_id tool_id tool_name tool_args)a def changeset(model, attrs \\ %{}) do model @@ -35,6 +36,7 @@ defmodule Console.Schema.WorkbenchJobThought do |> sanitize_text([:content, :tool_name, :tool_args]) |> cast_embed(:attributes, with: &attributes_changeset/2) |> foreign_key_constraint(:activity_id) + |> foreign_key_constraint(:tool_id) |> validate_required([:activity_id]) end diff --git a/priv/prompts/workbench/infrastructure/cluster.md.eex b/priv/prompts/workbench/infrastructure/cluster.md.eex index 849d776de8..bcc1ff85d3 100644 --- a/priv/prompts/workbench/infrastructure/cluster.md.eex +++ b/priv/prompts/workbench/infrastructure/cluster.md.eex @@ -7,8 +7,8 @@ Kubernetes Version: <%= @cluster.current_version %> Kubernetes Distribution: <%= @cluster.distro %> Extended Support: <%= Console.Deployments.Clusters.extended_support(@cluster) |> Jason.encode!() %> Metadata: <%= Jason.encode!(@cluster.metadata || %{}) %> -Tags: <%= Enum.map(@cluster.tags, & %{name: &1.name, value: &1.value}) |> Jason.encode!() %> -Project: <%= @cluster.project.name %> +Tags: <%= Enum.map(@cluster.tags || [], & %{name: &1.name, value: &1.value}) |> Jason.encode!() %> +Project: <%= @cluster.project && @cluster.project.name %> # Upgrade plan diff --git a/priv/prompts/workbench/infrastructure/service.md.eex b/priv/prompts/workbench/infrastructure/service.md.eex index c49fc2e0a5..46e5ee285f 100644 --- a/priv/prompts/workbench/infrastructure/service.md.eex +++ b/priv/prompts/workbench/infrastructure/service.md.eex @@ -17,9 +17,9 @@ Here are the details for the Plural service **<%= @service.name %>** (namespace: ```json <%= Jason.encode!(%{ - id: @service.cluster.id, - handle: @service.cluster.handle, - name: @service.cluster.name + id: @service.cluster && @service.cluster.id, + handle: @service.cluster && @service.cluster.handle, + name: @service.cluster && @service.cluster.name }, pretty: true) %> ``` @@ -30,7 +30,7 @@ This service is created as a ServiceDeployment resource in a parent service with <%= Jason.encode!(%{ id: @service.parent.id, name: @service.parent.name, - cluster: @service.parent.cluster.handle + cluster: @service.parent.cluster && @service.parent.cluster.handle }, pretty: true) %> ``` <% end %> @@ -85,11 +85,11 @@ This service is owned by a GlobalService resource, which itself has this basic s ```json <%= Jason.encode!(%{ - parent_service: @service.owner.parent && %{id: @service.owner.parent.id, name: @service.owner.parent.name, cluster: @service.owner.parent.cluster.handle}, + parent_service: @service.owner.parent && %{id: @service.owner.parent.id, name: @service.owner.parent.name, cluster: @service.owner.parent.cluster && @service.owner.parent.cluster.handle}, name: @service.owner.name, distro: @service.owner.distro, mgmt: @service.owner.mgmt, - tags: Enum.map(@service.owner.tags, & %{name: &1.name, value: &1.value}) + tags: Enum.map(@service.owner.tags || [], & %{name: &1.name, value: &1.value}) }, pretty: true) %> ``` diff --git a/priv/prompts/workbench/infrastructure/stack.md.eex b/priv/prompts/workbench/infrastructure/stack.md.eex index cf53b58198..a929a8ea69 100644 --- a/priv/prompts/workbench/infrastructure/stack.md.eex +++ b/priv/prompts/workbench/infrastructure/stack.md.eex @@ -63,7 +63,7 @@ This stack has the following state resources: The stack status is **failed**. This section summarizes the most recent **failed** stack run including log output Run id: `<%= @failed_run.run_id %>` -Failed command: `<%= @failed_run.failing_step.cmd %> <%= Enum.join(@failed_run.failing_step.args, " ") %>` +Failed command: `<%= @failed_run.failing_step.cmd %> <%= Enum.join(@failed_run.failing_step.args || [], " ") %>` <%= if !Enum.empty?(@failed_run.run_errors) do %> ## Run-level errors <%= for error <- @failed_run.run_errors do %> diff --git a/priv/prompts/workbench/job.md.eex b/priv/prompts/workbench/job.md.eex index c7ee4cfd33..207f6e3d32 100644 --- a/priv/prompts/workbench/job.md.eex +++ b/priv/prompts/workbench/job.md.eex @@ -2,6 +2,7 @@ You're a senior engineer being assigned a task to do. This can be anything from the task. You'll be given the following: * a list of documentation of the "skills" available to you, use this as a knowledge base of docs on how to navigate your environment +* a small knowledge base of facts gathered in previous runs on this workbench. Use it for system understanding, but treat entries as potentially stale. * the various capabilities of the tools at your disposal, this could be querying observability systems, introspecting infrastructure configuration, and more. * interactions with additional tools like task management software or internal apis. * in addition you'll have some set of the following subagents to delegate work to, any of which could be useful to accomplish your task: @@ -35,6 +36,14 @@ a gitops change is unlikely to be the fix or the fix needs to be applied immedia **You will also be given a list of skills to guide your investigation. You should search them at least once to potentially gather useful context on how this should be done. Skills do not change between investigations though, so don't requery them if you already know them** +## Workbench Knowledge + +This workbench also has a small knowledge base of facts gathered in previous runs. Consult it with `workbench_list_knowledge` and `workbench_knowledge` for system understanding, how infrastructure is constructed, and how to search for information. These facts can be useful, but they can also be stale — prefer live investigation when they conflict with current evidence. Call `workbench_knowledge_used` when you apply an existing entry. + +If you discover a new fact that is repeatably useful — especially how a system is constructed, or how to search for information in infrastructure — record it with `workbench_knowledge_upsert`. Keep entries thematically aligned: update an existing related entry rather than creating a new one unless nothing current fits. + +Deletion is useful when an entry is stale or you need room for new knowledge (at most 10 entries). Use `workbench_knowledge_delete`, and use usage statistics (`usages`, `last_used_at`) from the list tool to decide which old entries have the least utility. + There can be follow-up messages for this job sent by a user, which can redefine the task at hand. If a user resteers an investigation, use `workbench_notes` to set its `objective` and regenerate the TODOs and working theory for that objective. The objective recorded through `workbench_notes` is the sole active task. Investigations can be resteered based on subsequent prompts; do not overindex on the original prompt if the task should evolve. diff --git a/priv/repo/migrations/20260824150329_add_runtime_model.exs b/priv/repo/migrations/20260824150329_add_runtime_model.exs new file mode 100644 index 0000000000..b3a999ab96 --- /dev/null +++ b/priv/repo/migrations/20260824150329_add_runtime_model.exs @@ -0,0 +1,9 @@ +defmodule Console.Repo.Migrations.AddRuntimeModel do + use Ecto.Migration + + def change do + alter table(:agent_runtimes) do + add :model, :map + end + end +end diff --git a/priv/repo/migrations/20260825162939_add_flow_max_previews.exs b/priv/repo/migrations/20260825162939_add_flow_max_previews.exs new file mode 100644 index 0000000000..116689fe78 --- /dev/null +++ b/priv/repo/migrations/20260825162939_add_flow_max_previews.exs @@ -0,0 +1,19 @@ +defmodule Console.Repo.Migrations.AddFlowMaxPreviews do + use Ecto.Migration + + @week_in_seconds 60 * 60 * 24 * 7 + + def change do + alter table(:flows) do + add :max_previews, :integer, default: 10 + end + + alter table(:preview_environment_templates) do + add :preview_ttl, :integer, default: @week_in_seconds + end + + alter table(:preview_environment_instances) do + add :preview_expires_at, :utc_datetime_usec + end + end +end diff --git a/priv/repo/migrations/20260826155529_add_thought_tool_id.exs b/priv/repo/migrations/20260826155529_add_thought_tool_id.exs new file mode 100644 index 0000000000..6f20c01ece --- /dev/null +++ b/priv/repo/migrations/20260826155529_add_thought_tool_id.exs @@ -0,0 +1,11 @@ +defmodule Console.Repo.Migrations.AddThoughtToolId do + use Ecto.Migration + + def change do + alter table(:workbench_job_thoughts) do + add :tool_id, references(:workbench_tools, type: :uuid, on_delete: :nilify_all) + end + + create index(:workbench_job_thoughts, [:tool_id]) + end +end diff --git a/priv/tools/workbench/knowledge_delete.json b/priv/tools/workbench/knowledge_delete.json new file mode 100644 index 0000000000..ee3742dbd4 --- /dev/null +++ b/priv/tools/workbench/knowledge_delete.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "knowledge_id": { + "type": "string", + "description": "The id of the knowledge entry to delete. Use workbench_list_knowledge to list ids and usage data first." + } + }, + "required": ["knowledge_id"] +} diff --git a/priv/tools/workbench/knowledge_upsert.json b/priv/tools/workbench/knowledge_upsert.json new file mode 100644 index 0000000000..0c8f3d0174 --- /dev/null +++ b/priv/tools/workbench/knowledge_upsert.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique name for this knowledge entry on the workbench. If an entry with this name already exists it will be updated; otherwise a new entry is created." + }, + "description": { + "type": "string", + "description": "Short summary of what this knowledge captures and when to use it." + }, + "knowledge": { + "type": "string", + "description": "The full knowledge body to persist (facts, runbooks, durable notes)." + }, + "labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional labels to categorize this knowledge entry." + } + }, + "required": ["name", "knowledge"] +} diff --git a/priv/tools/workbench/knowledge_used.json b/priv/tools/workbench/knowledge_used.json new file mode 100644 index 0000000000..e6149f85f3 --- /dev/null +++ b/priv/tools/workbench/knowledge_used.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The knowledge entry that was used. You can find names with the workbench_list_knowledge tool." + } + }, + "required": ["name"] +} diff --git a/priv/tools/workbench/read_knowledge.json b/priv/tools/workbench/read_knowledge.json new file mode 100644 index 0000000000..c0d3a069b1 --- /dev/null +++ b/priv/tools/workbench/read_knowledge.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The knowledge entry to read. You can find the list of knowledge names with the workbench_list_knowledge tool." + } + }, + "required": ["name"] +} diff --git a/schema/openapi.json b/schema/openapi.json index 4520b505d2..dc06142351 100644 --- a/schema/openapi.json +++ b/schema/openapi.json @@ -257,6 +257,9 @@ "format": "date-time", "type": "string" }, + "model": { + "$ref": "#/components/schemas/WorkbenchJobModel" + }, "name": { "description": "Human-readable name of this runtime", "type": "string" diff --git a/schema/schema.graphql b/schema/schema.graphql index 340d57810f..2aa2af9124 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -2065,6 +2065,9 @@ input AgentRuntimeAttributes { "the name of the scm connection to use for this runtime" scmConnection: String + + "default model override for runs on this runtime" + model: WorkbenchJobModelAttributes } input AgentBindingAttributes { @@ -2365,6 +2368,9 @@ type AgentRuntime { "default interval in seconds between babysit checks for runs on this runtime" babysitInterval: Int + "default model override for runs on this runtime" + model: WorkbenchJobModel + "the cluster this runtime is running on" cluster: Cluster @@ -6030,6 +6036,9 @@ input FlowAttributes { repositories: [String] + "the maximum number of preview environments allowed for this flow (1-25, default 10)" + maxPreviews: Int + readBindings: [PolicyBindingAttributes] writeBindings: [PolicyBindingAttributes] @@ -6098,6 +6107,9 @@ input PreviewEnvironmentTemplateAttributes { "an scm connection id to use for PR preview comment generation" connectionId: ID + + "how long preview environments should live, as a kubernetes duration (e.g. 1d, 5s)" + previewTtl: String } type Flow { @@ -6114,6 +6126,9 @@ type Flow { "the git https urls of the application code repositories used in this flow" repositories: [String] + "the maximum number of preview environments allowed for this flow (1-25, default 10)" + maxPreviews: Int + "the agent runtime for this flow" agentRuntime: AgentRuntime @@ -6250,23 +6265,42 @@ type McpServerTool { "A template for generating preview environments" type PreviewEnvironmentTemplate { id: ID! + name: String! + commentTemplate: String + + "how long preview environments should live, in seconds" + previewTtl: Int + flow: Flow + referenceService: ServiceDeployment + template: ServiceTemplate + connection: ScmConnection + insertedAt: DateTime + updatedAt: DateTime } "An instance of a preview environment template" type PreviewEnvironmentInstance { id: ID! + + "when this preview environment instance expires" + previewExpiresAt: DateTime + service: ServiceDeployment + pullRequest: PullRequest + template: PreviewEnvironmentTemplate + insertedAt: DateTime + updatedAt: DateTime } diff --git a/test/console/ai/model_selection_test.exs b/test/console/ai/model_selection_test.exs new file mode 100644 index 0000000000..65e8abbbca --- /dev/null +++ b/test/console/ai/model_selection_test.exs @@ -0,0 +1,110 @@ +defmodule Console.AI.ModelSelectionTest do + use Console.DataCase, async: true + alias Console.AI.ModelSelection + alias Console.Repo + + describe "tool_model/2" do + test "prefers a job model override over the configured tool model" do + settings = deployment_settings(ai: %{ + enabled: true, + provider: :openai, + tool_provider: :anthropic, + openai: %{tool_model: "openai-tool-model"}, + anthropic: %{tool_model: "anthropic-tool-model"} + }) + + job = insert(:workbench_job, modes: %{model: %{provider: :openai, model: "job-tool-model"}}) + + assert ModelSelection.tool_model(job, settings) == %{ + provider: :openai, + model: "job-tool-model" + } + end + + test "falls back to the configured tool model" do + settings = deployment_settings(ai: %{ + enabled: true, + provider: :openai, + tool_provider: :anthropic, + openai: %{tool_model: "openai-tool-model"}, + anthropic: %{tool_model: "anthropic-tool-model"} + }) + + job = insert(:workbench_job) + + assert ModelSelection.tool_model(job, settings) == %{ + provider: :anthropic, + model: "anthropic-tool-model" + } + end + end + + describe "runtime_model/1" do + test "reads the model from an agent runtime" do + runtime = insert(:agent_runtime, model: %{provider: :anthropic, model: "claude-sonnet-4-5"}) + + assert ModelSelection.runtime_model(runtime) == %{ + provider: :anthropic, + model: "claude-sonnet-4-5" + } + end + + test "reads the model from an agent run's runtime" do + runtime = insert(:agent_runtime, model: %{provider: :openai, model: "gpt-5.4"}) + run = insert(:agent_run, runtime: runtime) |> Repo.preload(:runtime) + + assert ModelSelection.runtime_model(run) == %{provider: :openai, model: "gpt-5.4"} + end + + test "reads the model from a workbench job's agent runtime" do + runtime = insert(:agent_runtime, model: %{provider: :vertex, model: "gemini-2.5-pro"}) + workbench = insert(:workbench, agent_runtime: runtime) + job = insert(:workbench_job, workbench: workbench) |> Repo.preload(workbench: :agent_runtime) + + assert ModelSelection.runtime_model(job) == %{provider: :vertex, model: "gemini-2.5-pro"} + end + + test "is independent of the workbench tool model override" do + runtime = insert(:agent_runtime, model: %{provider: :anthropic, model: "claude-sonnet-4-5"}) + workbench = insert(:workbench, agent_runtime: runtime) + job = insert(:workbench_job, + workbench: workbench, + modes: %{model: %{provider: :openai, model: "job-tool-model"}} + ) |> Repo.preload(workbench: :agent_runtime) + + assert ModelSelection.runtime_model(job) == %{ + provider: :anthropic, + model: "claude-sonnet-4-5" + } + end + + test "returns nil when the runtime has no model" do + runtime = insert(:agent_runtime) + refute ModelSelection.runtime_model(runtime) + end + end + + describe "backfill_usage/2" do + test "computes missing costs from a price sheet" do + usage = ModelSelection.backfill_usage( + %{input_tokens: 1_000_000, output_tokens: 250_000}, + %{input_price: 3.0, output_price: 15.0} + ) + + assert usage.input_cost == 3.0 + assert usage.output_cost == 3.75 + assert usage.total_cost == 6.75 + end + + test "preserves costs already reported by the agent" do + usage = ModelSelection.backfill_usage( + %{input_tokens: 1_000_000, output_tokens: 250_000, input_cost: 1.0, output_cost: 2.0, total_cost: 3.0}, + %{input_price: 3.0, output_price: 15.0} + ) + + assert usage.input_cost == 1.0 + assert usage.output_cost == 2.0 + assert usage.total_cost == 3.0 + end + end +end diff --git a/test/console/ai/tools/agent/base_test.exs b/test/console/ai/tools/agent/base_test.exs index 8aa2e83afc..1e4c4e9a6f 100644 --- a/test/console/ai/tools/agent/base_test.exs +++ b/test/console/ai/tools/agent/base_test.exs @@ -41,6 +41,14 @@ defmodule Console.AI.Tools.Agent.BaseTest do assert {:vsphere, %VSphereCredentials{} = credentials} = pb.credentials assert credentials.allow_unverified_ssl == "false" end + + test "returns nil when configuration or provider credentials are missing" do + assert is_nil(Base.to_pb(%CloudConnection{provider: :aws, configuration: nil})) + assert is_nil(Base.to_pb(%CloudConnection{ + provider: :aws, + configuration: %CloudConnection.Configuration{aws: nil} + })) + end end defp vsphere_connection(allow_unverified_ssl) do diff --git a/test/console/ai/tools/workbench/integration/slack/tools_test.exs b/test/console/ai/tools/workbench/integration/slack/tools_test.exs new file mode 100644 index 0000000000..fe4cef6321 --- /dev/null +++ b/test/console/ai/tools/workbench/integration/slack/tools_test.exs @@ -0,0 +1,36 @@ +defmodule Console.AI.Tools.Workbench.Integration.Slack.ToolsTest do + use ExUnit.Case, async: true + + alias Console.AI.Tools.Workbench.Integration.Slack.{ + CreateChannel, + EditMessage, + FindChannelByName, + InviteToChannel, + ListChannels, + ListMessages, + ListUserGroups, + PostMessage, + ReactToMessage, + Tools + } + + alias Console.Schema.WorkbenchTool + + describe "expand/1" do + test "exposes the Slack workspace tools for a workbench tool" do + tool = %WorkbenchTool{name: "slack", tool: :slack} + + assert [ + %ListChannels{tool: ^tool}, + %ListMessages{tool: ^tool}, + %ListUserGroups{tool: ^tool}, + %FindChannelByName{tool: ^tool}, + %InviteToChannel{tool: ^tool}, + %CreateChannel{tool: ^tool}, + %PostMessage{tool: ^tool}, + %EditMessage{tool: ^tool}, + %ReactToMessage{tool: ^tool} + ] = Tools.expand(tool) + end + end +end diff --git a/test/console/ai/tools/workbench/knowledge_test.exs b/test/console/ai/tools/workbench/knowledge_test.exs new file mode 100644 index 0000000000..c53ba81a22 --- /dev/null +++ b/test/console/ai/tools/workbench/knowledge_test.exs @@ -0,0 +1,175 @@ +defmodule Console.AI.Tools.Workbench.KnowledgeTest do + use Console.DataCase, async: true + + alias Console.AI.Tool + alias Console.AI.Tools.Workbench.{ListKnowledge, Knowledge, KnowledgeUsed, KnowledgeUpsert, KnowledgeDelete} + alias Console.AI.Workbench.Subagents.Base, as: SubagentBase + + describe "ListKnowledge.implement/1" do + test "lists knowledge for the job workbench with usage data" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + k1 = insert(:workbench_knowledge, workbench: workbench, name: "runbook", description: "ops", usages: 3, labels: ["ops"]) + insert(:workbench_knowledge, name: "other-bench") + + {:ok, json} = ListKnowledge.implement(%ListKnowledge{job: job}) + {:ok, listed} = Jason.decode(json) + + assert length(listed) == 1 + [entry] = listed + assert entry["id"] == k1.id + assert entry["name"] == "runbook" + assert entry["description"] == "ops" + assert entry["labels"] == ["ops"] + assert entry["usages"] == 3 + refute Map.has_key?(entry, "knowledge") + end + end + + describe "Knowledge.implement/1" do + test "returns full knowledge contents and records a usage" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + knowledge = insert(:workbench_knowledge, + workbench: workbench, + name: "runbook", + knowledge: "restart the pod", + usages: 1 + ) + + {:ok, parsed} = Tool.validate(%Knowledge{job: job}, %{"name" => "runbook"}) + {:ok, json} = Knowledge.implement(parsed) + {:ok, body} = Jason.decode(json) + + assert body["id"] == knowledge.id + assert body["name"] == "runbook" + assert body["knowledge"] == "restart the pod" + assert body["usages"] == 2 + assert body["last_used_at"] + assert refetch(knowledge).usages == 2 + end + + test "returns an error when the name is missing" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + + {:ok, parsed} = Tool.validate(%Knowledge{job: job}, %{"name" => "missing"}) + {:error, "knowledge not found"} = Knowledge.implement(parsed) + end + end + + describe "KnowledgeUsed.implement/1" do + test "records usage for a knowledge entry by name" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + knowledge = insert(:workbench_knowledge, workbench: workbench, name: "runbook", usages: 1) + + {:ok, parsed} = Tool.validate(%KnowledgeUsed{job: job}, %{"name" => "runbook"}) + {:ok, json} = KnowledgeUsed.implement(parsed) + {:ok, body} = Jason.decode(json) + + assert body["id"] == knowledge.id + assert body["name"] == "runbook" + assert body["usages"] == 2 + assert body["last_used_at"] + assert refetch(knowledge).usages == 2 + end + + test "returns an error when the name is missing" do + job = insert(:workbench_job) + + {:ok, parsed} = Tool.validate(%KnowledgeUsed{job: job}, %{"name" => "missing"}) + {:error, "knowledge not found"} = KnowledgeUsed.implement(parsed) + end + end + + describe "skill_knowledge_tools/2" do + test "includes read-only skill and knowledge tools plus usage recording" do + job = insert(:workbench_job) + names = SubagentBase.skill_knowledge_tools(job, %{}) + |> Enum.map(&Tool.name/1) + + assert "workbench_skills" in names + assert "workbench_skill" in names + assert "workbench_list_knowledge" in names + assert "workbench_knowledge" in names + assert "workbench_knowledge_used" in names + refute "workbench_knowledge_upsert" in names + refute "workbench_knowledge_delete" in names + end + end + + describe "KnowledgeUpsert.implement/1" do + test "creates a knowledge entry" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + + {:ok, parsed} = Tool.validate(%KnowledgeUpsert{job: job}, %{ + "name" => "runbook", + "description" => "ops notes", + "knowledge" => "restart the pod", + "labels" => ["ops"] + }) + {:ok, json} = KnowledgeUpsert.implement(parsed) + {:ok, body} = Jason.decode(json) + + assert body["name"] == "runbook" + assert body["knowledge"] == "restart the pod" + assert body["labels"] == ["ops"] + end + + test "updates an existing knowledge entry by name" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + existing = insert(:workbench_knowledge, workbench: workbench, name: "runbook", knowledge: "old") + + {:ok, parsed} = Tool.validate(%KnowledgeUpsert{job: job}, %{ + "name" => "runbook", + "knowledge" => "new body" + }) + {:ok, json} = KnowledgeUpsert.implement(parsed) + {:ok, body} = Jason.decode(json) + + assert body["id"] == existing.id + assert body["knowledge"] == "new body" + end + + test "fails to create when the workbench already has 10 entries" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + for i <- 1..10, do: insert(:workbench_knowledge, workbench: workbench, name: "k-#{i}") + + {:ok, parsed} = Tool.validate(%KnowledgeUpsert{job: job}, %{ + "name" => "overflow", + "knowledge" => "too many" + }) + {:error, error} = KnowledgeUpsert.implement(parsed) + assert error =~ "10 knowledge entries" + end + end + + describe "KnowledgeDelete.implement/1" do + test "deletes a knowledge entry by id on the job's workbench" do + workbench = insert(:workbench) + job = insert(:workbench_job, workbench: workbench) + knowledge = insert(:workbench_knowledge, workbench: workbench, name: "stale") + + {:ok, parsed} = Tool.validate(%KnowledgeDelete{job: job}, %{"knowledge_id" => knowledge.id}) + {:ok, msg} = KnowledgeDelete.implement(parsed) + + assert msg =~ knowledge.id + assert msg =~ "stale" + refute refetch(knowledge) + end + + test "does not delete knowledge from another workbench" do + job = insert(:workbench_job) + knowledge = insert(:workbench_knowledge, name: "stale") + + {:ok, parsed} = Tool.validate(%KnowledgeDelete{job: job}, %{"knowledge_id" => knowledge.id}) + {:error, "knowledge not found"} = KnowledgeDelete.implement(parsed) + + assert refetch(knowledge) + end + end +end diff --git a/test/console/ai/workbench/environment_test.exs b/test/console/ai/workbench/environment_test.exs index ed9bfbe94f..95f8ffd76d 100644 --- a/test/console/ai/workbench/environment_test.exs +++ b/test/console/ai/workbench/environment_test.exs @@ -71,5 +71,34 @@ defmodule Console.AI.Workbench.EnvironmentTest do assert policy_id == policy.id assert Regex.match?(regex, "protected_tool") end + + test "uses the workbench tool model, not the agent runtime model" do + deployment_settings(ai: %{ + enabled: true, + provider: :openai, + openai: %{tool_model: "default-tool-model"}, + price_sheets: [ + %{provider: :openai, model: "default-tool-model", input_price: 1.0, output_price: 2.0}, + %{provider: :anthropic, model: "claude-sonnet-4-5", input_price: 3.0, output_price: 15.0} + ] + }) + + runtime = insert(:agent_runtime, model: %{provider: :anthropic, model: "claude-sonnet-4-5"}) + workbench = insert(:workbench, agent_runtime: runtime) + job = insert(:workbench_job, + workbench: workbench, + modes: %{model: %{provider: :openai, model: "default-tool-model"}} + ) |> Repo.preload(workbench: :agent_runtime) + run = insert(:agent_run, runtime: runtime) |> Repo.preload(:runtime) + + engine_opts = Environment.engine_opts(job) + assert engine_opts[:provider] == :openai + assert engine_opts[:model] == "default-tool-model" + + assert Console.AI.ModelSelection.runtime_model(run) == %{ + provider: :anthropic, + model: "claude-sonnet-4-5" + } + end end end diff --git a/test/console/ai/workbench/heartbeat_test.exs b/test/console/ai/workbench/heartbeat_test.exs index 5632bb1a50..c9aef0b37a 100644 --- a/test/console/ai/workbench/heartbeat_test.exs +++ b/test/console/ai/workbench/heartbeat_test.exs @@ -51,6 +51,42 @@ defmodule Console.AI.Workbench.HeartbeatTest do assert usage.total_cost == 6.75 end + test "backfills agent-run usage using the agent runtime model price sheet" do + deployment_settings(ai: %{ + enabled: true, + provider: :openai, + openai: %{tool_model: "default-tool-model"}, + price_sheets: [ + %{provider: :openai, model: "default-tool-model", input_price: 1.0, output_price: 2.0}, + %{provider: :anthropic, model: "claude-sonnet-4-5", input_price: 3.0, output_price: 15.0} + ] + }) + + runtime = insert(:agent_runtime, model: %{provider: :anthropic, model: "claude-sonnet-4-5"}) + workbench = insert(:workbench, agent_runtime: runtime) + job = insert(:workbench_job, status: :running, workbench: workbench) + |> Repo.preload(workbench: :agent_runtime) + run = insert(:agent_run, runtime: runtime) |> Repo.preload(:runtime) + + {:ok, pid} = Heartbeat.start_link(job) + Process.unlink(pid) + + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(pid, :normal) + end) + + Console.AI.Workbench.Environment.runtime_usage_callback(job, run, %{ + input_tokens: 1_000_000, + output_tokens: 250_000 + }) + + %{usage: usage} = :sys.get_state(pid) + + assert usage.input_cost == 3.0 + assert usage.output_cost == 3.75 + assert usage.total_cost == 6.75 + end + test "backfills missing costs using the job model override price sheet" do settings = deployment_settings(ai: %{ enabled: true, diff --git a/test/console/ai/workbench/subagents/integration_test.exs b/test/console/ai/workbench/subagents/integration_test.exs index 8e87e6a282..a98ecc1fac 100644 --- a/test/console/ai/workbench/subagents/integration_test.exs +++ b/test/console/ai/workbench/subagents/integration_test.exs @@ -4,6 +4,7 @@ defmodule Console.AI.Workbench.Subagents.IntegrationTest do alias Console.AI.Workbench.{Subagents, Environment, Engine} alias Console.AI.Tools.Workbench.Http alias Console.AI.{Provider, Tool} + alias Console.Schema.WorkbenchJobThought import ElasticsearchUtils setup :set_mimic_global @@ -73,6 +74,17 @@ defmodule Console.AI.Workbench.Subagents.IntegrationTest do assert result[:status] == :successful assert result[:result][:output] == "complete" + + thoughts = + WorkbenchJobThought.for_activity(activity.id) + |> WorkbenchJobThought.ordered() + |> Repo.all() + + http_thought = Enum.find(thoughts, & &1.tool_name == "http_integration_example") + assert http_thought + assert http_thought.tool_id == tool.id + assert http_thought.tool_args == %{"input" => %{"hello" => "world"}} + assert http_thought.content =~ "http response: world" end # @tag :skip @@ -149,6 +161,15 @@ defmodule Console.AI.Workbench.Subagents.IntegrationTest do assert result[:status] == :successful assert result[:result][:output] == "complete" + + thoughts = + WorkbenchJobThought.for_activity(activity.id) + |> WorkbenchJobThought.ordered() + |> Repo.all() + + mcp_thought = Enum.find(thoughts, & &1.tool_name == "mcp_example_echo") + assert mcp_thought + assert mcp_thought.tool_id == tool.id end @tag :skip diff --git a/test/console/ai/workbench/subagents/observability_test.exs b/test/console/ai/workbench/subagents/observability_test.exs index aaeb572f09..9e287035a9 100644 --- a/test/console/ai/workbench/subagents/observability_test.exs +++ b/test/console/ai/workbench/subagents/observability_test.exs @@ -5,6 +5,7 @@ defmodule Console.AI.Workbench.Subagents.ObservabilityTest do alias Console.AI.{Provider, Tool} alias Console.AI.Tools.Workbench.Observability.Metrics alias Console.Deployments.Workbenches + alias Console.Schema.WorkbenchJobThought import ElasticsearchUtils setup :set_mimic_global @@ -36,6 +37,15 @@ defmodule Console.AI.Workbench.Subagents.ObservabilityTest do ] result_output = "Investigation complete. CPU usage is at 50%." + expect(Provider, :completion, fn _, _ -> + {:ok, "enabling tools", [ + %Tool{ + name: "enable_tools", + arguments: %{"tools" => [metrics_tool_name]}, + id: "0" + } + ]} + end) expect(Provider, :completion, fn _, _ -> {:ok, "querying metrics", [ %Tool{ @@ -99,6 +109,20 @@ defmodule Console.AI.Workbench.Subagents.ObservabilityTest do [persisted_log] = updated.result.logs assert persisted_log.message == "connection reset" assert persisted_log.labels == %{"pod" => "api-1"} + + thoughts = + WorkbenchJobThought.for_activity(activity.id) + |> WorkbenchJobThought.ordered() + |> Repo.all() + + metrics_thought = Enum.find(thoughts, & &1.tool_name == metrics_tool_name) + assert metrics_thought + assert metrics_thought.tool_id == tool.id + assert metrics_thought.tool_args == %{"query" => "up"} + + enable_thought = Enum.find(thoughts, & &1.tool_name == "enable_tools") + assert enable_thought + refute enable_thought.tool_id end end end diff --git a/test/console/ai/workbench/tools_test.exs b/test/console/ai/workbench/tools_test.exs new file mode 100644 index 0000000000..28a84456a9 --- /dev/null +++ b/test/console/ai/workbench/tools_test.exs @@ -0,0 +1,278 @@ +defmodule Console.AI.Workbench.ToolsTest do + use Console.DataCase, async: false + use Mimic + + alias Console.AI.Workbench.{Tools, Environment, MCP} + alias Console.AI.MCP.Tool, as: MCPToolSpec + alias Console.AI.Tools.Workbench.{Http, FunctionCall} + alias Console.AI.Tools.Workbench.MCP, as: MCPTool + alias Console.AI.Tools.Workbench.Observability.{Metrics, MetricsSearch, MetricsLabelSearch, Logs, Traces} + alias Console.AI.Tools.Workbench.Infrastructure.{CloudSchemas, RawCloudQuery, CloudTables} + alias Console.AI.Tools.Workbench.Integration.Github.ListIssues + alias Console.AI.Tools.Workbench.Integration.Sentry.ListIssues, as: SentryListIssues + alias Console.AI.Tools.Workbench.Integration.Slack.ListChannels + + describe "index/1" do + test "maps workbench tool names to {module, workbench_tool}" do + workbench = insert(:workbench) + prom = insert_associated_tool(workbench, :prometheus, "prom", [:metrics], %{ + prometheus: %{url: "https://prom.example.com", token: "token", tenant_id: nil} + }) + http = insert_associated_tool(workbench, :http, "example", [:integration], %{ + http: %{ + url: "https://example.com", + method: :get, + input_schema: %{"type" => "object", "properties" => %{}} + } + }) + cloud = insert_associated_tool(workbench, :cloud, "aws", [:infrastructure], %{}, + cloud_connection: insert(:cloud_connection) + ) + + workbench = Repo.preload(workbench, :tools) + index = Tools.index(workbench) + + assert_indexed(index, "workbench_observability_metrics_prom", Metrics, prom) + assert_indexed(index, "workbench_observability_metric_search_prom", MetricsSearch, prom) + assert_indexed(index, "workbench_observability_metric_label_search_prom", MetricsLabelSearch, prom) + assert_indexed(index, "http_integration_example", Http, http) + assert_indexed(index, "cloud_schemas_aws", CloudSchemas, cloud) + assert_indexed(index, "cloud_query_aws", RawCloudQuery, cloud) + assert_indexed(index, "cloud_tables_aws", CloudTables, cloud) + + {Http, found} = Tools.get(workbench, "http_integration_example") + assert found.id == http.id + refute Tools.get(workbench, "missing") + refute Tools.get(%{}, nil) + refute Tools.get(nil, "http_integration_example") + end + + test "preloads nested cloud_connection when indexing" do + connection = insert(:cloud_connection) + tool = insert(:workbench_tool, + tool: :cloud, + name: "aws", + categories: [:infrastructure], + cloud_connection: connection + ) + # Simulate a caller that only loaded the tool row, not nested associations. + tool = Repo.get!(Console.Schema.WorkbenchTool, tool.id) + refute Ecto.assoc_loaded?(tool.cloud_connection) + + index = Tools.index([tool]) + + assert_indexed(index, "cloud_schemas_aws", CloudSchemas, tool) + assert_indexed(index, "cloud_query_aws", RawCloudQuery, tool) + assert_indexed(index, "cloud_tables_aws", CloudTables, tool) + + {RawCloudQuery, found} = Tools.get(index, "cloud_query_aws") + assert %Console.Schema.CloudConnection{id: id} = found.cloud_connection + assert id == connection.id + end + + test "indexes function, slack, sentry, and scm tools" do + workbench = insert(:workbench) + lambda = insert(:workbench_function_tool) + insert(:workbench_tool_association, workbench: workbench, tool: lambda) + + slack = insert_associated_tool(workbench, :slack, "slack", [:chat], %{ + slack: %{bot_token: "xoxb-test"} + }) + sentry = insert_associated_tool(workbench, :sentry, "sentry", [:error_tracking], %{ + sentry: %{access_token: "token"} + }) + github = insert_associated_tool(workbench, :github, "gh", [:scm], %{ + github: %{access_token: "token"} + }) + loki = insert_associated_tool(workbench, :loki, "loki", [:logs], %{ + loki: %{url: "https://loki.example.com"} + }) + tempo = insert_associated_tool(workbench, :tempo, "tempo", [:traces], %{ + tempo: %{url: "https://tempo.example.com"} + }) + + workbench = Repo.preload(workbench, :tools) + index = Tools.index(workbench) + + assert_indexed(index, "lambda_function_call_#{lambda.name}", FunctionCall, lambda) + assert_indexed(index, "slack_list_channels_slack", ListChannels, slack) + assert_indexed(index, "sentry_list_issues_sentry", SentryListIssues, sentry) + assert_indexed(index, "github_gh_list_issues", ListIssues, github) + assert_indexed(index, "workbench_observability_logs_loki", Logs, loki) + assert_indexed(index, "workbench_observability_traces_tempo", Traces, tempo) + end + + test "does not treat http function tools as integrations" do + workbench = insert(:workbench) + tool = insert_associated_tool(workbench, :http, "fn", [:function], %{ + http: %{ + url: "https://example.com", + method: :get, + function: true, + input_schema: %{"type" => "object", "properties" => %{}} + } + }) + + workbench = Repo.preload(workbench, :tools) + index = Tools.index(workbench) + + assert_indexed(index, "http_function_call_fn", FunctionCall, tool) + refute Map.has_key?(index, "http_integration_fn") + end + end + + describe "index/2" do + test "includes MCP expansions when a job is provided" do + server = insert(:mcp_server, name: "example", url: "http://localhost:3001/mcp") + workbench = insert(:workbench) + tool = insert_associated_tool(workbench, :mcp, "example", [:integration], %{}, mcp_server: server) + job = insert(:workbench_job, workbench: workbench) + workbench = Repo.preload(workbench, :tools) + + expect(MCP, :expand_tools, fn tools, found_job -> + assert Enum.any?(tools, & &1.id == tool.id) + assert found_job.id == job.id + [%MCPTool{ + tool: tool, + job: job, + mcp_tool: %MCPToolSpec{ + name: "echo", + description: "echo a message", + input_schema: %{"type" => "object", "properties" => %{"message" => %{"type" => "string"}}} + } + }] + end) + + index = Tools.index(workbench, job) + + assert_indexed(index, "mcp_example_echo", MCPTool, tool) + {MCPTool, found} = Tools.get(index, "mcp_example_echo") + assert found.id == tool.id + end + + test "indexes environment tools including functions" do + workbench = insert(:workbench) + http = insert_associated_tool(workbench, :http, "example", [:integration], %{ + http: %{ + url: "https://example.com", + method: :get, + input_schema: %{"type" => "object", "properties" => %{}} + } + }) + lambda = insert(:workbench_function_tool) + insert(:workbench_tool_association, workbench: workbench, tool: lambda) + job = insert(:workbench_job, workbench: workbench) + env = Environment.new(job, [http, lambda], []) + + index = Tools.index(env) + + assert_indexed(index, "http_integration_example", Http, http) + assert_indexed(index, "lambda_function_call_#{lambda.name}", FunctionCall, lambda) + end + + test "indexes cloud tools from an environment without crashing" do + workbench = insert(:workbench) + cloud = insert_associated_tool(workbench, :cloud, "aws", [:infrastructure], %{}, + cloud_connection: insert(:cloud_connection) + ) + job = insert(:workbench_job, workbench: workbench) + env = Environment.new(job, [cloud], []) + + assert_indexed(env.tool_index, "cloud_query_aws", RawCloudQuery, cloud) + assert_indexed(env.tool_index, "cloud_schemas_aws", CloudSchemas, cloud) + assert_indexed(env.tool_index, "cloud_tables_aws", CloudTables, cloud) + end + end + + describe "cloud_tools/1" do + test "expands cloud workbench tools" do + tool = insert(:workbench_tool, + tool: :cloud, + name: "aws", + categories: [:infrastructure], + cloud_connection: insert(:cloud_connection) + ) + + assert [ + %CloudSchemas{tool: found}, + %RawCloudQuery{tool: found}, + %CloudTables{tool: found} + ] = Tools.cloud_tools([tool]) + assert found.id == tool.id + end + + test "preloads nested cloud_connection" do + connection = insert(:cloud_connection) + tool = insert(:workbench_tool, + tool: :cloud, + name: "aws", + categories: [:infrastructure], + cloud_connection: connection + ) + tool = Repo.get!(Console.Schema.WorkbenchTool, tool.id) + refute Ecto.assoc_loaded?(tool.cloud_connection) + + [%RawCloudQuery{tool: found}] = Enum.filter(Tools.cloud_tools([tool]), &match?(%RawCloudQuery{}, &1)) + assert %Console.Schema.CloudConnection{id: id} = found.cloud_connection + assert id == connection.id + end + end + + describe "obs_tools/1" do + test "expands metrics categories and ignores unrelated tools" do + prom = insert(:workbench_tool, + tool: :prometheus, + name: "prom", + categories: [:metrics], + configuration: %{prometheus: %{url: "https://prom.example.com"}} + ) + http = insert(:workbench_tool, tool: :http, name: "http") + + names = Tools.obs_tools([prom, http]) |> Enum.map(&Console.AI.Tool.name/1) + + assert "workbench_observability_metrics_prom" in names + refute Enum.any?(names, &String.contains?(&1, "http")) + end + end + + describe "scm_tools/1" do + test "only expands scm-category tools" do + github = insert(:workbench_tool, + tool: :github, + name: "gh", + categories: [:scm], + configuration: %{github: %{access_token: "token"}} + ) + slack = insert(:workbench_tool, + tool: :slack, + name: "slack", + categories: [:chat], + configuration: %{slack: %{bot_token: "xoxb-test"}} + ) + + names = Tools.scm_tools([github, slack]) |> Enum.map(&Console.AI.Tool.name/1) + + assert "github_gh_list_issues" in names + refute Enum.any?(names, &String.starts_with?(&1, "slack_")) + end + end + + defp assert_indexed(index, name, mod, tool) do + assert {^mod, found} = Map.fetch!(index, name) + assert found.id == tool.id + end + + defp insert_associated_tool(workbench, type, name, categories, configuration, opts \\ []) do + tool = + insert(:workbench_tool, Keyword.merge([ + tool: type, + name: name, + categories: categories, + configuration: configuration, + project: workbench.project + ], opts)) + + insert(:workbench_tool_association, workbench: workbench, tool: tool) + tool + end +end diff --git a/test/console/deployments/cron_test.exs b/test/console/deployments/cron_test.exs index 3769ab386d..da0a6a3953 100644 --- a/test/console/deployments/cron_test.exs +++ b/test/console/deployments/cron_test.exs @@ -395,4 +395,18 @@ defmodule Console.Deployments.CronTest do end end + describe "#prune_preview_environments/0" do + test "it will delete expired preview environment instances" do + bot("console") + expired = insert(:preview_environment_instance, preview_expires_at: Timex.shift(Timex.now(), hours: -1)) + keep = insert(:preview_environment_instance, preview_expires_at: Timex.shift(Timex.now(), hours: 1)) + unexpiring = insert(:preview_environment_instance) + + :ok = Cron.prune_preview_environments() + + assert refetch(expired.service).deleted_at + refute refetch(keep.service).deleted_at + refute refetch(unexpiring.service).deleted_at + end + end end diff --git a/test/console/deployments/flows_test.exs b/test/console/deployments/flows_test.exs index e8b68ad2b7..70c5940bb4 100644 --- a/test/console/deployments/flows_test.exs +++ b/test/console/deployments/flows_test.exs @@ -186,13 +186,15 @@ defmodule Console.Deployments.FlowsTest do name: "test", flow_id: flow.id, template: %{namespace: "test-blah"}, - reference_service_id: svc.id + reference_service_id: svc.id, + preview_ttl: "1d" }, user) assert template.name == "test" assert template.flow_id == flow.id assert template.template.namespace == "test-blah" assert template.reference_service_id == svc.id + assert template.preview_ttl == 86_400 assert_receive {:event, %PubSub.PreviewEnvironmentTemplateCreated{item: ^template}} end diff --git a/test/console/deployments/pubsub/preview_test.exs b/test/console/deployments/pubsub/preview_test.exs index 24465dcc56..b85685e982 100644 --- a/test/console/deployments/pubsub/preview_test.exs +++ b/test/console/deployments/pubsub/preview_test.exs @@ -41,6 +41,97 @@ defmodule Console.Deployments.PubSub.PreviewTest do assert Jason.decode!(svc.helm.values) == %{"image" => %{"tag" => "pr-123"}} end + test "it will set preview_expires_at from the template ttl" do + flow = insert(:flow) + pr = insert(:pull_request, status: :open, flow: flow, commit_sha: "pr-123", preview: "test") + service = insert(:service, namespace: "test", flow: flow) + template = insert(:preview_environment_template, + name: "test", + flow: flow, + preview_ttl: 3600, + reference_service: service, + template: build(:service_template, + namespace: "test-{{ commitSha }}", + name: "test-{{ commitSha }}" + ) + ) + + event = %PubSub.PullRequestCreated{item: pr} + {:ok, inst} = Preview.handle_event(event) + + assert inst.template_id == template.id + assert inst.preview_expires_at + assert DateTime.diff(inst.preview_expires_at, DateTime.utc_now()) in 3590..3605 + end + + test "it will not set preview_expires_at when the template ttl is nil" do + flow = insert(:flow) + pr = insert(:pull_request, status: :open, flow: flow, commit_sha: "pr-123", preview: "test") + service = insert(:service, namespace: "test", flow: flow) + template = insert(:preview_environment_template, + name: "test", + flow: flow, + reference_service: service, + template: build(:service_template, + namespace: "test-{{ commitSha }}", + name: "test-{{ commitSha }}" + ) + ) + {:ok, _} = template |> Ecto.Changeset.change(%{preview_ttl: nil}) |> Repo.update() + + event = %PubSub.PullRequestCreated{item: pr} + {:ok, inst} = Preview.handle_event(event) + + refute inst.preview_expires_at + end + + test "it will not create a preview instance when the flow is at max previews" do + flow = insert(:flow, max_previews: 1) + pr = insert(:pull_request, status: :open, flow: flow, commit_sha: "pr-123", preview: "test") + service = insert(:service, namespace: "test", flow: flow) + template = insert(:preview_environment_template, + name: "test", + flow: flow, + reference_service: service, + template: build(:service_template, + namespace: "test-{{ commitSha }}", + name: "test-{{ commitSha }}" + ) + ) + insert(:preview_environment_instance, template: template) + + event = %PubSub.PullRequestCreated{item: pr} + {:error, msg} = Preview.handle_event(event) + + assert msg =~ "maximum of 1 preview" + refute Repo.get_by(Console.Schema.PreviewEnvironmentInstance, pull_request_id: pr.id) + end + + test "it ignores deleted preview services when enforcing max previews" do + flow = insert(:flow, max_previews: 1) + pr = insert(:pull_request, status: :open, flow: flow, commit_sha: "pr-123", preview: "test") + service = insert(:service, namespace: "test", flow: flow) + template = insert(:preview_environment_template, + name: "test", + flow: flow, + reference_service: service, + template: build(:service_template, + namespace: "test-{{ commitSha }}", + name: "test-{{ commitSha }}" + ) + ) + insert(:preview_environment_instance, + template: template, + service: insert(:service, deleted_at: Timex.now()) + ) + + event = %PubSub.PullRequestCreated{item: pr} + {:ok, inst} = Preview.handle_event(event) + + assert inst.pull_request_id == pr.id + assert inst.template_id == template.id + end + test "it will can intelligently merge a helm preview instance" do flow = insert(:flow) pr = insert(:pull_request, status: :open, flow: flow, commit_sha: "pr-123", preview: "test") diff --git a/test/console/deployments/workbenches_test.exs b/test/console/deployments/workbenches_test.exs index 2d2f10490b..00528412a1 100644 --- a/test/console/deployments/workbenches_test.exs +++ b/test/console/deployments/workbenches_test.exs @@ -2743,6 +2743,147 @@ defmodule Console.Deployments.WorkbenchesTest do assert refetch(knowledge) end + + test "deletes knowledge by id scoped to a workbench without a user" do + workbench = insert(:workbench) + knowledge = insert(:workbench_knowledge, workbench: workbench) + + {:ok, deleted} = Workbenches.delete_workbench_knowledge(knowledge.id, workbench.id) + + assert deleted.id == knowledge.id + refute refetch(knowledge) + end + + test "does not delete knowledge that belongs to another workbench" do + workbench = insert(:workbench) + knowledge = insert(:workbench_knowledge) + + {:error, "knowledge not found"} = Workbenches.delete_workbench_knowledge(knowledge.id, workbench.id) + + assert refetch(knowledge) + end + end + + describe "list_workbench_knowledge/1" do + test "lists knowledge for a workbench including usage data" do + workbench = insert(:workbench) + k1 = insert(:workbench_knowledge, workbench: workbench, name: "alpha", usages: 2) + k2 = insert(:workbench_knowledge, workbench: workbench, name: "beta", usages: 0) + insert(:workbench_knowledge) + + listed = Workbenches.list_workbench_knowledge(workbench.id) + + assert ids_equal(listed, [k1, k2]) + assert Enum.find(listed, & &1.id == k1.id).usages == 2 + assert Enum.find(listed, & &1.id == k2.id).usages == 0 + end + end + + describe "knowledge_used/1" do + test "increments usages and last_used_at by knowledge id" do + knowledge = insert(:workbench_knowledge, usages: 2) + + {:ok, used} = Workbenches.knowledge_used(knowledge.id) + + assert used.id == knowledge.id + assert used.usages == 3 + assert used.last_used_at + end + + test "increments usages by knowledge struct" do + knowledge = insert(:workbench_knowledge, usages: 0) + + {:ok, used} = Workbenches.knowledge_used(knowledge) + + assert used.usages == 1 + assert used.last_used_at + end + + test "returns an error when the knowledge id does not exist" do + {:error, "knowledge not found"} = Workbenches.knowledge_used(Ecto.UUID.generate()) + end + end + + describe "knowledge_used/2" do + test "increments usages and last_used_at by workbench id and name" do + workbench = insert(:workbench) + knowledge = insert(:workbench_knowledge, workbench: workbench, name: "runbook", usages: 4) + + {:ok, used} = Workbenches.knowledge_used(workbench.id, "runbook") + + assert used.id == knowledge.id + assert used.usages == 5 + assert used.last_used_at + end + + test "returns an error when the name is missing on the workbench" do + workbench = insert(:workbench) + insert(:workbench_knowledge, workbench: workbench, name: "other") + + {:error, "knowledge not found"} = Workbenches.knowledge_used(workbench.id, "missing") + end + end + + describe "upsert_workbench_knowledge/2" do + test "creates knowledge when the name is new" do + workbench = insert(:workbench) + + {:ok, knowledge} = Workbenches.upsert_workbench_knowledge(%{ + name: "runbook", + description: "ops notes", + knowledge: "restart the pod", + labels: ["ops"] + }, workbench.id) + + assert knowledge.workbench_id == workbench.id + assert knowledge.name == "runbook" + assert knowledge.description == "ops notes" + assert knowledge.knowledge == "restart the pod" + assert knowledge.labels == ["ops"] + end + + test "updates knowledge when the name already exists" do + workbench = insert(:workbench) + existing = insert(:workbench_knowledge, workbench: workbench, name: "runbook", knowledge: "old") + + {:ok, updated} = Workbenches.upsert_workbench_knowledge(%{ + name: "runbook", + description: "new desc", + knowledge: "new body", + labels: ["updated"] + }, workbench.id) + + assert updated.id == existing.id + assert updated.knowledge == "new body" + assert updated.description == "new desc" + assert updated.labels == ["updated"] + end + + test "fails to create when the workbench already has 10 knowledge entries" do + workbench = insert(:workbench) + for i <- 1..10, do: insert(:workbench_knowledge, workbench: workbench, name: "k-#{i}") + + {:error, error} = Workbenches.upsert_workbench_knowledge(%{ + name: "overflow", + knowledge: "too many" + }, workbench.id) + + assert error =~ "10 knowledge entries" + end + + test "still updates an existing entry when the workbench is at the 10 entry cap" do + workbench = insert(:workbench) + existing = insert(:workbench_knowledge, workbench: workbench, name: "k-1", knowledge: "old") + for i <- 2..10, do: insert(:workbench_knowledge, workbench: workbench, name: "k-#{i}") + + {:ok, updated} = Workbenches.upsert_workbench_knowledge(%{ + name: "k-1", + knowledge: "still allowed" + }, workbench.id) + + assert updated.id == existing.id + assert updated.knowledge == "still allowed" + end end describe "create_workbench_eval/3" do diff --git a/test/console/graphql/mutations/deployments/flow_mutations_test.exs b/test/console/graphql/mutations/deployments/flow_mutations_test.exs index e1c63564ab..6edbe18936 100644 --- a/test/console/graphql/mutations/deployments/flow_mutations_test.exs +++ b/test/console/graphql/mutations/deployments/flow_mutations_test.exs @@ -8,11 +8,13 @@ defmodule Console.GraphQl.Deployments.FlowMutationsTest do upsertFlow(attributes: $attrs) { id name + maxPreviews } } - """, %{"attrs" => %{"name" => "test"}}, %{current_user: admin_user()}) + """, %{"attrs" => %{"name" => "test", "maxPreviews" => 5}}, %{current_user: admin_user()}) assert flow["name"] == "test" + assert flow["maxPreviews"] == 5 end test "upsertFlow can set workbenches on create" do @@ -129,6 +131,7 @@ defmodule Console.GraphQl.Deployments.FlowMutationsTest do upsertPreviewEnvironmentTemplate(attributes: $attrs) { id name + previewTtl } } """, %{ @@ -136,12 +139,14 @@ defmodule Console.GraphQl.Deployments.FlowMutationsTest do "name" => "test", "flow_id" => flow.id, "template" => %{"namespace" => "test"}, - "reference_service_id" => svc.id + "reference_service_id" => svc.id, + "previewTtl" => "1d" } }, %{current_user: user}) assert template["id"] assert template["name"] == "test" + assert template["previewTtl"] == 86_400 end end diff --git a/test/console/graphql/queries/deployments/flow_queries_test.exs b/test/console/graphql/queries/deployments/flow_queries_test.exs index 4224b68324..cd335ecc82 100644 --- a/test/console/graphql/queries/deployments/flow_queries_test.exs +++ b/test/console/graphql/queries/deployments/flow_queries_test.exs @@ -218,30 +218,33 @@ defmodule Console.GraphQl.Deployments.FlowQueriesTest do test "it can fetch preview environment templates within a flow" do user = insert(:user) flow = insert(:flow, read_bindings: [%{user_id: user.id}]) - templates = insert_list(3, :preview_environment_template, flow: flow) + templates = insert_list(3, :preview_environment_template, flow: flow, preview_ttl: 86_400) insert_list(3, :preview_environment_template) {:ok, %{data: %{"flow" => found}}} = run_query(""" query flow($id: ID!) { flow(id: $id) { id + maxPreviews previewEnvironmentTemplates(first: 5) { - edges { node { id } } + edges { node { id previewTtl } } } } } """, %{"id" => flow.id}, %{current_user: user}) assert found["id"] == flow.id + assert found["maxPreviews"] == 10 assert from_connection(found["previewEnvironmentTemplates"]) |> ids_equal(templates) + assert Enum.all?(found["previewEnvironmentTemplates"]["edges"], & &1["node"]["previewTtl"] == 86_400) end test "it can fetch preview environment instances within a flow" do user = insert(:user) flow = insert(:flow, read_bindings: [%{user_id: user.id}]) template = insert(:preview_environment_template, flow: flow) - instances = insert_list(3, :preview_environment_instance, template: template) + instances = insert_list(3, :preview_environment_instance, template: template, preview_expires_at: ~U[2026-09-01 00:00:00.000000Z]) insert_list(3, :preview_environment_instance) {:ok, %{data: %{"flow" => found}}} = run_query(""" @@ -249,7 +252,7 @@ defmodule Console.GraphQl.Deployments.FlowQueriesTest do flow(id: $id) { id previewEnvironmentInstances(first: 5) { - edges { node { id } } + edges { node { id previewExpiresAt } } } } } @@ -258,6 +261,7 @@ defmodule Console.GraphQl.Deployments.FlowQueriesTest do assert found["id"] == flow.id assert from_connection(found["previewEnvironmentInstances"]) |> ids_equal(instances) + assert Enum.all?(found["previewEnvironmentInstances"]["edges"], & &1["node"]["previewExpiresAt"]) end test "it can fetch workbenches within a flow" do diff --git a/test/console/schema/preview_environment_template_test.exs b/test/console/schema/preview_environment_template_test.exs new file mode 100644 index 0000000000..1df218ab4b --- /dev/null +++ b/test/console/schema/preview_environment_template_test.exs @@ -0,0 +1,50 @@ +defmodule Console.Schema.PreviewEnvironmentTemplateTest do + use Console.DataCase, async: true + + alias Console.Schema.PreviewEnvironmentTemplate + + describe "changeset/2" do + test "parses kubernetes duration strings into seconds" do + changeset = changeset(%{preview_ttl: "1d"}) + + assert changeset.valid? + assert changeset.changes.preview_ttl == 86_400 + end + + test "parses compound kubernetes durations" do + changeset = changeset(%{preview_ttl: "1h30m"}) + + assert changeset.valid? + assert changeset.changes.preview_ttl == 5_400 + end + + test "parses second kubernetes durations" do + changeset = changeset(%{preview_ttl: "5s"}) + + assert changeset.valid? + assert changeset.changes.preview_ttl == 5 + end + + test "accepts integer seconds" do + changeset = changeset(%{preview_ttl: 120}) + + assert changeset.valid? + assert changeset.changes.preview_ttl == 120 + end + + test "rejects invalid kubernetes durations" do + changeset = changeset(%{preview_ttl: "not-a-duration"}) + + refute changeset.valid? + assert "invalid duration" in errors_on(changeset).preview_ttl + end + end + + defp changeset(attrs) do + PreviewEnvironmentTemplate.changeset(%PreviewEnvironmentTemplate{}, Map.merge(%{ + name: "preview", + flow_id: Ecto.UUID.generate(), + reference_service_id: Ecto.UUID.generate() + }, attrs)) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 9704038c66..9966e3e2c8 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -57,6 +57,7 @@ Mimic.copy(Console.AI.Workbench.Subagents.Integration) Mimic.copy(Console.AI.Workbench.Subagents.Canvas) Mimic.copy(Console.AI.Workbench.Skills) Mimic.copy(Console.AI.Workbench.Activity) +Mimic.copy(Console.AI.Workbench.MCP) Mimic.copy(Console.AI.Tools.Pr) Mimic.copy(Console.AI.Tools.Workbench.Http) Mimic.copy(Console.AI.Tools.Workbench.Integration.Slack.ListChannels)