CopilotJobControlService.java

package com.taxonomy.portfolio.service;

import com.taxonomy.portfolio.dto.PortfolioDtos.AnalysisJobView;
import com.taxonomy.portfolio.model.PortfolioTypes.AnalysisStatus;
import com.taxonomy.portfolio.model.RequirementAnalysisJob;
import com.taxonomy.portfolio.repository.RequirementAnalysisJobRepository;
import com.taxonomy.workspace.service.WorkspaceContext;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.List;

/** Transactional cancellation boundary for one exact-tenant persisted analysis job. */
@Service
public class CopilotJobControlService {

    private final RequirementAnalysisJobRepository jobRepository;
    private final PortfolioAnalysisPersistenceService persistenceService;
    private final EntityManager entityManager;

    public CopilotJobControlService(
            RequirementAnalysisJobRepository jobRepository,
            PortfolioAnalysisPersistenceService persistenceService,
            EntityManager entityManager) {
        this.jobRepository = jobRepository;
        this.persistenceService = persistenceService;
        this.entityManager = entityManager;
    }

    @Transactional
    public AnalysisJobView cancel(
            String jobId,
            Long projectId,
            String username,
            WorkspaceContext context) {
        String scopeKey = PortfolioScope.key(username, context);
        RequirementAnalysisJob job = jobRepository
                .findByIdAndProjectIdAndScopeKey(jobId, projectId, scopeKey)
                .orElseThrow(() -> PortfolioException.notFound(
                        "Analysis job not found: " + jobId));
        if (isTerminal(job.getStatus())) {
            return persistenceService.getJob(jobId, projectId, username, context);
        }
        Instant now = Instant.now();
        entityManager.createQuery("""
                update RequirementAnalysisJobItem item
                   set item.status = :cancelled,
                       item.completedAt = :completedAt,
                       item.errorMessage = :message,
                       item.rowVersion = item.rowVersion + 1
                 where item.jobId = :jobId
                   and item.projectId = :projectId
                   and item.scopeKey = :scopeKey
                   and item.status in :activeStatuses
                """)
                .setParameter("cancelled", AnalysisStatus.CANCELLED)
                .setParameter("completedAt", now)
                .setParameter("message", "Cancelled by user")
                .setParameter("jobId", jobId)
                .setParameter("projectId", projectId)
                .setParameter("scopeKey", scopeKey)
                .setParameter("activeStatuses", List.of(
                        AnalysisStatus.PENDING, AnalysisStatus.RUNNING))
                .executeUpdate();
        job.cancel(now);
        jobRepository.save(job);
        entityManager.flush();
        entityManager.clear();
        return persistenceService.getJob(jobId, projectId, username, context);
    }

    private static boolean isTerminal(AnalysisStatus status) {
        return status == AnalysisStatus.SUCCESS
                || status == AnalysisStatus.PARTIAL
                || status == AnalysisStatus.FAILED
                || status == AnalysisStatus.CANCELLED;
    }
}