Skip to content

Commit c7681f7

Browse files
committed
feat: add ConfigMap-driven AI agent registry
Replace hardcoded agent definitions with a Kubernetes ConfigMap (`ai-agent-registry`) so administrators can add, remove, or update AI agents without dashboard code changes. Backend: new GET /dashboard/api/ai-agent-registry route reads the ConfigMap via service account token and returns the agent list. Terminal proxy parameterized to accept workspace name and port. Frontend: new AiAgentRegistry Redux store fetched at bootstrap; DevWorkspace spec built dynamically from AiAgentDefinition; agent panel and terminal init command driven by registry data; UI hidden when no agents are registered. Assisted-by: Claude Opus 4.6 Signed-off-by: Oleksii Orel <oorel@redhat.com>
1 parent 4a5c5ea commit c7681f7

20 files changed

Lines changed: 445 additions & 98 deletions

File tree

packages/common/src/dto/api/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,29 @@ export interface IPatch {
7777
value?: any;
7878
}
7979

80+
// Generated by Claude Opus 4.6
81+
82+
export interface AiAgentDefinition {
83+
id: string;
84+
name: string;
85+
publisher: string;
86+
description: string;
87+
icon: string;
88+
docsUrl: string;
89+
image: string;
90+
tag: string;
91+
memoryLimit: string;
92+
cpuLimit: string;
93+
terminalPort: number;
94+
env: Array<{ name: string; value: string }>;
95+
initCommand: string;
96+
}
97+
98+
export interface IAiAgentRegistry {
99+
agents: AiAgentDefinition[];
100+
defaultAgentId: string;
101+
}
102+
80103
export interface IGitConfig {
81104
resourceVersion?: string;
82105
gitconfig: {

packages/dashboard-backend/src/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { registerCors } from '@/plugins/cors';
2222
import { registerStaticServer } from '@/plugins/staticServer';
2323
import { registerSwagger } from '@/plugins/swagger';
2424
import { registerWebSocket } from '@/plugins/webSocket';
25+
import { registerAiAgentRegistryRoute } from '@/routes/api/aiAgentRegistry';
2526
import { registerAirGapSampleRoute } from '@/routes/api/airGapSample';
2627
import { registerBackupRoutes } from '@/routes/api/backup';
2728
import { registerClusterConfigRoute } from '@/routes/api/clusterConfig';
@@ -149,5 +150,7 @@ export default async function buildApp(server: FastifyInstance): Promise<unknown
149150
registerDevfileCreatorRoute(server),
150151

151152
registerDevfileSchemaRoute(server),
153+
154+
registerAiAgentRegistryRoute(server),
152155
]);
153156
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright (c) 2018-2025 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
13+
// Generated by Claude Opus 4.6
14+
15+
import { api } from '@eclipse-che/common';
16+
import * as k8s from '@kubernetes/client-node';
17+
import { FastifyInstance } from 'fastify';
18+
19+
import { baseApiPath } from '@/constants/config';
20+
import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken';
21+
import { getSchema } from '@/services/helpers';
22+
import { KubeConfigProvider } from '@/services/kubeclient/kubeConfigProvider';
23+
import { logger } from '@/utils/logger';
24+
25+
const tags = ['AI Agent Registry'];
26+
27+
const AI_AGENT_REGISTRY_LABEL_SELECTOR =
28+
'app.kubernetes.io/component=ai-agent-registry,app.kubernetes.io/part-of=che.eclipse.org';
29+
30+
const EMPTY_REGISTRY: api.IAiAgentRegistry = { agents: [], defaultAgentId: '' };
31+
32+
function isValidAgent(obj: unknown): obj is api.AiAgentDefinition {
33+
if (typeof obj !== 'object' || obj === null) return false;
34+
const agent = obj as Record<string, unknown>;
35+
return (
36+
typeof agent.id === 'string' &&
37+
typeof agent.name === 'string' &&
38+
typeof agent.image === 'string' &&
39+
typeof agent.tag === 'string' &&
40+
typeof agent.terminalPort === 'number'
41+
);
42+
}
43+
44+
function parseRegistryJson(raw: string): api.IAiAgentRegistry {
45+
const parsed = JSON.parse(raw) as Record<string, unknown>;
46+
const agents = Array.isArray(parsed.agents) ? parsed.agents.filter(isValidAgent) : [];
47+
const defaultAgentId = typeof parsed.defaultAgentId === 'string' ? parsed.defaultAgentId : '';
48+
return { agents, defaultAgentId };
49+
}
50+
51+
export function registerAiAgentRegistryRoute(instance: FastifyInstance) {
52+
instance.register(async server => {
53+
server.get(
54+
`${baseApiPath}/ai-agent-registry`,
55+
getSchema({ tags }),
56+
async function (): Promise<api.IAiAgentRegistry> {
57+
const cheNamespace = process.env.CHECLUSTER_CR_NAMESPACE;
58+
if (!cheNamespace) {
59+
logger.warn('CHECLUSTER_CR_NAMESPACE not set, returning empty AI agent registry');
60+
return EMPTY_REGISTRY;
61+
}
62+
63+
try {
64+
const token = getServiceAccountToken();
65+
const provider = new KubeConfigProvider();
66+
const kc = provider.getKubeConfig(token);
67+
const coreV1Api = kc.makeApiClient(k8s.CoreV1Api);
68+
69+
const response = await coreV1Api.listNamespacedConfigMap({
70+
namespace: cheNamespace,
71+
labelSelector: AI_AGENT_REGISTRY_LABEL_SELECTOR,
72+
});
73+
74+
const configMaps = response.items;
75+
if (configMaps.length === 0) {
76+
return EMPTY_REGISTRY;
77+
}
78+
79+
const data = configMaps[0].data;
80+
if (!data) {
81+
return EMPTY_REGISTRY;
82+
}
83+
84+
const registryJson = data['registry.json'];
85+
if (!registryJson) {
86+
return EMPTY_REGISTRY;
87+
}
88+
89+
return parseRegistryJson(registryJson);
90+
} catch (error) {
91+
logger.error(error, 'Failed to read AI agent registry ConfigMap');
92+
return EMPTY_REGISTRY;
93+
}
94+
},
95+
);
96+
});
97+
}

packages/dashboard-backend/src/routes/api/helpers/terminal/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ export interface DevWorkspaceItem {
2929
/**
3030
* Returns the in-cluster service URL for the agent terminal (HTTP, no TLS).
3131
*/
32-
export async function getTerminalServiceUrl(token: string, namespace: string): Promise<string> {
32+
export async function getTerminalServiceUrl(
33+
token: string,
34+
namespace: string,
35+
workspaceName = 'devfile-agent',
36+
terminalPort = 8080,
37+
): Promise<string> {
3338
const kubeConfig = getKubeConfig(token);
3439
const customObjectsApi = kubeConfig.makeApiClient(k8s.CustomObjectsApi);
3540

@@ -41,14 +46,14 @@ export async function getTerminalServiceUrl(token: string, namespace: string): P
4146
});
4247

4348
const workspaces = (dwList as { items: Array<DevWorkspaceItem> }).items;
44-
const agentWs = workspaces.find(ws => ws.metadata.name === 'devfile-agent');
49+
const agentWs = workspaces.find(ws => ws.metadata.name === workspaceName);
4550

4651
if (!agentWs?.status?.devworkspaceId) {
47-
throw new Error('Agent DevWorkspace not found or not ready');
52+
throw new Error(`Agent DevWorkspace '${workspaceName}' not found or not ready`);
4853
}
4954

5055
const workspaceId = agentWs.status.devworkspaceId;
51-
return `http://${workspaceId}-service.${namespace}.svc:8080`;
56+
return `http://${workspaceId}-service.${namespace}.svc:${terminalPort}`;
5257
}
5358

5459
/**

packages/dashboard-frontend/src/components/AgentTerminal/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface Props {
4444
namespace: string;
4545
devfileName: string;
4646
isDarkTheme: boolean;
47+
initCommand?: string;
4748
}
4849

4950
interface State {
@@ -127,7 +128,10 @@ export default class AgentTerminal extends React.PureComponent<Props, State> {
127128
};
128129

129130
private buildInitCommand(): string {
130-
const { namespace, devfileName } = this.props;
131+
const { namespace, devfileName, initCommand } = this.props;
132+
if (initCommand) {
133+
return initCommand + '\r';
134+
}
131135
return (
132136
'claude --bare --dangerously-skip-permissions ' +
133137
'--append-system-prompt-file "$HOME/CLAUDE.md" ' +

packages/dashboard-frontend/src/components/DevfileEditor/index.module.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
border: 1px solid var(--pf-t--global--border--color--default);
2121
}
2222

23+
.devfileEditor > * {
24+
height: inherit;
25+
}
26+
2327
.error {
2428
padding: 4px 8px;
2529
font-size: 13px;

packages/dashboard-frontend/src/components/DevfileEditor/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ const readOnlyCompartment = new Compartment();
9090
const onValidationRef: { current?: (errorMessage: string) => void } = { current: undefined };
9191
const onChangeRef: { current?: (value: string) => void } = { current: undefined };
9292
const setErrorRef: { current?: (msg: string) => void } = { current: undefined };
93+
const isProgrammaticChangeRef = { current: false };
9394

9495
function diagnosticsReporter() {
9596
return EditorView.updateListener.of(update => {
@@ -129,7 +130,7 @@ function diagnosticsReporter() {
129130

130131
function changeListener() {
131132
return EditorView.updateListener.of(update => {
132-
if (update.docChanged && onChangeRef.current) {
133+
if (update.docChanged && onChangeRef.current && !isProgrammaticChangeRef.current) {
133134
onChangeRef.current(update.state.doc.toString());
134135
}
135136
});
@@ -173,9 +174,11 @@ export const DevfileEditor: React.FC<Props> = ({
173174
if (viewRef.current && value !== prevValueRef.current) {
174175
const currentContent = viewRef.current.state.doc.toString();
175176
if (value !== currentContent) {
177+
isProgrammaticChangeRef.current = true;
176178
viewRef.current.dispatch({
177179
changes: { from: 0, to: currentContent.length, insert: value },
178180
});
181+
isProgrammaticChangeRef.current = false;
179182
}
180183
prevValueRef.current = value;
181184
}

packages/dashboard-frontend/src/containers/DevfileDetails/index.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Fallback from '@/components/Fallback';
2020
import { useTheme } from '@/contexts/ThemeContext';
2121
import DevfileDetails from '@/pages/DevfileDetails';
2222
import { RootState } from '@/store';
23+
import { selectAiAgentRegistryEnabled, selectDefaultAgent } from '@/store/AiAgentRegistry';
2324
import { devfileSchemaActionCreators, selectDevfileSchema } from '@/store/DevfileSchema';
2425
import { actionCreators, clearAgentTerminalUrl } from '@/store/LocalDevfiles';
2526
import {
@@ -131,7 +132,10 @@ export class DevfileDetailsContainer extends React.PureComponent<Props> {
131132
}
132133

133134
private handleStartAgent = () => {
134-
this.props.startAgentWorkspace();
135+
const { defaultAgent } = this.props;
136+
if (defaultAgent) {
137+
this.props.startAgentWorkspace(defaultAgent);
138+
}
135139
};
136140

137141
render() {
@@ -145,6 +149,8 @@ export class DevfileDetailsContainer extends React.PureComponent<Props> {
145149
namespace,
146150
navigate,
147151
isDarkTheme,
152+
agentEnabled,
153+
defaultAgent,
148154
} = this.props;
149155

150156
const devfile = devfiles.find(d => d.name === devfileName);
@@ -177,6 +183,8 @@ export class DevfileDetailsContainer extends React.PureComponent<Props> {
177183
agentWorkspace={agentWorkspace}
178184
agentTerminalUrl={agentTerminalUrl}
179185
isDarkTheme={isDarkTheme}
186+
agentEnabled={agentEnabled}
187+
agentInitCommand={defaultAgent?.initCommand}
180188
/>
181189
);
182190
}
@@ -206,6 +214,8 @@ const mapStateToProps = (state: RootState) => ({
206214
isLoading: selectLocalDevfilesIsLoading(state),
207215
agentWorkspaces: selectAgentWorkspaces(state),
208216
agentTerminalUrl: selectAgentTerminalUrl(state),
217+
agentEnabled: selectAiAgentRegistryEnabled(state),
218+
defaultAgent: selectDefaultAgent(state),
209219
});
210220

211221
const mapDispatchToProps = {

packages/dashboard-frontend/src/pages/DevfileDetails/__tests__/index.spec.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ function renderComponent(overrides?: Partial<React.ComponentProps<typeof Devfile
8585
agentWorkspace={undefined}
8686
agentTerminalUrl={undefined}
8787
isDarkTheme={true}
88+
agentEnabled={true}
8889
{...overrides}
8990
/>,
9091
);
@@ -171,6 +172,6 @@ describe('DevfileDetails', () => {
171172

172173
test('shows Start button when no agent workspace', () => {
173174
renderComponent();
174-
expect(screen.getByRole('button', { name: /^Start$/i })).toBeDefined();
175+
expect(screen.getByRole('button', { name: /Start Agent/i })).toBeDefined();
175176
});
176177
});

packages/dashboard-frontend/src/pages/DevfileDetails/index.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ interface Props {
6262
agentWorkspace: Workspace | undefined;
6363
agentTerminalUrl: string | undefined;
6464
isDarkTheme: boolean;
65+
agentEnabled: boolean;
66+
agentInitCommand?: string;
6567
}
6668

6769
interface State {
@@ -107,7 +109,7 @@ export default class DevfileDetails extends React.PureComponent<Props, State> {
107109
private handleEditorChange = (value: string) => {
108110
this.setState({
109111
editorContent: value,
110-
isSaved: value === this.props.devfile.content,
112+
isSaved: false,
111113
});
112114
};
113115

@@ -226,6 +228,7 @@ export default class DevfileDetails extends React.PureComponent<Props, State> {
226228
namespace={this.props.namespace}
227229
devfileName={this.props.devfile.name}
228230
isDarkTheme={this.props.isDarkTheme}
231+
initCommand={this.props.agentInitCommand}
229232
/>
230233
</div>
231234
);
@@ -317,7 +320,7 @@ export default class DevfileDetails extends React.PureComponent<Props, State> {
317320
}
318321

319322
render(): React.ReactElement {
320-
const { devfile, agentWorkspace, agentTerminalUrl } = this.props;
323+
const { devfile, agentWorkspace, agentTerminalUrl, agentEnabled } = this.props;
321324
const { editorContent, isSaved, saveError, hasValidationError } = this.state;
322325
const isAgentConnected = agentWorkspace?.isRunning && agentTerminalUrl;
323326

@@ -404,17 +407,19 @@ export default class DevfileDetails extends React.PureComponent<Props, State> {
404407
</Toolbar>
405408
</SplitItem>
406409

407-
<SplitItem
408-
style={{
409-
width: isAgentConnected ? '50%' : '500px',
410-
minWidth: '350px',
411-
transition: 'width 0.3s ease',
412-
maxHeight: '452px',
413-
marginBottom: '45px',
414-
}}
415-
>
416-
{this.renderAgentPanel()}
417-
</SplitItem>
410+
{agentEnabled && (
411+
<SplitItem
412+
style={{
413+
width: isAgentConnected ? '50%' : '500px',
414+
minWidth: '350px',
415+
transition: 'width 0.3s ease',
416+
maxHeight: '452px',
417+
marginBottom: '45px',
418+
}}
419+
>
420+
{this.renderAgentPanel()}
421+
</SplitItem>
422+
)}
418423
</Split>
419424
</PageSection>
420425
</React.Fragment>

0 commit comments

Comments
 (0)