diff --git a/workspaces/x2a/.changeset/large-cloths-bow.md b/workspaces/x2a/.changeset/large-cloths-bow.md new file mode 100644 index 00000000000..8f464293bb7 --- /dev/null +++ b/workspaces/x2a/.changeset/large-cloths-bow.md @@ -0,0 +1,8 @@ +--- +'@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 +--- + +Downstream phase jobs are automatically marked as stale when an upstream phase completes successfully. diff --git a/workspaces/x2a/plugins/x2a-backend/migrations/2026081900_expand_jobs_constraints.ts b/workspaces/x2a/plugins/x2a-backend/migrations/2026081900_expand_jobs_constraints.ts new file mode 100644 index 00000000000..aef0dbd06dc --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/migrations/2026081900_expand_jobs_constraints.ts @@ -0,0 +1,155 @@ +/* + * 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'; + +const PHASES = [ + 'init', + 'analyze', + 'migrate', + 'publish', + 'adversarial-analyze', + 'adversarial-migrate', +]; + +const EXTENDED_STATUSES = [ + 'pending', + 'running', + 'success', + 'error', + 'cancelled', + 'stale', +]; + +const ORIGINAL_STATUSES = [ + 'pending', + 'running', + 'success', + 'error', + 'cancelled', +]; + +function createJobsTable( + table: Knex.CreateTableBuilder, + statuses: string[], +): void { + table.uuid('id').primary(); + table.text('log'); + table.timestamp('started_at').notNullable(); + table.timestamp('finished_at'); + table.string('status').notNullable().defaultTo('pending').checkIn(statuses); + table.string('phase').notNullable().defaultTo('init').checkIn(PHASES); + 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.text('error_details'); + table.text('telemetry'); + table.string('k8s_job_name'); + table.string('callback_token'); + table.string('commit_id').nullable(); + table.index('started_at'); + table.index('finished_at'); + table.index('status'); + table.index('phase'); + table.index('k8s_job_name'); +} + +async function recreateJobsTableSqlite( + knex: Knex, + statuses: string[], + staging: string, +): Promise { + // staging must be unique across all migrations — SQLite index names are + // global and survive table renames, so reusing a prior staging name causes + // an "index already exists" error on the next recreate. + await knex.schema.raw('PRAGMA foreign_keys = OFF'); + try { + await knex.schema.createTable(staging, table => + createJobsTable(table, statuses), + ); + await knex.schema.raw( + `INSERT INTO ${staging} (id, log, started_at, finished_at, status, phase, error_details, telemetry, k8s_job_name, callback_token, commit_id, project_id, module_id)` + + ` SELECT id, log, started_at, finished_at, status, phase, error_details, telemetry, k8s_job_name, callback_token, commit_id, project_id, module_id FROM jobs`, + ); + await knex.schema.dropTable('jobs'); + await knex.schema.raw(`ALTER TABLE ${staging} RENAME TO jobs`); + } finally { + await knex.schema.raw('PRAGMA foreign_keys = ON'); + } +} + +/** + * Expands the jobs.status CHECK constraint to include 'stale', + * needed for cascading invalidation of downstream phase jobs. + * + * @public + */ +export async function up(knex: Knex): Promise { + const client = knex.client.config.client; + + if (client === 'better-sqlite3') { + await recreateJobsTableSqlite( + knex, + EXTENDED_STATUSES, + 'jobs_status_expanded', + ); + } else { + await knex.raw( + `ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check`, + ); + await knex.raw( + `ALTER TABLE jobs ADD CONSTRAINT jobs_status_check CHECK (status IN ('pending', 'running', 'success', 'error', 'cancelled', 'stale'))`, + ); + } +} + +/** + * Reverts the jobs.status CHECK constraint to exclude 'stale'. + * + * @public + */ +export async function down(knex: Knex): Promise { + // Stale rows must be converted before reinstating the constraint; + await knex('jobs').where('status', 'stale').update({ status: 'success' }); + + const client = knex.client.config.client; + + if (client === 'better-sqlite3') { + await recreateJobsTableSqlite( + knex, + ORIGINAL_STATUSES, + 'jobs_status_contracted', + ); + } else { + await knex.raw( + `ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check`, + ); + await knex.raw( + `ALTER TABLE jobs ADD CONSTRAINT jobs_status_check CHECK (status IN ('pending', 'running', 'success', 'error', 'cancelled'))`, + ); + } +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts b/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts index 8392b79fcd0..daf17083078 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/__testUtils__/routerHelpers.ts @@ -255,6 +255,7 @@ export interface MockRouterDeps { listJobsForModule: jest.Mock; createJob: jest.Mock; getJobLogs: jest.Mock; + markJobsAsStale: jest.Mock; }; kubeService: { createJob: jest.Mock; @@ -298,11 +299,12 @@ export function createMockRouterDeps(): MockRouterDeps { softDeleteModule: jest.fn().mockResolvedValue(1), restoreModule: jest.fn().mockResolvedValue(1), updateModule: jest.fn().mockResolvedValue(1), - listJobs: jest.fn(), + listJobs: jest.fn().mockResolvedValue([]), listJobsForProject: jest.fn(), listJobsForModule: jest.fn(), createJob: jest.fn(), getJobLogs: jest.fn(), + markJobsAsStale: jest.fn().mockResolvedValue(undefined), }, kubeService: { createJob: jest.fn().mockResolvedValue({ k8sJobName: 'test-job' }), diff --git a/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts b/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts index 6ffb3f380c5..7a1c8971a5d 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts @@ -141,6 +141,9 @@ const getX2aDatabaseServiceMock = (): typeof x2aDatabaseServiceRef.T => ({ listJobsForModule: jest .fn() .mockRejectedValue(new NotAllowedError('mock error')), + markJobsAsStale: jest + .fn() + .mockRejectedValue(new NotAllowedError('mock error')), // rules createRule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), updateRule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')), diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.test.ts index 9fe90307525..b6775657bd1 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.test.ts @@ -465,4 +465,236 @@ describe('collectArtifacts routes', () => { ); }); }); + + describe('cascade invalidation', () => { + it('marks downstream jobs as stale when analyze completes', async () => { + const job = { + id: jobId, + projectId, + moduleId, + phase: 'analyze', + status: 'running', + startedAt: new Date(), + k8sJobName: 'k8s-job', + callbackToken, + artifacts: [], + } as any; + + const migrateJob = { + id: 'migrate-job-id', + projectId, + moduleId, + phase: 'migrate' as const, + status: 'success', + artifacts: [], + }; + const publishJob = { + id: 'publish-job-id', + projectId, + moduleId, + phase: 'publish' as const, + status: 'success', + artifacts: [], + }; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + mockDeps.x2aDatabase.listJobs + .mockResolvedValueOnce([]) // adversarial-analyze + .mockResolvedValueOnce([migrateJob]) // migrate + .mockResolvedValueOnce([]) // adversarial-migrate + .mockResolvedValueOnce([publishJob]); // publish + mockDeps.x2aDatabase.markJobsAsStale.mockResolvedValue(undefined); + + const requestBody = { + status: 'success', + jobId, + artifacts: [], + }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post( + `/projects/${projectId}/collectArtifacts?phase=analyze&moduleId=${moduleId}`, + ) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.markJobsAsStale).toHaveBeenCalledWith([ + 'migrate-job-id', + 'publish-job-id', + ]); + }); + + it('marks downstream jobs as stale when migrate completes', async () => { + const job = { + id: jobId, + projectId, + moduleId, + phase: 'migrate', + status: 'running', + startedAt: new Date(), + k8sJobName: 'k8s-job', + callbackToken, + artifacts: [], + } as any; + + const publishJob = { + id: 'publish-job-id', + projectId, + moduleId, + phase: 'publish' as const, + status: 'success', + artifacts: [], + }; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + mockDeps.x2aDatabase.listJobs + .mockResolvedValueOnce([]) // adversarial-migrate + .mockResolvedValueOnce([publishJob]); // publish + mockDeps.x2aDatabase.markJobsAsStale.mockResolvedValue(undefined); + + const requestBody = { + status: 'success', + jobId, + artifacts: [], + }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post( + `/projects/${projectId}/collectArtifacts?phase=migrate&moduleId=${moduleId}`, + ) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.markJobsAsStale).toHaveBeenCalledWith([ + 'publish-job-id', + ]); + }); + + it('does not mark any jobs when publish completes (no downstream)', async () => { + const job = { + id: jobId, + projectId, + moduleId, + phase: 'publish', + status: 'running', + startedAt: new Date(), + k8sJobName: 'k8s-job', + callbackToken, + artifacts: [], + } as any; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + mockDeps.x2aDatabase.markJobsAsStale.mockResolvedValue(undefined); + + const requestBody = { + status: 'success', + jobId, + artifacts: [], + }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post( + `/projects/${projectId}/collectArtifacts?phase=publish&moduleId=${moduleId}`, + ) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.markJobsAsStale).not.toHaveBeenCalled(); + }); + + it('does not mark errored downstream jobs as stale', async () => { + const job = { + id: jobId, + projectId, + moduleId, + phase: 'analyze', + status: 'running', + startedAt: new Date(), + k8sJobName: 'k8s-job', + callbackToken, + artifacts: [], + } as any; + + const erroredMigrateJob = { + id: 'migrate-job-id', + projectId, + moduleId, + phase: 'migrate' as const, + status: 'error', + artifacts: [], + }; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + mockDeps.x2aDatabase.listJobs + .mockResolvedValueOnce([]) // adversarial-analyze + .mockResolvedValueOnce([erroredMigrateJob]) // migrate — error, should not be staled + .mockResolvedValueOnce([]) // adversarial-migrate + .mockResolvedValueOnce([]); // publish + mockDeps.x2aDatabase.markJobsAsStale.mockResolvedValue(undefined); + + const requestBody = { status: 'success', jobId, artifacts: [] }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post( + `/projects/${projectId}/collectArtifacts?phase=analyze&moduleId=${moduleId}`, + ) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.markJobsAsStale).not.toHaveBeenCalled(); + }); + + it('does not invalidate downstream jobs when phase fails', async () => { + const job = { + id: jobId, + projectId, + moduleId, + phase: 'analyze', + status: 'running', + startedAt: new Date(), + k8sJobName: 'k8s-job', + callbackToken, + artifacts: [], + } as any; + + mockDeps.x2aDatabase.getJob.mockResolvedValue(job); + mockDeps.kubeService.getJobLogs.mockResolvedValue('logs'); + mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined); + mockDeps.x2aDatabase.markJobsAsStale.mockResolvedValue(undefined); + + const requestBody = { + status: 'error', + jobId, + errorDetails: 'Something went wrong', + artifacts: [], + }; + const signature = signRequestBody(requestBody, callbackToken); + + const res = await request(app) + .post( + `/projects/${projectId}/collectArtifacts?phase=analyze&moduleId=${moduleId}`, + ) + .set('X-Callback-Signature', signature) + .send(requestBody); + + expect(res.status).toBe(200); + expect(mockDeps.x2aDatabase.markJobsAsStale).not.toHaveBeenCalled(); + }); + }); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.ts b/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.ts index db12297276e..4d3d7ab69c4 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router/collectArtifacts.ts @@ -34,6 +34,20 @@ import { CallbackToken } from '@red-hat-developer-hub/backstage-plugin-x2a-node' import type { RouterDeps } from './types'; import { executePhaseActions } from './phaseActions'; +const DOWNSTREAM_PHASES: Record = { + init: [], + analyze: [ + 'adversarial-analyze', + 'migrate', + 'adversarial-migrate', + 'publish', + ] as const, + migrate: ['adversarial-migrate', 'publish'] as const, + publish: [], + 'adversarial-analyze': [], + 'adversarial-migrate': [], +}; + const agentMetricsSchema = z.object({ name: z.string(), startedAt: z.string().optional(), @@ -314,6 +328,44 @@ export function registerCollectArtifactsRoutes( ); } +async function invalidateDownstreamPhases( + phase: MigrationPhase, + projectId: string, + moduleId: string | undefined, + x2aDatabase: RouterDeps['x2aDatabase'], + logger: RouterDeps['logger'], +): Promise { + const downstreamPhases = DOWNSTREAM_PHASES[phase]; + if (downstreamPhases.length === 0 || !moduleId) { + return; + } + + logger.info( + `Invalidating downstream phases for ${phase} on module ${moduleId}: ${downstreamPhases.join(', ')}`, + ); + + const jobsToStale: string[] = []; + + for (const downstreamPhase of downstreamPhases) { + const jobs = await x2aDatabase.listJobs({ + projectId, + moduleId, + phase: downstreamPhase, + lastJobOnly: true, + }); + + const lastJob = jobs[0]; + if (lastJob && JobStatus.from(lastJob.status).isSuccess()) { + jobsToStale.push(lastJob.id); + } + } + + if (jobsToStale.length > 0) { + await x2aDatabase.markJobsAsStale(jobsToStale); + logger.info(`Marked ${jobsToStale.length} downstream jobs as stale`); + } +} + async function processJobCompletion( validatedRequest: CollectArtifactsRequestBody, phase: MigrationPhase, @@ -355,6 +407,16 @@ async function processJobCompletion( commitId: validatedRequest.commitId, }); + if (jobStatus.isSuccess()) { + await invalidateDownstreamPhases( + phase, + projectId, + job.moduleId, + x2aDatabase, + logger, + ); + } + return { message: 'Artifacts collected successfully' }; } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index f71dfbdcedc..76a36437c92 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -999,6 +999,7 @@ components: - success - error - cancelled + - stale ModuleStatus: type: string @@ -1017,6 +1018,7 @@ components: - error - cancelled - removed + - stale ProjectStatusState: type: string diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/JobStatusEnum.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/JobStatusEnum.model.ts index e9ca5de9baf..1bc0974bc09 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/JobStatusEnum.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/JobStatusEnum.model.ts @@ -26,4 +26,5 @@ export type JobStatusEnum = | 'running' | 'success' | 'error' - | 'cancelled'; + | 'cancelled' + | 'stale'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts index 4599c18b08a..79029c069fd 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/models/ModuleStatus.model.ts @@ -27,4 +27,5 @@ export type ModuleStatus = | 'success' | 'error' | 'cancelled' - | 'removed'; + | 'removed' + | 'stale'; 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 03bbfdcbe97..3c15eb9965e 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 @@ -1481,7 +1481,8 @@ export const spec = { "running", "success", "error", - "cancelled" + "cancelled", + "stale" ] }, "ModuleStatus": { @@ -1493,7 +1494,8 @@ export const spec = { "success", "error", "cancelled", - "removed" + "removed", + "stale" ] }, "ProjectStatusState": { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/2026081900_expand_jobs_constraints.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/2026081900_expand_jobs_constraints.test.ts new file mode 100644 index 00000000000..fc98a5f4550 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/2026081900_expand_jobs_constraints.test.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 { + createDatabase, + supportedDatabaseIds, + tearDownDatabases, +} from '../../__testUtils__'; +import { + up, + down, +} from '../../../migrations/2026081900_expand_jobs_constraints'; + +const PROJECT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const JOB_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + +describe('migration 2026081900_expand_jobs_constraints', () => { + afterEach(async () => { + await tearDownDatabases(); + }); + + it.each(supportedDatabaseIds)( + 'up() preserves job columns after table recreation - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + + await client('projects').insert({ + id: PROJECT_ID, + name: 'Test Project', + dir_name: 'test-project', + source_repo_url: 'https://github.com/source/repo', + source_repo_branch: 'main', + target_repo_url: 'https://github.com/target/repo', + target_repo_branch: 'main', + owned_by: 'test-user', + }); + + await client('jobs').insert({ + id: JOB_ID, + project_id: PROJECT_ID, + status: 'success', + phase: 'analyze', + started_at: new Date('2026-01-01T00:00:00Z'), + error_details: 'some error detail', + }); + + // Round-trip through down() and up() exercises the SQLite table-recreation + // path and verifies the named-column INSERT maps values correctly. + await down(client); + await up(client); + + const [row] = await client('jobs') + .where({ id: JOB_ID }) + .select('project_id', 'error_details', 'status', 'phase'); + + expect(row.project_id).toBe(PROJECT_ID); + expect(row.error_details).toBe('some error detail'); + expect(row.status).toBe('success'); + expect(row.phase).toBe('analyze'); + }, + ); +}); 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 cb58d1fe1d8..d017d1e606e 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/index.ts @@ -603,6 +603,10 @@ export class X2ADatabaseService implements X2ADatabaseServiceApi { return this.#jobOps.deleteJob({ id }); } + async markJobsAsStale(jobIds: string[]): Promise { + return this.#jobOps.markJobsAsStale(jobIds); + } + // Rules async createRule(input: { diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobOperations.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobOperations.ts index 70474ff8c2e..696d629fb31 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobOperations.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobOperations.ts @@ -460,4 +460,21 @@ export class JobOperations { return deletedCount; } + + async markJobsAsStale(jobIds: string[]): Promise { + if (jobIds.length === 0) { + return; + } + + this.#logger.info(`markJobsAsStale called for ${jobIds.length} jobs`); + + const updatedCount = await this.#dbClient('jobs') + .whereIn('id', jobIds) + .whereNotIn('status', ['pending', 'running']) + .update({ status: 'stale' }); + + this.#logger.info( + `Marked ${updatedCount} jobs as stale (${jobIds.length - updatedCount} were skipped as active)`, + ); + } } diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobsUpdateDelete.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobsUpdateDelete.test.ts index 13ff5ad8d0c..c00faa82234 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobsUpdateDelete.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/jobsUpdateDelete.test.ts @@ -704,4 +704,138 @@ describe('X2ADatabaseService – jobs (update & delete)', () => { }, ); }); + + describe('markJobsAsStale', () => { + it.each(supportedDatabaseIds)( + 'marks terminal jobs as stale - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Test Project', + description: 'D', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const module = await service.createModule({ + name: 'Test Module', + sourcePath: '/path', + projectId: project.id, + }); + + const successJob = await service.createJob({ + projectId: project.id, + moduleId: module.id, + phase: 'analyze' as const, + status: 'success', + }); + const errorJob = await service.createJob({ + projectId: project.id, + moduleId: module.id, + phase: 'migrate' as const, + status: 'error', + }); + const cancelledJob = await service.createJob({ + projectId: project.id, + moduleId: module.id, + phase: 'publish' as const, + status: 'cancelled', + }); + + await service.markJobsAsStale([ + successJob.id, + errorJob.id, + cancelledJob.id, + ]); + + const updatedSuccess = await service.getJob({ id: successJob.id }); + const updatedError = await service.getJob({ id: errorJob.id }); + const updatedCancelled = await service.getJob({ id: cancelledJob.id }); + + expect(updatedSuccess?.status).toBe('stale'); + expect(updatedError?.status).toBe('stale'); + expect(updatedCancelled?.status).toBe('stale'); + }, + ); + + it.each(supportedDatabaseIds)( + 'skips active jobs (pending, running) - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + const credentials = mockCredentials.user(); + const project = await service.createProject( + { + name: 'Test Project', + description: 'D', + ...defaultProjectRepoFields, + }, + { credentials }, + ); + const module = await service.createModule({ + name: 'Test Module', + sourcePath: '/path', + projectId: project.id, + }); + + const pendingJob = await service.createJob({ + projectId: project.id, + moduleId: module.id, + phase: 'analyze' as const, + status: 'pending', + }); + const runningJob = await service.createJob({ + projectId: project.id, + moduleId: module.id, + phase: 'migrate' as const, + status: 'running', + }); + const successJob = await service.createJob({ + projectId: project.id, + moduleId: module.id, + phase: 'publish' as const, + status: 'success', + }); + + await service.markJobsAsStale([ + pendingJob.id, + runningJob.id, + successJob.id, + ]); + + const updatedPending = await service.getJob({ id: pendingJob.id }); + const updatedRunning = await service.getJob({ id: runningJob.id }); + const updatedSuccess = await service.getJob({ id: successJob.id }); + + expect(updatedPending?.status).toBe('pending'); + expect(updatedRunning?.status).toBe('running'); + expect(updatedSuccess?.status).toBe('stale'); + }, + ); + + it.each(supportedDatabaseIds)( + 'handles empty array - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + await expect(service.markJobsAsStale([])).resolves.toBeUndefined(); + }, + ); + + it.each(supportedDatabaseIds)( + 'handles non-existent job IDs - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + await expect( + service.markJobsAsStale([nonExistentId]), + ).resolves.toBeUndefined(); + }, + ); + }); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts index d2158073c5d..6c005589568 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService/projectStatus.ts @@ -69,8 +69,9 @@ export function calculateProjectStatus( ).length; const waiting = modulesWithStatus.filter( m => - m.status?.isSuccess() && - (!m.module.publish || m.publishStatus?.isCancelled()), + m.status?.isStale() || + (m.status?.isSuccess() && + (!m.module.publish || m.publishStatus?.isCancelled())), ).length; const pending = modulesWithStatus.filter(m => m.status?.isPending()).length; const running = modulesWithStatus.filter(m => m.status?.isRunning()).length; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/JobStatusEnum.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/JobStatusEnum.model.ts index e9ca5de9baf..1bc0974bc09 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/JobStatusEnum.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/JobStatusEnum.model.ts @@ -26,4 +26,5 @@ export type JobStatusEnum = | 'running' | 'success' | 'error' - | 'cancelled'; + | 'cancelled' + | 'stale'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts index 4599c18b08a..79029c069fd 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/models/ModuleStatus.model.ts @@ -27,4 +27,5 @@ export type ModuleStatus = | 'success' | 'error' | 'cancelled' - | 'removed'; + | 'removed' + | 'stale'; diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index bc18e9bab66..5abdc217609 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -451,12 +451,16 @@ export class JobStatus { // (undocumented) isRunning(): boolean; // (undocumented) + isStale(): boolean; + // (undocumented) isSuccess(): boolean; // (undocumented) static readonly PENDING: JobStatus; // (undocumented) static readonly RUNNING: JobStatus; // (undocumented) + static readonly STALE: JobStatus; + // (undocumented) static readonly SUCCESS: JobStatus; // (undocumented) toString(): string; @@ -472,7 +476,8 @@ export type JobStatusEnum = | 'running' | 'success' | 'error' - | 'cancelled'; + | 'cancelled' + | 'stale'; // @public export const MAX_BACKOFF_MS: number; @@ -535,7 +540,8 @@ export type ModuleStatus = | 'success' | 'error' | 'cancelled' - | 'removed'; + | 'removed' + | 'stale'; // @public export function normalizeRepoUrl(url: string): string; @@ -1109,6 +1115,7 @@ export const X2A_JOB_STATUS_VALUES: readonly [ 'success', 'error', 'cancelled', + 'stale', ]; // @public diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.test.ts b/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.test.ts index 54d57941145..621028ef6eb 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.test.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.test.ts @@ -38,23 +38,28 @@ describe('JobStatus', () => { expect(JobStatus.from('cancelled')).toBe(JobStatus.CANCELLED); }); + it('returns JobStatus.STALE for "stale"', () => { + expect(JobStatus.from('stale')).toBe(JobStatus.STALE); + }); + it('throws for an invalid status', () => { expect(() => JobStatus.from('invalid')).toThrow( - 'Invalid job status: "invalid". Valid: pending, running, success, error, cancelled', + 'Invalid job status: "invalid". Valid: pending, running, success, error, cancelled, stale', ); }); }); describe('all', () => { - it('returns 5 statuses in defined order', () => { + it('returns 6 statuses in defined order', () => { const all = JobStatus.all(); - expect(all).toHaveLength(5); + expect(all).toHaveLength(6); expect(all).toEqual([ JobStatus.PENDING, JobStatus.RUNNING, JobStatus.SUCCESS, JobStatus.ERROR, JobStatus.CANCELLED, + JobStatus.STALE, ]); }); }); @@ -67,6 +72,7 @@ describe('JobStatus', () => { 'success', 'error', 'cancelled', + 'stale', ]); }); }); @@ -81,11 +87,12 @@ describe('JobStatus', () => { }); describe('finishedStatuses', () => { - it('returns success, error, and cancelled', () => { + it('returns success, error, cancelled, and stale', () => { expect(JobStatus.finishedStatuses()).toEqual([ JobStatus.SUCCESS, JobStatus.ERROR, JobStatus.CANCELLED, + JobStatus.STALE, ]); }); }); @@ -115,6 +122,11 @@ describe('JobStatus', () => { expect(JobStatus.CANCELLED.isFinished()).toBe(true); expect(JobStatus.CANCELLED.isActive()).toBe(false); }); + + it('STALE is finished', () => { + expect(JobStatus.STALE.isFinished()).toBe(true); + expect(JobStatus.STALE.isActive()).toBe(false); + }); }); describe('individual predicates', () => { @@ -142,6 +154,11 @@ describe('JobStatus', () => { expect(JobStatus.CANCELLED.isCancelled()).toBe(true); expect(JobStatus.PENDING.isCancelled()).toBe(false); }); + + it('isStale', () => { + expect(JobStatus.STALE.isStale()).toBe(true); + expect(JobStatus.SUCCESS.isStale()).toBe(false); + }); }); describe('toString', () => { @@ -151,6 +168,7 @@ describe('JobStatus', () => { expect(JobStatus.SUCCESS.toString()).toBe('success'); expect(JobStatus.ERROR.toString()).toBe('error'); expect(JobStatus.CANCELLED.toString()).toBe('cancelled'); + expect(JobStatus.STALE.toString()).toBe('stale'); }); }); @@ -175,6 +193,7 @@ describe('JobStatus', () => { expect(JobStatus.from('success')).toBe(JobStatus.SUCCESS); expect(JobStatus.from('error')).toBe(JobStatus.ERROR); expect(JobStatus.from('cancelled')).toBe(JobStatus.CANCELLED); + expect(JobStatus.from('stale')).toBe(JobStatus.STALE); }); }); }); diff --git a/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.ts b/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.ts index 4dada6eef0e..59496e0285b 100644 --- a/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.ts +++ b/workspaces/x2a/plugins/x2a-common/src/domain/JobStatus.ts @@ -23,6 +23,7 @@ export class JobStatus { static readonly SUCCESS = new JobStatus('success'); static readonly ERROR = new JobStatus('error'); static readonly CANCELLED = new JobStatus('cancelled'); + static readonly STALE = new JobStatus('stale'); private static readonly BY_VALUE = new Map( [ @@ -31,6 +32,7 @@ export class JobStatus { JobStatus.SUCCESS, JobStatus.ERROR, JobStatus.CANCELLED, + JobStatus.STALE, ].map(s => [s.value, s]), ); @@ -53,6 +55,7 @@ export class JobStatus { JobStatus.SUCCESS, JobStatus.ERROR, JobStatus.CANCELLED, + JobStatus.STALE, ]; } @@ -65,7 +68,12 @@ export class JobStatus { } static finishedStatuses(): readonly JobStatus[] { - return [JobStatus.SUCCESS, JobStatus.ERROR, JobStatus.CANCELLED]; + return [ + JobStatus.SUCCESS, + JobStatus.ERROR, + JobStatus.CANCELLED, + JobStatus.STALE, + ]; } isActive(): boolean { @@ -96,6 +104,10 @@ export class JobStatus { return this === JobStatus.CANCELLED; } + isStale(): boolean { + return this === JobStatus.STALE; + } + equals(other: JobStatus): boolean { return this.value === other.value; } diff --git a/workspaces/x2a/plugins/x2a-common/src/x2aJobStatusLiterals.ts b/workspaces/x2a/plugins/x2a-common/src/x2aJobStatusLiterals.ts index 9e40958dba6..79a8605da7e 100644 --- a/workspaces/x2a/plugins/x2a-common/src/x2aJobStatusLiterals.ts +++ b/workspaces/x2a/plugins/x2a-common/src/x2aJobStatusLiterals.ts @@ -29,6 +29,7 @@ export const X2A_JOB_STATUS_VALUES = [ 'success', 'error', 'cancelled', + 'stale', ] as const satisfies readonly JobStatusEnum[]; /** diff --git a/workspaces/x2a/plugins/x2a-node/report.api.md b/workspaces/x2a/plugins/x2a-node/report.api.md index 9ccb78616d9..6af372171e5 100644 --- a/workspaces/x2a/plugins/x2a-node/report.api.md +++ b/workspaces/x2a/plugins/x2a-node/report.api.md @@ -417,6 +417,8 @@ export interface X2ADatabaseServiceApi { // (undocumented) listRules(): Promise; // (undocumented) + markJobsAsStale(jobIds: string[]): Promise; + // (undocumented) restoreModule(args: { id: string }): Promise; // (undocumented) softDeleteModule(args: { id: string }): Promise; diff --git a/workspaces/x2a/plugins/x2a-node/src/moduleStatus.test.ts b/workspaces/x2a/plugins/x2a-node/src/moduleStatus.test.ts index 5f40f075d28..40e86c529eb 100644 --- a/workspaces/x2a/plugins/x2a-node/src/moduleStatus.test.ts +++ b/workspaces/x2a/plugins/x2a-node/src/moduleStatus.test.ts @@ -166,6 +166,37 @@ describe('calculateModuleStatus', () => { ).toBe('success'); }); + describe('stale status', () => { + it('returns stale when the most-advanced phase is stale', () => { + expect( + calculateModuleStatus({ + analyze: job('success'), + migrate: job('stale'), + }).status, + ).toBe('stale'); + }); + + it('returns stale when publish is stale', () => { + expect( + calculateModuleStatus({ + analyze: job('success'), + migrate: job('success'), + publish: job('stale'), + }).status, + ).toBe('stale'); + }); + + it('returns stale when migrate and publish are stale', () => { + expect( + calculateModuleStatus({ + analyze: job('success'), + migrate: job('stale'), + publish: job('stale'), + }).status, + ).toBe('stale'); + }); + }); + describe('errorDetails', () => { it('returns errorDetails from analyze job when only analyze is provided', () => { const result = calculateModuleStatus({ diff --git a/workspaces/x2a/plugins/x2a-node/src/moduleStatus.ts b/workspaces/x2a/plugins/x2a-node/src/moduleStatus.ts index 343fc931efa..c0b5f95c85f 100644 --- a/workspaces/x2a/plugins/x2a-node/src/moduleStatus.ts +++ b/workspaces/x2a/plugins/x2a-node/src/moduleStatus.ts @@ -40,19 +40,16 @@ export function calculateModuleStatus({ publish?: Job; }): { status: ModuleStatus; errorDetails?: string } { const latestPhaseJob = publish ?? migrate ?? analyze; - if (latestPhaseJob && JobStatus.from(latestPhaseJob.status).isCancelled()) { - return { status: 'cancelled', errorDetails: undefined }; - } - if (publish) { - return { status: publish.status, errorDetails: publish.errorDetails }; - } - if (migrate) { - return { status: migrate.status, errorDetails: migrate.errorDetails }; + if (!latestPhaseJob) { + return { status: 'pending', errorDetails: undefined }; } - if (analyze) { - return { status: analyze.status, errorDetails: analyze.errorDetails }; + + if (JobStatus.from(latestPhaseJob.status).isCancelled()) { + return { status: 'cancelled', errorDetails: undefined }; } - return { status: 'pending', errorDetails: undefined }; + const status: ModuleStatus = latestPhaseJob.status as ModuleStatus; + + return { status, errorDetails: latestPhaseJob.errorDetails }; } diff --git a/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts index 77c2a02798e..735b9029b4f 100644 --- a/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-node/src/services/X2ADatabaseService.ts @@ -170,6 +170,8 @@ export interface X2ADatabaseServiceApi { deleteJob(args: { id: string }): Promise; + markJobsAsStale(jobIds: string[]): Promise; + // Rules createRule(input: { diff --git a/workspaces/x2a/plugins/x2a/report-alpha.api.md b/workspaces/x2a/plugins/x2a/report-alpha.api.md index 1b5bb61af4b..f9bac65dd77 100644 --- a/workspaces/x2a/plugins/x2a/report-alpha.api.md +++ b/workspaces/x2a/plugins/x2a/report-alpha.api.md @@ -224,7 +224,9 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'modulePage.phases.statuses.running': string; readonly 'modulePage.phases.statuses.success': string; readonly 'modulePage.phases.statuses.cancelled': string; + readonly 'modulePage.phases.statuses.stale': string; readonly 'modulePage.phases.resyncMigrationPlanInstructions': string; + readonly 'modulePage.phases.staleInstructions': string; readonly 'modulePage.phases.reanalyzeInstructions': string; readonly 'modulePage.phases.rerunAnalyze': string; readonly 'modulePage.phases.analyzeInstructions': string; @@ -342,6 +344,7 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'module.statuses.running': string; readonly 'module.statuses.success': string; readonly 'module.statuses.cancelled': string; + readonly 'module.statuses.stale': string; readonly 'module.statuses.removed': string; readonly 'module.notStarted': string; readonly 'module.actions.runNextPhase': string; diff --git a/workspaces/x2a/plugins/x2a/report.api.md b/workspaces/x2a/plugins/x2a/report.api.md index 9c291c5a67c..2ffbd159737 100644 --- a/workspaces/x2a/plugins/x2a/report.api.md +++ b/workspaces/x2a/plugins/x2a/report.api.md @@ -130,7 +130,9 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'modulePage.phases.statuses.running': string; readonly 'modulePage.phases.statuses.success': string; readonly 'modulePage.phases.statuses.cancelled': string; + readonly 'modulePage.phases.statuses.stale': string; readonly 'modulePage.phases.resyncMigrationPlanInstructions': string; + readonly 'modulePage.phases.staleInstructions': string; readonly 'modulePage.phases.reanalyzeInstructions': string; readonly 'modulePage.phases.rerunAnalyze': string; readonly 'modulePage.phases.analyzeInstructions': string; @@ -248,6 +250,7 @@ export const x2aPluginTranslationRef: TranslationRef< readonly 'module.statuses.running': string; readonly 'module.statuses.success': string; readonly 'module.statuses.cancelled': string; + readonly 'module.statuses.stale': string; readonly 'module.statuses.removed': string; readonly 'module.notStarted': string; readonly 'module.actions.runNextPhase': string; diff --git a/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx b/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx index d75eb7e1626..d22746e69ef 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ModuleStatusCell.tsx @@ -67,6 +67,8 @@ const StatusWithText = ({ return {children}; case 'pending': return {children}; + case 'stale': + return {children}; case 'cancelled': return {children}; case 'removed': diff --git a/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx b/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx index 211b2b37e1b..2965f20496d 100644 --- a/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/PhaseDetails.tsx @@ -75,13 +75,17 @@ const PhaseRunAction = ({ const { t } = useTranslation(); const classes = useStyles(); + const isStale = !!phase?.status && JobStatus.from(phase.status).isStale(); const previousRunSucceeded = - !!phase?.status && JobStatus.from(phase.status).isSuccess(); + !!phase?.status && (JobStatus.from(phase.status).isSuccess() || isStale); if (!onRunPhase) { return null; } const getInstructions = () => { + if (isStale) { + return t('modulePage.phases.staleInstructions'); + } if (phaseName === 'init') { return t('modulePage.phases.resyncMigrationPlanInstructions'); } diff --git a/workspaces/x2a/plugins/x2a/src/components/PhaseStatus.tsx b/workspaces/x2a/plugins/x2a/src/components/PhaseStatus.tsx index 52be1a484a5..dcd4c548ec3 100644 --- a/workspaces/x2a/plugins/x2a/src/components/PhaseStatus.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/PhaseStatus.tsx @@ -24,6 +24,7 @@ import { StatusOK, StatusPending, StatusRunning, + StatusWarning, } from '@backstage/core-components'; export const PhaseStatusIcon = ({ @@ -44,6 +45,8 @@ export const PhaseStatusIcon = ({ return {children}; case 'cancelled': return {children}; + case 'stale': + return {children}; default: // to work nicely in the tab titles return null; diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.test.ts b/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.test.ts index c3947682f71..260c2745bba 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.test.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.test.ts @@ -126,5 +126,28 @@ describe('getLastPhaseReached', () => { expect(result?.phase).toBe('analyze'); expect(result?.status).toBe('running'); }); + + it('skips stale migrate and returns analyze', () => { + const module: Module = { + ...baseModule, + analyze: makeJob('analyze', 'success'), + migrate: makeJob('migrate', 'stale'), + }; + const result = getLastPhaseReached(module, true); + expect(result?.phase).toBe('analyze'); + expect(result?.status).toBe('success'); + }); + + it('skips stale migrate and publish and returns analyze', () => { + const module: Module = { + ...baseModule, + analyze: makeJob('analyze', 'success'), + migrate: makeJob('migrate', 'stale'), + publish: makeJob('publish', 'stale'), + }; + const result = getLastPhaseReached(module, true); + expect(result?.phase).toBe('analyze'); + expect(result?.status).toBe('success'); + }); }); }); diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.ts b/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.ts index ce0ec35cfcd..c4b279807d4 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/getLastPhaseReached.ts @@ -30,7 +30,10 @@ export const getLastPhaseReached = ( for (const phase of phases) { const job = rowData[phase]; if (job?.phase) { - if (ignoreCancelled && job.status === 'cancelled') { + if ( + ignoreCancelled && + (job.status === 'cancelled' || job.status === 'stale') + ) { continue; } return job; diff --git a/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.test.ts b/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.test.ts index 96eacd07533..1afe0c969fa 100644 --- a/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.test.ts +++ b/workspaces/x2a/plugins/x2a/src/components/tools/getNextPhase.test.ts @@ -138,4 +138,25 @@ describe('getNextPhase', () => { expect(getNextPhase(module)).toBe('analyze'); }); }); + + describe('stale status handling', () => { + it('returns "migrate" when migrate is stale', () => { + const module: Module = { + ...baseModule, + analyze: makeJob('analyze', 'success'), + migrate: makeJob('migrate', 'stale'), + }; + expect(getNextPhase(module)).toBe('migrate'); + }); + + it('returns "migrate" when migrate and publish are stale', () => { + const module: Module = { + ...baseModule, + analyze: makeJob('analyze', 'success'), + migrate: makeJob('migrate', 'stale'), + publish: makeJob('publish', 'stale'), + }; + expect(getNextPhase(module)).toBe('migrate'); + }); + }); }); diff --git a/workspaces/x2a/plugins/x2a/src/translations/de.ts b/workspaces/x2a/plugins/x2a/src/translations/de.ts index d43057a9269..eb8e32f0aeb 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/de.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/de.ts @@ -142,6 +142,7 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'module.statuses.success': 'Erfolg', 'module.statuses.error': 'Fehler', 'module.statuses.cancelled': 'Abgebrochen', + 'module.statuses.stale': 'Veraltet', 'module.statuses.removed': 'Entfernt', 'artifact.types.migrated_sources': 'Migrierte Quellen', 'artifact.types.project_metadata': 'Projektmetadaten', @@ -167,6 +168,7 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'modulePage.phases.statuses.success': 'Erfolg', 'modulePage.phases.statuses.error': 'Fehler', 'modulePage.phases.statuses.cancelled': 'Abgebrochen', + 'modulePage.phases.statuses.stale': 'Veraltet', 'modulePage.phases.reanalyzeInstructions': 'Der Modulmigrationsplan ist bereits vorhanden. Falls der gesamte Projektmigrationsplan aktualisiert wurde, lösen Sie die Analyse erneut aus, um die Änderungen widerzuspiegeln.', 'modulePage.phases.rerunAnalyze': 'Modulmigrationsplan neu erstellen', @@ -211,6 +213,8 @@ const x2aPluginTranslationDe = createTranslationMessages({ 'modulePage.phases.telemetry.totalOutputTokens': 'Gesamte Ausgabe-Tokens', 'modulePage.phases.resyncMigrationPlanInstructions': 'Modulliste synchronisieren, um sie an den Migrationsplan anzupassen.', + 'modulePage.phases.staleInstructions': + 'Dieses Phasenergebnis ist veraltet, weil eine vorgelagerte Phase erneut ausgeführt wurde. Erneut ausführen, um zu aktualisieren.', 'time.duration.daysAndHours': '{{days}}T {{hours}}Std', 'time.duration.daysOnly': '{{days}}T', 'time.duration.hoursAndMinutes': '{{hours}}Std {{minutes}}Min', diff --git a/workspaces/x2a/plugins/x2a/src/translations/es.ts b/workspaces/x2a/plugins/x2a/src/translations/es.ts index 8473cd321be..c5938611be5 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/es.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/es.ts @@ -143,6 +143,7 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'module.statuses.success': 'Éxito', 'module.statuses.error': 'Error', 'module.statuses.cancelled': 'Cancelado', + 'module.statuses.stale': 'Obsoleto', 'module.statuses.removed': 'Eliminado', 'artifact.types.migrated_sources': 'Fuentes migradas', 'artifact.types.project_metadata': 'Metadatos del proyecto', @@ -170,6 +171,7 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'modulePage.phases.statuses.success': 'Éxito', 'modulePage.phases.statuses.error': 'Error', 'modulePage.phases.statuses.cancelled': 'Cancelado', + 'modulePage.phases.statuses.stale': 'Obsoleto', 'modulePage.phases.reanalyzeInstructions': 'El plan de migración del módulo ya existe. Si el plan de migración general del proyecto se ha actualizado, vuelva a ejecutar el análisis para reflejar los cambios.', 'modulePage.phases.rerunAnalyze': 'Recrear el plan de migración del módulo', @@ -215,6 +217,8 @@ const x2aPluginTranslationEs = createTranslationMessages({ 'Total de tokens de salida', 'modulePage.phases.resyncMigrationPlanInstructions': 'Resincronizar la lista de módulos para que coincida con el plan de migración.', + 'modulePage.phases.staleInstructions': + 'El resultado de esta fase es obsoleto porque una fase anterior se volvió a ejecutar. Vuelva a ejecutar para actualizar.', 'time.duration.daysAndHours': '{{days}}d {{hours}}h', 'time.duration.daysOnly': '{{days}}d', 'time.duration.hoursAndMinutes': '{{hours}}h {{minutes}}min', diff --git a/workspaces/x2a/plugins/x2a/src/translations/fr.ts b/workspaces/x2a/plugins/x2a/src/translations/fr.ts index da6ac3c630f..80af4aeb553 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/fr.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/fr.ts @@ -144,6 +144,7 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'module.statuses.success': 'Succès', 'module.statuses.error': 'Erreur', 'module.statuses.cancelled': 'Annulé', + 'module.statuses.stale': 'Périmé', 'module.statuses.removed': 'Supprimé', 'artifact.types.migrated_sources': 'Sources migrées', 'artifact.types.project_metadata': 'Métadonnées du projet', @@ -169,6 +170,7 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'modulePage.phases.statuses.success': 'Succès', 'modulePage.phases.statuses.error': 'Erreur', 'modulePage.phases.statuses.cancelled': 'Annulé', + 'modulePage.phases.statuses.stale': 'Périmé', 'modulePage.phases.reanalyzeInstructions': "Le plan de migration du module est déjà présent. Si le plan de migration global du projet a été mis à jour, relancez l'analyse pour refléter les changements.", 'modulePage.phases.rerunAnalyze': 'Recréer le plan de migration du module', @@ -214,6 +216,8 @@ const x2aPluginTranslationFr = createTranslationMessages({ 'Total des jetons de sortie', 'modulePage.phases.resyncMigrationPlanInstructions': 'Resynchroniser la liste des modules pour correspondre au plan de migration.', + 'modulePage.phases.staleInstructions': + 'Le résultat de cette phase est périmé car une phase en amont a été réexécutée. Réexécutez pour mettre à jour.', 'time.duration.daysAndHours': '{{days}}j {{hours}}h', 'time.duration.daysOnly': '{{days}}j', 'time.duration.hoursAndMinutes': '{{hours}}h {{minutes}}min', diff --git a/workspaces/x2a/plugins/x2a/src/translations/it.ts b/workspaces/x2a/plugins/x2a/src/translations/it.ts index b92bef7f70e..221656f0568 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/it.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/it.ts @@ -142,6 +142,7 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'module.statuses.success': 'Successo', 'module.statuses.error': 'Errore', 'module.statuses.cancelled': 'Annullato', + 'module.statuses.stale': 'Non aggiornato', 'module.statuses.removed': 'Rimosso', 'artifact.types.migrated_sources': 'Sorgenti migrate', 'artifact.types.project_metadata': 'Metadati del progetto', @@ -169,6 +170,7 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'modulePage.phases.statuses.success': 'Successo', 'modulePage.phases.statuses.error': 'Errore', 'modulePage.phases.statuses.cancelled': 'Annullato', + 'modulePage.phases.statuses.stale': 'Non aggiornato', 'modulePage.phases.reanalyzeInstructions': "Il piano di migrazione del modulo è già presente. Se il piano di migrazione complessivo del progetto è stato aggiornato, riavvia l'analisi per riflettere le modifiche.", 'modulePage.phases.rerunAnalyze': @@ -215,6 +217,8 @@ const x2aPluginTranslationIt = createTranslationMessages({ 'modulePage.phases.telemetry.totalOutputTokens': 'Token di output totali', 'modulePage.phases.resyncMigrationPlanInstructions': "Sincronizza l'elenco dei moduli con il piano di migrazione.", + 'modulePage.phases.staleInstructions': + 'Il risultato di questa fase non è aggiornato perché una fase precedente è stata rieseguita. Rieseguire per aggiornare.', 'time.duration.daysAndHours': '{{days}}g {{hours}}h', 'time.duration.daysOnly': '{{days}}g', 'time.duration.hoursAndMinutes': '{{hours}}h {{minutes}}min', diff --git a/workspaces/x2a/plugins/x2a/src/translations/ref.ts b/workspaces/x2a/plugins/x2a/src/translations/ref.ts index 5892577fa31..0c5b003acf4 100644 --- a/workspaces/x2a/plugins/x2a/src/translations/ref.ts +++ b/workspaces/x2a/plugins/x2a/src/translations/ref.ts @@ -110,9 +110,12 @@ export const x2aPluginMessages = { success: 'Success', error: 'Error', cancelled: 'Cancelled', + stale: 'Stale', }, resyncMigrationPlanInstructions: 'Resync module list to match the migration plan.', + staleInstructions: + 'This phase result is stale because an upstream phase was re-run. Re-run to update.', reanalyzeInstructions: 'The module migration plan is already present. In case the overall project migration plan has been updated, retrigger the analysis to reflect the changes.', rerunAnalyze: 'Recreate the module migration plan', @@ -315,6 +318,7 @@ export const x2aPluginMessages = { success: 'Success', error: 'Error', cancelled: 'Cancelled', + stale: 'Stale', removed: 'Removed', }, },