From f6fb6dbe75c750ef8b5e54cccbd36b3713802f37 Mon Sep 17 00:00:00 2001 From: yray Date: Wed, 8 Jul 2026 14:40:41 +0300 Subject: [PATCH 1/6] feat(x2a): add adversarial agents management UI and scaffolder picker --- .../x2a/.changeset/chilly-needles-own.md | 10 + workspaces/x2a/app-config.yaml | 2 + .../src/actions/createAndInitProject.ts | 3 + .../src/actions/createProjectAction.ts | 9 + .../conversion-project-template.yaml | 8 + .../2025012401_create_jobs_table.ts | 9 +- ...7081000_create_adversarial_agents_table.ts | 75 +++ .../plugins/x2a-backend/src/plugin.test.ts | 22 + .../src/router/GitRepositoryResolver.ts | 24 + .../src/router/adversarialAgents.test.ts | 530 ++++++++++++++++++ .../src/router/adversarialAgents.ts | 176 ++++++ .../plugins/x2a-backend/src/router/index.ts | 2 + .../plugins/x2a-backend/src/router/jobs.ts | 13 +- .../x2a-backend/src/router/projects.ts | 131 +++++ .../x2a-backend/src/schema/openapi.yaml | 312 ++++++++++- .../openapi/generated/apis/Api.server.ts | 74 ++- .../models/AdversarialAgent.model.ts | 63 +++ .../models/AdversarialAgentSnapshot.model.ts | 46 ++ .../AdversarialAgentsGet200Response.model.ts | 31 + .../AdversarialAgentsPostRequest.model.ts | 46 ++ .../generated/models/ArtifactType.model.ts | 3 +- .../generated/models/MigrationPhase.model.ts | 8 +- .../openapi/generated/models/Module.model.ts | 2 + .../openapi/generated/models/Project.model.ts | 5 + .../models/ProjectsPostRequest.model.ts | 4 + ...ctIdAdversarialRunPost202Response.model.ts | 33 ++ ...rojectIdAdversarialRunPostRequest.model.ts | 42 ++ .../schema/openapi/generated/models/index.ts | 6 + .../src/schema/openapi/generated/router.ts | 443 ++++++++++++++- .../src/services/JobResourceBuilder.ts | 76 ++- .../x2a-backend/src/services/KubeService.ts | 52 +- .../adversarialAgentOperations.ts | 231 ++++++++ .../src/services/X2ADatabaseService/index.ts | 127 ++++- .../services/X2ADatabaseService/mappers.ts | 17 + .../X2ADatabaseService/projectOperations.ts | 2 +- .../x2a-backend/templates/x2a-job-script.sh | 64 ++- .../openapi/generated/apis/Api.client.ts | 216 ++++++- .../models/AdversarialAgent.model.ts | 63 +++ .../models/AdversarialAgentSnapshot.model.ts | 46 ++ .../AdversarialAgentsGet200Response.model.ts | 31 + .../AdversarialAgentsPostRequest.model.ts | 46 ++ .../generated/models/ArtifactType.model.ts | 3 +- .../generated/models/MigrationPhase.model.ts | 8 +- .../openapi/generated/models/Module.model.ts | 2 + .../openapi/generated/models/Project.model.ts | 5 + .../models/ProjectsPostRequest.model.ts | 4 + ...ctIdAdversarialRunPost202Response.model.ts | 33 ++ ...rojectIdAdversarialRunPostRequest.model.ts | 42 ++ .../schema/openapi/generated/models/index.ts | 6 + .../src/domain/AdversarialAgent.test.ts | 186 ++++++ .../x2a-common/src/domain/AdversarialAgent.ts | 126 +++++ .../x2a-common/src/domain/ArtifactKind.ts | 2 + .../x2a-common/src/domain/Phase.test.ts | 57 +- .../plugins/x2a-common/src/domain/Phase.ts | 31 +- .../plugins/x2a-common/src/domain/index.ts | 1 + .../x2a-common/src/x2aArtifactTypeLiterals.ts | 1 + .../src/actions/createListModulesAction.ts | 9 +- .../src/services/X2ADatabaseService.ts | 43 +- .../plugins/x2a-node/src/services/types.ts | 3 + workspaces/x2a/plugins/x2a/src/alpha.tsx | 21 +- .../AdversarialAgentsPage.tsx | 69 +++ .../AdversarialAgentsTable.tsx | 234 ++++++++ .../AdversarialAgentsPage/AgentDialog.tsx | 241 ++++++++ .../DeleteAgentDialog.tsx | 79 +++ .../components/AdversarialAgentsPage/index.ts | 17 + .../AdversarialAgentsSelector.tsx | 131 +++++ .../x2a/src/components/CurrentPhaseCell.tsx | 5 +- .../src/components/Dashboard/Dashboard.tsx | 10 +- .../ModulePage/AdversarialJobDetails.tsx | 259 +++++++++ .../src/components/ModulePage/ModulePage.tsx | 65 ++- .../src/components/ModulePage/PhasesCard.tsx | 23 + .../components/ModuleTable/ModuleTable.tsx | 3 +- .../x2a/src/components/PhaseDetails.tsx | 28 +- .../x2a/plugins/x2a/src/components/Router.tsx | 6 + .../x2a/src/components/tools/getNextPhase.ts | 13 +- workspaces/x2a/plugins/x2a/src/index.ts | 1 + workspaces/x2a/plugins/x2a/src/plugin.ts | 9 + workspaces/x2a/plugins/x2a/src/routes.ts | 6 + .../AdversarialAgentsPickerFieldExtension.tsx | 39 ++ .../index.ts | 17 + .../x2a/plugins/x2a/src/scaffolder/index.ts | 1 + .../x2a/plugins/x2a/src/translations/de.ts | 2 + .../x2a/plugins/x2a/src/translations/es.ts | 2 + .../x2a/plugins/x2a/src/translations/fr.ts | 2 + .../x2a/plugins/x2a/src/translations/it.ts | 2 + .../x2a/plugins/x2a/src/translations/ref.ts | 75 +++ 86 files changed, 4949 insertions(+), 76 deletions(-) create mode 100644 workspaces/x2a/.changeset/chilly-needles-own.md create mode 100644 workspaces/x2a/plugins/x2a-backend/migrations/202607081000_create_adversarial_agents_table.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.test.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgent.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgent.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts create mode 100644 workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.ts create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/index.ts create mode 100644 workspaces/x2a/plugins/x2a/src/components/CreateProjectPage/AdversarialAgentsSelector.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/AdversarialAgentsPickerFieldExtension.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/index.ts diff --git a/workspaces/x2a/.changeset/chilly-needles-own.md b/workspaces/x2a/.changeset/chilly-needles-own.md new file mode 100644 index 00000000000..aea6ea55d59 --- /dev/null +++ b/workspaces/x2a/.changeset/chilly-needles-own.md @@ -0,0 +1,10 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scaffolder-backend-module-x2a': patch +'@red-hat-developer-hub/backstage-plugin-x2a-mcp-extras': patch +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a-node': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +implemented adversarial agents diff --git a/workspaces/x2a/app-config.yaml b/workspaces/x2a/app-config.yaml index 410739f8682..1320f8cc60e 100644 --- a/workspaces/x2a/app-config.yaml +++ b/workspaces/x2a/app-config.yaml @@ -192,6 +192,8 @@ x2a: # X2A convertor container image image: ${X2A_KUBERNETES_IMAGE:-quay.io/x2ansible/x2a-convertor} imageTag: ${X2A_KUBERNETES_IMAGE_TAG:-latest} + # Image pull policy — set to Never or IfNotPresent for locally-loaded kind images + imagePullPolicy: ${X2A_KUBERNETES_IMAGE_PULL_POLICY:-IfNotPresent} # Auto-delete completed jobs after 24 hours (86400 seconds) ttlSecondsAfterFinished: ${X2A_KUBERNETES_TTL_SECONDS:-86400} # Resource requests and limits for job pods diff --git a/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createAndInitProject.ts b/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createAndInitProject.ts index 1e4c83aa82d..8e9142b0c4d 100644 --- a/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createAndInitProject.ts +++ b/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createAndInitProject.ts @@ -30,6 +30,7 @@ export type CreateAndInitProjectParams = { targetRepoToken: string; userPrompt?: string; acceptedRuleIds?: string[]; + adversarialAgentIds?: string[]; backstageToken?: string; hostProviderMap: Map; logger: ActionLogger; @@ -45,6 +46,7 @@ export const createAndInitProject = async ( targetRepoToken, userPrompt, acceptedRuleIds, + adversarialAgentIds, backstageToken: token, logger, } = params; @@ -58,6 +60,7 @@ export const createAndInitProject = async ( sourceRepoBranch: row.sourceRepoBranch, targetRepoBranch: row.targetRepoBranch, acceptedRuleIds, + adversarialAgentIds, }; logger.info(`Creating project "${row.name}" (${JSON.stringify(body)})`); diff --git a/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createProjectAction.ts b/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createProjectAction.ts index 2f4c238adea..e1e3e60134e 100644 --- a/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createProjectAction.ts +++ b/workspaces/x2a/plugins/scaffolder-backend-module-x2a/src/actions/createProjectAction.ts @@ -110,6 +110,7 @@ async function handleManualCreation(params: { targetRepoBranch: string; userPrompt?: string; acceptedRuleIds?: string; + adversarialAgentIds?: string[]; }; secrets: Record | undefined; api: DefaultApiClient; @@ -187,6 +188,7 @@ async function handleManualCreation(params: { targetRepoToken, userPrompt: input.userPrompt, acceptedRuleIds, + adversarialAgentIds: input.adversarialAgentIds, backstageToken: token, hostProviderMap, logger, @@ -365,6 +367,12 @@ export function createProjectAction( description: 'JSON-stringified array of accepted rule UUIDs', }) .optional(), + adversarialAgentIds: z + .array(z.string(), { + description: + 'UUIDs of adversarial agents to attach to the project', + }) + .optional(), csvContent: z.string().optional(), }), z.object({ @@ -397,6 +405,7 @@ export function createProjectAction( targetRepoUrl: z.string().optional(), targetRepoBranch: z.string().optional(), acceptedRuleIds: z.string().optional(), + adversarialAgentIds: z.array(z.string()).optional(), }), ]), output: { diff --git a/workspaces/x2a/plugins/scaffolder-backend-module-x2a/templates/conversion-project-template.yaml b/workspaces/x2a/plugins/scaffolder-backend-module-x2a/templates/conversion-project-template.yaml index 9caba1a0a61..4cc34b6a7c2 100644 --- a/workspaces/x2a/plugins/scaffolder-backend-module-x2a/templates/conversion-project-template.yaml +++ b/workspaces/x2a/plugins/scaffolder-backend-module-x2a/templates/conversion-project-template.yaml @@ -181,6 +181,7 @@ spec: ui:order: - userPrompt - acceptedRuleIds + - adversarialAgentIds properties: userPrompt: title: User prompt @@ -193,6 +194,12 @@ spec: title: Project rules type: string ui:field: RulesAcceptance + adversarialAgentIds: + title: Adversarial agents + type: array + items: + type: string + ui:field: AdversarialAgentsPicker steps: - id: createProject @@ -211,6 +218,7 @@ spec: targetRepoBranch: ${{ parameters.targetRepoBranch }} userPrompt: ${{ parameters.userPrompt }} acceptedRuleIds: ${{ parameters.acceptedRuleIds }} + adversarialAgentIds: ${{ parameters.adversarialAgentIds }} # Outputs are displayed to the user after a successful execution of the template. output: diff --git a/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts b/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts index 7daf02f42b9..1a26782b7f4 100644 --- a/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts +++ b/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts @@ -36,7 +36,14 @@ export async function up(knex: Knex): Promise { .string('phase') .notNullable() .defaultTo('init') - .checkIn(['init', 'analyze', 'migrate', 'publish']); + .checkIn([ + 'init', + 'analyze', + 'migrate', + 'publish', + 'adversarial-analyze', + 'adversarial-migrate', + ]); table.text('error_details'); table.text('telemetry'); // JSON-serialized Telemetry object table.string('k8s_job_name'); diff --git a/workspaces/x2a/plugins/x2a-backend/migrations/202607081000_create_adversarial_agents_table.ts b/workspaces/x2a/plugins/x2a-backend/migrations/202607081000_create_adversarial_agents_table.ts new file mode 100644 index 00000000000..1b7f12c8e08 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/migrations/202607081000_create_adversarial_agents_table.ts @@ -0,0 +1,75 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Knex } from 'knex'; + +/** + * Creates the adversarial_agents table, adds adversarial_agents column to projects, + * and expands the jobs.phase CHECK constraint to include adversarial phases (PostgreSQL only). + * + * @public + */ +export async function up(knex: Knex): Promise { + if (knex.client.config.client === 'pg') { + await knex.schema.raw( + `ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_phase_check`, + ); + await knex.schema.raw( + `ALTER TABLE jobs ADD CONSTRAINT jobs_phase_check CHECK (phase IN ('init', 'analyze', 'migrate', 'publish', 'adversarial-analyze', 'adversarial-migrate'))`, + ); + } + + await knex.schema.createTable('adversarial_agents', table => { + table.uuid('id').primary(); + table.string('name', 100).notNullable(); + table.text('prompt').notNullable(); + table.text('phases').notNullable(); + table.boolean('critical').notNullable().defaultTo(false); + table.string('created_by', 255).notNullable(); + table.timestamp('created_at').notNullable().defaultTo(knex.fn.now()); + table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now()); + + table.index('updated_at'); + table.index('name'); + }); + + await knex.schema.alterTable('projects', table => { + table.text('adversarial_agents').nullable(); + }); +} + +/** + * Drops adversarial_agents column from projects, drops adversarial_agents table, + * and restores the original jobs.phase CHECK constraint (PostgreSQL only). + * + * @public + */ +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('projects', table => { + table.dropColumn('adversarial_agents'); + }); + + await knex.schema.dropTable('adversarial_agents'); + + if (knex.client.config.client === 'pg') { + await knex.schema.raw( + `ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_phase_check`, + ); + await knex.schema.raw( + `ALTER TABLE jobs ADD CONSTRAINT jobs_phase_check CHECK (phase IN ('init', 'analyze', 'migrate', 'publish'))`, + ); + } +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts b/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts index 764a60cb0c8..c06c795cfb1 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts @@ -153,6 +153,28 @@ const getX2aDatabaseServiceMock = (): typeof x2aDatabaseServiceRef.T => ({ getAcceptedRulesForProject: jest .fn() .mockRejectedValue(new NotAllowedError('mock error')), + // adversarial agents + createAdversarialAgent: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), + listAdversarialAgents: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), + getAdversarialAgent: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), + updateAdversarialAgent: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), + deleteAdversarialAgent: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), + attachAdversarialAgentsToProject: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), + getAdversarialAgentsForProject: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), }); const getKubeServiceMock = () => diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/GitRepositoryResolver.ts b/workspaces/x2a/plugins/x2a-backend/src/router/GitRepositoryResolver.ts index 58a8f2f6dd0..7182dae320b 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/GitRepositoryResolver.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/GitRepositoryResolver.ts @@ -76,4 +76,28 @@ export class GitRepositoryResolver { ), }; } + + resolveTargetOnly(params: { + project: Pick< + ResolveParams['project'], + 'targetRepoUrl' | 'targetRepoBranch' + >; + targetRepoAuth?: { token: string }; + }): GitRepository { + const targetToken = + params.targetRepoAuth?.token ?? + this.config.getOptionalString('x2a.git.targetRepo.token'); + + if (!targetToken) { + throw new InputError( + 'Target repository token is required. Provide it in the request or configure x2a.git.targetRepo.token.', + ); + } + + return new GitRepository( + params.project.targetRepoUrl, + params.project.targetRepoBranch, + targetToken, + ); + } } diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.test.ts new file mode 100644 index 00000000000..ae2a2f82291 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.test.ts @@ -0,0 +1,530 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import request from 'supertest'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { + createApp, + createDatabase, + createDatabaseAndService, + LONG_TEST_TIMEOUT, + nonExistentId, + supportedDatabaseIds, + tearDownDatabases, +} from '../__testUtils__'; + +const VALID_PROMPT = + 'Review the migration output for security vulnerabilities, privilege escalation, and correctness issues in the generated Ansible playbooks.'; + +const validAgent = { + name: 'Security Checker', + prompt: VALID_PROMPT, + phases: ['analyze'], + critical: false, +}; + +describe('createRouter – adversarial-agents', () => { + afterEach(async () => { + await tearDownDatabases(); + }); + + describe('GET /adversarial-agents', () => { + it.each(supportedDatabaseIds)( + 'returns 200 and empty list when no agents exist - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app).get('/adversarial-agents').send(); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ agents: [], total: 0 }); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 200 with all agents - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const app = await createApp(client); + + await x2aDatabase.createAdversarialAgent({ + ...validAgent, + name: 'Agent Alpha', + createdBy: 'user:default/admin', + }); + await x2aDatabase.createAdversarialAgent({ + ...validAgent, + name: 'Agent Beta', + critical: true, + createdBy: 'user:default/admin', + }); + + const response = await request(app).get('/adversarial-agents').send(); + + expect(response.status).toBe(200); + expect(response.body.total).toBe(2); + expect(response.body.agents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Agent Alpha', critical: false }), + expect.objectContaining({ name: 'Agent Beta', critical: true }), + ]), + ); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'filters by phase when ?phase= is provided - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const app = await createApp(client); + + await x2aDatabase.createAdversarialAgent({ + ...validAgent, + name: 'Analyze Only', + phases: ['analyze'], + createdBy: 'user:default/admin', + }); + await x2aDatabase.createAdversarialAgent({ + ...validAgent, + name: 'Migrate Only', + phases: ['migrate'], + createdBy: 'user:default/admin', + }); + + const response = await request(app) + .get('/adversarial-agents?phase=analyze') + .send(); + + expect(response.status).toBe(200); + expect(response.body.total).toBe(1); + expect(response.body.agents[0].name).toBe('Analyze Only'); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 403 when user has no read permission - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp( + client, + AuthorizeResult.DENY, + undefined, + undefined, + AuthorizeResult.DENY, + ); + + const response = await request(app).get('/adversarial-agents').send(); + + expect(response.status).toBe(403); + expect(response.body.error.name).toBe('NotAllowedError'); + }, + LONG_TEST_TIMEOUT, + ); + }); + + describe('GET /adversarial-agents/:id', () => { + it.each(supportedDatabaseIds)( + 'returns 200 with the agent - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const app = await createApp(client); + + const agent = await x2aDatabase.createAdversarialAgent({ + ...validAgent, + createdBy: 'user:default/admin', + }); + + const response = await request(app) + .get(`/adversarial-agents/${agent.id}`) + .send(); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: agent.id, + name: validAgent.name, + phases: validAgent.phases, + critical: false, + }); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 404 when agent does not exist - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app) + .get(`/adversarial-agents/${nonExistentId}`) + .send(); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + error: { + name: 'NotFoundError', + message: 'Adversarial agent not found', + }, + }); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 403 when user has no read permission - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp( + client, + AuthorizeResult.DENY, + undefined, + undefined, + AuthorizeResult.DENY, + ); + + const response = await request(app) + .get(`/adversarial-agents/${nonExistentId}`) + .send(); + + expect(response.status).toBe(403); + expect(response.body.error.name).toBe('NotAllowedError'); + }, + LONG_TEST_TIMEOUT, + ); + }); + + describe('POST /adversarial-agents', () => { + it.each(supportedDatabaseIds)( + 'creates an agent and returns 201 - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app) + .post('/adversarial-agents') + .send(validAgent); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + id: expect.any(String), + name: validAgent.name, + prompt: validAgent.prompt, + phases: validAgent.phases, + critical: false, + }); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'creates a critical agent - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app) + .post('/adversarial-agents') + .send({ ...validAgent, critical: true }); + + expect(response.status).toBe(201); + expect(response.body.critical).toBe(true); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 400 when name is missing - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const { name: _name, ...withoutName } = validAgent; + const response = await request(app) + .post('/adversarial-agents') + .send(withoutName); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 400 when prompt is too short - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app) + .post('/adversarial-agents') + .send({ ...validAgent, prompt: 'too short' }); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 400 when phases is empty - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app) + .post('/adversarial-agents') + .send({ ...validAgent, phases: [] }); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 400 when critical is missing - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const { critical: _critical, ...withoutCritical } = validAgent; + const response = await request(app) + .post('/adversarial-agents') + .send(withoutCritical); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 403 when user lacks admin write permission - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.DENY, + ); + + const response = await request(app) + .post('/adversarial-agents') + .send(validAgent); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + error: { + name: 'NotAllowedError', + message: 'You are not allowed to create adversarial agents', + }, + }); + }, + LONG_TEST_TIMEOUT, + ); + }); + + describe('PUT /adversarial-agents/:id', () => { + it.each(supportedDatabaseIds)( + 'updates an agent and returns 200 - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const app = await createApp(client); + + const agent = await x2aDatabase.createAdversarialAgent({ + ...validAgent, + createdBy: 'user:default/admin', + }); + + const response = await request(app) + .put(`/adversarial-agents/${agent.id}`) + .send({ ...validAgent, name: 'Updated Name', critical: true }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: agent.id, + name: 'Updated Name', + critical: true, + }); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 404 when agent does not exist - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app) + .put(`/adversarial-agents/${nonExistentId}`) + .send(validAgent); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + error: { + name: 'NotFoundError', + message: 'Adversarial agent not found', + }, + }); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 400 when body is invalid - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const app = await createApp(client); + + const agent = await x2aDatabase.createAdversarialAgent({ + ...validAgent, + createdBy: 'user:default/admin', + }); + + const response = await request(app) + .put(`/adversarial-agents/${agent.id}`) + .send({ name: 'Missing required fields' }); + + expect(response.status).toBe(400); + expect(response.body.error.name).toBe('InputError'); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 403 when user lacks admin write permission - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const agent = await x2aDatabase.createAdversarialAgent({ + ...validAgent, + createdBy: 'user:default/admin', + }); + + const app = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.DENY, + ); + + const response = await request(app) + .put(`/adversarial-agents/${agent.id}`) + .send({ ...validAgent, name: 'Forbidden Update' }); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + error: { + name: 'NotAllowedError', + message: 'You are not allowed to update adversarial agents', + }, + }); + }, + LONG_TEST_TIMEOUT, + ); + }); + + describe('DELETE /adversarial-agents/:id', () => { + it.each(supportedDatabaseIds)( + 'deletes an agent and returns 204 - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const app = await createApp(client); + + const agent = await x2aDatabase.createAdversarialAgent({ + ...validAgent, + createdBy: 'user:default/admin', + }); + + const response = await request(app) + .delete(`/adversarial-agents/${agent.id}`) + .send(); + + expect(response.status).toBe(204); + + // Verify agent is gone + const listResponse = await request(app) + .get('/adversarial-agents') + .send(); + expect(listResponse.body.total).toBe(0); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 404 when agent does not exist - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client); + + const response = await request(app) + .delete(`/adversarial-agents/${nonExistentId}`) + .send(); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + error: { + name: 'NotFoundError', + message: 'Adversarial agent not found', + }, + }); + }, + LONG_TEST_TIMEOUT, + ); + + it.each(supportedDatabaseIds)( + 'returns 403 when user lacks admin write permission - %p', + async databaseId => { + const { client, x2aDatabase } = + await createDatabaseAndService(databaseId); + const agent = await x2aDatabase.createAdversarialAgent({ + ...validAgent, + createdBy: 'user:default/admin', + }); + + const app = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.DENY, + ); + + const response = await request(app) + .delete(`/adversarial-agents/${agent.id}`) + .send(); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + error: { + name: 'NotAllowedError', + message: 'You are not allowed to delete adversarial agents', + }, + }); + }, + LONG_TEST_TIMEOUT, + ); + }); +}); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts b/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts new file mode 100644 index 00000000000..b6641e95157 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts @@ -0,0 +1,176 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import express from 'express'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { x2aAdminWritePermission } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +import type { RouterDeps } from './types'; +import { authorize, useEnforceX2APermissions } from './common'; + +export function registerAdversarialAgentRoutes( + router: express.Router, + deps: RouterDeps, +): void { + const { httpAuth, x2aDatabase, logger, permissionsSvc } = deps; + + router.get('/adversarial-agents', async (req, res) => { + const endpoint = 'GET /adversarial-agents'; + logger.info(`${endpoint} request received`); + + await useEnforceX2APermissions({ + req, + readOnly: true, + permissionsSvc, + httpAuth, + }); + + const { phase } = req.query; + const agents = await x2aDatabase.listAdversarialAgents( + phase ? { phase: String(phase) } : undefined, + ); + res.json({ agents, total: agents.length }); + }); + + router.get('/adversarial-agents/:id', async (req, res) => { + const endpoint = 'GET /adversarial-agents/:id'; + const { id } = req.params; + logger.info(`${endpoint} request received: id=${id}`); + + await useEnforceX2APermissions({ + req, + readOnly: true, + permissionsSvc, + httpAuth, + }); + + const agent = await x2aDatabase.getAdversarialAgent({ id }); + if (!agent) { + throw new NotFoundError('Adversarial agent not found'); + } + + res.json(agent); + }); + + router.post('/adversarial-agents', async (req, res) => { + const endpoint = 'POST /adversarial-agents'; + logger.info(`${endpoint} request received`); + + const decision = await authorize( + req, + [x2aAdminWritePermission], + permissionsSvc, + httpAuth, + ); + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError( + 'You are not allowed to create adversarial agents', + ); + } + + const createAgentSchema = z.object({ + name: z.string().min(3).max(100), + prompt: z.string().min(50).max(5000), + phases: z.array(z.string()).min(1), + critical: z.boolean(), + }); + + const parsedBody = createAgentSchema.safeParse(req.body); + if (!parsedBody.success) { + throw new InputError(`Invalid body ${endpoint}: ${parsedBody.error}`); + } + + const credentials = await httpAuth.credentials(req, { + allow: ['user', 'service'], + }); + const principal = credentials.principal; + const createdBy = + principal.type === 'user' ? principal.userEntityRef : 'service'; + + const agent = await x2aDatabase.createAdversarialAgent({ + ...parsedBody.data, + createdBy, + }); + res.status(201).json(agent); + }); + + router.put('/adversarial-agents/:id', async (req, res) => { + const endpoint = 'PUT /adversarial-agents/:id'; + const { id } = req.params; + logger.info(`${endpoint} request received: id=${id}`); + + const decision = await authorize( + req, + [x2aAdminWritePermission], + permissionsSvc, + httpAuth, + ); + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError( + 'You are not allowed to update adversarial agents', + ); + } + + const updateAgentSchema = z.object({ + name: z.string().min(3).max(100), + prompt: z.string().min(50).max(5000), + phases: z.array(z.string()).min(1), + critical: z.boolean(), + }); + + const parsedBody = updateAgentSchema.safeParse(req.body); + if (!parsedBody.success) { + throw new InputError(`Invalid body ${endpoint}: ${parsedBody.error}`); + } + + const agent = await x2aDatabase.updateAdversarialAgent({ + id, + ...parsedBody.data, + }); + if (!agent) { + throw new NotFoundError('Adversarial agent not found'); + } + + res.json(agent); + }); + + router.delete('/adversarial-agents/:id', async (req, res) => { + const endpoint = 'DELETE /adversarial-agents/:id'; + const { id } = req.params; + logger.info(`${endpoint} request received: id=${id}`); + + const decision = await authorize( + req, + [x2aAdminWritePermission], + permissionsSvc, + httpAuth, + ); + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError( + 'You are not allowed to delete adversarial agents', + ); + } + + const deletedCount = await x2aDatabase.deleteAdversarialAgent({ id }); + if (deletedCount === 0) { + throw new NotFoundError('Adversarial agent not found'); + } + + res.status(204).send(); + }); +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/index.ts b/workspaces/x2a/plugins/x2a-backend/src/router/index.ts index fdcb81dd7cd..a27b50fae0a 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/index.ts @@ -19,6 +19,7 @@ import { resolvePackagePath } from '@backstage/backend-plugin-api'; import { createOpenApiRouter } from '../schema/openapi'; import type { RouterDeps } from './types'; +import { registerAdversarialAgentRoutes } from './adversarialAgents'; import { registerProjectRoutes } from './projects'; import { registerModuleRoutes } from './modules'; import { registerJobRoutes } from './jobs'; @@ -47,6 +48,7 @@ export async function createRouter(deps: RouterDeps): Promise { registerModuleRoutes(apiRouter, deps); registerJobRoutes(apiRouter, deps); registerRuleRoutes(apiRouter, deps); + registerAdversarialAgentRoutes(apiRouter, deps); // Mount API router under main router mainRouter.use(apiRouter); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/jobs.ts b/workspaces/x2a/plugins/x2a-backend/src/router/jobs.ts index 4e6d11fbf04..1212f43fcc8 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/jobs.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/jobs.ts @@ -18,7 +18,7 @@ import express from 'express'; import type { Readable } from 'node:stream'; import { InputError, NotFoundError } from '@backstage/errors'; import { - ModulePhase, + MigrationPhase, Job, JobStatus, Phase, @@ -146,12 +146,15 @@ export function registerJobRoutes( const { projectId, moduleId } = req.params; const rawStreaming = req.query.streaming as string | boolean | undefined; const streaming = rawStreaming === 'true' || rawStreaming === true; - const phase = req.query.phase as ModulePhase; + const phase = req.query.phase as MigrationPhase; - // Validate phase parameter (required) - if (!phase || !Phase.modulePhaseValues().includes(phase)) { + // Validate phase parameter (required) — all module-scoped phases except init + const validLogPhases = Phase.all() + .filter(p => p.isModulePhase()) + .map(p => p.value); + if (!phase || !validLogPhases.includes(phase)) { throw new InputError( - 'phase query parameter is required and must be one of: analyze, migrate, publish', + `phase query parameter is required and must be one of: ${validLogPhases.join(', ')}`, ); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts index 3cea136bc6f..5ff98aa9207 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts @@ -21,6 +21,7 @@ import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; import { ENTITY_REF_RE, JobStatus, + Phase, x2aAdminWritePermission, x2aUserPermission, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; @@ -149,6 +150,7 @@ export function registerProjectRoutes( sourceRepoBranch: z.string(), targetRepoBranch: z.string(), acceptedRuleIds: z.array(z.string()).optional(), + adversarialAgentIds: z.array(z.string().uuid()).optional(), }); const parsedBody = projectCreateRequestSchema @@ -185,6 +187,17 @@ export function registerProjectRoutes( ruleIds: requestBody.acceptedRuleIds ?? [], }); + // Attach adversarial agents if provided (validates agent IDs exist) + if ( + requestBody.adversarialAgentIds && + requestBody.adversarialAgentIds.length > 0 + ) { + await x2aDatabase.attachAdversarialAgentsToProject({ + projectId: newProject.id, + agentIds: requestBody.adversarialAgentIds, + }); + } + // Include accepted rules in the response newProject.acceptedRules = await x2aDatabase.getAcceptedRulesForProject({ projectId: newProject.id, @@ -457,4 +470,122 @@ export function registerProjectRoutes( return res.json({ status: 'pending', jobId: job.id } as any); }, ); + + router.post( + '/projects/:projectId/adversarial-run', + async (req: express.Request, res: express.Response) => { + const endpoint = 'POST /projects/:projectId/adversarial-run'; + const { projectId } = req.params; + logger.info(`${endpoint} request received: projectId=${projectId}`); + + const adversarialRunSchema = z.object({ + phase: z.enum(['analyze', 'migrate']), + moduleId: z.string().uuid(), + targetRepoAuth: z.object({ token: z.string() }).optional(), + }); + + const parsedBody = adversarialRunSchema.safeParse(req.body); + if (!parsedBody.success) { + throw new InputError(`Invalid body ${endpoint}: ${parsedBody.error}`); + } + const { phase, moduleId, targetRepoAuth } = parsedBody.data; + + const { project, userRef } = await useEnforceProjectPermissions({ + req, + readOnly: false, + projectId, + x2aDatabase, + httpAuth, + permissionsSvc, + catalog, + }); + + assertProjectHasDirName(project); + + const adversarialAgents = + await x2aDatabase.getAdversarialAgentsForProject({ projectId }); + if (!adversarialAgents || adversarialAgents.length === 0) { + return res.status(400).json({ + error: 'NoAdversarialAgents', + message: 'No adversarial agents are configured for this project', + }); + } + + const module = await x2aDatabase.getModule({ id: moduleId }); + if (!module) { + throw new InputError(`Module "${moduleId}" not found`); + } + if (module.projectId !== projectId) { + throw new InputError( + `Module "${moduleId}" does not belong to project "${projectId}"`, + ); + } + + // Check for a running adversarial job for this module+phase + const adversarialPhase = Phase.from(`adversarial-${phase}`); + const existingJobs = await x2aDatabase.listJobsForModule({ + projectId, + moduleId, + }); + const activeAdversarialJob = existingJobs.find( + j => + j.phase === adversarialPhase.value && + JobStatus.from(j.status).isActive(), + ); + if (activeAdversarialJob) { + return res.status(409).json({ + error: 'JobAlreadyRunning', + message: `An adversarial-${phase} job is already running for this module`, + activeJobId: activeAdversarialJob.id, + }); + } + + const targetRepo = gitRepoResolver.resolveTargetOnly({ + project, + targetRepoAuth, + }); + + const callbackToken = CallbackToken.generate(); + const job = await x2aDatabase.createJob({ + projectId, + moduleId, + phase: adversarialPhase.value, + status: 'pending', + callbackToken: callbackToken.value, + }); + + const baseUrl = + config.getOptionalString('x2a.callbackBaseUrl') ?? + (await discoveryApi.getBaseUrl('x2a')); + const callbackUrl = `${baseUrl}/projects/${projectId}/collectArtifacts`; + + const { k8sJobName } = await kubeService.createJob({ + jobId: job.id, + projectId, + projectName: project.name, + projectDirName: project.dirName, + phase: adversarialPhase.value, + user: userRef, + callbackToken: callbackToken.value, + callbackUrl, + moduleId, + moduleName: module.name, + sourceRepo: targetRepo, + targetRepo, + adversarialAgents, + }); + + await x2aDatabase.updateJob({ + id: job.id, + k8sJobName, + status: 'running', + }); + + logger.info( + `Adversarial-${phase} job created: jobId=${job.id}, moduleId=${moduleId}, k8sJobName=${k8sJobName}`, + ); + + return res.status(202).json({ jobId: job.id, k8sJobName } as any); + }, + ); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index fa92be9a687..860d3edd7b0 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -96,6 +96,12 @@ paths: items: type: string description: UUIDs of rules the project accepts (required rules auto-appended) + adversarialAgentIds: + type: array + items: + type: string + format: uuid + description: Optional list of agent IDs to enable for this project (snapshots will be stored) required: - name - description @@ -234,6 +240,165 @@ paths: '404': description: Rule not found. + /adversarial-agents: + get: + summary: Returns a list of all adversarial agents. + parameters: + - in: query + name: phase + schema: + type: string + enum: + - analyze + - migrate + required: false + description: Filter agents by workflow phase + responses: + '200': + description: All adversarial agents, optionally filtered by phase. + content: + application/json: + schema: + type: object + properties: + agents: + type: array + items: + $ref: '#/components/schemas/AdversarialAgent' + total: + type: integer + description: Total number of agents returned + post: + summary: Creates a new adversarial agent (admin only). + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 3 + maxLength: 100 + description: Name of the agent + prompt: + type: string + minLength: 50 + maxLength: 5000 + description: AI prompt describing what the agent should check for + phases: + type: array + items: + type: string + enum: + - analyze + - migrate + minItems: 1 + description: Workflow phases this agent runs in + critical: + type: boolean + description: Whether this is a critical security/correctness check + required: + - name + - prompt + - phases + - critical + responses: + '201': + description: Created adversarial agent. + content: + application/json: + schema: + $ref: '#/components/schemas/AdversarialAgent' + '400': + description: Invalid input. + + /adversarial-agents/{id}: + get: + summary: Returns an adversarial agent by ID. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + '200': + description: Adversarial agent data. + content: + application/json: + schema: + $ref: '#/components/schemas/AdversarialAgent' + '404': + description: Adversarial agent not found. + put: + summary: Updates an adversarial agent by ID (admin only). + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 3 + maxLength: 100 + description: Name of the agent + prompt: + type: string + minLength: 50 + maxLength: 5000 + description: AI prompt describing what the agent should check for + phases: + type: array + items: + type: string + enum: + - analyze + - migrate + minItems: 1 + description: Workflow phases this agent runs in + critical: + type: boolean + description: Whether this is a critical security/correctness check + required: + - name + - prompt + - phases + - critical + responses: + '200': + description: Updated adversarial agent. + content: + application/json: + schema: + $ref: '#/components/schemas/AdversarialAgent' + '400': + description: Invalid input. + '404': + description: Adversarial agent not found. + delete: + summary: Deletes an adversarial agent by ID (admin only). + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + '204': + description: Adversarial agent deleted successfully. + '404': + description: Adversarial agent not found. + /projects/{projectId}: get: summary: Returns a project by ID. @@ -546,7 +711,7 @@ paths: - in: query name: phase schema: - $ref: '#/components/schemas/ModulePhase' + $ref: '#/components/schemas/MigrationPhase' required: true description: Migration module phase to filter responses: @@ -559,6 +724,65 @@ paths: '404': description: Module not found or no jobs exist + /projects/{projectId}/adversarial-run: + post: + summary: Triggers an adversarial review job for a module phase + description: | + Runs adversarial agents against the output of a completed analyze or migrate phase. + The agents review the committed artifacts in the target repository and append + a markdown report alongside a JSON summary back to the target repo. + parameters: + - in: path + name: projectId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + phase: + type: string + enum: + - analyze + - migrate + description: The phase whose output should be reviewed + moduleId: + type: string + description: UUID of the module to review + targetRepoAuth: + $ref: '#/components/schemas/GitRepoAuth' + required: + - phase + - moduleId + - targetRepoAuth + responses: + '202': + description: Adversarial review job accepted + content: + application/json: + schema: + type: object + properties: + jobId: + type: string + description: UUID of the created job + k8sJobName: + type: string + description: Kubernetes job name + required: + - jobId + - k8sJobName + '400': + description: Invalid request (bad phase, module not found, or no adversarial agents configured) + '404': + description: Project not found + '409': + description: An adversarial job is already running for this module and phase + /projects/{projectId}/collectArtifacts: post: security: @@ -714,6 +938,11 @@ components: items: $ref: '#/components/schemas/RuleSnapshot' description: Snapshot of rules accepted at project creation time + adversarialAgents: + type: array + items: + $ref: '#/components/schemas/AdversarialAgentSnapshot' + description: Snapshot of adversarial agents selected at project creation time required: - id - name @@ -748,6 +977,10 @@ components: $ref: '#/components/schemas/Job' publish: $ref: '#/components/schemas/Job' + adversarialAnalyze: + $ref: '#/components/schemas/Job' + adversarialMigrate: + $ref: '#/components/schemas/Job' status: $ref: '#/components/schemas/ModuleStatus' errorDetails: @@ -921,6 +1154,7 @@ components: - migrated_sources - project_metadata - ansible_project + - adversarial_report Artifact: type: object @@ -977,6 +1211,8 @@ components: - analyze - migrate - publish + - adversarial-analyze + - adversarial-migrate description: All migration phases ModulePhase: @@ -1072,6 +1308,80 @@ components: - title - description + AdversarialAgentSnapshot: + type: object + description: Snapshot of an adversarial agent at the time it was selected for a project + properties: + id: + type: string + description: UUID of the agent + name: + type: string + description: Name of the agent at selection time + prompt: + type: string + description: Prompt of the agent at selection time + phases: + type: array + items: + type: string + description: Workflow phases the agent runs in + critical: + type: boolean + description: Whether this is a critical agent + required: + - id + - name + - prompt + - phases + - critical + + AdversarialAgent: + type: object + description: AI agent that reviews migration outputs for security, functional gaps, and correctness issues + properties: + id: + type: string + format: uuid + description: UUID for the adversarial agent + name: + type: string + description: Name of the agent + prompt: + type: string + description: AI prompt describing what the agent should check for + phases: + type: array + items: + type: string + enum: + - analyze + - migrate + description: Workflow phases this agent runs in (analyze and migrate only) + critical: + type: boolean + description: Whether this is a critical security/correctness check + createdBy: + type: string + description: User or system that created the agent + createdAt: + type: string + format: date-time + description: Date/time when the agent was created + updatedAt: + type: string + format: date-time + description: Date/time when the agent was last updated + required: + - id + - name + - prompt + - phases + - critical + - createdBy + - createdAt + - updatedAt + AgentMetrics: type: object description: Telemetry data for a single agent execution within a phase diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts index 179a222e784..53bcae2a1d5 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -19,12 +19,16 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** +import { AdversarialAgent } from '../models/AdversarialAgent.model'; +import { AdversarialAgentsGet200Response } from '../models/AdversarialAgentsGet200Response.model'; +import { AdversarialAgentsPostRequest } from '../models/AdversarialAgentsPostRequest.model'; import { MigrationPhase } from '../models/MigrationPhase.model'; import { Module } from '../models/Module.model'; -import { ModulePhase } from '../models/ModulePhase.model'; import { Project } from '../models/Project.model'; import { ProjectsGet200Response } from '../models/ProjectsGet200Response.model'; import { ProjectsPostRequest } from '../models/ProjectsPostRequest.model'; +import { ProjectsProjectIdAdversarialRunPost202Response } from '../models/ProjectsProjectIdAdversarialRunPost202Response.model'; +import { ProjectsProjectIdAdversarialRunPostRequest } from '../models/ProjectsProjectIdAdversarialRunPostRequest.model'; import { ProjectsProjectIdCollectArtifactsPost200Response } from '../models/ProjectsProjectIdCollectArtifactsPost200Response.model'; import { ProjectsProjectIdCollectArtifactsPostRequest } from '../models/ProjectsProjectIdCollectArtifactsPostRequest.model'; import { ProjectsProjectIdDelete200Response } from '../models/ProjectsProjectIdDelete200Response.model'; @@ -39,6 +43,50 @@ import { RulesPostRequest } from '../models/RulesPostRequest.model'; import { RulesRuleIdDelete200Response } from '../models/RulesRuleIdDelete200Response.model'; import { RulesRuleIdPutRequest } from '../models/RulesRuleIdPutRequest.model'; +/** + * @public + */ +export type AdversarialAgentsGet = { + query: { + phase?: 'analyze' | 'migrate'; + }; + response: AdversarialAgentsGet200Response; +}; +/** + * @public + */ +export type AdversarialAgentsIdDelete = { + path: { + id: string; + }; + response: void | void; +}; +/** + * @public + */ +export type AdversarialAgentsIdGet = { + path: { + id: string; + }; + response: AdversarialAgent | void; +}; +/** + * @public + */ +export type AdversarialAgentsIdPut = { + path: { + id: string; + }; + body: AdversarialAgentsPostRequest; + response: AdversarialAgent | void | void; +}; +/** + * @public + */ +export type AdversarialAgentsPost = { + body: AdversarialAgentsPostRequest; + response: AdversarialAgent | void; +}; /** * @public */ @@ -58,6 +106,16 @@ export type ProjectsPost = { body: ProjectsPostRequest; response: Project; }; +/** + * @public + */ +export type ProjectsProjectIdAdversarialRunPost = { + path: { + projectId: string; + }; + body: ProjectsProjectIdAdversarialRunPostRequest; + response: ProjectsProjectIdAdversarialRunPost202Response | void | void | void; +}; /** * @public */ @@ -145,7 +203,7 @@ export type ProjectsProjectIdModulesModuleIdLogGet = { }; query: { streaming?: boolean; - phase: ModulePhase; + phase: MigrationPhase; }; response: string | void; }; @@ -223,10 +281,22 @@ export type RulesRuleIdPut = { }; export type EndpointMap = { + '#get|/adversarial-agents': AdversarialAgentsGet; + + '#_delete|/adversarial-agents/{id}': AdversarialAgentsIdDelete; + + '#get|/adversarial-agents/{id}': AdversarialAgentsIdGet; + + '#put|/adversarial-agents/{id}': AdversarialAgentsIdPut; + + '#post|/adversarial-agents': AdversarialAgentsPost; + '#get|/projects': ProjectsGet; '#post|/projects': ProjectsPost; + '#post|/projects/{projectId}/adversarial-run': ProjectsProjectIdAdversarialRunPost; + '#post|/projects/{projectId}/collectArtifacts': ProjectsProjectIdCollectArtifactsPost; '#_delete|/projects/{projectId}': ProjectsProjectIdDelete; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgent.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgent.model.ts new file mode 100644 index 00000000000..c29a96561e9 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgent.model.ts @@ -0,0 +1,63 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * AI agent that reviews migration outputs for security, functional gaps, and correctness issues + * @public + */ +export interface AdversarialAgent { + /** + * UUID for the adversarial agent + */ + id: string; + /** + * Name of the agent + */ + name: string; + /** + * AI prompt describing what the agent should check for + */ + prompt: string; + /** + * Workflow phases this agent runs in (analyze and migrate only) + */ + phases: Array; + /** + * Whether this is a critical security/correctness check + */ + critical: boolean; + /** + * User or system that created the agent + */ + createdBy: string; + /** + * Date/time when the agent was created + */ + createdAt: Date; + /** + * Date/time when the agent was last updated + */ + updatedAt: Date; +} + +/** + * @public + */ +export type AdversarialAgentPhasesEnum = 'analyze' | 'migrate'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts new file mode 100644 index 00000000000..c241f964851 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts @@ -0,0 +1,46 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Snapshot of an adversarial agent at the time it was selected for a project + * @public + */ +export interface AdversarialAgentSnapshot { + /** + * UUID of the agent + */ + id: string; + /** + * Name of the agent at selection time + */ + name: string; + /** + * Prompt of the agent at selection time + */ + prompt: string; + /** + * Workflow phases the agent runs in + */ + phases: Array; + /** + * Whether this is a critical agent + */ + critical: boolean; +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts new file mode 100644 index 00000000000..5d006ad1cbb --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts @@ -0,0 +1,31 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { AdversarialAgent } from '../models/AdversarialAgent.model'; + +/** + * @public + */ +export interface AdversarialAgentsGet200Response { + agents?: Array; + /** + * Total number of agents returned + */ + total?: number; +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts new file mode 100644 index 00000000000..acc21226023 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts @@ -0,0 +1,46 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface AdversarialAgentsPostRequest { + /** + * Name of the agent + */ + name: string; + /** + * AI prompt describing what the agent should check for + */ + prompt: string; + /** + * Workflow phases this agent runs in + */ + phases: Array; + /** + * Whether this is a critical security/correctness check + */ + critical: boolean; +} + +/** + * @public + */ +export type AdversarialAgentsPostRequestPhasesEnum = 'analyze' | 'migrate'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ArtifactType.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ArtifactType.model.ts index c69331c2707..5e44672fa9c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ArtifactType.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ArtifactType.model.ts @@ -26,4 +26,5 @@ export type ArtifactType = | 'module_migration_plan' | 'migrated_sources' | 'project_metadata' - | 'ansible_project'; + | 'ansible_project' + | 'adversarial_report'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/MigrationPhase.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/MigrationPhase.model.ts index 930d251bd94..2230b5f09f6 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/MigrationPhase.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/MigrationPhase.model.ts @@ -21,4 +21,10 @@ /** * @public */ -export type MigrationPhase = 'init' | 'analyze' | 'migrate' | 'publish'; +export type MigrationPhase = + | 'init' + | 'analyze' + | 'migrate' + | 'publish' + | 'adversarial-analyze' + | 'adversarial-migrate'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts index 9ff1a039338..a2f76190d1c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Module.model.ts @@ -45,6 +45,8 @@ export interface Module { analyze?: Job; migrate?: Job; publish?: Job; + adversarialAnalyze?: Job; + adversarialMigrate?: Job; status?: ModuleStatus; /** * Detailed error information if the module failed to execute diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts index 5e2bd2098ab..b4369914918 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/Project.model.ts @@ -17,6 +17,7 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** +import { AdversarialAgentSnapshot } from '../models/AdversarialAgentSnapshot.model'; import { Artifact } from '../models/Artifact.model'; import { Job } from '../models/Job.model'; import { ProjectStatus } from '../models/ProjectStatus.model'; @@ -73,4 +74,8 @@ export interface Project { * Snapshot of rules accepted at project creation time */ acceptedRules?: Array; + /** + * Snapshot of adversarial agents selected at project creation time + */ + adversarialAgents?: Array; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts index 240043a4afd..8145067831c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts @@ -54,4 +54,8 @@ export interface ProjectsPostRequest { * UUIDs of rules the project accepts (required rules auto-appended) */ acceptedRuleIds?: Array; + /** + * Optional list of agent IDs to enable for this project (snapshots will be stored) + */ + adversarialAgentIds?: Array; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts new file mode 100644 index 00000000000..2e36e214734 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts @@ -0,0 +1,33 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ProjectsProjectIdAdversarialRunPost202Response { + /** + * UUID of the created job + */ + jobId: string; + /** + * Kubernetes job name + */ + k8sJobName: string; +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts new file mode 100644 index 00000000000..7c3232804e7 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts @@ -0,0 +1,42 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { GitRepoAuth } from '../models/GitRepoAuth.model'; + +/** + * @public + */ +export interface ProjectsProjectIdAdversarialRunPostRequest { + /** + * The phase whose output should be reviewed + */ + phase: ProjectsProjectIdAdversarialRunPostRequestPhaseEnum; + /** + * UUID of the module to review + */ + moduleId: string; + targetRepoAuth: GitRepoAuth; +} + +/** + * @public + */ +export type ProjectsProjectIdAdversarialRunPostRequestPhaseEnum = + | 'analyze' + | 'migrate'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts index 320cc999ef2..d5401999aa7 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts @@ -15,6 +15,10 @@ */ export * from '../models/AAPCredentials.model'; +export * from '../models/AdversarialAgent.model'; +export * from '../models/AdversarialAgentSnapshot.model'; +export * from '../models/AdversarialAgentsGet200Response.model'; +export * from '../models/AdversarialAgentsPostRequest.model'; export * from '../models/AgentMetrics.model'; export * from '../models/Artifact.model'; export * from '../models/ArtifactType.model'; @@ -31,6 +35,8 @@ export * from '../models/ProjectStatus.model'; export * from '../models/ProjectStatusState.model'; export * from '../models/ProjectsGet200Response.model'; export * from '../models/ProjectsPostRequest.model'; +export * from '../models/ProjectsProjectIdAdversarialRunPost202Response.model'; +export * from '../models/ProjectsProjectIdAdversarialRunPostRequest.model'; export * from '../models/ProjectsProjectIdCollectArtifactsPost200Response.model'; export * from '../models/ProjectsProjectIdCollectArtifactsPostRequest.model'; export * from '../models/ProjectsProjectIdDelete200Response.model'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index c7971ce6f98..67ac6182ebe 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -155,6 +155,14 @@ export const spec = { "type": "string" }, "description": "UUIDs of rules the project accepts (required rules auto-appended)" + }, + "adversarialAgentIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Optional list of agent IDs to enable for this project (snapshots will be stored)" } }, "required": [ @@ -375,6 +383,242 @@ export const spec = { } } }, + "/adversarial-agents": { + "get": { + "summary": "Returns a list of all adversarial agents.", + "parameters": [ + { + "in": "query", + "name": "phase", + "schema": { + "type": "string", + "enum": [ + "analyze", + "migrate" + ] + }, + "required": false, + "description": "Filter agents by workflow phase" + } + ], + "responses": { + "200": { + "description": "All adversarial agents, optionally filtered by phase.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdversarialAgent" + } + }, + "total": { + "type": "integer", + "description": "Total number of agents returned" + } + } + } + } + } + } + } + }, + "post": { + "summary": "Creates a new adversarial agent (admin only).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Name of the agent" + }, + "prompt": { + "type": "string", + "minLength": 50, + "maxLength": 5000, + "description": "AI prompt describing what the agent should check for" + }, + "phases": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "analyze", + "migrate" + ] + }, + "minItems": 1, + "description": "Workflow phases this agent runs in" + }, + "critical": { + "type": "boolean", + "description": "Whether this is a critical security/correctness check" + } + }, + "required": [ + "name", + "prompt", + "phases", + "critical" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created adversarial agent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdversarialAgent" + } + } + } + }, + "400": { + "description": "Invalid input." + } + } + } + }, + "/adversarial-agents/{id}": { + "get": { + "summary": "Returns an adversarial agent by ID.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Adversarial agent data.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdversarialAgent" + } + } + } + }, + "404": { + "description": "Adversarial agent not found." + } + } + }, + "put": { + "summary": "Updates an adversarial agent by ID (admin only).", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 100, + "description": "Name of the agent" + }, + "prompt": { + "type": "string", + "minLength": 50, + "maxLength": 5000, + "description": "AI prompt describing what the agent should check for" + }, + "phases": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "analyze", + "migrate" + ] + }, + "minItems": 1, + "description": "Workflow phases this agent runs in" + }, + "critical": { + "type": "boolean", + "description": "Whether this is a critical security/correctness check" + } + }, + "required": [ + "name", + "prompt", + "phases", + "critical" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated adversarial agent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdversarialAgent" + } + } + } + }, + "400": { + "description": "Invalid input." + }, + "404": { + "description": "Adversarial agent not found." + } + } + }, + "delete": { + "summary": "Deletes an adversarial agent by ID (admin only).", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "Adversarial agent deleted successfully." + }, + "404": { + "description": "Adversarial agent not found." + } + } + } + }, "/projects/{projectId}": { "get": { "summary": "Returns a project by ID.", @@ -856,7 +1100,7 @@ export const spec = { "in": "query", "name": "phase", "schema": { - "$ref": "#/components/schemas/ModulePhase" + "$ref": "#/components/schemas/MigrationPhase" }, "required": true, "description": "Migration module phase to filter" @@ -879,6 +1123,89 @@ export const spec = { } } }, + "/projects/{projectId}/adversarial-run": { + "post": { + "summary": "Triggers an adversarial review job for a module phase", + "description": "Runs adversarial agents against the output of a completed analyze or migrate phase.\nThe agents review the committed artifacts in the target repository and append\na markdown report alongside a JSON summary back to the target repo.\n", + "parameters": [ + { + "in": "path", + "name": "projectId", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": [ + "analyze", + "migrate" + ], + "description": "The phase whose output should be reviewed" + }, + "moduleId": { + "type": "string", + "description": "UUID of the module to review" + }, + "targetRepoAuth": { + "$ref": "#/components/schemas/GitRepoAuth" + } + }, + "required": [ + "phase", + "moduleId", + "targetRepoAuth" + ] + } + } + } + }, + "responses": { + "202": { + "description": "Adversarial review job accepted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "description": "UUID of the created job" + }, + "k8sJobName": { + "type": "string", + "description": "Kubernetes job name" + } + }, + "required": [ + "jobId", + "k8sJobName" + ] + } + } + } + }, + "400": { + "description": "Invalid request (bad phase, module not found, or no adversarial agents configured)" + }, + "404": { + "description": "Project not found" + }, + "409": { + "description": "An adversarial job is already running for this module and phase" + } + } + } + }, "/projects/{projectId}/collectArtifacts": { "post": { "security": [ @@ -1077,6 +1404,13 @@ export const spec = { "$ref": "#/components/schemas/RuleSnapshot" }, "description": "Snapshot of rules accepted at project creation time" + }, + "adversarialAgents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AdversarialAgentSnapshot" + }, + "description": "Snapshot of adversarial agents selected at project creation time" } }, "required": [ @@ -1121,6 +1455,12 @@ export const spec = { "publish": { "$ref": "#/components/schemas/Job" }, + "adversarialAnalyze": { + "$ref": "#/components/schemas/Job" + }, + "adversarialMigrate": { + "$ref": "#/components/schemas/Job" + }, "status": { "$ref": "#/components/schemas/ModuleStatus" }, @@ -1317,7 +1657,8 @@ export const spec = { "module_migration_plan", "migrated_sources", "project_metadata", - "ansible_project" + "ansible_project", + "adversarial_report" ] }, "Artifact": { @@ -1389,7 +1730,9 @@ export const spec = { "init", "analyze", "migrate", - "publish" + "publish", + "adversarial-analyze", + "adversarial-migrate" ], "description": "All migration phases" }, @@ -1509,6 +1852,100 @@ export const spec = { "description" ] }, + "AdversarialAgentSnapshot": { + "type": "object", + "description": "Snapshot of an adversarial agent at the time it was selected for a project", + "properties": { + "id": { + "type": "string", + "description": "UUID of the agent" + }, + "name": { + "type": "string", + "description": "Name of the agent at selection time" + }, + "prompt": { + "type": "string", + "description": "Prompt of the agent at selection time" + }, + "phases": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflow phases the agent runs in" + }, + "critical": { + "type": "boolean", + "description": "Whether this is a critical agent" + } + }, + "required": [ + "id", + "name", + "prompt", + "phases", + "critical" + ] + }, + "AdversarialAgent": { + "type": "object", + "description": "AI agent that reviews migration outputs for security, functional gaps, and correctness issues", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "UUID for the adversarial agent" + }, + "name": { + "type": "string", + "description": "Name of the agent" + }, + "prompt": { + "type": "string", + "description": "AI prompt describing what the agent should check for" + }, + "phases": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "analyze", + "migrate" + ] + }, + "description": "Workflow phases this agent runs in (analyze and migrate only)" + }, + "critical": { + "type": "boolean", + "description": "Whether this is a critical security/correctness check" + }, + "createdBy": { + "type": "string", + "description": "User or system that created the agent" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Date/time when the agent was created" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Date/time when the agent was last updated" + } + }, + "required": [ + "id", + "name", + "prompt", + "phases", + "critical", + "createdBy", + "createdAt", + "updatedAt" + ] + }, "AgentMetrics": { "type": "object", "description": "Telemetry data for a single agent execution within a phase", diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts index 92ea675b6be..33da14f47d1 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/JobResourceBuilder.ts @@ -204,9 +204,14 @@ export class JobResourceBuilder { * * @param params - Job creation parameters * @param config - X2A configuration from app-config.yaml + * @param adversarialAgentsConfigMap - Optional ConfigMap name for adversarial agents * @returns V1Job resource ready to be created in Kubernetes */ - static buildJobSpec(params: JobCreateParams, config: X2AConfig): V1Job { + static buildJobSpec( + params: JobCreateParams, + config: X2AConfig, + adversarialAgentsConfigMap?: string, + ): V1Job { const shortId = crypto.randomBytes(4).toString('hex'); const jobName = `job-x2a-${params.phase}-${shortId}`; const projectSecretName = `x2a-project-secret-${params.projectId}`; @@ -269,6 +274,8 @@ export class JobResourceBuilder { { name: 'x2a', image: `${config.kubernetes.image}:${config.kubernetes.imageTag}`, + imagePullPolicy: + config.kubernetes.imagePullPolicy ?? 'IfNotPresent', command: ['/bin/bash', '-c'], args: [this.buildMainContainerScript(params, config)], // Mount both secrets: @@ -404,6 +411,15 @@ export class JobResourceBuilder { }, ] : []), + ...(adversarialAgentsConfigMap + ? [ + { + name: 'adversarial-agents-config', + mountPath: '/config/adversarial-agents', + readOnly: true, + }, + ] + : []), ], resources: { requests: { @@ -417,7 +433,7 @@ export class JobResourceBuilder { }, }, ], - // Shared volume for git repositories + optional rules ConfigMap + // Shared volume for git repositories + optional rules ConfigMap + optional adversarial agents ConfigMap volumes: [ { name: 'workspace', @@ -433,6 +449,16 @@ export class JobResourceBuilder { }, ] : []), + ...(adversarialAgentsConfigMap + ? [ + { + name: 'adversarial-agents-config', + configMap: { + name: adversarialAgentsConfigMap, + }, + }, + ] + : []), ], }, }, @@ -541,6 +567,52 @@ export class JobResourceBuilder { }; } + /** + * Builds a ConfigMap containing adversarial agent snapshots for the job. + * Owned by the job (auto-deleted when job is deleted). + * + * @param jobId - Job UUID + * @param params - Job creation parameters containing adversarial agents snapshots + * @param ownerReference - Owner reference to the parent Job for garbage collection + * @returns V1ConfigMap resource, or undefined if no agents + */ + static buildAdversarialAgentsConfigMap( + configMapName: string, + params: JobCreateParams, + ownerReference: V1OwnerReference, + ): V1ConfigMap | undefined { + if (!params.adversarialAgents || params.adversarialAgents.length === 0) { + return undefined; + } + + // The ConfigMap contains a single agents.json file with all agent snapshots + const agentsJSON = JSON.stringify(params.adversarialAgents, null, 2); + + return { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: configMapName, + labels: { + 'app.kubernetes.io/name': 'x2a-convertor', + 'app.kubernetes.io/component': 'adversarial-agents', + 'app.kubernetes.io/managed-by': 'x2a-backend-plugin', + 'x2a.redhat.com/job-id': params.jobId, + }, + annotations: { + 'x2a.redhat.com/created-by': 'x2a-backend-plugin', + 'x2a.redhat.com/description': + 'Adversarial agent definitions for X2A job (auto-deleted with job)', + 'x2a.redhat.com/agent-count': String(params.adversarialAgents.length), + }, + ownerReferences: [ownerReference], + }, + data: { + 'agents.json': agentsJSON, + }, + }; + } + /** * Builds the main container script that executes x2a tool, commits, and pushes * diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/KubeService.ts b/workspaces/x2a/plugins/x2a-backend/src/services/KubeService.ts index 7805a9d9846..82eee7c5287 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/KubeService.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/KubeService.ts @@ -290,7 +290,20 @@ export class KubeService implements KubeServiceApi { await this.createProjectSecret(params.projectId, params.aapCredentials); // Step 2: Create the Kubernetes job - const job = JobResourceBuilder.buildJobSpec(params, this.#config); + // The adversarial agents ConfigMap is only relevant for adversarial phases + const isAdversarialPhase = params.phase.startsWith('adversarial-'); + const adversarialAgentsConfigMapName = + isAdversarialPhase && + params.adversarialAgents && + params.adversarialAgents.length > 0 + ? `x2a-adversarial-agents-${params.jobId}` + : undefined; + + const job = JobResourceBuilder.buildJobSpec( + params, + this.#config, + adversarialAgentsConfigMapName, + ); const k8sJobName = job.metadata?.name || ''; const createdJob = await this.#batchV1Api.createNamespacedJob({ @@ -344,6 +357,24 @@ export class KubeService implements KubeServiceApi { body: rulesConfigMap, }); } + + // Step 5: Create adversarial agents ConfigMap if agents are present + const adversarialAgentsConfigMap = adversarialAgentsConfigMapName + ? JobResourceBuilder.buildAdversarialAgentsConfigMap( + adversarialAgentsConfigMapName, + params, + ownerReference, + ) + : undefined; + if (adversarialAgentsConfigMap) { + this.#logger.info( + `Creating adversarial agents ConfigMap with ${params.adversarialAgents!.length} agents for job: ${params.jobId}`, + ); + await this.#coreV1Api.createNamespacedConfigMap({ + namespace: this.#namespace, + body: adversarialAgentsConfigMap, + }); + } } catch (error: any) { this.#logger.error( `Failed to create job resources, cleaning up job ${k8sJobName}: ${error.message}`, @@ -510,6 +541,24 @@ export class KubeService implements KubeServiceApi { } } + /** + * Cleans up all resources for a job, including the job itself and associated ConfigMaps. + * This is a best-effort operation - if the job has ownerReferences, associated resources + * will be automatically garbage collected. This method provides explicit cleanup. + * + * @param jobId - The job UUID (used to identify associated ConfigMaps) + * @param k8sJobName - The Kubernetes job name + */ + async cleanupJobResources(jobId: string, k8sJobName: string): Promise { + this.#logger.info(`Cleaning up resources for job: ${jobId}`); + + // Delete the job (this will trigger cascading deletion of owned resources + // including ConfigMaps due to ownerReferences) + await this.deleteJob(k8sJobName); + + this.#logger.info(`Cleanup complete for job: ${jobId}`); + } + /** * Lists all jobs for a specific project */ @@ -580,6 +629,7 @@ export const kubeServiceFactory = createServiceFactory({ image: rawConfig?.kubernetes?.image ?? DEFAULT_KUBERNETES_IMAGE, imageTag: rawConfig?.kubernetes?.imageTag ?? DEFAULT_KUBERNETES_IMAGE_TAG, + imagePullPolicy: rawConfig?.kubernetes?.imagePullPolicy, ttlSecondsAfterFinished: rawConfig?.kubernetes?.ttlSecondsAfterFinished ?? DEFAULT_TTL_SECONDS_AFTER_FINISHED, diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts new file mode 100644 index 00000000000..fd92d09b09a --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts @@ -0,0 +1,231 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Knex } from 'knex'; +import crypto from 'node:crypto'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { InputError } from '@backstage/errors'; +import { + AdversarialAgentEntity, + type AdversarialAgentSnapshot, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +export class AdversarialAgentOperations { + readonly #logger: LoggerService; + readonly #dbClient: Knex; + + constructor(logger: LoggerService, dbClient: Knex) { + this.#logger = logger; + this.#dbClient = dbClient; + } + + async createAdversarialAgent(input: { + name: string; + prompt: string; + phases: string[]; + critical: boolean; + createdBy: string; + }): Promise { + const id = crypto.randomUUID(); + const now = new Date(); + + await this.#dbClient('adversarial_agents').insert({ + id, + name: input.name, + prompt: input.prompt, + phases: JSON.stringify(input.phases), + critical: input.critical, + created_by: input.createdBy, + created_at: now, + updated_at: now, + }); + + this.#logger.info(`Created adversarial agent: ${id} "${input.name}"`); + + return new AdversarialAgentEntity( + id, + input.name, + input.prompt, + input.phases, + input.critical, + input.createdBy, + now, + now, + ); + } + + async listAdversarialAgents(filters?: { + phase?: string; + }): Promise { + const rows = await this.#dbClient('adversarial_agents').orderBy( + 'created_at', + 'asc', + ); + + return rows + .map((row: Record) => { + const phases = + typeof row.phases === 'string' + ? JSON.parse(row.phases as string) + : row.phases; + return AdversarialAgentEntity.fromRow({ ...row, phases }); + }) + .filter(agent => { + if (!filters?.phase) return true; + return agent.phases.includes(filters.phase); + }); + } + + async getAdversarialAgent(opts: { + id: string; + }): Promise { + const row = await this.#dbClient('adversarial_agents') + .where('id', opts.id) + .first(); + if (!row) { + return undefined; + } + const phases = + typeof row.phases === 'string' + ? JSON.parse(row.phases as string) + : row.phases; + return AdversarialAgentEntity.fromRow({ + ...(row as Record), + phases, + }); + } + + async updateAdversarialAgent(opts: { + id: string; + name: string; + prompt: string; + phases: string[]; + critical: boolean; + }): Promise { + const now = new Date(); + + const updated = await this.#dbClient('adversarial_agents') + .where('id', opts.id) + .update({ + name: opts.name, + prompt: opts.prompt, + phases: JSON.stringify(opts.phases), + critical: opts.critical, + updated_at: now, + }); + + if (updated === 0) { + return undefined; + } + + this.#logger.info(`Updated adversarial agent: ${opts.id} "${opts.name}"`); + + const row = await this.#dbClient('adversarial_agents') + .where('id', opts.id) + .first(); + const phases = + typeof row.phases === 'string' + ? JSON.parse(row.phases as string) + : row.phases; + return AdversarialAgentEntity.fromRow({ + ...(row as Record), + phases, + }); + } + + async deleteAdversarialAgent(opts: { id: string }): Promise { + this.#logger.info(`deleteAdversarialAgent called for id: ${opts.id}`); + + const deletedCount = await this.#dbClient('adversarial_agents') + .where('id', opts.id) + .delete(); + + if (deletedCount === 0) { + this.#logger.warn(`No adversarial agent found with id: ${opts.id}`); + } + + return deletedCount; + } + + async attachAdversarialAgentsToProject(args: { + projectId: string; + agentIds: string[]; + }): Promise { + const { projectId, agentIds } = args; + + // Fetch explicitly requested agents + const requestedAgents = + agentIds.length > 0 + ? await this.#dbClient('adversarial_agents').whereIn('id', agentIds) + : []; + + // Validate all provided IDs exist + const foundIds = new Set( + requestedAgents.map((r: Record) => r.id as string), + ); + const missingIds = agentIds.filter(id => !foundIds.has(id)); + if (missingIds.length > 0) { + throw new InputError( + `Adversarial agents not found: ${missingIds.join(', ')}`, + ); + } + + // Parse phases from JSON + const agents = requestedAgents.map((row: Record) => { + const phases = JSON.parse(row.phases as string) as string[]; + return { + ...(row as Record), + phases, + }; + }); + + const snapshots: AdversarialAgentSnapshot[] = agents.map(row => + AdversarialAgentEntity.fromRow(row).toSnapshot(), + ); + + await this.#dbClient('projects') + .where('id', projectId) + .update({ adversarial_agents: JSON.stringify(snapshots) }); + + this.#logger.info( + `Attached ${snapshots.length} adversarial agent(s) to project ${projectId}`, + ); + } + + async getAdversarialAgentsForProject(args: { + projectId: string; + }): Promise { + const row = await this.#dbClient('projects') + .where('id', args.projectId) + .select('adversarial_agents') + .first(); + + if (!row?.adversarial_agents) { + return []; + } + + try { + return JSON.parse( + row.adversarial_agents as string, + ) as AdversarialAgentSnapshot[]; + } catch { + this.#logger.warn( + `Failed to parse adversarial_agents JSON for project ${args.projectId}`, + ); + return []; + } + } +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts index 5adc85bf967..ca99d41725c 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts @@ -39,6 +39,8 @@ import { ProjectsGet, RuleEntity, type RuleSnapshot, + AdversarialAgentEntity, + type AdversarialAgentSnapshot, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { x2aDatabaseServiceRef, @@ -48,6 +50,7 @@ import { type CreateJobInput, } from '@red-hat-developer-hub/backstage-plugin-x2a-node'; +import { AdversarialAgentOperations } from './adversarialAgentOperations'; import { JobOperations } from './jobOperations'; import { ModuleOperations } from './moduleOperations'; import { ProjectOperations } from './projectOperations'; @@ -81,6 +84,7 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { readonly #moduleOps: ModuleOperations; readonly #jobOps: JobOperations; readonly #ruleOps: RuleOperations; + readonly #adversarialAgentOps: AdversarialAgentOperations; static create(options: { logger: LoggerService; dbClient: Knex }) { return new X2ADatabaseService(options.logger, options.dbClient); @@ -92,6 +96,10 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { this.#moduleOps = new ModuleOperations(logger, dbClient); this.#jobOps = new JobOperations(logger, dbClient); this.#ruleOps = new RuleOperations(logger, dbClient); + this.#adversarialAgentOps = new AdversarialAgentOperations( + logger, + dbClient, + ); } /** @@ -344,29 +352,55 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { if (!skipEnrichment) { // Fetch last jobs - const lastAnalyzeJobsOfModule = await this.listJobs({ - projectId: module.projectId, - moduleId: id, - phase: 'analyze', - lastJobOnly: true, - }); - const lastMigrateJobsOfModule = await this.listJobs({ - projectId: module.projectId, - moduleId: id, - phase: 'migrate', - lastJobOnly: true, - }); - const lastPublishJobsOfModule = await this.listJobs({ - projectId: module.projectId, - moduleId: id, - phase: 'publish', - lastJobOnly: true, - }); + const [ + lastAnalyzeJobs, + lastMigrateJobs, + lastPublishJobs, + lastAdversarialAnalyzeJobs, + lastAdversarialMigrateJobs, + ] = await Promise.all([ + this.listJobs({ + projectId: module.projectId, + moduleId: id, + phase: 'analyze', + lastJobOnly: true, + }), + this.listJobs({ + projectId: module.projectId, + moduleId: id, + phase: 'migrate', + lastJobOnly: true, + }), + this.listJobs({ + projectId: module.projectId, + moduleId: id, + phase: 'publish', + lastJobOnly: true, + }), + this.listJobs({ + projectId: module.projectId, + moduleId: id, + phase: 'adversarial-analyze', + lastJobOnly: true, + }), + this.listJobs({ + projectId: module.projectId, + moduleId: id, + phase: 'adversarial-migrate', + lastJobOnly: true, + }), + ]); // Update module with last jobs - module.analyze = removeSensitiveFromJob(lastAnalyzeJobsOfModule[0]); - module.migrate = removeSensitiveFromJob(lastMigrateJobsOfModule[0]); - module.publish = removeSensitiveFromJob(lastPublishJobsOfModule[0]); + module.analyze = removeSensitiveFromJob(lastAnalyzeJobs[0]); + module.migrate = removeSensitiveFromJob(lastMigrateJobs[0]); + module.publish = removeSensitiveFromJob(lastPublishJobs[0]); + module.adversarialAnalyze = removeSensitiveFromJob( + lastAdversarialAnalyzeJobs[0], + ); + module.adversarialMigrate = removeSensitiveFromJob( + lastAdversarialMigrateJobs[0], + ); // Attach attempt stats per phase const phases = ['analyze', 'migrate', 'publish'] as const; @@ -608,6 +642,57 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { }): Promise { return this.#ruleOps.getAcceptedRulesForProject(args); } + + // Adversarial Agents + + async createAdversarialAgent(input: { + name: string; + prompt: string; + phases: string[]; + critical: boolean; + createdBy: string; + }): Promise { + return this.#adversarialAgentOps.createAdversarialAgent(input); + } + + async listAdversarialAgents(filters?: { + phase?: string; + }): Promise { + return this.#adversarialAgentOps.listAdversarialAgents(filters); + } + + async getAdversarialAgent(opts: { + id: string; + }): Promise { + return this.#adversarialAgentOps.getAdversarialAgent(opts); + } + + async updateAdversarialAgent(opts: { + id: string; + name: string; + prompt: string; + phases: string[]; + critical: boolean; + }): Promise { + return this.#adversarialAgentOps.updateAdversarialAgent(opts); + } + + async deleteAdversarialAgent(opts: { id: string }): Promise { + return this.#adversarialAgentOps.deleteAdversarialAgent(opts); + } + + async attachAdversarialAgentsToProject(args: { + projectId: string; + agentIds: string[]; + }): Promise { + return this.#adversarialAgentOps.attachAdversarialAgentsToProject(args); + } + + async getAdversarialAgentsForProject(args: { + projectId: string; + }): Promise { + return this.#adversarialAgentOps.getAdversarialAgentsForProject(args); + } } // Re-export the canonical service ref from x2a-node. diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts index 0c091c2abd2..1ca6af94cd3 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/mappers.ts @@ -24,6 +24,7 @@ import { MigrationPhase, SourceTechnology, Telemetry, + AdversarialAgentSnapshot, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; export function mapRowToProject(row: Record): Project { @@ -39,6 +40,9 @@ export function mapRowToProject(row: Record): Project { createdAt: new Date(row.created_at as string | Date), dirName: (row.dir_name as string) || undefined, acceptedRules: parseAcceptedRules(row.accepted_rules as string | undefined), + adversarialAgents: parseAdversarialAgents( + row.adversarial_agents as string | undefined, + ), }; } @@ -55,6 +59,19 @@ function parseAcceptedRules( } } +function parseAdversarialAgents( + raw: string | undefined, +): AdversarialAgentSnapshot[] | undefined { + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + export function mapRowToModule(row: Record): Module { return { id: row.id as string, diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectOperations.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectOperations.ts index cd099b388da..5488cb8435a 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectOperations.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectOperations.ts @@ -217,7 +217,7 @@ export class ProjectOperations { `updateProject called for projectId: ${projectId} by ${calledByUserRef}`, ); - const updateFields: Record = {}; + const updateFields: Record = {}; if (input.name !== undefined) updateFields.name = input.name; if (input.ownedBy !== undefined) updateFields.owned_by = input.ownedBy; if (input.description !== undefined) diff --git a/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh b/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh index 10f9e3929a7..d79d21e09f7 100644 --- a/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh +++ b/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh @@ -307,10 +307,13 @@ cleanup() { } git_clone_repos() { - echo "=== Cloning source repository ===" - ERROR_MESSAGE="Failed to clone source repository from ${SOURCE_REPO_URL}" - git_source_repo clone --depth=1 --single-branch \ - --branch="${SOURCE_REPO_BRANCH}" "${SOURCE_REPO_URL}" /workspace/source + # Adversarial phases only read from the target repo (artifacts are already committed) + if [[ "${PHASE}" != adversarial-* ]]; then + echo "=== Cloning source repository ===" + ERROR_MESSAGE="Failed to clone source repository from ${SOURCE_REPO_URL}" + git_source_repo clone --depth=1 --single-branch \ + --branch="${SOURCE_REPO_BRANCH}" "${SOURCE_REPO_URL}" /workspace/source + fi echo "=== Cloning target repository ===" ERROR_MESSAGE="Failed to clone target repository from ${TARGET_REPO_URL}" @@ -382,7 +385,10 @@ git_clone_repos # Define paths TARGET_BASE="/workspace/target" -SOURCE_BASE="/workspace/source" +# SOURCE_BASE is only set for phases that clone the source repository +if [[ "${PHASE}" != adversarial-* ]]; then + SOURCE_BASE="/workspace/source" +fi # PROJECT_DIR is pre-computed by the backend (sanitized name + short UUID) PROJECT_PATH="${TARGET_BASE}/${PROJECT_DIR}" @@ -657,6 +663,54 @@ case "${PHASE}" in fi ;; + adversarial-analyze | adversarial-migrate) + ACTUAL_PHASE="${PHASE#adversarial-}" + echo "=== Running adversarial review (${ACTUAL_PHASE} phase) ===" + OUTPUT_DIR="${PROJECT_PATH}/modules/${MODULE_NAME}" + + if [ "${ACTUAL_PHASE}" = "analyze" ]; then + SOURCE_DIR="${OUTPUT_DIR}" + else + SOURCE_DIR="${OUTPUT_DIR}/ansible" + fi + + if [ ! -d "${SOURCE_DIR}" ]; then + ERROR_MESSAGE="Source directory not found: ${SOURCE_DIR}. Ensure the ${ACTUAL_PHASE} phase completed before running adversarial review." + exit 1 + fi + + AGENTS_CONFIG="/config/adversarial-agents/agents.json" + if [ ! -f "${AGENTS_CONFIG}" ]; then + ERROR_MESSAGE="Adversarial agents config not found at ${AGENTS_CONFIG}" + exit 1 + fi + + if [ ! -d /app ] || [ ! -f /app/app.py ]; then + ERROR_MESSAGE="/app/app.py not found - x2a tool is required" + exit 1 + fi + + REPORT_MD="${OUTPUT_DIR}/adversarial-report-${ACTUAL_PHASE}.md" + REPORT_JSON_DEST="${OUTPUT_DIR}/adversarial-report-${ACTUAL_PHASE}.json" + + cd /app + # Telemetry lands in /app (Python CWD) — outside the target git repo. + # Set SOURCE_BASE before run_x2a so cleanup picks up telemetry even on failure. + SOURCE_BASE="/app" + + run_x2a uv run app.py adversarial-run \ + --phase "${ACTUAL_PHASE}" \ + --source-dir "${SOURCE_DIR}" \ + --config "${AGENTS_CONFIG}" \ + --report-path "${REPORT_MD}" + + if [ -f "/app/agent-adversarial-report.json" ]; then + cp "/app/agent-adversarial-report.json" "${REPORT_JSON_DEST}" + fi + + ARTIFACTS+=("adversarial_report:${PROJECT_DIR}/modules/${MODULE_NAME}/adversarial-report-${ACTUAL_PHASE}.md") + ;; + *) ERROR_MESSAGE="Unknown phase: ${PHASE}" exit 1 diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts index b7090262e7a..ef12aa36074 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts @@ -23,12 +23,16 @@ import { FetchApi } from '../types/fetch'; import crossFetch from 'cross-fetch'; import { pluginId } from '../pluginId'; import * as parser from 'uri-template'; +import { AdversarialAgent } from '../models/AdversarialAgent.model'; +import { AdversarialAgentsGet200Response } from '../models/AdversarialAgentsGet200Response.model'; +import { AdversarialAgentsPostRequest } from '../models/AdversarialAgentsPostRequest.model'; import { MigrationPhase } from '../models/MigrationPhase.model'; import { Module } from '../models/Module.model'; -import { ModulePhase } from '../models/ModulePhase.model'; import { Project } from '../models/Project.model'; import { ProjectsGet200Response } from '../models/ProjectsGet200Response.model'; import { ProjectsPostRequest } from '../models/ProjectsPostRequest.model'; +import { ProjectsProjectIdAdversarialRunPost202Response } from '../models/ProjectsProjectIdAdversarialRunPost202Response.model'; +import { ProjectsProjectIdAdversarialRunPostRequest } from '../models/ProjectsProjectIdAdversarialRunPostRequest.model'; import { ProjectsProjectIdCollectArtifactsPost200Response } from '../models/ProjectsProjectIdCollectArtifactsPost200Response.model'; import { ProjectsProjectIdCollectArtifactsPostRequest } from '../models/ProjectsProjectIdCollectArtifactsPostRequest.model'; import { ProjectsProjectIdDelete200Response } from '../models/ProjectsProjectIdDelete200Response.model'; @@ -60,6 +64,45 @@ export type TypedResponse = Omit & { export interface RequestOptions { token?: string; } +/** + * @public + */ +export type AdversarialAgentsGet = { + query: { + phase?: 'analyze' | 'migrate'; + }; +}; +/** + * @public + */ +export type AdversarialAgentsIdDelete = { + path: { + id: string; + }; +}; +/** + * @public + */ +export type AdversarialAgentsIdGet = { + path: { + id: string; + }; +}; +/** + * @public + */ +export type AdversarialAgentsIdPut = { + path: { + id: string; + }; + body: AdversarialAgentsPostRequest; +}; +/** + * @public + */ +export type AdversarialAgentsPost = { + body: AdversarialAgentsPostRequest; +}; /** * @public */ @@ -77,6 +120,15 @@ export type ProjectsGet = { export type ProjectsPost = { body: ProjectsPostRequest; }; +/** + * @public + */ +export type ProjectsProjectIdAdversarialRunPost = { + path: { + projectId: string; + }; + body: ProjectsProjectIdAdversarialRunPostRequest; +}; /** * @public */ @@ -157,7 +209,7 @@ export type ProjectsProjectIdModulesModuleIdLogGet = { }; query: { streaming?: boolean; - phase: ModulePhase; + phase: MigrationPhase; }; }; /** @@ -239,6 +291,137 @@ export class DefaultApiClient { this.fetchApi = options.fetchApi || { fetch: crossFetch }; } + /** + * Returns a list of all adversarial agents. + * @param phase - Filter agents by workflow phase + */ + public async adversarialAgentsGet( + // @ts-ignore + request: AdversarialAgentsGet, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/adversarial-agents{?phase}`; + + const uri = parser.parse(uriTemplate).expand({ + ...request.query, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'GET', + }); + } + + /** + * Deletes an adversarial agent by ID (admin only). + * @param id - + */ + public async adversarialAgentsIdDelete( + // @ts-ignore + request: AdversarialAgentsIdDelete, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/adversarial-agents/{id}`; + + const uri = parser.parse(uriTemplate).expand({ + id: request.path.id, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'DELETE', + }); + } + + /** + * Returns an adversarial agent by ID. + * @param id - + */ + public async adversarialAgentsIdGet( + // @ts-ignore + request: AdversarialAgentsIdGet, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/adversarial-agents/{id}`; + + const uri = parser.parse(uriTemplate).expand({ + id: request.path.id, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'GET', + }); + } + + /** + * Updates an adversarial agent by ID (admin only). + * @param id - + * @param adversarialAgentsPostRequest - + */ + public async adversarialAgentsIdPut( + // @ts-ignore + request: AdversarialAgentsIdPut, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/adversarial-agents/{id}`; + + const uri = parser.parse(uriTemplate).expand({ + id: request.path.id, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'PUT', + body: JSON.stringify(request.body), + }); + } + + /** + * Creates a new adversarial agent (admin only). + * @param adversarialAgentsPostRequest - + */ + public async adversarialAgentsPost( + // @ts-ignore + request: AdversarialAgentsPost, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/adversarial-agents`; + + const uri = parser.parse(uriTemplate).expand({}); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify(request.body), + }); + } + /** * Returns a list of projects. * @param page - Page number @@ -293,6 +476,35 @@ export class DefaultApiClient { }); } + /** + * Runs adversarial agents against the output of a completed analyze or migrate phase. The agents review the committed artifacts in the target repository and append a markdown report alongside a JSON summary back to the target repo. + * Triggers an adversarial review job for a module phase + * @param projectId - + * @param projectsProjectIdAdversarialRunPostRequest - + */ + public async projectsProjectIdAdversarialRunPost( + // @ts-ignore + request: ProjectsProjectIdAdversarialRunPost, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/projects/{projectId}/adversarial-run`; + + const uri = parser.parse(uriTemplate).expand({ + projectId: request.path.projectId, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify(request.body), + }); + } + /** * Callback endpoint for X2Ansible jobs to submit execution artifacts and results. This endpoint is called by the X2Ansible job runner when a migration phase completes. Authentication: Requires HMAC-SHA256 signature in X-Callback-Signature header. The signature is computed as: HMAC-SHA256(callbackToken, raw_request_body) Replay attack prevention: Jobs are only accepted within 3 hours of job creation time (based on job.startedAt) * Collects artifacts from a completed X2Ansible job diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgent.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgent.model.ts new file mode 100644 index 00000000000..c29a96561e9 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgent.model.ts @@ -0,0 +1,63 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * AI agent that reviews migration outputs for security, functional gaps, and correctness issues + * @public + */ +export interface AdversarialAgent { + /** + * UUID for the adversarial agent + */ + id: string; + /** + * Name of the agent + */ + name: string; + /** + * AI prompt describing what the agent should check for + */ + prompt: string; + /** + * Workflow phases this agent runs in (analyze and migrate only) + */ + phases: Array; + /** + * Whether this is a critical security/correctness check + */ + critical: boolean; + /** + * User or system that created the agent + */ + createdBy: string; + /** + * Date/time when the agent was created + */ + createdAt: Date; + /** + * Date/time when the agent was last updated + */ + updatedAt: Date; +} + +/** + * @public + */ +export type AdversarialAgentPhasesEnum = 'analyze' | 'migrate'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts new file mode 100644 index 00000000000..c241f964851 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentSnapshot.model.ts @@ -0,0 +1,46 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Snapshot of an adversarial agent at the time it was selected for a project + * @public + */ +export interface AdversarialAgentSnapshot { + /** + * UUID of the agent + */ + id: string; + /** + * Name of the agent at selection time + */ + name: string; + /** + * Prompt of the agent at selection time + */ + prompt: string; + /** + * Workflow phases the agent runs in + */ + phases: Array; + /** + * Whether this is a critical agent + */ + critical: boolean; +} diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts new file mode 100644 index 00000000000..5d006ad1cbb --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsGet200Response.model.ts @@ -0,0 +1,31 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { AdversarialAgent } from '../models/AdversarialAgent.model'; + +/** + * @public + */ +export interface AdversarialAgentsGet200Response { + agents?: Array; + /** + * Total number of agents returned + */ + total?: number; +} diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts new file mode 100644 index 00000000000..acc21226023 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/AdversarialAgentsPostRequest.model.ts @@ -0,0 +1,46 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface AdversarialAgentsPostRequest { + /** + * Name of the agent + */ + name: string; + /** + * AI prompt describing what the agent should check for + */ + prompt: string; + /** + * Workflow phases this agent runs in + */ + phases: Array; + /** + * Whether this is a critical security/correctness check + */ + critical: boolean; +} + +/** + * @public + */ +export type AdversarialAgentsPostRequestPhasesEnum = 'analyze' | 'migrate'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ArtifactType.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ArtifactType.model.ts index c69331c2707..5e44672fa9c 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ArtifactType.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ArtifactType.model.ts @@ -26,4 +26,5 @@ export type ArtifactType = | 'module_migration_plan' | 'migrated_sources' | 'project_metadata' - | 'ansible_project'; + | 'ansible_project' + | 'adversarial_report'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/MigrationPhase.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/MigrationPhase.model.ts index 930d251bd94..2230b5f09f6 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/MigrationPhase.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/MigrationPhase.model.ts @@ -21,4 +21,10 @@ /** * @public */ -export type MigrationPhase = 'init' | 'analyze' | 'migrate' | 'publish'; +export type MigrationPhase = + | 'init' + | 'analyze' + | 'migrate' + | 'publish' + | 'adversarial-analyze' + | 'adversarial-migrate'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts index 9ff1a039338..a2f76190d1c 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Module.model.ts @@ -45,6 +45,8 @@ export interface Module { analyze?: Job; migrate?: Job; publish?: Job; + adversarialAnalyze?: Job; + adversarialMigrate?: Job; status?: ModuleStatus; /** * Detailed error information if the module failed to execute diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts index 5e2bd2098ab..b4369914918 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/Project.model.ts @@ -17,6 +17,7 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** +import { AdversarialAgentSnapshot } from '../models/AdversarialAgentSnapshot.model'; import { Artifact } from '../models/Artifact.model'; import { Job } from '../models/Job.model'; import { ProjectStatus } from '../models/ProjectStatus.model'; @@ -73,4 +74,8 @@ export interface Project { * Snapshot of rules accepted at project creation time */ acceptedRules?: Array; + /** + * Snapshot of adversarial agents selected at project creation time + */ + adversarialAgents?: Array; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts index 240043a4afd..8145067831c 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsPostRequest.model.ts @@ -54,4 +54,8 @@ export interface ProjectsPostRequest { * UUIDs of rules the project accepts (required rules auto-appended) */ acceptedRuleIds?: Array; + /** + * Optional list of agent IDs to enable for this project (snapshots will be stored) + */ + adversarialAgentIds?: Array; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts new file mode 100644 index 00000000000..2e36e214734 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPost202Response.model.ts @@ -0,0 +1,33 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface ProjectsProjectIdAdversarialRunPost202Response { + /** + * UUID of the created job + */ + jobId: string; + /** + * Kubernetes job name + */ + k8sJobName: string; +} diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts new file mode 100644 index 00000000000..7c3232804e7 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts @@ -0,0 +1,42 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { GitRepoAuth } from '../models/GitRepoAuth.model'; + +/** + * @public + */ +export interface ProjectsProjectIdAdversarialRunPostRequest { + /** + * The phase whose output should be reviewed + */ + phase: ProjectsProjectIdAdversarialRunPostRequestPhaseEnum; + /** + * UUID of the module to review + */ + moduleId: string; + targetRepoAuth: GitRepoAuth; +} + +/** + * @public + */ +export type ProjectsProjectIdAdversarialRunPostRequestPhaseEnum = + | 'analyze' + | 'migrate'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts index 320cc999ef2..d5401999aa7 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts @@ -15,6 +15,10 @@ */ export * from '../models/AAPCredentials.model'; +export * from '../models/AdversarialAgent.model'; +export * from '../models/AdversarialAgentSnapshot.model'; +export * from '../models/AdversarialAgentsGet200Response.model'; +export * from '../models/AdversarialAgentsPostRequest.model'; export * from '../models/AgentMetrics.model'; export * from '../models/Artifact.model'; export * from '../models/ArtifactType.model'; @@ -31,6 +35,8 @@ export * from '../models/ProjectStatus.model'; export * from '../models/ProjectStatusState.model'; export * from '../models/ProjectsGet200Response.model'; export * from '../models/ProjectsPostRequest.model'; +export * from '../models/ProjectsProjectIdAdversarialRunPost202Response.model'; +export * from '../models/ProjectsProjectIdAdversarialRunPostRequest.model'; export * from '../models/ProjectsProjectIdCollectArtifactsPost200Response.model'; export * from '../models/ProjectsProjectIdCollectArtifactsPostRequest.model'; export * from '../models/ProjectsProjectIdDelete200Response.model'; diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts b/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts new file mode 100644 index 00000000000..c331afb666d --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AdversarialAgentEntity } from './AdversarialAgent'; + +const VALID_PROMPT = + 'Review the migration output for security vulnerabilities, privilege escalation, and correctness issues in the generated Ansible playbooks.'; + +const makeEntity = (overrides = {}) => { + const now = new Date('2025-01-01T00:00:00Z'); + return new AdversarialAgentEntity( + overrides.id ?? 'aaaaaaaa-0000-0000-0000-000000000001', + overrides.name ?? 'Security Checker', + overrides.prompt ?? VALID_PROMPT, + overrides.phases ?? ['analyze'], + overrides.critical ?? false, + overrides.createdBy ?? 'user:default/admin', + overrides.createdAt ?? now, + overrides.updatedAt ?? now, + ); +}; + +describe('AdversarialAgentEntity', () => { + describe('constructor – validation', () => { + it('constructs with valid inputs', () => { + const entity = makeEntity(); + expect(entity.name).toBe('Security Checker'); + expect(entity.phases).toEqual(['analyze']); + expect(entity.critical).toBe(false); + }); + + it('throws when name is too short', () => { + expect(() => makeEntity({ name: 'ab' })).toThrow( + 'Agent name must be between 3 and 100 characters', + ); + }); + + it('throws when name is too long', () => { + expect(() => makeEntity({ name: 'a'.repeat(101) })).toThrow( + 'Agent name must be between 3 and 100 characters', + ); + }); + + it('throws when prompt is too short', () => { + expect(() => makeEntity({ prompt: 'too short' })).toThrow( + 'Agent prompt must be between 50 and 5000 characters', + ); + }); + + it('throws when prompt is too long', () => { + expect(() => makeEntity({ prompt: 'a'.repeat(5001) })).toThrow( + 'Agent prompt must be between 50 and 5000 characters', + ); + }); + + it('throws when phases is empty', () => { + expect(() => makeEntity({ phases: [] })).toThrow( + 'Agent must have at least one phase', + ); + }); + + it('throws for a phase that is not analyze or migrate', () => { + expect(() => makeEntity({ phases: ['init'] })).toThrow( + 'Invalid phase: "init". Valid phases: analyze, migrate', + ); + }); + + it('throws for an adversarial phase value', () => { + expect(() => makeEntity({ phases: ['adversarial-analyze'] })).toThrow( + 'Invalid phase: "adversarial-analyze". Valid phases: analyze, migrate', + ); + }); + + it('accepts both analyze and migrate together', () => { + const entity = makeEntity({ phases: ['analyze', 'migrate'] }); + expect(entity.phases).toEqual(['analyze', 'migrate']); + }); + + it('throws when createdBy is empty', () => { + expect(() => makeEntity({ createdBy: '' })).toThrow( + 'Agent created_by must be a non-empty string', + ); + }); + }); + + describe('fromRow', () => { + it('constructs from a database row', () => { + const now = new Date('2025-06-01T12:00:00Z'); + const entity = AdversarialAgentEntity.fromRow({ + id: 'bbbbbbbb-0000-0000-0000-000000000002', + name: 'Privilege Check', + prompt: VALID_PROMPT, + phases: ['migrate'], + critical: 1, + created_by: 'user:default/alice', + created_at: now, + updated_at: now, + }); + expect(entity.id).toBe('bbbbbbbb-0000-0000-0000-000000000002'); + expect(entity.name).toBe('Privilege Check'); + expect(entity.phases).toEqual(['migrate']); + expect(entity.critical).toBe(true); + expect(entity.createdBy).toBe('user:default/alice'); + }); + + it('defaults critical to false when null', () => { + const now = new Date(); + const entity = AdversarialAgentEntity.fromRow({ + id: 'cccccccc-0000-0000-0000-000000000003', + name: 'Non-critical Agent', + prompt: VALID_PROMPT, + phases: ['analyze'], + critical: null, + created_by: 'user:default/bob', + created_at: now, + updated_at: now, + }); + expect(entity.critical).toBe(false); + }); + }); + + describe('toSnapshot', () => { + it('returns a snapshot with all required fields', () => { + const entity = makeEntity({ critical: true }); + const snapshot = entity.toSnapshot(); + expect(snapshot).toEqual({ + id: entity.id, + name: entity.name, + prompt: entity.prompt, + phases: entity.phases, + critical: true, + }); + }); + }); + + describe('equals', () => { + it('returns true for entities with the same fields', () => { + const a = makeEntity(); + const b = makeEntity(); + expect(a.equals(b)).toBe(true); + }); + + it('returns false when name differs', () => { + const a = makeEntity({ name: 'Agent A' }); + const b = makeEntity({ name: 'Agent B' }); + expect(a.equals(b)).toBe(false); + }); + + it('returns false when phases differ', () => { + const a = makeEntity({ phases: ['analyze'] }); + const b = makeEntity({ phases: ['migrate'] }); + expect(a.equals(b)).toBe(false); + }); + + it('returns false when critical differs', () => { + const a = makeEntity({ critical: true }); + const b = makeEntity({ critical: false }); + expect(a.equals(b)).toBe(false); + }); + }); + + describe('toString', () => { + it('returns a readable representation', () => { + const entity = makeEntity({ + id: 'dddddddd-0000-0000-0000-000000000004', + name: 'My Agent', + }); + expect(entity.toString()).toBe( + 'AdversarialAgentEntity(dddddddd-0000-0000-0000-000000000004: My Agent)', + ); + }); + }); +}); diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.ts b/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.ts new file mode 100644 index 00000000000..21313027a28 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.ts @@ -0,0 +1,126 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { AdversarialAgentSnapshot } from '../../client/src/schema/openapi'; + +import { Phase } from './Phase'; + +/** @public */ +export class AdversarialAgentEntity { + readonly id: string; + readonly name: string; + readonly prompt: string; + readonly phases: string[]; + readonly critical: boolean; + readonly createdBy: string; + readonly createdAt: Date; + readonly updatedAt: Date; + + constructor( + id: string, + name: string, + prompt: string, + phases: string[], + critical: boolean, + createdBy: string, + createdAt: Date, + updatedAt: Date, + ) { + if (!name || name.length < 3 || name.length > 100) { + throw new Error('Agent name must be between 3 and 100 characters'); + } + if (!prompt || prompt.length < 50 || prompt.length > 5000) { + throw new Error('Agent prompt must be between 50 and 5000 characters'); + } + if (!phases || phases.length === 0) { + throw new Error('Agent must have at least one phase'); + } + + const validPhases = Phase.adversarialAgentPhaseValues(); + for (const phase of phases) { + if (!validPhases.includes(phase as any)) { + throw new Error( + `Invalid phase: "${phase}". Valid phases: ${validPhases.join(', ')}`, + ); + } + } + + if (!createdBy) { + throw new Error('Agent created_by must be a non-empty string'); + } + + this.id = id; + this.name = name; + this.prompt = prompt; + this.phases = phases; + this.critical = critical; + this.createdBy = createdBy; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + static fromRow(row: Record): AdversarialAgentEntity { + return new AdversarialAgentEntity( + row.id as string, + row.name as string, + row.prompt as string, + row.phases as string[], + Boolean(row.critical ?? false), + row.created_by as string, + new Date(row.created_at as string | Date), + new Date(row.updated_at as string | Date), + ); + } + + static fromJSON(json: unknown): AdversarialAgentEntity { + const obj = json as Record; + return new AdversarialAgentEntity( + obj.id as string, + obj.name as string, + obj.prompt as string, + obj.phases as string[], + Boolean(obj.critical ?? false), + obj.createdBy as string, + obj.createdAt ? new Date(obj.createdAt as string | Date) : new Date(), + obj.updatedAt ? new Date(obj.updatedAt as string | Date) : new Date(), + ); + } + + equals(other: AdversarialAgentEntity): boolean { + return ( + this.id === other.id && + this.name === other.name && + this.prompt === other.prompt && + JSON.stringify(this.phases) === JSON.stringify(other.phases) && + this.critical === other.critical && + this.createdBy === other.createdBy + ); + } + + toSnapshot(): AdversarialAgentSnapshot { + return { + id: this.id, + name: this.name, + prompt: this.prompt, + phases: this.phases, + critical: this.critical, + }; + } + + toString(): string { + return `AdversarialAgentEntity(${this.id}: ${this.name})`; + } +} diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts b/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts index 577006a2989..3eb66952625 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts @@ -26,6 +26,7 @@ export class ArtifactKind { static readonly MIGRATED_SOURCES = new ArtifactKind('migrated_sources'); static readonly PROJECT_METADATA = new ArtifactKind('project_metadata'); static readonly ANSIBLE_PROJECT = new ArtifactKind('ansible_project'); + static readonly ADVERSARIAL_REPORT = new ArtifactKind('adversarial_report'); private static readonly ALL = Object.freeze([ ArtifactKind.MIGRATION_PLAN, @@ -33,6 +34,7 @@ export class ArtifactKind { ArtifactKind.MIGRATED_SOURCES, ArtifactKind.PROJECT_METADATA, ArtifactKind.ANSIBLE_PROJECT, + ArtifactKind.ADVERSARIAL_REPORT, ]); private static readonly BY_VALUE = new Map( diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/Phase.test.ts b/workspaces/x2a/plugins/x2a-common/src/domain/Phase.test.ts index a3c6d51c1e6..cf8263e9b73 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/Phase.test.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/Phase.test.ts @@ -34,22 +34,32 @@ describe('Phase', () => { expect(Phase.from('publish')).toBe(Phase.PUBLISH); }); + it('returns Phase.ADVERSARIAL_ANALYZE for "adversarial-analyze"', () => { + expect(Phase.from('adversarial-analyze')).toBe(Phase.ADVERSARIAL_ANALYZE); + }); + + it('returns Phase.ADVERSARIAL_MIGRATE for "adversarial-migrate"', () => { + expect(Phase.from('adversarial-migrate')).toBe(Phase.ADVERSARIAL_MIGRATE); + }); + it('throws for an invalid phase', () => { expect(() => Phase.from('invalid')).toThrow( - 'Invalid migration phase: "invalid". Valid: init, analyze, migrate, publish', + 'Invalid migration phase: "invalid". Valid: init, analyze, migrate, publish, adversarial-analyze, adversarial-migrate', ); }); }); describe('all', () => { - it('returns 4 phases in ordinal order', () => { + it('returns all 6 phases in ordinal order', () => { const all = Phase.all(); - expect(all).toHaveLength(4); + expect(all).toHaveLength(6); expect(all).toEqual([ Phase.INIT, Phase.ANALYZE, Phase.MIGRATE, Phase.PUBLISH, + Phase.ADVERSARIAL_ANALYZE, + Phase.ADVERSARIAL_MIGRATE, ]); }); }); @@ -66,9 +76,34 @@ describe('Phase', () => { }); }); + describe('adversarialPhases', () => { + it('returns the two adversarial phases', () => { + expect(Phase.adversarialPhases()).toEqual([ + Phase.ADVERSARIAL_ANALYZE, + Phase.ADVERSARIAL_MIGRATE, + ]); + }); + }); + + describe('adversarialAgentPhaseValues', () => { + it('returns ["analyze", "migrate"]', () => { + expect(Phase.adversarialAgentPhaseValues()).toEqual([ + 'analyze', + 'migrate', + ]); + }); + }); + describe('values', () => { it('returns raw string values for all phases', () => { - expect(Phase.values()).toEqual(['init', 'analyze', 'migrate', 'publish']); + expect(Phase.values()).toEqual([ + 'init', + 'analyze', + 'migrate', + 'publish', + 'adversarial-analyze', + 'adversarial-migrate', + ]); }); }); @@ -102,6 +137,16 @@ describe('Phase', () => { expect(Phase.PUBLISH.isModulePhase()).toBe(true); expect(Phase.PUBLISH.isProjectPhase()).toBe(false); }); + + it('ADVERSARIAL_ANALYZE is a module phase', () => { + expect(Phase.ADVERSARIAL_ANALYZE.isModulePhase()).toBe(true); + expect(Phase.ADVERSARIAL_ANALYZE.isProjectPhase()).toBe(false); + }); + + it('ADVERSARIAL_MIGRATE is a module phase', () => { + expect(Phase.ADVERSARIAL_MIGRATE.isModulePhase()).toBe(true); + expect(Phase.ADVERSARIAL_MIGRATE.isProjectPhase()).toBe(false); + }); }); describe('ordinal', () => { @@ -110,6 +155,8 @@ describe('Phase', () => { expect(Phase.ANALYZE.ordinal).toBe(1); expect(Phase.MIGRATE.ordinal).toBe(2); expect(Phase.PUBLISH.ordinal).toBe(3); + expect(Phase.ADVERSARIAL_ANALYZE.ordinal).toBe(4); + expect(Phase.ADVERSARIAL_MIGRATE.ordinal).toBe(5); }); }); @@ -119,6 +166,8 @@ describe('Phase', () => { expect(Phase.ANALYZE.toString()).toBe('analyze'); expect(Phase.MIGRATE.toString()).toBe('migrate'); expect(Phase.PUBLISH.toString()).toBe('publish'); + expect(Phase.ADVERSARIAL_ANALYZE.toString()).toBe('adversarial-analyze'); + expect(Phase.ADVERSARIAL_MIGRATE.toString()).toBe('adversarial-migrate'); }); }); diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/Phase.ts b/workspaces/x2a/plugins/x2a-common/src/domain/Phase.ts index ddc7472cc6c..6269c331498 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/Phase.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/Phase.ts @@ -25,12 +25,18 @@ export class Phase { static readonly ANALYZE = new Phase('analyze', 1); static readonly MIGRATE = new Phase('migrate', 2); static readonly PUBLISH = new Phase('publish', 3); + static readonly ADVERSARIAL_ANALYZE = new Phase('adversarial-analyze', 4); + static readonly ADVERSARIAL_MIGRATE = new Phase('adversarial-migrate', 5); private static readonly BY_VALUE = new Map( - [Phase.INIT, Phase.ANALYZE, Phase.MIGRATE, Phase.PUBLISH].map(p => [ - p.value, - p, - ]), + [ + Phase.INIT, + Phase.ANALYZE, + Phase.MIGRATE, + Phase.PUBLISH, + Phase.ADVERSARIAL_ANALYZE, + Phase.ADVERSARIAL_MIGRATE, + ].map(p => [p.value, p]), ); private constructor( @@ -49,13 +55,28 @@ export class Phase { } static all(): readonly Phase[] { - return [Phase.INIT, Phase.ANALYZE, Phase.MIGRATE, Phase.PUBLISH]; + return [ + Phase.INIT, + Phase.ANALYZE, + Phase.MIGRATE, + Phase.PUBLISH, + Phase.ADVERSARIAL_ANALYZE, + Phase.ADVERSARIAL_MIGRATE, + ]; } static modulePhases(): readonly Phase[] { return [Phase.ANALYZE, Phase.MIGRATE, Phase.PUBLISH]; } + static adversarialPhases(): readonly Phase[] { + return [Phase.ADVERSARIAL_ANALYZE, Phase.ADVERSARIAL_MIGRATE]; + } + + static adversarialAgentPhaseValues(): readonly ('analyze' | 'migrate')[] { + return ['analyze', 'migrate']; + } + static values(): readonly MigrationPhase[] { return Phase.all().map(p => p.value); } diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/index.ts b/workspaces/x2a/plugins/x2a-common/src/domain/index.ts index d161cdbd3ee..0362d846598 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/index.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export { AdversarialAgentEntity } from './AdversarialAgent'; export { ArtifactKind } from './ArtifactKind'; export { GitRepository } from './GitRepository'; export { JobStatus } from './JobStatus'; diff --git a/workspaces/x2a/plugins/x2a-common/src/x2aArtifactTypeLiterals.ts b/workspaces/x2a/plugins/x2a-common/src/x2aArtifactTypeLiterals.ts index 663e37d3d3a..5b019f39394 100644 --- a/workspaces/x2a/plugins/x2a-common/src/x2aArtifactTypeLiterals.ts +++ b/workspaces/x2a/plugins/x2a-common/src/x2aArtifactTypeLiterals.ts @@ -26,6 +26,7 @@ export const X2A_ARTIFACT_TYPE_VALUES = [ 'migration_plan', 'module_migration_plan', 'migrated_sources', + 'adversarial_report', 'project_metadata', 'ansible_project', ] as const satisfies readonly ArtifactType[]; diff --git a/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts b/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts index 1a5f6e75698..f6d057e0c75 100644 --- a/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts +++ b/workspaces/x2a/plugins/x2a-mcp-extras/src/actions/createListModulesAction.ts @@ -40,7 +40,14 @@ export function buildListModulesOutputSchema(z: typeof zod) { .string() .optional() .describe('ISO 8601 when the job finished, if complete.'), - phase: z.enum(['init', 'analyze', 'migrate', 'publish']), + phase: z.enum([ + 'init', + 'analyze', + 'migrate', + 'publish', + 'adversarial-analyze', + 'adversarial-migrate', + ]), k8sJobName: z.string(), status: jobPhaseStatus, errorDetails: z.string().optional(), diff --git a/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts index 206677391b0..deec4a008d1 100644 --- a/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts @@ -29,9 +29,13 @@ import type { Telemetry, ProjectsGet, RuleSnapshot, + AdversarialAgentSnapshot, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; -import { RuleEntity } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { + RuleEntity, + AdversarialAgentEntity, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import type { CreateJobInput } from './types'; @@ -196,4 +200,41 @@ export interface X2ADatabaseServiceApi { getAcceptedRulesForProject(args: { projectId: string; }): Promise; + + // Adversarial Agents + + createAdversarialAgent(input: { + name: string; + prompt: string; + phases: string[]; + critical: boolean; + createdBy: string; + }): Promise; + + listAdversarialAgents(filters?: { + phase?: string; + }): Promise; + + getAdversarialAgent(opts: { + id: string; + }): Promise; + + updateAdversarialAgent(opts: { + id: string; + name: string; + prompt: string; + phases: string[]; + critical: boolean; + }): Promise; + + deleteAdversarialAgent(opts: { id: string }): Promise; + + attachAdversarialAgentsToProject(args: { + projectId: string; + agentIds: string[]; + }): Promise; + + getAdversarialAgentsForProject(args: { + projectId: string; + }): Promise; } diff --git a/workspaces/x2a/plugins/x2a-node/src/services/types.ts b/workspaces/x2a/plugins/x2a-node/src/services/types.ts index 05f2b1ffed2..46e15032fa7 100644 --- a/workspaces/x2a/plugins/x2a-node/src/services/types.ts +++ b/workspaces/x2a/plugins/x2a-node/src/services/types.ts @@ -20,6 +20,7 @@ import type { Artifact, SourceTechnology, RuleSnapshot, + AdversarialAgentSnapshot, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; /** @@ -33,6 +34,7 @@ export interface X2AConfig { namespace: string; image: string; imageTag: string; + imagePullPolicy?: string; ttlSecondsAfterFinished: number; resources: { requests: { @@ -119,5 +121,6 @@ export interface JobCreateParams { targetRepo: GitRepo; aapCredentials?: AAPCredentials; acceptedRules?: RuleSnapshot[]; + adversarialAgents?: AdversarialAgentSnapshot[]; refresh?: boolean; } diff --git a/workspaces/x2a/plugins/x2a/src/alpha.tsx b/workspaces/x2a/plugins/x2a/src/alpha.tsx index 4472a27448d..0c22426e11c 100644 --- a/workspaces/x2a/plugins/x2a/src/alpha.tsx +++ b/workspaces/x2a/plugins/x2a/src/alpha.tsx @@ -81,13 +81,32 @@ const rulesAcceptanceField = FormFieldBlueprint.make({ }, }); +const adversarialAgentsPickerField = FormFieldBlueprint.make({ + name: 'AdversarialAgentsPicker', + params: { + field: () => + import('./scaffolder').then(m => + createFormField({ + name: 'AdversarialAgentsPicker', + component: m.AdversarialAgentsPickerFieldExtension, + validation: async () => {}, + }), + ), + }, +}); + /** * The X2Ansible plugin for the new frontend system. * @alpha */ export default createFrontendPlugin({ pluginId: 'x2a', - extensions: [x2aPage, repoAuthField, rulesAcceptanceField], + extensions: [ + x2aPage, + repoAuthField, + rulesAcceptanceField, + adversarialAgentsPickerField, + ], routes: { root: rootRouteRef, }, diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.tsx new file mode 100644 index 00000000000..5842ae0bf50 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Page, Header, Content, EmptyState } from '@backstage/core-components'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { x2aAdminWritePermission } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { useTranslation } from '../../hooks/useTranslation'; +import { AdversarialAgentsTable } from './AdversarialAgentsTable'; + +export const AdversarialAgentsPage = () => { + const { t } = useTranslation(); + const { allowed, loading } = usePermission({ + permission: x2aAdminWritePermission, + }); + + if (loading) { + return ( + +
+ + + ); + } + + if (!allowed) { + return ( + +
+ + + + + ); + } + + return ( + +
+ + + + + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.tsx new file mode 100644 index 00000000000..70fdfd44e0f --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.tsx @@ -0,0 +1,234 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + Table, + TableColumn, + ResponseErrorPanel, +} from '@backstage/core-components'; +import { Box, Button, Chip } from '@material-ui/core'; +import DeleteIcon from '@material-ui/icons/Delete'; +import EditIcon from '@material-ui/icons/Edit'; +import type { AdversarialAgent } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { useClientService } from '../../ClientService'; +import { useTranslation } from '../../hooks/useTranslation'; +import { extractResponseError, isHttpSuccessResponse } from '../tools'; +import { DeleteAgentDialog } from './DeleteAgentDialog'; +import { AgentDialog } from './AgentDialog'; + +const EditIconComponent = () => ; +const DeleteIconComponent = () => ; + +export const AdversarialAgentsTable = () => { + const clientService = useClientService(); + const { t } = useTranslation(); + + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editTarget, setEditTarget] = useState( + undefined, + ); + const [deleteTarget, setDeleteTarget] = useState< + AdversarialAgent | undefined + >(undefined); + const [isDeleting, setIsDeleting] = useState(false); + + const fetchAgents = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await clientService.adversarialAgentsGet({ + query: {}, + }); + if (!isHttpSuccessResponse(response)) { + const message = await extractResponseError( + response, + t('adversarialAgentsPage.table.fetchError'), + ); + setError(new Error(message)); + return; + } + const data = await response.json(); + setAgents(data.agents ?? []); + } catch (e) { + setError(e as Error); + } finally { + setLoading(false); + } + }, [clientService, t]); + + useEffect(() => { + fetchAgents(); + }, [fetchAgents]); + + const handleOpenCreate = () => { + setEditTarget(undefined); + setDialogOpen(true); + }; + + const handleOpenEdit = (agent: AdversarialAgent) => { + setEditTarget(agent); + setDialogOpen(true); + }; + + const handleDialogClose = () => { + setDialogOpen(false); + setEditTarget(undefined); + }; + + const handleSaved = () => { + handleDialogClose(); + fetchAgents(); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + setIsDeleting(true); + try { + const response = await clientService.adversarialAgentsIdDelete({ + path: { id: deleteTarget.id }, + }); + if (!isHttpSuccessResponse(response)) { + const message = await extractResponseError( + response, + t('adversarialAgentsPage.deleteConfirm.deleteError'), + ); + setError(new Error(message)); + return; + } + setDeleteTarget(undefined); + fetchAgents(); + } catch (e) { + setError(e as Error); + } finally { + setIsDeleting(false); + } + }; + + const columns = useMemo( + (): TableColumn[] => [ + { + title: t('adversarialAgentsPage.table.name'), + field: 'name', + }, + { + title: t('adversarialAgentsPage.table.prompt'), + field: 'prompt', + render: (rowData: AdversarialAgent) => { + const prompt = rowData.prompt ?? ''; + return prompt.length > 100 ? `${prompt.slice(0, 100)}...` : prompt; + }, + }, + { + title: t('adversarialAgentsPage.table.phases'), + field: 'phases', + render: (rowData: AdversarialAgent) => ( + + {(rowData.phases ?? []).map(phase => ( + + ))} + + ), + }, + { + title: t('adversarialAgentsPage.table.severity'), + field: 'critical', + render: (rowData: AdversarialAgent) => + rowData.critical + ? t('adversarialAgentsPage.table.critical') + : t('adversarialAgentsPage.table.warning'), + }, + { + title: t('adversarialAgentsPage.table.createdAt'), + field: 'createdAt', + render: (rowData: AdversarialAgent) => + rowData.createdAt ? new Date(rowData.createdAt).toLocaleString() : '', + }, + ], + [t], + ); + + const actions = useMemo( + () => [ + (rowData: AdversarialAgent) => ({ + icon: EditIconComponent, + onClick: () => handleOpenEdit(rowData), + tooltip: t('adversarialAgentsPage.table.editAgent'), + }), + (rowData: AdversarialAgent) => ({ + icon: DeleteIconComponent, + onClick: () => setDeleteTarget(rowData), + tooltip: t('adversarialAgentsPage.table.deleteAgent'), + }), + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [t], + ); + + return ( + <> + {error && } + + + + + + + title={t('adversarialAgentsPage.title')} + columns={columns} + data={agents} + actions={actions} + isLoading={loading} + options={{ + search: false, + paging: true, + actionsColumnIndex: -1, + padding: 'default', + emptyRowsWhenPaging: false, + }} + emptyContent={t('adversarialAgentsPage.table.noAgents')} + /> + + + + setDeleteTarget(undefined)} + onConfirm={handleDeleteConfirm} + isDeleting={isDeleting} + agentName={deleteTarget?.name ?? ''} + /> + + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.tsx new file mode 100644 index 00000000000..110a946a367 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.tsx @@ -0,0 +1,241 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; + +import { ResponseErrorPanel } from '@backstage/core-components'; +import { + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + FormGroup, + FormLabel, + TextField, + Box, + Switch, +} from '@material-ui/core'; +import type { AdversarialAgent } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { useClientService } from '../../ClientService'; +import { useTranslation } from '../../hooks/useTranslation'; +import { extractResponseError, isHttpSuccessResponse } from '../tools'; + +interface AgentDialogProps { + open: boolean; + onClose: () => void; + onSaved: () => void; + agent?: AdversarialAgent; +} + +const PHASES = ['analyze', 'migrate'] as const; + +const PHASE_LABELS: Record<(typeof PHASES)[number], string> = { + analyze: 'adversarialAgentsPage.dialog.phaseAnalyze', + migrate: 'adversarialAgentsPage.dialog.phaseMigrate', +}; + +export const AgentDialog = ({ + open, + onClose, + onSaved, + agent, +}: AgentDialogProps) => { + const clientService = useClientService(); + const { t } = useTranslation(); + + const isEdit = !!agent; + + const [name, setName] = useState(''); + const [prompt, setPrompt] = useState(''); + const [phases, setPhases] = useState>(new Set()); + const [critical, setCritical] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setName(agent?.name ?? ''); + setPrompt(agent?.prompt ?? ''); + setPhases(new Set(agent?.phases ?? [])); + setCritical(agent?.critical ?? false); + setError(null); + } + }, [open, agent]); + + const handlePhaseToggle = (phase: string, checked: boolean) => { + const newPhases = new Set(phases); + if (checked) { + newPhases.add(phase); + } else { + newPhases.delete(phase); + } + setPhases(newPhases); + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + + try { + const body = { + name, + prompt, + phases: Array.from(phases) as any, + critical, + }; + + const response = isEdit + ? await clientService.adversarialAgentsIdPut({ + path: { id: agent!.id }, + body, + }) + : await clientService.adversarialAgentsPost({ + body, + }); + + if (!isHttpSuccessResponse(response)) { + const errorKey = isEdit + ? 'adversarialAgentsPage.dialog.updateError' + : 'adversarialAgentsPage.dialog.createError'; + const message = await extractResponseError(response, t(errorKey)); + setError(new Error(message)); + return; + } + + onSaved(); + } catch (e) { + setError(e as Error); + } finally { + setSaving(false); + } + }; + + const canSave = + name.trim().length >= 3 && + name.trim().length <= 100 && + prompt.trim().length >= 50 && + prompt.trim().length <= 5000 && + phases.size > 0; + + return ( + + + {isEdit + ? t('adversarialAgentsPage.dialog.editTitle') + : t('adversarialAgentsPage.dialog.createTitle')} + + + + {error && } + + + setName(e.target.value)} + fullWidth + required + error={name.length > 0 && (name.length < 3 || name.length > 100)} + helperText={ + name.length > 0 && (name.length < 3 || name.length > 100) + ? t('adversarialAgentsPage.dialog.nameValidation') + : '' + } + /> + + + + setPrompt(e.target.value)} + multiline + rows={6} + fullWidth + required + error={ + prompt.length > 0 && + (prompt.length < 50 || prompt.length > 5000) + } + helperText={`${prompt.length}/5000 characters (min 50)`} + /> + + + + + {t('adversarialAgentsPage.dialog.phasesField')} + + + {PHASES.map(phase => ( + handlePhaseToggle(phase, e.target.checked)} + /> + } + label={t(PHASE_LABELS[phase] as any, {})} + /> + ))} + + {phases.size === 0 && ( + + {t('adversarialAgentsPage.dialog.phasesValidation')} + + )} + + + + setCritical(e.target.checked)} + color="primary" + /> + } + label={t('adversarialAgentsPage.dialog.criticalField')} + /> + + {t('adversarialAgentsPage.dialog.criticalHelper')} + + + + + + + + + + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.tsx new file mode 100644 index 00000000000..1563654a1bd --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CircularProgress, + Typography, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@material-ui/core'; +import { useTranslation } from '../../hooks/useTranslation'; + +export const DeleteAgentDialog = ({ + onClose, + onConfirm, + open, + isDeleting, + agentName, +}: { + onClose: () => void; + onConfirm: () => void; + open: boolean; + isDeleting: boolean; + agentName: string; +}) => { + const { t } = useTranslation(); + + return ( + + + {t('adversarialAgentsPage.deleteConfirm.title' as any, { + name: agentName, + })} + + + + {t('adversarialAgentsPage.deleteConfirm.message')} + + + + + + + + + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/index.ts b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/index.ts new file mode 100644 index 00000000000..c4ef169f4a9 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AdversarialAgentsPage } from './AdversarialAgentsPage'; diff --git a/workspaces/x2a/plugins/x2a/src/components/CreateProjectPage/AdversarialAgentsSelector.tsx b/workspaces/x2a/plugins/x2a/src/components/CreateProjectPage/AdversarialAgentsSelector.tsx new file mode 100644 index 00000000000..11d212a8b80 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/CreateProjectPage/AdversarialAgentsSelector.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { Box, Chip, TextField, Typography } from '@material-ui/core'; +import { Alert, Autocomplete } from '@material-ui/lab'; +import type { AdversarialAgent } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { useClientService } from '../../ClientService'; +import { useTranslation } from '../../hooks/useTranslation'; +import { extractResponseError, isHttpSuccessResponse } from '../tools'; + +interface AdversarialAgentsSelectorProps { + selectedAgentIds: string[]; + onSelectionChange: (agentIds: string[]) => void; +} + +export const AdversarialAgentsSelector = ({ + selectedAgentIds, + onSelectionChange, +}: AdversarialAgentsSelectorProps) => { + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const client = useClientService(); + const { t } = useTranslation(); + + useEffect(() => { + const fetchAgents = async () => { + setLoading(true); + setError(null); + try { + const response = await client.adversarialAgentsGet({ query: {} }); + if (!isHttpSuccessResponse(response)) { + const message = await extractResponseError( + response, + t('createProjectPage.adversarialAgents.loadingError'), + ); + setError(message); + return; + } + const data = await response.json(); + setAgents(data.agents || []); + } catch { + setError(t('createProjectPage.adversarialAgents.loadingError')); + } finally { + setLoading(false); + } + }; + fetchAgents(); + }, [client, t]); + + if (error) { + return {error}; + } + + const selectedAgents = agents.filter(a => selectedAgentIds.includes(a.id)); + + return ( + agent.name} + getOptionSelected={(option, value) => option.id === value.id} + onChange={(_event, newValue) => { + onSelectionChange(newValue.map(a => a.id)); + }} + noOptionsText={t('createProjectPage.adversarialAgents.noAgentsAvailable')} + renderTags={(value, getTagProps) => + value.map((agent, index) => ( + + )) + } + renderOption={agent => ( + + + {agent.name} + {agent.critical && ( + + )} + + {agent.prompt && ( + + {agent.prompt.length > 120 + ? `${agent.prompt.substring(0, 120)}…` + : agent.prompt} + + )} + + )} + renderInput={params => ( + + )} + /> + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/CurrentPhaseCell.tsx b/workspaces/x2a/plugins/x2a/src/components/CurrentPhaseCell.tsx index f5b9979bad1..3731081f596 100644 --- a/workspaces/x2a/plugins/x2a/src/components/CurrentPhaseCell.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/CurrentPhaseCell.tsx @@ -18,7 +18,7 @@ import { MigrationPhase } from '@red-hat-developer-hub/backstage-plugin-x2a-comm import { useTranslation } from '../hooks/useTranslation'; -const phaseToStep: Record = { +const phaseToStep: Partial> = { init: 0, analyze: 1, migrate: 2, @@ -34,7 +34,8 @@ export const CurrentPhaseCell = ({ phase }: { phase?: MigrationPhase }) => { const stepNumber = phaseToStep[phase]; const phaseName = t(`module.phases.${phase}`); - const displayText = `${phaseName} (${stepNumber}/3)`; + const displayText = + stepNumber !== undefined ? `${phaseName} (${stepNumber}/3)` : phaseName; return
{displayText}
; }; diff --git a/workspaces/x2a/plugins/x2a/src/components/Dashboard/Dashboard.tsx b/workspaces/x2a/plugins/x2a/src/components/Dashboard/Dashboard.tsx index d91ebe95f43..232fa657994 100644 --- a/workspaces/x2a/plugins/x2a/src/components/Dashboard/Dashboard.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/Dashboard/Dashboard.tsx @@ -19,12 +19,13 @@ import { useRouteRef } from '@backstage/core-plugin-api'; import { usePermission } from '@backstage/plugin-permission-react'; import { x2aAdminWritePermission } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { useTranslation } from '../../hooks/useTranslation'; -import { rulesRouteRef } from '../../routes'; +import { rulesRouteRef, adversarialAgentsRouteRef } from '../../routes'; import { ProjectList } from '../ProjectList'; export const Dashboard = () => { const { t } = useTranslation(); const rulesPath = useRouteRef(rulesRouteRef); + const adversarialAgentsPath = useRouteRef(adversarialAgentsRouteRef); const { allowed: isAdmin, loading: permLoading } = usePermission({ permission: x2aAdminWritePermission, }); @@ -37,6 +38,13 @@ export const Dashboard = () => { {t('rulesPage.manageRules')} + + {t('adversarialAgentsPage.manageAdversarialAgents')} + )}
diff --git a/workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx b/workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx new file mode 100644 index 00000000000..681b92a2a39 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx @@ -0,0 +1,259 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useMemo, useState } from 'react'; + +import { LogViewer, Progress } from '@backstage/core-components'; +import { + Box, + Button, + Divider, + Grid, + Typography, + makeStyles, +} from '@material-ui/core'; +import { + ArtifactKind, + Job, + MigrationPhase, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { useLogStream } from '../../hooks/useLogStream'; +import { useClientService } from '../../ClientService'; +import { ItemField } from '../ItemField'; +import { PhaseStatus } from '../PhaseStatus'; +import { PhaseTelemetry } from '../PhaseTelemetry'; +import { ArtifactLink } from '../ArtifactLink'; +import { + downloadLogFile, + formatDuration, + getEffectiveDurationSeconds, + humanizeDate, + secondsBetween, +} from '../tools'; + +const useStyles = makeStyles(theme => ({ + logViewerWrapper: { + height: 400, + '& a[role="row"]': { + userSelect: 'none', + }, + }, + sectionTitle: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), + }, +})); + +export const AdversarialJobDetails = ({ + job, + projectId, + moduleId, + phaseName, + targetRepoUrl, + targetRepoBranch, +}: { + job?: Job; + projectId: string; + moduleId: string; + phaseName: MigrationPhase; + targetRepoUrl: string; + targetRepoBranch: string; +}) => { + const { t } = useTranslation(); + const classes = useStyles(); + const clientService = useClientService(); + const empty = t('module.phases.none'); + const [showLog, setShowLog] = useState(false); + + const fetchLog = useCallback( + () => + clientService.projectsProjectIdModulesModuleIdLogGet({ + path: { projectId, moduleId }, + query: { phase: phaseName, streaming: true }, + }), + [clientService, projectId, moduleId, phaseName], + ); + + const { logText, logStreamHasData, logLoading, logError } = useLogStream({ + enabled: showLog && !!job, + phaseId: job?.id, + phaseStatus: job?.status, + projectId, + moduleId, + phaseName, + fetchLog, + }); + + const logViewerText = useMemo((): string => { + if (logStreamHasData) { + return logText ?? ''; + } + if (logLoading) { + return t('modulePage.phases.logWaitingForStream'); + } + return logText || t('modulePage.phases.noLogsAvailable'); + }, [logStreamHasData, logText, logLoading, t]); + + if (!job) return null; + + const reportArtifact = job.artifacts?.find(a => + ArtifactKind.from(a.type).equals(ArtifactKind.ADVERSARIAL_REPORT), + ); + + const durationSeconds = getEffectiveDurationSeconds(job); + const duration = + durationSeconds === undefined ? empty : formatDuration(t, durationSeconds); + + const attemptCount = job.attemptCount ?? 1; + const totalDuration = + attemptCount > 1 && job.firstAttemptAt && job.finishedAt + ? formatDuration(t, secondsBetween(job.firstAttemptAt, job.finishedAt)) + : undefined; + + return ( + + + + + {t('modulePage.phases.adversarialReview')} + + + + } + /> + + + + + + + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + {/* space holder */} + + + + + + + {showLog && ( + + {logLoading && } + {logError && ( + {logError.message} + )} + {logText !== undefined && ( +
+ + downloadLogFile( + logText || '', + `${phaseName}-${projectId}`, + ) + } + /> +
+ )} +
+ )} + + {job.telemetry && ( + <> + + + {t('modulePage.phases.telemetry.title')} + + + + + + + )} +
+
+
+ ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx b/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx index d588c063303..20a1228a4c1 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx @@ -26,6 +26,7 @@ import { Box, Grid } from '@material-ui/core'; import { resolveScmProvider, MigrationPhase, + ModulePhase, Module, Project, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; @@ -83,8 +84,7 @@ export const ModulePage = () => { const handleRunPhase = useCallback( async (phase: MigrationPhase) => { - if (!project || phase === 'init') { - // The init phase belongs to the project's page + if (!project) { return; } setError(undefined); @@ -111,7 +111,7 @@ export const ModulePage = () => { await clientService.projectsProjectIdModulesModuleIdRunPost({ path: { projectId, moduleId }, body: { - phase, + phase: phase as ModulePhase, sourceRepoAuth: { token: sourceRepoAuthToken }, targetRepoAuth: { token: targetRepoAuthToken }, }, @@ -143,10 +143,62 @@ export const ModulePage = () => { ], ); + const handleRunAdversarial = useCallback( + async (phase: 'analyze' | 'migrate') => { + if (!project) return; + setError(undefined); + + try { + const targetRepoAuthToken = ( + await repoAuthentication.authenticate([ + resolveScmProvider( + project.targetRepoUrl, + hostMap, + ).getAuthTokenDescriptor(false), + ]) + )[0].token; + + const response = + await clientService.projectsProjectIdAdversarialRunPost({ + path: { projectId }, + body: { + phase, + moduleId, + targetRepoAuth: { token: targetRepoAuthToken }, + }, + }); + + if (response.status !== 202) { + const body = (await response.json().catch(() => ({}))) as { + message?: string; + }; + setError(body.message || t('modulePage.phases.adversarialRunError')); + } + + refetch(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t('modulePage.phases.adversarialRunError'), + ); + } + }, + [ + clientService, + t, + projectId, + moduleId, + project, + repoAuthentication, + hostMap, + refetch, + ], + ); + const handleCancelPhase = useCallback( async (phase: MigrationPhase) => { - if (!project || phase === 'init') { - // The init phase belongs to the project's page + if (!project) { return; } setError(undefined); @@ -155,7 +207,7 @@ export const ModulePage = () => { const response = await clientService.projectsProjectIdModulesModuleIdCancelPost({ path: { projectId, moduleId }, - body: { phase }, + body: { phase: phase as ModulePhase }, }); if (response.status !== 200) { const body = await response @@ -224,6 +276,7 @@ export const ModulePage = () => { moduleId={moduleId} onRunPhase={handleRunPhase} onCancelPhase={handleCancelPhase} + onRunAdversarial={handleRunAdversarial} activeTab={activeTab} handleTabChange={handleTabChange} /> diff --git a/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx b/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx index f56425782bd..8985a975b01 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx @@ -26,6 +26,7 @@ import { useTranslation } from '../../hooks/useTranslation'; import { PhaseDetails } from '../PhaseDetails'; import { PhaseStatusIcon } from '../PhaseStatus'; import { hasPhasePrerequisites } from '../tools'; +import { AdversarialJobDetails } from './AdversarialJobDetails'; const useStyles = makeStyles(theme => ({ tabs: { @@ -77,6 +78,7 @@ export const PhasesCard = ({ handleTabChange, onRunPhase, onCancelPhase, + onRunAdversarial, }: { module?: Module; project?: Project; @@ -86,6 +88,7 @@ export const PhasesCard = ({ handleTabChange: (event: React.ChangeEvent<{}>, newValue: number) => void; onRunPhase?: (phase: MigrationPhase) => void; onCancelPhase?: (phase: MigrationPhase) => void; + onRunAdversarial?: (phase: 'analyze' | 'migrate') => void; }) => { const { t } = useTranslation(); const classes = useStyles(); @@ -93,6 +96,8 @@ export const PhasesCard = ({ const analyzePhase = module?.analyze; const migratePhase = module?.migrate; const publishPhase = module?.publish; + const adversarialAnalyzePhase = module?.adversarialAnalyze; + const adversarialMigratePhase = module?.adversarialMigrate; return ( @@ -151,6 +156,15 @@ export const PhasesCard = ({ moduleId={moduleId} onRunPhase={onRunPhase} onCancelPhase={onCancelPhase} + onRunAdversarial={onRunAdversarial} + /> + @@ -161,6 +175,15 @@ export const PhasesCard = ({ moduleId={moduleId} onRunPhase={onRunPhase} onCancelPhase={onCancelPhase} + onRunAdversarial={onRunAdversarial} + /> + diff --git a/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx index 27a42530607..54757f728cb 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModuleTable/ModuleTable.tsx @@ -18,6 +18,7 @@ import { Artifact, resolveScmProvider, Module, + ModulePhase, Project, Job, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; @@ -216,7 +217,7 @@ export const ModuleTable = ({ const response = await clientService.projectsProjectIdModulesModuleIdCancelPost({ path: { projectId: lastJob.projectId, moduleId: lastJob.moduleId }, - body: { phase: lastJob.phase }, + body: { phase: lastJob.phase as ModulePhase }, }); if (response.status !== 200) { const body = await response diff --git a/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx b/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx index 0160387789a..8bc868222cb 100644 --- a/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx @@ -169,6 +169,7 @@ export const PhaseDetails = ( projectId: string; onRunPhase?: (phase: MigrationPhase) => void; onCancelPhase?: (phase: MigrationPhase) => void; + onRunAdversarial?: (phase: 'analyze' | 'migrate') => void; } & OptionalModuleId, ) => { const { t } = useTranslation(); @@ -177,7 +178,14 @@ export const PhaseDetails = ( const empty = t('module.phases.none'); const [showLog, setShowLog] = useState(false); - const { phase, projectId, phaseName, onRunPhase, onCancelPhase } = props; + const { + phase, + projectId, + phaseName, + onRunPhase, + onCancelPhase, + onRunAdversarial, + } = props; const moduleId = 'moduleId' in props ? props.moduleId : undefined; const durationSeconds = phase @@ -246,6 +254,24 @@ export const PhaseDetails = ( onCancelPhase={onCancelPhase} /> )} + {onRunAdversarial && + (phaseName === 'analyze' || phaseName === 'migrate') && + phase?.status === 'success' && ( + <> + + + {t('modulePage.phases.adversarialReviewInstructions')} + + + )} diff --git a/workspaces/x2a/plugins/x2a/src/components/Router.tsx b/workspaces/x2a/plugins/x2a/src/components/Router.tsx index c02425acef7..b5288ef15aa 100644 --- a/workspaces/x2a/plugins/x2a/src/components/Router.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/Router.tsx @@ -15,6 +15,7 @@ */ import { Route, Routes } from 'react-router-dom'; +import { AdversarialAgentsPage } from './AdversarialAgentsPage'; import { Dashboard } from './Dashboard'; import { DownloadStaticPublicFile } from './DownloadStaticPublicFile'; import { ModulePage } from './ModulePage'; @@ -24,6 +25,7 @@ import { moduleRouteRef, projectRouteRef, rulesRouteRef, + adversarialAgentsRouteRef, } from '../routes'; import { ProjectPage } from './ProjectPage'; @@ -38,6 +40,10 @@ export const Router = () => { } /> } /> } /> + } + /> } /> ); diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.ts b/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.ts index c6d16f41ee9..451333db730 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.ts @@ -17,24 +17,27 @@ import { MigrationPhase, Module, ModulePhase, + Phase, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { getLastPhaseReached } from './getLastPhaseReached'; -const nextPhases: Record = { +const nextPhases: Partial> = { init: 'analyze', analyze: 'migrate', migrate: 'publish', - publish: undefined, }; export const getNextPhase = (module: Module): ModulePhase | undefined => { const lastJob = getLastPhaseReached(module, true); const lastPhase: MigrationPhase = lastJob?.phase || 'init'; - if (lastJob?.status === 'error' && lastPhase !== 'init') { - // If the last is in error, let the user to rerun it. - return lastPhase; + if ( + lastJob?.status === 'error' && + Phase.modulePhaseValues().includes(lastPhase as ModulePhase) + ) { + // If the last regular phase is in error, let the user rerun it. + return lastPhase as ModulePhase; } return nextPhases[lastPhase]; diff --git a/workspaces/x2a/plugins/x2a/src/index.ts b/workspaces/x2a/plugins/x2a/src/index.ts index 00210f43f53..409637176bb 100644 --- a/workspaces/x2a/plugins/x2a/src/index.ts +++ b/workspaces/x2a/plugins/x2a/src/index.ts @@ -18,6 +18,7 @@ export { X2APage, RepoAuthenticationExtension, RulesAcceptanceExtension, + AdversarialAgentsPickerExtension, } from './plugin'; export { x2aPluginTranslations, x2aPluginTranslationRef } from './translations'; export { diff --git a/workspaces/x2a/plugins/x2a/src/plugin.ts b/workspaces/x2a/plugins/x2a/src/plugin.ts index c033a116fca..be820043eb7 100644 --- a/workspaces/x2a/plugins/x2a/src/plugin.ts +++ b/workspaces/x2a/plugins/x2a/src/plugin.ts @@ -25,6 +25,7 @@ import { repoAuthenticationValidation, RulesAcceptance, rulesAcceptanceValidation, + AdversarialAgentsPickerFieldExtension, } from './scaffolder'; /** @public */ @@ -61,3 +62,11 @@ export const RulesAcceptanceExtension = x2APlugin.provide( validation: rulesAcceptanceValidation, }), ); + +/** @public */ +export const AdversarialAgentsPickerExtension = x2APlugin.provide( + createScaffolderFieldExtension({ + component: AdversarialAgentsPickerFieldExtension, + name: 'AdversarialAgentsPicker', // name used in ui:field in templates + }), +); diff --git a/workspaces/x2a/plugins/x2a/src/routes.ts b/workspaces/x2a/plugins/x2a/src/routes.ts index 32fe8211e63..2a67390abbc 100644 --- a/workspaces/x2a/plugins/x2a/src/routes.ts +++ b/workspaces/x2a/plugins/x2a/src/routes.ts @@ -39,6 +39,12 @@ export const rulesRouteRef = createSubRouteRef({ path: '/rules', }); +export const adversarialAgentsRouteRef = createSubRouteRef({ + id: 'x2a.adversarial-agents', + parent: rootRouteRef, + path: '/adversarial-agents', +}); + export const downloadRouteRef = createSubRouteRef({ id: 'x2a.download', parent: rootRouteRef, diff --git a/workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/AdversarialAgentsPickerFieldExtension.tsx b/workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/AdversarialAgentsPickerFieldExtension.tsx new file mode 100644 index 00000000000..50d828f50a1 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/AdversarialAgentsPickerFieldExtension.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react'; +import { AdversarialAgentsSelector } from '../../components/CreateProjectPage/AdversarialAgentsSelector'; + +/** @public */ +export const AdversarialAgentsPickerFieldExtension = ( + props: FieldExtensionComponentProps, +) => { + const { onChange, rawErrors, formData } = props; + + return ( + <> + + {rawErrors && rawErrors.length > 0 && ( +
+ {rawErrors.join(', ')} +
+ )} + + ); +}; diff --git a/workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/index.ts b/workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/index.ts new file mode 100644 index 00000000000..2d58ae77d49 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/scaffolder/AdversarialAgentsPickerFieldExtension/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AdversarialAgentsPickerFieldExtension } from './AdversarialAgentsPickerFieldExtension'; diff --git a/workspaces/x2a/plugins/x2a/src/scaffolder/index.ts b/workspaces/x2a/plugins/x2a/src/scaffolder/index.ts index d8e3b7e9e36..b51c8d9c15e 100644 --- a/workspaces/x2a/plugins/x2a/src/scaffolder/index.ts +++ b/workspaces/x2a/plugins/x2a/src/scaffolder/index.ts @@ -14,3 +14,4 @@ */ export * from './RepoAuthentication'; export { RulesAcceptance, rulesAcceptanceValidation } from './RulesAcceptance'; +export { AdversarialAgentsPickerFieldExtension } from './AdversarialAgentsPickerFieldExtension'; diff --git a/workspaces/x2a/plugins/x2a/src/translations/de.ts b/workspaces/x2a/plugins/x2a/src/translations/de.ts index 06618f20b6f..f9370246e4f 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/de.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/de.ts @@ -107,6 +107,8 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'module.phases.analyze': 'Analysieren', 'module.phases.migrate': 'Migrieren', 'module.phases.publish': 'Veröffentlichen', + 'module.phases.adversarial-analyze': 'Adversarielle Analyse', + 'module.phases.adversarial-migrate': 'Adversarielle Migration', 'module.summary.total': 'Gesamt', 'module.summary.finished': 'Abgeschlossen', 'module.summary.waiting': 'Wartend', diff --git a/workspaces/x2a/plugins/x2a/src/translations/es.ts b/workspaces/x2a/plugins/x2a/src/translations/es.ts index 66af538d0f2..ba227862d60 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/es.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/es.ts @@ -108,6 +108,8 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'module.phases.analyze': 'Analizar', 'module.phases.migrate': 'Migrar', 'module.phases.publish': 'Publicar', + 'module.phases.adversarial-analyze': 'Análisis Adversarial', + 'module.phases.adversarial-migrate': 'Migración Adversarial', 'module.summary.total': 'Total', 'module.summary.finished': 'Finalizado', 'module.summary.waiting': 'En espera', diff --git a/workspaces/x2a/plugins/x2a/src/translations/fr.ts b/workspaces/x2a/plugins/x2a/src/translations/fr.ts index d8600913cba..b17319ab5ea 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/fr.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/fr.ts @@ -109,6 +109,8 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'module.phases.analyze': 'Analyser', 'module.phases.migrate': 'Migrer', 'module.phases.publish': 'Publier', + 'module.phases.adversarial-analyze': 'Analyse Adversariale', + 'module.phases.adversarial-migrate': 'Migration Adversariale', 'module.summary.total': 'Total', 'module.summary.finished': 'Terminé', 'module.summary.waiting': 'En attente', diff --git a/workspaces/x2a/plugins/x2a/src/translations/it.ts b/workspaces/x2a/plugins/x2a/src/translations/it.ts index ef7f52a4a11..87ee5c4b7ae 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/it.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/it.ts @@ -108,6 +108,8 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'module.phases.analyze': 'Analizzare', 'module.phases.migrate': 'Migrare', 'module.phases.publish': 'Pubblicare', + 'module.phases.adversarial-analyze': 'Analisi Avversariale', + 'module.phases.adversarial-migrate': 'Migrazione Avversariale', 'module.summary.total': 'Totale', 'module.summary.finished': 'Completato', 'module.summary.waiting': 'In attesa', diff --git a/workspaces/x2a/plugins/x2a/src/translations/ref.ts b/workspaces/x2a/plugins/x2a/src/translations/ref.ts index 38788fa0a18..59558240505 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/ref.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/ref.ts @@ -49,6 +49,21 @@ export const x2aPluginMessages = { projectTable: { deleteError: 'Failed to delete project', }, + createProjectPage: { + adversarialAgents: { + loading: 'Loading adversarial agents...', + title: 'Adversarial Agents', + subtitle: + 'Select AI agents to review migration outputs for security, functional gaps, and correctness', + placeholder: 'Choose agents…', + selected: 'selected', + noAgentsAvailable: + 'No adversarial agents available. Create agents in the Adversarial Agents page.', + loadingError: 'Failed to load adversarial agents', + tooltip: + 'Adversarial agents review migration outputs for security issues, functional gaps, and correctness problems', + }, + }, projectDetailsCard: { title: 'Project Details', name: 'Name', @@ -132,7 +147,12 @@ export const x2aPluginMessages = { 'The module has already been published. Retrigger the publish to update the target repository.', rerunPublish: 'Republish to target repository', cancel: 'Cancel', + runAdversarialReview: 'Run Adversarial Review', + adversarialReview: 'Adversarial Review', + adversarialReviewInstructions: + 'Run configured adversarial agents against the phase output. Agents will write a report to the target repository.', runError: 'Failed to run phase for module', + adversarialRunError: 'Failed to start adversarial review', cancelError: 'Failed to cancel phase for module', attempts: 'Attempts', totalElapsed: 'Total Elapsed', @@ -261,6 +281,8 @@ export const x2aPluginMessages = { analyze: 'Analyze', migrate: 'Migrate', publish: 'Publish', + 'adversarial-analyze': 'Adversarial Analyze', + 'adversarial-migrate': 'Adversarial Migrate', }, summary: { total: 'Total', @@ -318,6 +340,7 @@ export const x2aPluginMessages = { migrated_sources: 'Migrated Sources', project_metadata: 'Project Metadata', ansible_project: 'AAP Project', + adversarial_report: 'Adversarial Report', }, }, time: { @@ -388,6 +411,58 @@ export const x2aPluginMessages = { updateError: 'Failed to update rule', }, }, + adversarialAgentsPage: { + title: 'Adversarial Agents', + subtitle: + 'Manage AI agents that review migration outputs for security, functional gaps, and correctness issues.', + manageAdversarialAgents: 'Manage Adversarial Agents', + addAgent: 'Add Agent', + notAllowed: 'You do not have permission to manage adversarial agents.', + table: { + name: 'Name', + prompt: 'Prompt', + phases: 'Phases', + severity: 'Severity', + critical: 'Critical', + warning: 'Warning', + createdAt: 'Created', + createdBy: 'Created By', + editAgent: 'Edit agent', + deleteAgent: 'Delete agent', + noAgents: 'No adversarial agents defined yet.', + fetchError: 'Failed to fetch adversarial agents', + }, + deleteConfirm: { + title: 'Delete agent "{{name}}"?', + message: 'This action cannot be undone.', + confirm: 'Delete', + cancel: 'Cancel', + deleteError: 'Failed to delete agent', + }, + dialog: { + createTitle: 'Create Adversarial Agent', + editTitle: 'Edit Adversarial Agent', + nameField: 'Name', + namePlaceholder: 'e.g., Privilege Escalation Check', + promptField: 'Prompt', + promptPlaceholder: 'Describe what this agent should check for...', + promptHelper: + 'Be specific about what to look for and how to report findings (50-5000 characters)', + phasesField: 'Workflow Phases', + phasesHelper: 'Select which workflow phases this agent runs in', + phaseAnalyze: 'Analyze', + phaseMigrate: 'Migrate', + criticalField: 'Critical Agent', + criticalHelper: + 'Critical agents produce critical-severity findings; non-critical agents produce warnings', + nameValidation: 'Name must be between 3 and 100 characters', + phasesValidation: 'At least one phase is required', + save: 'Save', + cancel: 'Cancel', + createError: 'Failed to create agent', + updateError: 'Failed to update agent', + }, + }, empty: '-', }; From f85b938ba94f0e9091226842cf0799b3e90f9d68 Mon Sep 17 00:00:00 2001 From: yray Date: Thu, 6 Aug 2026 18:56:57 +0300 Subject: [PATCH 2/6] fix(x2a): fix CI failures, add UI tests and translations for adversarial agents --- ...070810_create_adversarial_agents_table.ts} | 0 .../src/router/adversarialAgents.ts | 4 +- .../x2a-backend/src/router/projects.ts | 10 +- .../src/domain/AdversarialAgent.test.ts | 2 +- .../src/domain/ArtifactKind.test.ts | 8 +- .../AdversarialAgentsPage.test.tsx | 68 +++++++ .../AdversarialAgentsTable.test.tsx | 164 +++++++++++++++++ .../AgentDialog.test.tsx | 173 ++++++++++++++++++ .../DeleteAgentDialog.test.tsx | 68 +++++++ .../x2a/plugins/x2a/src/translations/de.ts | 80 ++++++++ .../x2a/plugins/x2a/src/translations/es.ts | 75 ++++++++ .../x2a/plugins/x2a/src/translations/fr.ts | 78 ++++++++ .../x2a/plugins/x2a/src/translations/it.ts | 78 ++++++++ 13 files changed, 801 insertions(+), 7 deletions(-) rename workspaces/x2a/plugins/x2a-backend/migrations/{202607081000_create_adversarial_agents_table.ts => 2026070810_create_adversarial_agents_table.ts} (100%) create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.test.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx create mode 100644 workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.test.tsx diff --git a/workspaces/x2a/plugins/x2a-backend/migrations/202607081000_create_adversarial_agents_table.ts b/workspaces/x2a/plugins/x2a-backend/migrations/2026070810_create_adversarial_agents_table.ts similarity index 100% rename from workspaces/x2a/plugins/x2a-backend/migrations/202607081000_create_adversarial_agents_table.ts rename to workspaces/x2a/plugins/x2a-backend/migrations/2026070810_create_adversarial_agents_table.ts diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts b/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts index b6641e95157..dee420ec7cd 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/adversarialAgents.ts @@ -86,7 +86,7 @@ export function registerAdversarialAgentRoutes( const createAgentSchema = z.object({ name: z.string().min(3).max(100), prompt: z.string().min(50).max(5000), - phases: z.array(z.string()).min(1), + phases: z.array(z.enum(['analyze', 'migrate'])).min(1), critical: z.boolean(), }); @@ -129,7 +129,7 @@ export function registerAdversarialAgentRoutes( const updateAgentSchema = z.object({ name: z.string().min(3).max(100), prompt: z.string().min(50).max(5000), - phases: z.array(z.string()).min(1), + phases: z.array(z.enum(['analyze', 'migrate'])).min(1), critical: z.boolean(), }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts index 5ff98aa9207..baad00f7cca 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts @@ -527,11 +527,19 @@ export function registerProjectRoutes( projectId, moduleId, }); - const activeAdversarialJob = existingJobs.find( + const activeAdversarialJobs = existingJobs.filter( j => j.phase === adversarialPhase.value && JobStatus.from(j.status).isActive(), ); + const reconciledJobs = await Promise.all( + activeAdversarialJobs.map(job => + reconcileJobStatus(job, { kubeService, x2aDatabase, logger }), + ), + ); + const activeAdversarialJob = reconciledJobs.find(job => + JobStatus.from(job.status).isActive(), + ); if (activeAdversarialJob) { return res.status(409).json({ error: 'JobAlreadyRunning', diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts b/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts index c331afb666d..3dbf965cf81 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/AdversarialAgent.test.ts @@ -19,7 +19,7 @@ import { AdversarialAgentEntity } from './AdversarialAgent'; const VALID_PROMPT = 'Review the migration output for security vulnerabilities, privilege escalation, and correctness issues in the generated Ansible playbooks.'; -const makeEntity = (overrides = {}) => { +const makeEntity = (overrides: Partial = {}) => { const now = new Date('2025-01-01T00:00:00Z'); return new AdversarialAgentEntity( overrides.id ?? 'aaaaaaaa-0000-0000-0000-000000000001', diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.test.ts b/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.test.ts index 5af0d14aeac..a7bd5cb7b31 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.test.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.test.ts @@ -50,21 +50,22 @@ describe('ArtifactKind', () => { it('throws for an invalid type', () => { expect(() => ArtifactKind.from('invalid')).toThrow( - 'Invalid artifact type: "invalid". Valid: migration_plan, module_migration_plan, migrated_sources, project_metadata, ansible_project', + 'Invalid artifact type: "invalid". Valid: migration_plan, module_migration_plan, migrated_sources, adversarial_report, project_metadata, ansible_project', ); }); }); describe('all', () => { - it('returns 5 kinds in defined order', () => { + it('returns 6 kinds in defined order', () => { const all = ArtifactKind.all(); - expect(all).toHaveLength(5); + expect(all).toHaveLength(6); expect(all).toEqual([ ArtifactKind.MIGRATION_PLAN, ArtifactKind.MODULE_MIGRATION_PLAN, ArtifactKind.MIGRATED_SOURCES, ArtifactKind.PROJECT_METADATA, ArtifactKind.ANSIBLE_PROJECT, + ArtifactKind.ADVERSARIAL_REPORT, ]); }); }); @@ -75,6 +76,7 @@ describe('ArtifactKind', () => { 'migration_plan', 'module_migration_plan', 'migrated_sources', + 'adversarial_report', 'project_metadata', 'ansible_project', ]); diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.test.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.test.tsx new file mode 100644 index 00000000000..3b5e51f7700 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsPage.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockUseTranslation } from '../../test-utils/mockTranslations'; + +const mockUsePermission = jest.fn(); + +jest.mock('@backstage/plugin-permission-react', () => ({ + usePermission: () => mockUsePermission(), +})); + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: mockUseTranslation, +})); + +jest.mock('@backstage/core-components', () => ({ + Page: ({ children }: any) =>
{children}
, + Header: ({ title }: any) =>
{title}
, + Content: ({ children }: any) =>
{children}
, + EmptyState: ({ title }: any) =>
{title}
, +})); + +jest.mock('./AdversarialAgentsTable', () => ({ + AdversarialAgentsTable: () =>
, +})); + +import { render, screen } from '@testing-library/react'; +import { AdversarialAgentsPage } from './AdversarialAgentsPage'; + +describe('AdversarialAgentsPage', () => { + it('renders page shell while permission is loading', () => { + mockUsePermission.mockReturnValue({ allowed: false, loading: true }); + render(); + expect(screen.getByTestId('page')).toBeInTheDocument(); + expect( + screen.queryByTestId('adversarial-agents-table'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + }); + + it('renders empty state when user does not have write permission', () => { + mockUsePermission.mockReturnValue({ allowed: false, loading: false }); + render(); + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + expect( + screen.queryByTestId('adversarial-agents-table'), + ).not.toBeInTheDocument(); + }); + + it('renders the agents table when user has write permission', () => { + mockUsePermission.mockReturnValue({ allowed: true, loading: false }); + render(); + expect(screen.getByTestId('adversarial-agents-table')).toBeInTheDocument(); + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + }); +}); diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx new file mode 100644 index 00000000000..e73432e495f --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockUseTranslation } from '../../test-utils/mockTranslations'; + +const mockAdversarialAgentsGet = jest.fn(); +const clientServiceMock = { + adversarialAgentsGet: mockAdversarialAgentsGet, +}; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: mockUseTranslation, +})); + +jest.mock('../../ClientService', () => ({ + useClientService: () => clientServiceMock, +})); + +jest.mock('../tools', () => ({ + isHttpSuccessResponse: (r: any) => r?.ok === true, + extractResponseError: async (_r: any, fallback: string) => fallback, +})); + +jest.mock('@backstage/core-components', () => ({ + Table: ({ data, isLoading, emptyContent }: any) => { + if (isLoading) return
; + if (!data?.length) + return
{emptyContent}
; + return ( +
+ {data.map((agent: any) => ( +
+ {agent.name} +
+ ))} +
+ ); + }, + ResponseErrorPanel: ({ error }: any) => ( +
{error?.message}
+ ), +})); + +jest.mock('./AgentDialog', () => ({ + AgentDialog: ({ open }: any) => + open ?
: null, +})); + +jest.mock('./DeleteAgentDialog', () => ({ + DeleteAgentDialog: ({ open }: any) => + open ?
: null, +})); + +import { render, screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AdversarialAgentsTable } from './AdversarialAgentsTable'; + +const VALID_PROMPT = + 'Review the migration output for security vulnerabilities and correctness issues in the generated Ansible playbooks.'; + +const mockAgents = [ + { + id: 'agent-1', + name: 'Security Checker', + prompt: VALID_PROMPT, + phases: ['analyze'], + critical: false, + createdAt: new Date('2025-01-01').toISOString(), + createdBy: 'user:default/admin', + }, + { + id: 'agent-2', + name: 'Privilege Guard', + prompt: VALID_PROMPT, + phases: ['migrate'], + critical: true, + createdAt: new Date('2025-02-01').toISOString(), + createdBy: 'user:default/alice', + }, +]; + +const successResponse = (agents: any[]) => ({ + ok: true, + status: 200, + json: async () => ({ agents, total: agents.length }), +}); + +describe('AdversarialAgentsTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows loading state on initial fetch', async () => { + mockAdversarialAgentsGet.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByTestId('table-loading')).toBeInTheDocument(); + }); + + it('renders agents after successful fetch', async () => { + mockAdversarialAgentsGet.mockResolvedValue(successResponse(mockAgents)); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(screen.getByText('Security Checker')).toBeInTheDocument(); + expect(screen.getByText('Privilege Guard')).toBeInTheDocument(); + }); + }); + + it('shows empty state when no agents exist', async () => { + mockAdversarialAgentsGet.mockResolvedValue(successResponse([])); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(screen.getByTestId('table-empty')).toBeInTheDocument(); + }); + }); + + it('shows error panel when fetch fails', async () => { + mockAdversarialAgentsGet.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ message: 'Internal server error' }), + }); + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(screen.getByTestId('error-panel')).toBeInTheDocument(); + }); + }); + + it('opens create dialog when add button is clicked', async () => { + mockAdversarialAgentsGet.mockResolvedValue(successResponse([])); + + await act(async () => { + render(); + }); + + await waitFor(() => screen.getByTestId('table-empty')); + + await userEvent.click(screen.getByRole('button', { name: /add agent/i })); + expect(screen.getByTestId('agent-dialog')).toBeInTheDocument(); + }); +}); diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx new file mode 100644 index 00000000000..1bf81708bfc --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx @@ -0,0 +1,173 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockUseTranslation } from '../../test-utils/mockTranslations'; + +const mockAdversarialAgentsPost = jest.fn(); +const mockAdversarialAgentsIdPut = jest.fn(); +const clientServiceMock = { + adversarialAgentsPost: mockAdversarialAgentsPost, + adversarialAgentsIdPut: mockAdversarialAgentsIdPut, +}; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: mockUseTranslation, +})); + +jest.mock('../../ClientService', () => ({ + useClientService: () => clientServiceMock, +})); + +jest.mock('../tools', () => ({ + isHttpSuccessResponse: (r: any) => r?.ok === true, + extractResponseError: async (_r: any, fallback: string) => fallback, +})); + +jest.mock('@backstage/core-components', () => ({ + ResponseErrorPanel: ({ error }: any) => ( +
{error?.message}
+ ), +})); + +import { render, screen, act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AdversarialAgent } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { AgentDialog } from './AgentDialog'; + +const VALID_PROMPT = + 'Review the migration output for security vulnerabilities, privilege escalation, and correctness issues in the generated Ansible playbooks.'; + +const existingAgent: AdversarialAgent = { + id: 'agent-1', + name: 'Security Checker', + prompt: VALID_PROMPT, + phases: ['analyze'], + critical: false, + createdAt: new Date('2025-01-01').toISOString() as any, + updatedAt: new Date('2025-01-01').toISOString() as any, + createdBy: 'user:default/admin', +}; + +describe('AgentDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('create mode', () => { + it('renders empty form with create title', () => { + render(); + expect(screen.getByText(/create/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('enables save when all fields are valid', async () => { + render(); + + await userEvent.type( + screen.getByPlaceholderText('e.g., Privilege Escalation Check'), + 'My Agent', + ); + await userEvent.type( + screen.getByPlaceholderText( + 'Describe what this agent should check for...', + ), + VALID_PROMPT, + ); + await userEvent.click(screen.getByLabelText(/analyze/i)); + + expect(screen.getByRole('button', { name: /save/i })).toBeEnabled(); + }); + + it('calls adversarialAgentsPost on save', async () => { + const onSaved = jest.fn(); + mockAdversarialAgentsPost.mockResolvedValue({ ok: true, status: 201 }); + + render(); + + await userEvent.type( + screen.getByPlaceholderText('e.g., Privilege Escalation Check'), + 'My Agent', + ); + await userEvent.type( + screen.getByPlaceholderText( + 'Describe what this agent should check for...', + ), + VALID_PROMPT, + ); + await userEvent.click(screen.getByLabelText(/analyze/i)); + + await act(async () => { + await userEvent.click(screen.getByRole('button', { name: /save/i })); + }); + + await waitFor(() => { + expect(mockAdversarialAgentsPost).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ name: 'My Agent' }), + }), + ); + expect(onSaved).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('edit mode', () => { + it('renders edit title and pre-fills fields', () => { + render( + , + ); + expect(screen.getByText(/edit/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue('Security Checker')).toBeInTheDocument(); + expect(screen.getByDisplayValue(VALID_PROMPT)).toBeInTheDocument(); + }); + + it('calls adversarialAgentsIdPut on save', async () => { + const onSaved = jest.fn(); + mockAdversarialAgentsIdPut.mockResolvedValue({ ok: true, status: 200 }); + + render( + , + ); + + await act(async () => { + await userEvent.click(screen.getByRole('button', { name: /save/i })); + }); + + await waitFor(() => { + expect(mockAdversarialAgentsIdPut).toHaveBeenCalledWith( + expect.objectContaining({ path: { id: 'agent-1' } }), + ); + expect(onSaved).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('calls onClose when cancel is clicked', async () => { + const onClose = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.test.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.test.tsx new file mode 100644 index 00000000000..cef791728c5 --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/DeleteAgentDialog.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockUseTranslation } from '../../test-utils/mockTranslations'; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: mockUseTranslation, +})); + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DeleteAgentDialog } from './DeleteAgentDialog'; + +describe('DeleteAgentDialog', () => { + const defaultProps = { + open: true, + onClose: jest.fn(), + onConfirm: jest.fn(), + isDeleting: false, + agentName: 'My Agent', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the agent name in the title', () => { + render(); + expect(screen.getByText(/My Agent/)).toBeInTheDocument(); + }); + + it('calls onConfirm when delete button is clicked', async () => { + const onConfirm = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /delete/i })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when cancel button is clicked', async () => { + const onClose = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('disables buttons and shows spinner while deleting', () => { + render(); + expect(screen.getByRole('button', { name: /delete/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); + }); + + it('does not render when closed', () => { + render(); + expect(screen.queryByText(/My Agent/)).not.toBeInTheDocument(); + }); +}); diff --git a/workspaces/x2a/plugins/x2a/src/translations/de.ts b/workspaces/x2a/plugins/x2a/src/translations/de.ts index f9370246e4f..5dc545e2ca8 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/de.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/de.ts @@ -322,6 +322,86 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'rulesPage.dialog.cancel': 'Abbrechen', 'rulesPage.dialog.createError': 'Fehler beim Erstellen der Regel', 'rulesPage.dialog.updateError': 'Fehler beim Aktualisieren der Regel', + 'createProjectPage.adversarialAgents.loading': + 'Adversarielle Agenten werden geladen...', + 'createProjectPage.adversarialAgents.title': 'Adversarielle Agenten', + 'createProjectPage.adversarialAgents.subtitle': + 'Wählen Sie KI-Agenten aus, um Migrationsergebnisse auf Sicherheitslücken, funktionale Lücken und Korrektheit zu überprüfen', + 'createProjectPage.adversarialAgents.placeholder': 'Agenten auswählen…', + 'createProjectPage.adversarialAgents.selected': 'ausgewählt', + 'createProjectPage.adversarialAgents.noAgentsAvailable': + 'Keine adversariellen Agenten verfügbar. Erstellen Sie Agenten auf der Seite für adversarielle Agenten.', + 'createProjectPage.adversarialAgents.loadingError': + 'Fehler beim Laden der adversariellen Agenten', + 'createProjectPage.adversarialAgents.tooltip': + 'Adversarielle Agenten überprüfen Migrationsergebnisse auf Sicherheitsprobleme, funktionale Lücken und Korrektheitsprobleme', + 'modulePage.phases.runAdversarialReview': + 'Adversarielle Überprüfung starten', + 'modulePage.phases.adversarialReview': 'Adversarielle Überprüfung', + 'modulePage.phases.adversarialReviewInstructions': + 'Konfigurierte adversarielle Agenten gegen die Phasenausgabe ausführen. Agenten schreiben einen Bericht in das Ziel-Repository.', + 'modulePage.phases.adversarialRunError': + 'Fehler beim Starten der adversariellen Überprüfung', + 'artifact.types.adversarial_report': 'Adversarieller Bericht', + 'adversarialAgentsPage.title': 'Adversarielle Agenten', + 'adversarialAgentsPage.subtitle': + 'Verwalten Sie KI-Agenten, die Migrationsergebnisse auf Sicherheitslücken, funktionale Lücken und Korrektheitsprobleme überprüfen.', + 'adversarialAgentsPage.manageAdversarialAgents': + 'Adversarielle Agenten verwalten', + 'adversarialAgentsPage.addAgent': 'Agent hinzufügen', + 'adversarialAgentsPage.notAllowed': + 'Sie haben keine Berechtigung, adversarielle Agenten zu verwalten.', + 'adversarialAgentsPage.table.name': 'Name', + 'adversarialAgentsPage.table.prompt': 'Prompt', + 'adversarialAgentsPage.table.phases': 'Phasen', + 'adversarialAgentsPage.table.severity': 'Schweregrad', + 'adversarialAgentsPage.table.critical': 'Kritisch', + 'adversarialAgentsPage.table.warning': 'Warnung', + 'adversarialAgentsPage.table.createdAt': 'Erstellt', + 'adversarialAgentsPage.table.createdBy': 'Erstellt von', + 'adversarialAgentsPage.table.editAgent': 'Agent bearbeiten', + 'adversarialAgentsPage.table.deleteAgent': 'Agent löschen', + 'adversarialAgentsPage.table.noAgents': + 'Noch keine adversariellen Agenten definiert.', + 'adversarialAgentsPage.table.fetchError': + 'Fehler beim Abrufen der adversariellen Agenten', + 'adversarialAgentsPage.deleteConfirm.title': 'Agent "{{name}}" löschen?', + 'adversarialAgentsPage.deleteConfirm.message': + 'Diese Aktion kann nicht rückgängig gemacht werden.', + 'adversarialAgentsPage.deleteConfirm.confirm': 'Löschen', + 'adversarialAgentsPage.deleteConfirm.cancel': 'Abbrechen', + 'adversarialAgentsPage.deleteConfirm.deleteError': + 'Fehler beim Löschen des Agenten', + 'adversarialAgentsPage.dialog.createTitle': + 'Adversariellen Agenten erstellen', + 'adversarialAgentsPage.dialog.editTitle': + 'Adversariellen Agenten bearbeiten', + 'adversarialAgentsPage.dialog.nameField': 'Name', + 'adversarialAgentsPage.dialog.namePlaceholder': + 'z.B. Prüfung auf Rechteausweitung', + 'adversarialAgentsPage.dialog.promptField': 'Prompt', + 'adversarialAgentsPage.dialog.promptPlaceholder': + 'Beschreiben Sie, worauf dieser Agent achten soll...', + 'adversarialAgentsPage.dialog.promptHelper': + 'Geben Sie genau an, wonach gesucht werden soll und wie Ergebnisse gemeldet werden sollen (50–5000 Zeichen)', + 'adversarialAgentsPage.dialog.phasesField': 'Workflow-Phasen', + 'adversarialAgentsPage.dialog.phasesHelper': + 'Wählen Sie aus, in welchen Workflow-Phasen dieser Agent ausgeführt wird', + 'adversarialAgentsPage.dialog.phaseAnalyze': 'Analysieren', + 'adversarialAgentsPage.dialog.phaseMigrate': 'Migrieren', + 'adversarialAgentsPage.dialog.criticalField': 'Kritischer Agent', + 'adversarialAgentsPage.dialog.criticalHelper': + 'Kritische Agenten erzeugen Befunde mit kritischem Schweregrad; nicht-kritische Agenten erzeugen Warnungen', + 'adversarialAgentsPage.dialog.nameValidation': + 'Der Name muss zwischen 3 und 100 Zeichen lang sein', + 'adversarialAgentsPage.dialog.phasesValidation': + 'Mindestens eine Phase ist erforderlich', + 'adversarialAgentsPage.dialog.save': 'Speichern', + 'adversarialAgentsPage.dialog.cancel': 'Abbrechen', + 'adversarialAgentsPage.dialog.createError': + 'Fehler beim Erstellen des Agenten', + 'adversarialAgentsPage.dialog.updateError': + 'Fehler beim Aktualisieren des Agenten', }, }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/es.ts b/workspaces/x2a/plugins/x2a/src/translations/es.ts index ba227862d60..529d34c3fc0 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/es.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/es.ts @@ -327,6 +327,81 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'rulesPage.dialog.cancel': 'Cancelar', 'rulesPage.dialog.createError': 'Error al crear la regla', 'rulesPage.dialog.updateError': 'Error al actualizar la regla', + 'createProjectPage.adversarialAgents.loading': + 'Cargando agentes adversariales...', + 'createProjectPage.adversarialAgents.title': 'Agentes Adversariales', + 'createProjectPage.adversarialAgents.subtitle': + 'Seleccione agentes de IA para revisar los resultados de migración en busca de problemas de seguridad, brechas funcionales y corrección', + 'createProjectPage.adversarialAgents.placeholder': 'Elegir agentes…', + 'createProjectPage.adversarialAgents.selected': 'seleccionado', + 'createProjectPage.adversarialAgents.noAgentsAvailable': + 'No hay agentes adversariales disponibles. Cree agentes en la página de Agentes Adversariales.', + 'createProjectPage.adversarialAgents.loadingError': + 'Error al cargar los agentes adversariales', + 'createProjectPage.adversarialAgents.tooltip': + 'Los agentes adversariales revisan los resultados de migración en busca de problemas de seguridad, brechas funcionales y problemas de corrección', + 'modulePage.phases.runAdversarialReview': 'Ejecutar revisión adversarial', + 'modulePage.phases.adversarialReview': 'Revisión Adversarial', + 'modulePage.phases.adversarialReviewInstructions': + 'Ejecute los agentes adversariales configurados contra la salida de la fase. Los agentes escribirán un informe en el repositorio de destino.', + 'modulePage.phases.adversarialRunError': + 'Error al iniciar la revisión adversarial', + 'artifact.types.adversarial_report': 'Informe Adversarial', + 'adversarialAgentsPage.title': 'Agentes Adversariales', + 'adversarialAgentsPage.subtitle': + 'Gestione agentes de IA que revisan los resultados de migración en busca de problemas de seguridad, brechas funcionales y corrección.', + 'adversarialAgentsPage.manageAdversarialAgents': + 'Gestionar agentes adversariales', + 'adversarialAgentsPage.addAgent': 'Agregar agente', + 'adversarialAgentsPage.notAllowed': + 'No tiene permiso para gestionar agentes adversariales.', + 'adversarialAgentsPage.table.name': 'Nombre', + 'adversarialAgentsPage.table.prompt': 'Prompt', + 'adversarialAgentsPage.table.phases': 'Fases', + 'adversarialAgentsPage.table.severity': 'Gravedad', + 'adversarialAgentsPage.table.critical': 'Crítico', + 'adversarialAgentsPage.table.warning': 'Advertencia', + 'adversarialAgentsPage.table.createdAt': 'Creado', + 'adversarialAgentsPage.table.createdBy': 'Creado por', + 'adversarialAgentsPage.table.editAgent': 'Editar agente', + 'adversarialAgentsPage.table.deleteAgent': 'Eliminar agente', + 'adversarialAgentsPage.table.noAgents': + 'Aún no hay agentes adversariales definidos.', + 'adversarialAgentsPage.table.fetchError': + 'Error al obtener los agentes adversariales', + 'adversarialAgentsPage.deleteConfirm.title': '¿Eliminar agente "{{name}}"?', + 'adversarialAgentsPage.deleteConfirm.message': + 'Esta acción no se puede deshacer.', + 'adversarialAgentsPage.deleteConfirm.confirm': 'Eliminar', + 'adversarialAgentsPage.deleteConfirm.cancel': 'Cancelar', + 'adversarialAgentsPage.deleteConfirm.deleteError': + 'Error al eliminar el agente', + 'adversarialAgentsPage.dialog.createTitle': 'Crear agente adversarial', + 'adversarialAgentsPage.dialog.editTitle': 'Editar agente adversarial', + 'adversarialAgentsPage.dialog.nameField': 'Nombre', + 'adversarialAgentsPage.dialog.namePlaceholder': + 'p.ej., Verificación de escalada de privilegios', + 'adversarialAgentsPage.dialog.promptField': 'Prompt', + 'adversarialAgentsPage.dialog.promptPlaceholder': + 'Describa qué debe verificar este agente...', + 'adversarialAgentsPage.dialog.promptHelper': + 'Sea específico sobre qué buscar y cómo reportar los hallazgos (50-5000 caracteres)', + 'adversarialAgentsPage.dialog.phasesField': 'Fases del flujo de trabajo', + 'adversarialAgentsPage.dialog.phasesHelper': + 'Seleccione en qué fases del flujo de trabajo se ejecuta este agente', + 'adversarialAgentsPage.dialog.phaseAnalyze': 'Analizar', + 'adversarialAgentsPage.dialog.phaseMigrate': 'Migrar', + 'adversarialAgentsPage.dialog.criticalField': 'Agente crítico', + 'adversarialAgentsPage.dialog.criticalHelper': + 'Los agentes críticos producen hallazgos de gravedad crítica; los agentes no críticos producen advertencias', + 'adversarialAgentsPage.dialog.nameValidation': + 'El nombre debe tener entre 3 y 100 caracteres', + 'adversarialAgentsPage.dialog.phasesValidation': + 'Se requiere al menos una fase', + 'adversarialAgentsPage.dialog.save': 'Guardar', + 'adversarialAgentsPage.dialog.cancel': 'Cancelar', + 'adversarialAgentsPage.dialog.createError': 'Error al crear el agente', + 'adversarialAgentsPage.dialog.updateError': 'Error al actualizar el agente', }, }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/fr.ts b/workspaces/x2a/plugins/x2a/src/translations/fr.ts index b17319ab5ea..a504470de6d 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/fr.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/fr.ts @@ -330,6 +330,84 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'rulesPage.dialog.cancel': 'Annuler', 'rulesPage.dialog.createError': 'Erreur lors de la création de la règle', 'rulesPage.dialog.updateError': 'Erreur lors de la mise à jour de la règle', + 'createProjectPage.adversarialAgents.loading': + 'Chargement des agents adversariaux...', + 'createProjectPage.adversarialAgents.title': 'Agents Adversariaux', + 'createProjectPage.adversarialAgents.subtitle': + 'Sélectionnez des agents IA pour examiner les résultats de migration en matière de sécurité, de lacunes fonctionnelles et de correction', + 'createProjectPage.adversarialAgents.placeholder': 'Choisir des agents…', + 'createProjectPage.adversarialAgents.selected': 'sélectionné', + 'createProjectPage.adversarialAgents.noAgentsAvailable': + 'Aucun agent adversarial disponible. Créez des agents sur la page Agents Adversariaux.', + 'createProjectPage.adversarialAgents.loadingError': + 'Échec du chargement des agents adversariaux', + 'createProjectPage.adversarialAgents.tooltip': + 'Les agents adversariaux examinent les résultats de migration pour détecter des problèmes de sécurité, des lacunes fonctionnelles et des problèmes de correction', + 'modulePage.phases.runAdversarialReview': 'Lancer la revue adversariale', + 'modulePage.phases.adversarialReview': 'Revue Adversariale', + 'modulePage.phases.adversarialReviewInstructions': + 'Exécutez les agents adversariaux configurés sur la sortie de la phase. Les agents rédigeront un rapport dans le référentiel cible.', + 'modulePage.phases.adversarialRunError': + 'Échec du démarrage de la revue adversariale', + 'artifact.types.adversarial_report': 'Rapport Adversarial', + 'adversarialAgentsPage.title': 'Agents Adversariaux', + 'adversarialAgentsPage.subtitle': + 'Gérez les agents IA qui examinent les résultats de migration pour détecter des problèmes de sécurité, des lacunes fonctionnelles et des problèmes de correction.', + 'adversarialAgentsPage.manageAdversarialAgents': + 'Gérer les agents adversariaux', + 'adversarialAgentsPage.addAgent': 'Ajouter un agent', + 'adversarialAgentsPage.notAllowed': + "Vous n'avez pas la permission de gérer les agents adversariaux.", + 'adversarialAgentsPage.table.name': 'Nom', + 'adversarialAgentsPage.table.prompt': 'Invite', + 'adversarialAgentsPage.table.phases': 'Phases', + 'adversarialAgentsPage.table.severity': 'Gravité', + 'adversarialAgentsPage.table.critical': 'Critique', + 'adversarialAgentsPage.table.warning': 'Avertissement', + 'adversarialAgentsPage.table.createdAt': 'Créé', + 'adversarialAgentsPage.table.createdBy': 'Créé par', + 'adversarialAgentsPage.table.editAgent': "Modifier l'agent", + 'adversarialAgentsPage.table.deleteAgent': "Supprimer l'agent", + 'adversarialAgentsPage.table.noAgents': + "Aucun agent adversarial défini pour l'instant.", + 'adversarialAgentsPage.table.fetchError': + 'Échec de la récupération des agents adversariaux', + 'adversarialAgentsPage.deleteConfirm.title': + 'Supprimer l\'agent "{{name}}" ?', + 'adversarialAgentsPage.deleteConfirm.message': + 'Cette action est irréversible.', + 'adversarialAgentsPage.deleteConfirm.confirm': 'Supprimer', + 'adversarialAgentsPage.deleteConfirm.cancel': 'Annuler', + 'adversarialAgentsPage.deleteConfirm.deleteError': + "Échec de la suppression de l'agent", + 'adversarialAgentsPage.dialog.createTitle': 'Créer un agent adversarial', + 'adversarialAgentsPage.dialog.editTitle': 'Modifier un agent adversarial', + 'adversarialAgentsPage.dialog.nameField': 'Nom', + 'adversarialAgentsPage.dialog.namePlaceholder': + "ex : Vérification d'élévation de privilèges", + 'adversarialAgentsPage.dialog.promptField': 'Invite', + 'adversarialAgentsPage.dialog.promptPlaceholder': + 'Décrivez ce que cet agent doit vérifier...', + 'adversarialAgentsPage.dialog.promptHelper': + "Soyez précis sur ce qu'il faut rechercher et comment signaler les résultats (50-5000 caractères)", + 'adversarialAgentsPage.dialog.phasesField': 'Phases du flux de travail', + 'adversarialAgentsPage.dialog.phasesHelper': + "Sélectionnez dans quelles phases du flux de travail cet agent s'exécute", + 'adversarialAgentsPage.dialog.phaseAnalyze': 'Analyser', + 'adversarialAgentsPage.dialog.phaseMigrate': 'Migrer', + 'adversarialAgentsPage.dialog.criticalField': 'Agent critique', + 'adversarialAgentsPage.dialog.criticalHelper': + 'Les agents critiques produisent des résultats de gravité critique ; les agents non critiques produisent des avertissements', + 'adversarialAgentsPage.dialog.nameValidation': + 'Le nom doit comporter entre 3 et 100 caractères', + 'adversarialAgentsPage.dialog.phasesValidation': + 'Au moins une phase est requise', + 'adversarialAgentsPage.dialog.save': 'Enregistrer', + 'adversarialAgentsPage.dialog.cancel': 'Annuler', + 'adversarialAgentsPage.dialog.createError': + "Échec de la création de l'agent", + 'adversarialAgentsPage.dialog.updateError': + "Échec de la mise à jour de l'agent", }, }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/it.ts b/workspaces/x2a/plugins/x2a/src/translations/it.ts index 87ee5c4b7ae..4e5d6314c23 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/it.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/it.ts @@ -330,6 +330,84 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'rulesPage.dialog.cancel': 'Annulla', 'rulesPage.dialog.createError': 'Errore nella creazione della regola', 'rulesPage.dialog.updateError': "Errore nell'aggiornamento della regola", + 'createProjectPage.adversarialAgents.loading': + 'Caricamento degli agenti avversariali...', + 'createProjectPage.adversarialAgents.title': 'Agenti Avversariali', + 'createProjectPage.adversarialAgents.subtitle': + 'Selezionare agenti IA per esaminare i risultati della migrazione in termini di sicurezza, lacune funzionali e correttezza', + 'createProjectPage.adversarialAgents.placeholder': 'Scegliere gli agenti…', + 'createProjectPage.adversarialAgents.selected': 'selezionato', + 'createProjectPage.adversarialAgents.noAgentsAvailable': + 'Nessun agente avversariale disponibile. Creare agenti nella pagina Agenti Avversariali.', + 'createProjectPage.adversarialAgents.loadingError': + 'Errore nel caricamento degli agenti avversariali', + 'createProjectPage.adversarialAgents.tooltip': + 'Gli agenti avversariali esaminano i risultati della migrazione per individuare problemi di sicurezza, lacune funzionali e problemi di correttezza', + 'modulePage.phases.runAdversarialReview': 'Esegui revisione avversariale', + 'modulePage.phases.adversarialReview': 'Revisione Avversariale', + 'modulePage.phases.adversarialReviewInstructions': + 'Eseguire gli agenti avversariali configurati sul risultato della fase. Gli agenti scriveranno un report nel repository di destinazione.', + 'modulePage.phases.adversarialRunError': + "Errore nell'avvio della revisione avversariale", + 'artifact.types.adversarial_report': 'Report Avversariale', + 'adversarialAgentsPage.title': 'Agenti Avversariali', + 'adversarialAgentsPage.subtitle': + 'Gestire gli agenti IA che esaminano i risultati della migrazione per individuare problemi di sicurezza, lacune funzionali e problemi di correttezza.', + 'adversarialAgentsPage.manageAdversarialAgents': + 'Gestisci agenti avversariali', + 'adversarialAgentsPage.addAgent': 'Aggiungi agente', + 'adversarialAgentsPage.notAllowed': + "Non si dispone dell'autorizzazione per gestire gli agenti avversariali.", + 'adversarialAgentsPage.table.name': 'Nome', + 'adversarialAgentsPage.table.prompt': 'Prompt', + 'adversarialAgentsPage.table.phases': 'Fasi', + 'adversarialAgentsPage.table.severity': 'Gravità', + 'adversarialAgentsPage.table.critical': 'Critico', + 'adversarialAgentsPage.table.warning': 'Avviso', + 'adversarialAgentsPage.table.createdAt': 'Creato', + 'adversarialAgentsPage.table.createdBy': 'Creato da', + 'adversarialAgentsPage.table.editAgent': 'Modifica agente', + 'adversarialAgentsPage.table.deleteAgent': 'Elimina agente', + 'adversarialAgentsPage.table.noAgents': + 'Nessun agente avversariale definito.', + 'adversarialAgentsPage.table.fetchError': + 'Errore nel recupero degli agenti avversariali', + 'adversarialAgentsPage.deleteConfirm.title': + 'Eliminare l\'agente "{{name}}"?', + 'adversarialAgentsPage.deleteConfirm.message': + 'Questa azione non può essere annullata.', + 'adversarialAgentsPage.deleteConfirm.confirm': 'Elimina', + 'adversarialAgentsPage.deleteConfirm.cancel': 'Annulla', + 'adversarialAgentsPage.deleteConfirm.deleteError': + "Errore nell'eliminazione dell'agente", + 'adversarialAgentsPage.dialog.createTitle': 'Crea agente avversariale', + 'adversarialAgentsPage.dialog.editTitle': 'Modifica agente avversariale', + 'adversarialAgentsPage.dialog.nameField': 'Nome', + 'adversarialAgentsPage.dialog.namePlaceholder': + 'es., Verifica escalation privilegi', + 'adversarialAgentsPage.dialog.promptField': 'Prompt', + 'adversarialAgentsPage.dialog.promptPlaceholder': + 'Descrivere cosa deve verificare questo agente...', + 'adversarialAgentsPage.dialog.promptHelper': + 'Essere specifici su cosa cercare e come segnalare i risultati (50-5000 caratteri)', + 'adversarialAgentsPage.dialog.phasesField': 'Fasi del flusso di lavoro', + 'adversarialAgentsPage.dialog.phasesHelper': + 'Selezionare le fasi del flusso di lavoro in cui viene eseguito questo agente', + 'adversarialAgentsPage.dialog.phaseAnalyze': 'Analizzare', + 'adversarialAgentsPage.dialog.phaseMigrate': 'Migrare', + 'adversarialAgentsPage.dialog.criticalField': 'Agente critico', + 'adversarialAgentsPage.dialog.criticalHelper': + 'Gli agenti critici producono risultati di gravità critica; gli agenti non critici producono avvisi', + 'adversarialAgentsPage.dialog.nameValidation': + 'Il nome deve contenere tra 3 e 100 caratteri', + 'adversarialAgentsPage.dialog.phasesValidation': + 'È richiesta almeno una fase', + 'adversarialAgentsPage.dialog.save': 'Salva', + 'adversarialAgentsPage.dialog.cancel': 'Annulla', + 'adversarialAgentsPage.dialog.createError': + "Errore nella creazione dell'agente", + 'adversarialAgentsPage.dialog.updateError': + "Errore nell'aggiornamento dell'agente", }, }); From 6f799d1a5c4175235f98e83d3c5cfcbdf6f06ac7 Mon Sep 17 00:00:00 2001 From: yray Date: Sun, 9 Aug 2026 10:10:52 +0300 Subject: [PATCH 3/6] update API reports --- .../x2a/plugins/x2a-common/report.api.md | 197 +++++++++++++++++- workspaces/x2a/plugins/x2a-node/report.api.md | 40 ++++ .../x2a/plugins/x2a/report-alpha.api.md | 71 +++++++ workspaces/x2a/plugins/x2a/report.api.md | 62 ++++++ 4 files changed, 367 insertions(+), 3 deletions(-) diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 09eec3bc417..ac8e4a310e4 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -15,6 +15,122 @@ export interface AAPCredentials { username?: string; } +// @public +export interface AdversarialAgent { + createdAt: Date; + createdBy: string; + critical: boolean; + id: string; + name: string; + phases: Array; + prompt: string; + updatedAt: Date; +} + +// @public (undocumented) +export class AdversarialAgentEntity { + constructor( + id: string, + name: string, + prompt: string, + phases: string[], + critical: boolean, + createdBy: string, + createdAt: Date, + updatedAt: Date, + ); + // (undocumented) + readonly createdAt: Date; + // (undocumented) + readonly createdBy: string; + // (undocumented) + readonly critical: boolean; + // (undocumented) + equals(other: AdversarialAgentEntity): boolean; + // (undocumented) + static fromJSON(json: unknown): AdversarialAgentEntity; + // (undocumented) + static fromRow(row: Record): AdversarialAgentEntity; + // (undocumented) + readonly id: string; + // (undocumented) + readonly name: string; + // (undocumented) + readonly phases: string[]; + // (undocumented) + readonly prompt: string; + // (undocumented) + toSnapshot(): AdversarialAgentSnapshot; + // (undocumented) + toString(): string; + // (undocumented) + readonly updatedAt: Date; +} + +// @public (undocumented) +export type AdversarialAgentPhasesEnum = 'analyze' | 'migrate'; + +// @public (undocumented) +export type AdversarialAgentsGet = { + query: { + phase?: 'analyze' | 'migrate'; + }; +}; + +// @public (undocumented) +export interface AdversarialAgentsGet200Response { + // (undocumented) + agents?: Array; + total?: number; +} + +// @public (undocumented) +export type AdversarialAgentsIdDelete = { + path: { + id: string; + }; +}; + +// @public (undocumented) +export type AdversarialAgentsIdGet = { + path: { + id: string; + }; +}; + +// @public (undocumented) +export type AdversarialAgentsIdPut = { + path: { + id: string; + }; + body: AdversarialAgentsPostRequest; +}; + +// @public +export interface AdversarialAgentSnapshot { + critical: boolean; + id: string; + name: string; + phases: Array; + prompt: string; +} + +// @public (undocumented) +export type AdversarialAgentsPost = { + body: AdversarialAgentsPostRequest; +}; + +// @public (undocumented) +export interface AdversarialAgentsPostRequest { + critical: boolean; + name: string; + phases: Array; + prompt: string; +} + +// @public (undocumented) +export type AdversarialAgentsPostRequestPhasesEnum = 'analyze' | 'migrate'; + // @public export interface AgentMetrics { durationSeconds: number; @@ -44,6 +160,8 @@ export interface Artifact { // @public (undocumented) export class ArtifactKind { + // (undocumented) + static readonly ADVERSARIAL_REPORT: ArtifactKind; // (undocumented) static all(): readonly ArtifactKind[]; // (undocumented) @@ -84,7 +202,8 @@ export type ArtifactType = | 'module_migration_plan' | 'migrated_sources' | 'project_metadata' - | 'ansible_project'; + | 'ansible_project' + | 'adversarial_report'; // @public export interface AuthToken { @@ -132,6 +251,26 @@ export class DefaultApiClient { fetch: typeof fetch; }; }); + adversarialAgentsGet( + request: AdversarialAgentsGet, + options?: RequestOptions, + ): Promise>; + adversarialAgentsIdDelete( + request: AdversarialAgentsIdDelete, + options?: RequestOptions, + ): Promise>; + adversarialAgentsIdGet( + request: AdversarialAgentsIdGet, + options?: RequestOptions, + ): Promise>; + adversarialAgentsIdPut( + request: AdversarialAgentsIdPut, + options?: RequestOptions, + ): Promise>; + adversarialAgentsPost( + request: AdversarialAgentsPost, + options?: RequestOptions, + ): Promise>; projectsGet( request: ProjectsGet, options?: RequestOptions, @@ -140,6 +279,10 @@ export class DefaultApiClient { request: ProjectsPost, options?: RequestOptions, ): Promise>; + projectsProjectIdAdversarialRunPost( + request: ProjectsProjectIdAdversarialRunPost, + options?: RequestOptions, + ): Promise>; projectsProjectIdCollectArtifactsPost( request: ProjectsProjectIdCollectArtifactsPost, options?: RequestOptions, @@ -318,10 +461,20 @@ export const MAX_BACKOFF_MS: number; export const MAX_CONCURRENT_BULK_RUN = 3; // @public (undocumented) -export type MigrationPhase = 'init' | 'analyze' | 'migrate' | 'publish'; +export type MigrationPhase = + | 'init' + | 'analyze' + | 'migrate' + | 'publish' + | 'adversarial-analyze' + | 'adversarial-migrate'; // @public (undocumented) export interface Module { + // (undocumented) + adversarialAnalyze?: Job; + // (undocumented) + adversarialMigrate?: Job; // (undocumented) analyze?: Job; errorDetails?: string; @@ -377,6 +530,14 @@ export function parseCsvContent(dataUrl: string): CsvProjectRow[]; // @public (undocumented) export class Phase { + // (undocumented) + static readonly ADVERSARIAL_ANALYZE: Phase; + // (undocumented) + static readonly ADVERSARIAL_MIGRATE: Phase; + // (undocumented) + static adversarialAgentPhaseValues(): readonly ('analyze' | 'migrate')[]; + // (undocumented) + static adversarialPhases(): readonly Phase[]; // (undocumented) static all(): readonly Phase[]; // (undocumented) @@ -415,6 +576,7 @@ export const POLLING_INTERVAL_MS: number; // @public (undocumented) export interface Project { acceptedRules?: Array; + adversarialAgents?: Array; createdAt: Date; description?: string; dirName?: string; @@ -460,6 +622,7 @@ export type ProjectsPost = { // @public (undocumented) export interface ProjectsPostRequest { acceptedRuleIds?: Array; + adversarialAgentIds?: Array; description: string; name: string; ownedByGroup?: string; @@ -469,6 +632,33 @@ export interface ProjectsPostRequest { targetRepoUrl: string; } +// @public (undocumented) +export type ProjectsProjectIdAdversarialRunPost = { + path: { + projectId: string; + }; + body: ProjectsProjectIdAdversarialRunPostRequest; +}; + +// @public (undocumented) +export interface ProjectsProjectIdAdversarialRunPost202Response { + jobId: string; + k8sJobName: string; +} + +// @public (undocumented) +export interface ProjectsProjectIdAdversarialRunPostRequest { + moduleId: string; + phase: ProjectsProjectIdAdversarialRunPostRequestPhaseEnum; + // (undocumented) + targetRepoAuth: GitRepoAuth; +} + +// @public (undocumented) +export type ProjectsProjectIdAdversarialRunPostRequestPhaseEnum = + | 'analyze' + | 'migrate'; + // @public (undocumented) export type ProjectsProjectIdCollectArtifactsPost = { path: { @@ -572,7 +762,7 @@ export type ProjectsProjectIdModulesModuleIdLogGet = { }; query: { streaming?: boolean; - phase: ModulePhase; + phase: MigrationPhase; }; }; @@ -881,6 +1071,7 @@ export const X2A_ARTIFACT_TYPE_VALUES: readonly [ 'migration_plan', 'module_migration_plan', 'migrated_sources', + 'adversarial_report', 'project_metadata', 'ansible_project', ]; diff --git a/workspaces/x2a/plugins/x2a-node/report.api.md b/workspaces/x2a/plugins/x2a-node/report.api.md index 3dc631e7f98..3e9477b0e52 100644 --- a/workspaces/x2a/plugins/x2a-node/report.api.md +++ b/workspaces/x2a/plugins/x2a-node/report.api.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AdversarialAgentEntity } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import type { AdversarialAgentSnapshot } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import type { Artifact } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import type { BackstageCredentials } from '@backstage/backend-plugin-api'; import type { BackstageUserPrincipal } from '@backstage/backend-plugin-api'; @@ -121,6 +123,8 @@ export interface JobCreateParams { // (undocumented) acceptedRules?: RuleSnapshot[]; // (undocumented) + adversarialAgents?: AdversarialAgentSnapshot[]; + // (undocumented) callbackToken: string; // (undocumented) callbackUrl: string; @@ -257,6 +261,7 @@ export interface X2AConfig { namespace: string; image: string; imageTag: string; + imagePullPolicy?: string; ttlSecondsAfterFinished: number; resources: { requests: { @@ -273,12 +278,25 @@ export interface X2AConfig { // @public export interface X2ADatabaseServiceApi { + // (undocumented) + attachAdversarialAgentsToProject(args: { + projectId: string; + agentIds: string[]; + }): Promise; // (undocumented) attachRulesToProject(args: { projectId: string; ruleIds: string[]; }): Promise; // (undocumented) + createAdversarialAgent(input: { + name: string; + prompt: string; + phases: string[]; + critical: boolean; + createdBy: string; + }): Promise; + // (undocumented) createJob(job: CreateJobInput): Promise; // (undocumented) createModule(module: { @@ -309,6 +327,8 @@ export interface X2ADatabaseServiceApi { required?: boolean; }): Promise; // (undocumented) + deleteAdversarialAgent(opts: { id: string }): Promise; + // (undocumented) deleteJob(args: { id: string }): Promise; // (undocumented) deleteModule(args: { id: string }): Promise; @@ -330,6 +350,14 @@ export interface X2ADatabaseServiceApi { projectId: string; }): Promise; // (undocumented) + getAdversarialAgent(opts: { + id: string; + }): Promise; + // (undocumented) + getAdversarialAgentsForProject(args: { + projectId: string; + }): Promise; + // (undocumented) getJob(args: { id: string }): Promise; // (undocumented) getJobLogs(args: { jobId: string }): Promise; @@ -360,6 +388,10 @@ export interface X2ADatabaseServiceApi { // (undocumented) getRule(args: { id: string }): Promise; // (undocumented) + listAdversarialAgents(filters?: { + phase?: string; + }): Promise; + // (undocumented) listJobs(args: { projectId: string; moduleId?: string; @@ -397,6 +429,14 @@ export interface X2ADatabaseServiceApi { // (undocumented) softDeleteModule(args: { id: string }): Promise; // (undocumented) + updateAdversarialAgent(opts: { + id: string; + name: string; + prompt: string; + phases: string[]; + critical: boolean; + }): Promise; + // (undocumented) updateJob(update: { id: string; log?: string | null; diff --git a/workspaces/x2a/plugins/x2a/report-alpha.api.md b/workspaces/x2a/plugins/x2a/report-alpha.api.md index 3d74de3e5e6..e97c673a5d5 100644 --- a/workspaces/x2a/plugins/x2a/report-alpha.api.md +++ b/workspaces/x2a/plugins/x2a/report-alpha.api.md @@ -101,6 +101,21 @@ const _default: OverridableFrontendPlugin< noHeader?: boolean; }; }>; + 'scaffolder-form-field:x2a/AdversarialAgentsPicker': OverridableExtensionDefinition<{ + kind: 'scaffolder-form-field'; + name: 'AdversarialAgentsPicker'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + () => Promise, + 'scaffolder.form-field-loader', + {} + >; + inputs: {}; + params: { + field: () => Promise; + }; + }>; 'scaffolder-form-field:x2a/RepoAuthentication': OverridableExtensionDefinition<{ kind: 'scaffolder-form-field'; name: 'RepoAuthentication'; @@ -179,6 +194,14 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'projectPage.deleteConfirm.cancel': string; readonly 'projectPage.deleteConfirm.confirm': string; readonly 'projectTable.deleteError': string; + readonly 'createProjectPage.adversarialAgents.title': string; + readonly 'createProjectPage.adversarialAgents.loading': string; + readonly 'createProjectPage.adversarialAgents.placeholder': string; + readonly 'createProjectPage.adversarialAgents.selected': string; + readonly 'createProjectPage.adversarialAgents.tooltip': string; + readonly 'createProjectPage.adversarialAgents.subtitle': string; + readonly 'createProjectPage.adversarialAgents.noAgentsAvailable': string; + readonly 'createProjectPage.adversarialAgents.loadingError': string; readonly 'projectDetailsCard.title': string; readonly 'projectDetailsCard.name': string; readonly 'projectDetailsCard.dirName': string; @@ -237,7 +260,11 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'modulePage.phases.runPublish': string; readonly 'modulePage.phases.republishInstructions': string; readonly 'modulePage.phases.rerunPublish': string; + readonly 'modulePage.phases.runAdversarialReview': string; + readonly 'modulePage.phases.adversarialReview': string; + readonly 'modulePage.phases.adversarialReviewInstructions': string; readonly 'modulePage.phases.runError': string; + readonly 'modulePage.phases.adversarialRunError': string; readonly 'modulePage.phases.cancelError': string; readonly 'modulePage.phases.attempts': string; readonly 'modulePage.phases.totalElapsed': string; @@ -319,6 +346,8 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'module.phases.analyze': string; readonly 'module.phases.migrate': string; readonly 'module.phases.publish': string; + readonly 'module.phases.adversarial-analyze': string; + readonly 'module.phases.adversarial-migrate': string; readonly 'module.statuses.error': string; readonly 'module.statuses.none': string; readonly 'module.statuses.pending': string; @@ -347,6 +376,7 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'artifact.types.migrated_sources': string; readonly 'artifact.types.ansible_project': string; readonly 'artifact.types.project_metadata': string; + readonly 'artifact.types.adversarial_report': string; readonly 'scaffolder.rulesAcceptance.required': string; readonly 'scaffolder.rulesAcceptance.loadingRules': string; readonly 'scaffolder.rulesAcceptance.noRulesConfigured': string; @@ -379,6 +409,47 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'rulesPage.addRule': string; readonly 'rulesPage.manageRules': string; readonly 'rulesPage.notAllowed': string; + readonly 'adversarialAgentsPage.dialog.cancel': string; + readonly 'adversarialAgentsPage.dialog.updateError': string; + readonly 'adversarialAgentsPage.dialog.createTitle': string; + readonly 'adversarialAgentsPage.dialog.editTitle': string; + readonly 'adversarialAgentsPage.dialog.save': string; + readonly 'adversarialAgentsPage.dialog.createError': string; + readonly 'adversarialAgentsPage.dialog.nameField': string; + readonly 'adversarialAgentsPage.dialog.namePlaceholder': string; + readonly 'adversarialAgentsPage.dialog.promptField': string; + readonly 'adversarialAgentsPage.dialog.promptPlaceholder': string; + readonly 'adversarialAgentsPage.dialog.promptHelper': string; + readonly 'adversarialAgentsPage.dialog.phasesField': string; + readonly 'adversarialAgentsPage.dialog.phasesHelper': string; + readonly 'adversarialAgentsPage.dialog.phaseAnalyze': string; + readonly 'adversarialAgentsPage.dialog.phaseMigrate': string; + readonly 'adversarialAgentsPage.dialog.criticalField': string; + readonly 'adversarialAgentsPage.dialog.criticalHelper': string; + readonly 'adversarialAgentsPage.dialog.nameValidation': string; + readonly 'adversarialAgentsPage.dialog.phasesValidation': string; + readonly 'adversarialAgentsPage.table.name': string; + readonly 'adversarialAgentsPage.table.phases': string; + readonly 'adversarialAgentsPage.table.createdAt': string; + readonly 'adversarialAgentsPage.table.warning': string; + readonly 'adversarialAgentsPage.table.fetchError': string; + readonly 'adversarialAgentsPage.table.prompt': string; + readonly 'adversarialAgentsPage.table.severity': string; + readonly 'adversarialAgentsPage.table.critical': string; + readonly 'adversarialAgentsPage.table.createdBy': string; + readonly 'adversarialAgentsPage.table.editAgent': string; + readonly 'adversarialAgentsPage.table.deleteAgent': string; + readonly 'adversarialAgentsPage.table.noAgents': string; + readonly 'adversarialAgentsPage.title': string; + readonly 'adversarialAgentsPage.subtitle': string; + readonly 'adversarialAgentsPage.deleteConfirm.title': string; + readonly 'adversarialAgentsPage.deleteConfirm.deleteError': string; + readonly 'adversarialAgentsPage.deleteConfirm.message': string; + readonly 'adversarialAgentsPage.deleteConfirm.cancel': string; + readonly 'adversarialAgentsPage.deleteConfirm.confirm': string; + readonly 'adversarialAgentsPage.notAllowed': string; + readonly 'adversarialAgentsPage.manageAdversarialAgents': string; + readonly 'adversarialAgentsPage.addAgent': string; readonly empty: string; } >; diff --git a/workspaces/x2a/plugins/x2a/report.api.md b/workspaces/x2a/plugins/x2a/report.api.md index 53a47a9531d..832f40c53b0 100644 --- a/workspaces/x2a/plugins/x2a/report.api.md +++ b/workspaces/x2a/plugins/x2a/report.api.md @@ -12,6 +12,12 @@ import { TranslationFunction } from '@backstage/core-plugin-api/alpha'; import { TranslationRef } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; +// @public (undocumented) +export const AdversarialAgentsPickerExtension: FieldExtensionComponent< + string[], + {} +>; + // @public (undocumented) export const RepoAuthenticationExtension: FieldExtensionComponent; @@ -85,6 +91,14 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'projectPage.deleteConfirm.cancel': string; readonly 'projectPage.deleteConfirm.confirm': string; readonly 'projectTable.deleteError': string; + readonly 'createProjectPage.adversarialAgents.title': string; + readonly 'createProjectPage.adversarialAgents.loading': string; + readonly 'createProjectPage.adversarialAgents.placeholder': string; + readonly 'createProjectPage.adversarialAgents.selected': string; + readonly 'createProjectPage.adversarialAgents.tooltip': string; + readonly 'createProjectPage.adversarialAgents.subtitle': string; + readonly 'createProjectPage.adversarialAgents.noAgentsAvailable': string; + readonly 'createProjectPage.adversarialAgents.loadingError': string; readonly 'projectDetailsCard.title': string; readonly 'projectDetailsCard.name': string; readonly 'projectDetailsCard.dirName': string; @@ -143,7 +157,11 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'modulePage.phases.runPublish': string; readonly 'modulePage.phases.republishInstructions': string; readonly 'modulePage.phases.rerunPublish': string; + readonly 'modulePage.phases.runAdversarialReview': string; + readonly 'modulePage.phases.adversarialReview': string; + readonly 'modulePage.phases.adversarialReviewInstructions': string; readonly 'modulePage.phases.runError': string; + readonly 'modulePage.phases.adversarialRunError': string; readonly 'modulePage.phases.cancelError': string; readonly 'modulePage.phases.attempts': string; readonly 'modulePage.phases.totalElapsed': string; @@ -225,6 +243,8 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'module.phases.analyze': string; readonly 'module.phases.migrate': string; readonly 'module.phases.publish': string; + readonly 'module.phases.adversarial-analyze': string; + readonly 'module.phases.adversarial-migrate': string; readonly 'module.statuses.error': string; readonly 'module.statuses.none': string; readonly 'module.statuses.pending': string; @@ -253,6 +273,7 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'artifact.types.migrated_sources': string; readonly 'artifact.types.ansible_project': string; readonly 'artifact.types.project_metadata': string; + readonly 'artifact.types.adversarial_report': string; readonly 'scaffolder.rulesAcceptance.required': string; readonly 'scaffolder.rulesAcceptance.loadingRules': string; readonly 'scaffolder.rulesAcceptance.noRulesConfigured': string; @@ -285,6 +306,47 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'rulesPage.addRule': string; readonly 'rulesPage.manageRules': string; readonly 'rulesPage.notAllowed': string; + readonly 'adversarialAgentsPage.dialog.cancel': string; + readonly 'adversarialAgentsPage.dialog.updateError': string; + readonly 'adversarialAgentsPage.dialog.createTitle': string; + readonly 'adversarialAgentsPage.dialog.editTitle': string; + readonly 'adversarialAgentsPage.dialog.save': string; + readonly 'adversarialAgentsPage.dialog.createError': string; + readonly 'adversarialAgentsPage.dialog.nameField': string; + readonly 'adversarialAgentsPage.dialog.namePlaceholder': string; + readonly 'adversarialAgentsPage.dialog.promptField': string; + readonly 'adversarialAgentsPage.dialog.promptPlaceholder': string; + readonly 'adversarialAgentsPage.dialog.promptHelper': string; + readonly 'adversarialAgentsPage.dialog.phasesField': string; + readonly 'adversarialAgentsPage.dialog.phasesHelper': string; + readonly 'adversarialAgentsPage.dialog.phaseAnalyze': string; + readonly 'adversarialAgentsPage.dialog.phaseMigrate': string; + readonly 'adversarialAgentsPage.dialog.criticalField': string; + readonly 'adversarialAgentsPage.dialog.criticalHelper': string; + readonly 'adversarialAgentsPage.dialog.nameValidation': string; + readonly 'adversarialAgentsPage.dialog.phasesValidation': string; + readonly 'adversarialAgentsPage.table.name': string; + readonly 'adversarialAgentsPage.table.phases': string; + readonly 'adversarialAgentsPage.table.createdAt': string; + readonly 'adversarialAgentsPage.table.warning': string; + readonly 'adversarialAgentsPage.table.fetchError': string; + readonly 'adversarialAgentsPage.table.prompt': string; + readonly 'adversarialAgentsPage.table.severity': string; + readonly 'adversarialAgentsPage.table.critical': string; + readonly 'adversarialAgentsPage.table.createdBy': string; + readonly 'adversarialAgentsPage.table.editAgent': string; + readonly 'adversarialAgentsPage.table.deleteAgent': string; + readonly 'adversarialAgentsPage.table.noAgents': string; + readonly 'adversarialAgentsPage.title': string; + readonly 'adversarialAgentsPage.subtitle': string; + readonly 'adversarialAgentsPage.deleteConfirm.title': string; + readonly 'adversarialAgentsPage.deleteConfirm.deleteError': string; + readonly 'adversarialAgentsPage.deleteConfirm.message': string; + readonly 'adversarialAgentsPage.deleteConfirm.cancel': string; + readonly 'adversarialAgentsPage.deleteConfirm.confirm': string; + readonly 'adversarialAgentsPage.notAllowed': string; + readonly 'adversarialAgentsPage.manageAdversarialAgents': string; + readonly 'adversarialAgentsPage.addAgent': string; readonly empty: string; } >; From 9d2bdf2f09d89b5e819d503ef960a93ae46657c0 Mon Sep 17 00:00:00 2001 From: yray Date: Sun, 9 Aug 2026 13:44:11 +0300 Subject: [PATCH 4/6] exclude x2a openapi generated files from SonarCloud analysis --- .sonarcloud.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 191fd84b07a..e1b3f110e1f 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -1,3 +1,3 @@ sonar.cpd.exclusions=workspaces/*/packages/app/**, workspaces/*/packages/app-legacy/**, workspaces/*/packages/backend/**, workspaces/*/plugins/*test*/**, **/*.test.*, **/translations/*.ts, **/__fixtures__/*, **/playwright.config.ts -sonar.exclusions=workspaces/*/plugins/orchestrator-common/src/generated/** +sonar.exclusions=workspaces/*/plugins/orchestrator-common/src/generated/**, workspaces/x2a/**/openapi/generated/** From 2964152f004e0ac077fe8eea28abe1705a25a79f Mon Sep 17 00:00:00 2001 From: yray Date: Mon, 10 Aug 2026 15:54:14 +0300 Subject: [PATCH 5/6] address PR review feedback for adversarial agents --- .../2025012401_create_jobs_table.ts | 9 +- ...6070810_create_adversarial_agents_table.ts | 89 +++++++++++++++++-- .../plugins/x2a-backend/src/router/modules.ts | 10 ++- .../x2a-backend/src/router/projects.ts | 39 +++++++- .../x2a-backend/src/schema/openapi.yaml | 13 ++- .../models/CancellablePhase.model.ts | 29 ++++++ ...rojectIdAdversarialRunPostRequest.model.ts | 2 +- ...dModulesModuleIdCancelPostRequest.model.ts | 4 +- .../schema/openapi/generated/models/index.ts | 1 + .../src/schema/openapi/generated/router.ts | 16 +++- .../adversarialAgentOperations.ts | 30 ++++--- .../src/services/X2ADatabaseService/index.ts | 11 ++- .../x2a-backend/templates/x2a-job-script.sh | 10 +-- .../models/CancellablePhase.model.ts | 29 ++++++ ...rojectIdAdversarialRunPostRequest.model.ts | 2 +- ...dModulesModuleIdCancelPostRequest.model.ts | 4 +- .../schema/openapi/generated/models/index.ts | 1 + .../x2a/plugins/x2a-common/report.api.md | 12 ++- .../x2a-common/src/domain/ArtifactKind.ts | 4 + workspaces/x2a/plugins/x2a/app-config.yaml | 1 + .../x2a/plugins/x2a/report-alpha.api.md | 1 + workspaces/x2a/plugins/x2a/report.api.md | 1 + .../AdversarialAgentsTable.test.tsx | 20 ++--- .../AgentDialog.test.tsx | 10 +-- .../ModulePage/AdversarialJobDetails.tsx | 29 ++++-- .../src/components/ModulePage/ModulePage.tsx | 3 +- .../src/components/ModulePage/PhasesCard.tsx | 13 +++ .../x2a/src/components/PhaseDetails.tsx | 8 +- .../components/tools/humanizeArtifactType.ts | 1 + .../x2a/plugins/x2a/src/translations/de.ts | 2 + .../x2a/plugins/x2a/src/translations/es.ts | 2 + .../x2a/plugins/x2a/src/translations/fr.ts | 2 + .../x2a/plugins/x2a/src/translations/it.ts | 2 + .../x2a/plugins/x2a/src/translations/ref.ts | 2 + 34 files changed, 320 insertions(+), 92 deletions(-) create mode 100644 workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/CancellablePhase.model.ts create mode 100644 workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/CancellablePhase.model.ts diff --git a/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts b/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts index 1a26782b7f4..7daf02f42b9 100644 --- a/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts +++ b/workspaces/x2a/plugins/x2a-backend/migrations/2025012401_create_jobs_table.ts @@ -36,14 +36,7 @@ export async function up(knex: Knex): Promise { .string('phase') .notNullable() .defaultTo('init') - .checkIn([ - 'init', - 'analyze', - 'migrate', - 'publish', - 'adversarial-analyze', - 'adversarial-migrate', - ]); + .checkIn(['init', 'analyze', 'migrate', 'publish']); table.text('error_details'); table.text('telemetry'); // JSON-serialized Telemetry object table.string('k8s_job_name'); diff --git a/workspaces/x2a/plugins/x2a-backend/migrations/2026070810_create_adversarial_agents_table.ts b/workspaces/x2a/plugins/x2a-backend/migrations/2026070810_create_adversarial_agents_table.ts index 1b7f12c8e08..8f618fb0449 100644 --- a/workspaces/x2a/plugins/x2a-backend/migrations/2026070810_create_adversarial_agents_table.ts +++ b/workspaces/x2a/plugins/x2a-backend/migrations/2026070810_create_adversarial_agents_table.ts @@ -16,18 +16,87 @@ import type { Knex } from 'knex'; +const EXTENDED_PHASES = [ + 'init', + 'analyze', + 'migrate', + 'publish', + 'adversarial-analyze', + 'adversarial-migrate', +]; +const ORIGINAL_PHASES = ['init', 'analyze', 'migrate', 'publish']; + +function createJobsTable(table: Knex.CreateTableBuilder, phases: string[]) { + table.uuid('id').primary(); + table.text('log'); + table.timestamp('started_at').notNullable(); + table.timestamp('finished_at'); + table + .string('status') + .notNullable() + .defaultTo('pending') + .checkIn(['pending', 'running', 'success', 'error', 'cancelled']); + table.string('phase').notNullable().defaultTo('init').checkIn(phases); + table.text('error_details'); + table.text('telemetry'); + table.string('k8s_job_name'); + table.string('callback_token'); + table.string('commit_id').nullable(); + table + .uuid('project_id') + .notNullable() + .references('id') + .inTable('projects') + .onDelete('CASCADE') + .index(); + table + .uuid('module_id') + .nullable() + .references('id') + .inTable('modules') + .onDelete('CASCADE') + .index(); + table.index('started_at'); + table.index('finished_at'); + table.index('status'); + table.index('phase'); + table.index('k8s_job_name'); +} + +async function recreateJobsTableSqlite( + knex: Knex, + phases: string[], +): Promise { + await knex.schema.raw('PRAGMA foreign_keys = OFF'); + try { + await knex.schema.createTable('jobs_new', table => + createJobsTable(table, phases), + ); + await knex.schema.raw('INSERT INTO jobs_new SELECT * FROM jobs'); + await knex.schema.dropTable('jobs'); + await knex.schema.raw('ALTER TABLE jobs_new RENAME TO jobs'); + } finally { + await knex.schema.raw('PRAGMA foreign_keys = ON'); + } +} + /** * Creates the adversarial_agents table, adds adversarial_agents column to projects, - * and expands the jobs.phase CHECK constraint to include adversarial phases (PostgreSQL only). + * and expands the jobs.phase CHECK constraint to include adversarial phases. * * @public */ export async function up(knex: Knex): Promise { - if (knex.client.config.client === 'pg') { - await knex.schema.raw( + const client = knex.client.config.client; + + if (client === 'better-sqlite3') { + await recreateJobsTableSqlite(knex, EXTENDED_PHASES); + } else { + // PostgreSQL: drop and recreate the named constraint + await knex.raw( `ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_phase_check`, ); - await knex.schema.raw( + await knex.raw( `ALTER TABLE jobs ADD CONSTRAINT jobs_phase_check CHECK (phase IN ('init', 'analyze', 'migrate', 'publish', 'adversarial-analyze', 'adversarial-migrate'))`, ); } @@ -53,7 +122,7 @@ export async function up(knex: Knex): Promise { /** * Drops adversarial_agents column from projects, drops adversarial_agents table, - * and restores the original jobs.phase CHECK constraint (PostgreSQL only). + * and restores the original jobs.phase CHECK constraint. * * @public */ @@ -64,11 +133,15 @@ export async function down(knex: Knex): Promise { await knex.schema.dropTable('adversarial_agents'); - if (knex.client.config.client === 'pg') { - await knex.schema.raw( + const client = knex.client.config.client; + + if (client === 'better-sqlite3') { + await recreateJobsTableSqlite(knex, ORIGINAL_PHASES); + } else { + await knex.raw( `ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_phase_check`, ); - await knex.schema.raw( + await knex.raw( `ALTER TABLE jobs ADD CONSTRAINT jobs_phase_check CHECK (phase IN ('init', 'analyze', 'migrate', 'publish'))`, ); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts b/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts index 138de6535af..162d3602a80 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/modules.ts @@ -19,6 +19,7 @@ import express from 'express'; import { InputError, NotFoundError } from '@backstage/errors'; import { + type MigrationPhase, type ModulePhase, JobStatus, Phase, @@ -312,9 +313,10 @@ export function registerModuleRoutes( ); const cancelModuleRequestSchema = z.object({ - phase: z.enum( - Phase.modulePhaseValues() as [ModulePhase, ...ModulePhase[]], - ), + phase: z.enum([ + ...Phase.modulePhaseValues(), + ...Phase.adversarialPhases().map(p => p.value), + ] as [string, ...string[]]), }); const parsedBody = cancelModuleRequestSchema @@ -351,7 +353,7 @@ export function registerModuleRoutes( const jobs = await x2aDatabase.listJobs({ projectId, moduleId, - phase, + phase: phase as MigrationPhase, lastJobOnly: true, }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts index baad00f7cca..d33c673043e 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/projects.ts @@ -176,6 +176,36 @@ export function registerProjectRoutes( } } + // Pre-validate rule IDs to avoid orphan projects on invalid input + if (requestBody.acceptedRuleIds?.length) { + const ruleChecks = await Promise.all( + requestBody.acceptedRuleIds.map(async id => ({ + id, + exists: !!(await x2aDatabase.getRule({ id })), + })), + ); + const missingRules = ruleChecks.filter(r => !r.exists).map(r => r.id); + if (missingRules.length) { + throw new InputError(`Rules not found: ${missingRules.join(', ')}`); + } + } + + // Pre-validate adversarial agent IDs to avoid orphan projects on invalid input + if (requestBody.adversarialAgentIds?.length) { + const agentChecks = await Promise.all( + requestBody.adversarialAgentIds.map(async id => ({ + id, + exists: !!(await x2aDatabase.getAdversarialAgent({ id })), + })), + ); + const missingAgents = agentChecks.filter(a => !a.exists).map(a => a.id); + if (missingAgents.length) { + throw new InputError( + `Adversarial agents not found: ${missingAgents.join(', ')}`, + ); + } + } + // create project const newProject = await x2aDatabase.createProject(requestBody, { credentials: await httpAuth.credentials(req, { allow: ['user'] }), @@ -502,12 +532,13 @@ export function registerProjectRoutes( assertProjectHasDirName(project); - const adversarialAgents = - await x2aDatabase.getAdversarialAgentsForProject({ projectId }); - if (!adversarialAgents || adversarialAgents.length === 0) { + const adversarialAgents = ( + await x2aDatabase.getAdversarialAgentsForProject({ projectId }) + ).filter(agent => agent.phases.includes(phase)); + if (adversarialAgents.length === 0) { return res.status(400).json({ error: 'NoAdversarialAgents', - message: 'No adversarial agents are configured for this project', + message: `No adversarial agents are configured for the ${phase} phase on this project`, }); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 860d3edd7b0..5a9da082838 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -649,7 +649,7 @@ paths: type: object properties: phase: - $ref: '#/components/schemas/ModulePhase' + $ref: '#/components/schemas/CancellablePhase' required: - phase responses: @@ -758,7 +758,6 @@ paths: required: - phase - moduleId - - targetRepoAuth responses: '202': description: Adversarial review job accepted @@ -1223,6 +1222,16 @@ components: - publish description: Phases to execute on a module + CancellablePhase: + type: string + enum: + - analyze + - migrate + - publish + - adversarial-analyze + - adversarial-migrate + description: Phases that support cancellation + SourceTechnology: type: string enum: diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/CancellablePhase.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/CancellablePhase.model.ts new file mode 100644 index 00000000000..78f2823b829 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/CancellablePhase.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type CancellablePhase = + | 'analyze' + | 'migrate' + | 'publish' + | 'adversarial-analyze' + | 'adversarial-migrate'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts index 7c3232804e7..48564f7070f 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts @@ -31,7 +31,7 @@ export interface ProjectsProjectIdAdversarialRunPostRequest { * UUID of the module to review */ moduleId: string; - targetRepoAuth: GitRepoAuth; + targetRepoAuth?: GitRepoAuth; } /** diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts index 1f7eea9c64f..af7c3695a14 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts @@ -17,11 +17,11 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { ModulePhase } from '../models/ModulePhase.model'; +import { CancellablePhase } from '../models/CancellablePhase.model'; /** * @public */ export interface ProjectsProjectIdModulesModuleIdCancelPostRequest { - phase: ModulePhase; + phase: CancellablePhase; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts index d5401999aa7..b243259f40a 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/index.ts @@ -22,6 +22,7 @@ export * from '../models/AdversarialAgentsPostRequest.model'; export * from '../models/AgentMetrics.model'; export * from '../models/Artifact.model'; export * from '../models/ArtifactType.model'; +export * from '../models/CancellablePhase.model'; export * from '../models/GitRepoAuth.model'; export * from '../models/Job.model'; export * from '../models/JobStatusEnum.model'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index 67ac6182ebe..056887fbacf 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -1002,7 +1002,7 @@ export const spec = { "type": "object", "properties": { "phase": { - "$ref": "#/components/schemas/ModulePhase" + "$ref": "#/components/schemas/CancellablePhase" } }, "required": [ @@ -1162,8 +1162,7 @@ export const spec = { }, "required": [ "phase", - "moduleId", - "targetRepoAuth" + "moduleId" ] } } @@ -1745,6 +1744,17 @@ export const spec = { ], "description": "Phases to execute on a module" }, + "CancellablePhase": { + "type": "string", + "enum": [ + "analyze", + "migrate", + "publish", + "adversarial-analyze", + "adversarial-migrate" + ], + "description": "Phases that support cancellation" + }, "SourceTechnology": { "type": "string", "enum": [ diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts index fd92d09b09a..3b3fad8b195 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/adversarialAgentOperations.ts @@ -42,20 +42,7 @@ export class AdversarialAgentOperations { const id = crypto.randomUUID(); const now = new Date(); - await this.#dbClient('adversarial_agents').insert({ - id, - name: input.name, - prompt: input.prompt, - phases: JSON.stringify(input.phases), - critical: input.critical, - created_by: input.createdBy, - created_at: now, - updated_at: now, - }); - - this.#logger.info(`Created adversarial agent: ${id} "${input.name}"`); - - return new AdversarialAgentEntity( + const entity = new AdversarialAgentEntity( id, input.name, input.prompt, @@ -65,6 +52,21 @@ export class AdversarialAgentOperations { now, now, ); + + await this.#dbClient('adversarial_agents').insert({ + id: entity.id, + name: entity.name, + prompt: entity.prompt, + phases: JSON.stringify(entity.phases), + critical: entity.critical, + created_by: entity.createdBy, + created_at: entity.createdAt, + updated_at: entity.updatedAt, + }); + + this.#logger.info(`Created adversarial agent: ${id} "${input.name}"`); + + return entity; } async listAdversarialAgents(filters?: { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts index ca99d41725c..9f0ac8854e4 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts @@ -403,10 +403,15 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { ); // Attach attempt stats per phase - const phases = ['analyze', 'migrate', 'publish'] as const; + const phaseJobPairs: Array<[MigrationPhase, Job | undefined]> = [ + ['analyze', module.analyze], + ['migrate', module.migrate], + ['publish', module.publish], + ['adversarial-analyze', module.adversarialAnalyze], + ['adversarial-migrate', module.adversarialMigrate], + ]; await Promise.all( - phases.map(async phase => { - const job = module[phase]; + phaseJobPairs.map(async ([phase, job]) => { if (job) { const stats = await this.#jobOps.getPhaseAttemptStats({ projectId: module.projectId, diff --git a/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh b/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh index d79d21e09f7..14985b85054 100644 --- a/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh +++ b/workspaces/x2a/plugins/x2a-backend/templates/x2a-job-script.sh @@ -668,24 +668,24 @@ case "${PHASE}" in echo "=== Running adversarial review (${ACTUAL_PHASE} phase) ===" OUTPUT_DIR="${PROJECT_PATH}/modules/${MODULE_NAME}" - if [ "${ACTUAL_PHASE}" = "analyze" ]; then + if [[ "${ACTUAL_PHASE}" = "analyze" ]]; then SOURCE_DIR="${OUTPUT_DIR}" else SOURCE_DIR="${OUTPUT_DIR}/ansible" fi - if [ ! -d "${SOURCE_DIR}" ]; then + if [[ ! -d "${SOURCE_DIR}" ]]; then ERROR_MESSAGE="Source directory not found: ${SOURCE_DIR}. Ensure the ${ACTUAL_PHASE} phase completed before running adversarial review." exit 1 fi AGENTS_CONFIG="/config/adversarial-agents/agents.json" - if [ ! -f "${AGENTS_CONFIG}" ]; then + if [[ ! -f "${AGENTS_CONFIG}" ]]; then ERROR_MESSAGE="Adversarial agents config not found at ${AGENTS_CONFIG}" exit 1 fi - if [ ! -d /app ] || [ ! -f /app/app.py ]; then + if [[ ! -d /app ]] || [[ ! -f /app/app.py ]]; then ERROR_MESSAGE="/app/app.py not found - x2a tool is required" exit 1 fi @@ -704,7 +704,7 @@ case "${PHASE}" in --config "${AGENTS_CONFIG}" \ --report-path "${REPORT_MD}" - if [ -f "/app/agent-adversarial-report.json" ]; then + if [[ -f "/app/agent-adversarial-report.json" ]]; then cp "/app/agent-adversarial-report.json" "${REPORT_JSON_DEST}" fi diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/CancellablePhase.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/CancellablePhase.model.ts new file mode 100644 index 00000000000..78f2823b829 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/CancellablePhase.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type CancellablePhase = + | 'analyze' + | 'migrate' + | 'publish' + | 'adversarial-analyze' + | 'adversarial-migrate'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts index 7c3232804e7..48564f7070f 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdAdversarialRunPostRequest.model.ts @@ -31,7 +31,7 @@ export interface ProjectsProjectIdAdversarialRunPostRequest { * UUID of the module to review */ moduleId: string; - targetRepoAuth: GitRepoAuth; + targetRepoAuth?: GitRepoAuth; } /** diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts index 1f7eea9c64f..af7c3695a14 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ProjectsProjectIdModulesModuleIdCancelPostRequest.model.ts @@ -17,11 +17,11 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { ModulePhase } from '../models/ModulePhase.model'; +import { CancellablePhase } from '../models/CancellablePhase.model'; /** * @public */ export interface ProjectsProjectIdModulesModuleIdCancelPostRequest { - phase: ModulePhase; + phase: CancellablePhase; } diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts index d5401999aa7..b243259f40a 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/index.ts @@ -22,6 +22,7 @@ export * from '../models/AdversarialAgentsPostRequest.model'; export * from '../models/AgentMetrics.model'; export * from '../models/Artifact.model'; export * from '../models/ArtifactType.model'; +export * from '../models/CancellablePhase.model'; export * from '../models/GitRepoAuth.model'; export * from '../models/Job.model'; export * from '../models/JobStatusEnum.model'; diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index ac8e4a310e4..970db67d8b6 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -225,6 +225,14 @@ export const bitbucketProvider: ScmProvider; // @public export function buildScmHostMap(config: Config): Map; +// @public (undocumented) +export type CancellablePhase = + | 'analyze' + | 'migrate' + | 'publish' + | 'adversarial-analyze' + | 'adversarial-migrate'; + // @public export const CREATE_PROJECT_TEMPLATE_PATH = '/create/templates/default/x2a-conversion-project-template'; @@ -651,7 +659,7 @@ export interface ProjectsProjectIdAdversarialRunPostRequest { moduleId: string; phase: ProjectsProjectIdAdversarialRunPostRequestPhaseEnum; // (undocumented) - targetRepoAuth: GitRepoAuth; + targetRepoAuth?: GitRepoAuth; } // @public (undocumented) @@ -743,7 +751,7 @@ export type ProjectsProjectIdModulesModuleIdCancelPost = { // @public (undocumented) export interface ProjectsProjectIdModulesModuleIdCancelPostRequest { // (undocumented) - phase: ModulePhase; + phase: CancellablePhase; } // @public (undocumented) diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts b/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts index 3eb66952625..6889e869dd5 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/ArtifactKind.ts @@ -80,6 +80,10 @@ export class ArtifactKind { return this === ArtifactKind.ANSIBLE_PROJECT; } + isAdversarialReport(): boolean { + return this === ArtifactKind.ADVERSARIAL_REPORT; + } + equals(other: ArtifactKind): boolean { return this.value === other.value; } diff --git a/workspaces/x2a/plugins/x2a/app-config.yaml b/workspaces/x2a/plugins/x2a/app-config.yaml index fe500171f6f..5abf9fa9f0c 100644 --- a/workspaces/x2a/plugins/x2a/app-config.yaml +++ b/workspaces/x2a/plugins/x2a/app-config.yaml @@ -4,6 +4,7 @@ dynamicPlugins: scaffolderFieldExtensions: - importName: RepoAuthenticationExtension - importName: RulesAcceptanceExtension + - importName: AdversarialAgentsPickerExtension translationResources: - importName: x2aPluginTranslations ref: x2aPluginTranslationRef diff --git a/workspaces/x2a/plugins/x2a/report-alpha.api.md b/workspaces/x2a/plugins/x2a/report-alpha.api.md index e97c673a5d5..a4d5f37037d 100644 --- a/workspaces/x2a/plugins/x2a/report-alpha.api.md +++ b/workspaces/x2a/plugins/x2a/report-alpha.api.md @@ -263,6 +263,7 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'modulePage.phases.runAdversarialReview': string; readonly 'modulePage.phases.adversarialReview': string; readonly 'modulePage.phases.adversarialReviewInstructions': string; + readonly 'modulePage.phases.noAdversarialAgentsConfigured': string; readonly 'modulePage.phases.runError': string; readonly 'modulePage.phases.adversarialRunError': string; readonly 'modulePage.phases.cancelError': string; diff --git a/workspaces/x2a/plugins/x2a/report.api.md b/workspaces/x2a/plugins/x2a/report.api.md index 832f40c53b0..3d38edbba8a 100644 --- a/workspaces/x2a/plugins/x2a/report.api.md +++ b/workspaces/x2a/plugins/x2a/report.api.md @@ -160,6 +160,7 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'modulePage.phases.runAdversarialReview': string; readonly 'modulePage.phases.adversarialReview': string; readonly 'modulePage.phases.adversarialReviewInstructions': string; + readonly 'modulePage.phases.noAdversarialAgentsConfigured': string; readonly 'modulePage.phases.runError': string; readonly 'modulePage.phases.adversarialRunError': string; readonly 'modulePage.phases.cancelError': string; diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx index e73432e495f..755cb78bc22 100644 --- a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AdversarialAgentsTable.test.tsx @@ -63,7 +63,7 @@ jest.mock('./DeleteAgentDialog', () => ({ open ?
: null, })); -import { render, screen, act, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AdversarialAgentsTable } from './AdversarialAgentsTable'; @@ -111,9 +111,7 @@ describe('AdversarialAgentsTable', () => { it('renders agents after successful fetch', async () => { mockAdversarialAgentsGet.mockResolvedValue(successResponse(mockAgents)); - await act(async () => { - render(); - }); + render(); await waitFor(() => { expect(screen.getByText('Security Checker')).toBeInTheDocument(); @@ -124,9 +122,7 @@ describe('AdversarialAgentsTable', () => { it('shows empty state when no agents exist', async () => { mockAdversarialAgentsGet.mockResolvedValue(successResponse([])); - await act(async () => { - render(); - }); + render(); await waitFor(() => { expect(screen.getByTestId('table-empty')).toBeInTheDocument(); @@ -140,9 +136,7 @@ describe('AdversarialAgentsTable', () => { json: async () => ({ message: 'Internal server error' }), }); - await act(async () => { - render(); - }); + render(); await waitFor(() => { expect(screen.getByTestId('error-panel')).toBeInTheDocument(); @@ -152,11 +146,9 @@ describe('AdversarialAgentsTable', () => { it('opens create dialog when add button is clicked', async () => { mockAdversarialAgentsGet.mockResolvedValue(successResponse([])); - await act(async () => { - render(); - }); + render(); - await waitFor(() => screen.getByTestId('table-empty')); + await screen.findByTestId('table-empty'); await userEvent.click(screen.getByRole('button', { name: /add agent/i })); expect(screen.getByTestId('agent-dialog')).toBeInTheDocument(); diff --git a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx index 1bf81708bfc..147496ca681 100644 --- a/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/AdversarialAgentsPage/AgentDialog.test.tsx @@ -41,7 +41,7 @@ jest.mock('@backstage/core-components', () => ({ ), })); -import { render, screen, act, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AdversarialAgent } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { AgentDialog } from './AgentDialog'; @@ -108,9 +108,7 @@ describe('AgentDialog', () => { ); await userEvent.click(screen.getByLabelText(/analyze/i)); - await act(async () => { - await userEvent.click(screen.getByRole('button', { name: /save/i })); - }); + await userEvent.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => { expect(mockAdversarialAgentsPost).toHaveBeenCalledWith( @@ -151,9 +149,7 @@ describe('AgentDialog', () => { />, ); - await act(async () => { - await userEvent.click(screen.getByRole('button', { name: /save/i })); - }); + await userEvent.click(screen.getByRole('button', { name: /save/i })); await waitFor(() => { expect(mockAdversarialAgentsIdPut).toHaveBeenCalledWith( diff --git a/workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx b/workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx index 681b92a2a39..0a1f4b4bdbf 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModulePage/AdversarialJobDetails.tsx @@ -20,6 +20,7 @@ import { LogViewer, Progress } from '@backstage/core-components'; import { Box, Button, + ButtonGroup, Divider, Grid, Typography, @@ -36,6 +37,7 @@ import { useLogStream } from '../../hooks/useLogStream'; import { useClientService } from '../../ClientService'; import { ItemField } from '../ItemField'; import { PhaseStatus } from '../PhaseStatus'; +import { canCancelPhase } from '../tools'; import { PhaseTelemetry } from '../PhaseTelemetry'; import { ArtifactLink } from '../ArtifactLink'; import { @@ -66,6 +68,7 @@ export const AdversarialJobDetails = ({ phaseName, targetRepoUrl, targetRepoBranch, + onCancel, }: { job?: Job; projectId: string; @@ -73,6 +76,7 @@ export const AdversarialJobDetails = ({ phaseName: MigrationPhase; targetRepoUrl: string; targetRepoBranch: string; + onCancel?: () => void; }) => { const { t } = useTranslation(); const classes = useStyles(); @@ -207,15 +211,22 @@ export const AdversarialJobDetails = ({ - + + + {canCancelPhase(job.status) && onCancel && ( + + )} + {showLog && ( diff --git a/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx b/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx index 20a1228a4c1..7a680163c35 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModulePage/ModulePage.tsx @@ -25,6 +25,7 @@ import { import { Box, Grid } from '@material-ui/core'; import { resolveScmProvider, + CancellablePhase, MigrationPhase, ModulePhase, Module, @@ -207,7 +208,7 @@ export const ModulePage = () => { const response = await clientService.projectsProjectIdModulesModuleIdCancelPost({ path: { projectId, moduleId }, - body: { phase: phase as ModulePhase }, + body: { phase: phase as CancellablePhase }, }); if (response.status !== 200) { const body = await response diff --git a/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx b/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx index 8985a975b01..fc279161fcc 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModulePage/PhasesCard.tsx @@ -98,6 +98,7 @@ export const PhasesCard = ({ const publishPhase = module?.publish; const adversarialAnalyzePhase = module?.adversarialAnalyze; const adversarialMigratePhase = module?.adversarialMigrate; + const hasAdversarialAgents = (project?.adversarialAgents?.length ?? 0) > 0; return ( @@ -157,6 +158,7 @@ export const PhasesCard = ({ onRunPhase={onRunPhase} onCancelPhase={onCancelPhase} onRunAdversarial={onRunAdversarial} + hasAdversarialAgents={hasAdversarialAgents} /> onCancelPhase('adversarial-analyze') + : undefined + } /> @@ -176,6 +183,7 @@ export const PhasesCard = ({ onRunPhase={onRunPhase} onCancelPhase={onCancelPhase} onRunAdversarial={onRunAdversarial} + hasAdversarialAgents={hasAdversarialAgents} /> onCancelPhase('adversarial-migrate') + : undefined + } /> diff --git a/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx b/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx index 8bc868222cb..43d797cb771 100644 --- a/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx @@ -170,6 +170,7 @@ export const PhaseDetails = ( onRunPhase?: (phase: MigrationPhase) => void; onCancelPhase?: (phase: MigrationPhase) => void; onRunAdversarial?: (phase: 'analyze' | 'migrate') => void; + hasAdversarialAgents?: boolean; } & OptionalModuleId, ) => { const { t } = useTranslation(); @@ -185,6 +186,7 @@ export const PhaseDetails = ( onRunPhase, onCancelPhase, onRunAdversarial, + hasAdversarialAgents, } = props; const moduleId = 'moduleId' in props ? props.moduleId : undefined; @@ -262,13 +264,15 @@ export const PhaseDetails = ( variant="outlined" color="default" size="small" - disabled={!canRunPhase} + disabled={!canRunPhase || !hasAdversarialAgents} onClick={() => onRunAdversarial(phaseName)} > {t('modulePage.phases.runAdversarialReview')} - {t('modulePage.phases.adversarialReviewInstructions')} + {hasAdversarialAgents + ? t('modulePage.phases.adversarialReviewInstructions') + : t('modulePage.phases.noAdversarialAgentsConfigured')} )} diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/humanizeArtifactType.ts b/workspaces/x2a/plugins/x2a/src/components/tools/humanizeArtifactType.ts index 63ae41be2f4..b76bba7117c 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/humanizeArtifactType.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/humanizeArtifactType.ts @@ -33,6 +33,7 @@ export const humanizeArtifactType = ( if (kind.isMigratedSources()) return t('artifact.types.migrated_sources'); if (kind.isProjectMetadata()) return t('artifact.types.project_metadata'); if (kind.isAnsibleProject()) return t('artifact.types.ansible_project'); + if (kind.isAdversarialReport()) return t('artifact.types.adversarial_report'); // Do not fail but let developers know... // eslint-disable-next-line no-console diff --git a/workspaces/x2a/plugins/x2a/src/translations/de.ts b/workspaces/x2a/plugins/x2a/src/translations/de.ts index 5dc545e2ca8..252362d4d69 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/de.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/de.ts @@ -340,6 +340,8 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'modulePage.phases.adversarialReview': 'Adversarielle Überprüfung', 'modulePage.phases.adversarialReviewInstructions': 'Konfigurierte adversarielle Agenten gegen die Phasenausgabe ausführen. Agenten schreiben einen Bericht in das Ziel-Repository.', + 'modulePage.phases.noAdversarialAgentsConfigured': + 'Für dieses Projekt sind keine adversariellen Agenten konfiguriert.', 'modulePage.phases.adversarialRunError': 'Fehler beim Starten der adversariellen Überprüfung', 'artifact.types.adversarial_report': 'Adversarieller Bericht', diff --git a/workspaces/x2a/plugins/x2a/src/translations/es.ts b/workspaces/x2a/plugins/x2a/src/translations/es.ts index 529d34c3fc0..c1df6f8549e 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/es.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/es.ts @@ -344,6 +344,8 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'modulePage.phases.adversarialReview': 'Revisión Adversarial', 'modulePage.phases.adversarialReviewInstructions': 'Ejecute los agentes adversariales configurados contra la salida de la fase. Los agentes escribirán un informe en el repositorio de destino.', + 'modulePage.phases.noAdversarialAgentsConfigured': + 'No hay agentes adversariales configurados para este proyecto.', 'modulePage.phases.adversarialRunError': 'Error al iniciar la revisión adversarial', 'artifact.types.adversarial_report': 'Informe Adversarial', diff --git a/workspaces/x2a/plugins/x2a/src/translations/fr.ts b/workspaces/x2a/plugins/x2a/src/translations/fr.ts index a504470de6d..f6169103708 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/fr.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/fr.ts @@ -347,6 +347,8 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'modulePage.phases.adversarialReview': 'Revue Adversariale', 'modulePage.phases.adversarialReviewInstructions': 'Exécutez les agents adversariaux configurés sur la sortie de la phase. Les agents rédigeront un rapport dans le référentiel cible.', + 'modulePage.phases.noAdversarialAgentsConfigured': + "Aucun agent adversarial n'est configuré pour ce projet.", 'modulePage.phases.adversarialRunError': 'Échec du démarrage de la revue adversariale', 'artifact.types.adversarial_report': 'Rapport Adversarial', diff --git a/workspaces/x2a/plugins/x2a/src/translations/it.ts b/workspaces/x2a/plugins/x2a/src/translations/it.ts index 4e5d6314c23..46703bbc1db 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/it.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/it.ts @@ -347,6 +347,8 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'modulePage.phases.adversarialReview': 'Revisione Avversariale', 'modulePage.phases.adversarialReviewInstructions': 'Eseguire gli agenti avversariali configurati sul risultato della fase. Gli agenti scriveranno un report nel repository di destinazione.', + 'modulePage.phases.noAdversarialAgentsConfigured': + 'Nessun agente avversariale è configurato per questo progetto.', 'modulePage.phases.adversarialRunError': "Errore nell'avvio della revisione avversariale", 'artifact.types.adversarial_report': 'Report Avversariale', diff --git a/workspaces/x2a/plugins/x2a/src/translations/ref.ts b/workspaces/x2a/plugins/x2a/src/translations/ref.ts index 59558240505..da0aa37b47f 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/ref.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/ref.ts @@ -151,6 +151,8 @@ export const x2aPluginMessages = { adversarialReview: 'Adversarial Review', adversarialReviewInstructions: 'Run configured adversarial agents against the phase output. Agents will write a report to the target repository.', + noAdversarialAgentsConfigured: + 'No adversarial agents are configured for this project.', runError: 'Failed to run phase for module', adversarialRunError: 'Failed to start adversarial review', cancelError: 'Failed to cancel phase for module', From 4cf20c7a542ba1bfbe7e738d0edb0b0927e1a3ab Mon Sep 17 00:00:00 2001 From: yray Date: Tue, 11 Aug 2026 11:50:55 +0300 Subject: [PATCH 6/6] Generated new report --- workspaces/x2a/plugins/x2a-common/report.api.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 970db67d8b6..7812e408acd 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -171,6 +171,8 @@ export class ArtifactKind { // (undocumented) static from(raw: string): ArtifactKind; // (undocumented) + isAdversarialReport(): boolean; + // (undocumented) isAnsibleProject(): boolean; // (undocumented) isMigratedSources(): boolean;