mateclaw/mateclaw-server/src/main/java/vip/mate/team/service/TeamRunService.java

281 lines
12 KiB
Java

package vip.mate.team.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.team.model.AgentTeamEntity;
import vip.mate.team.model.TeamRunCreateCommand;
import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamRunStatus;
import vip.mate.team.model.TeamRunView;
import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/** Owns team run creation, lifecycle transitions, authorization, and reads. */
@Service
public class TeamRunService {
public record SealResult(TeamRunEntity run, boolean transitioned) {
}
public record CancelResult(TeamRunEntity run, boolean transitioned) {
}
private static final int MAX_TITLE_LENGTH = 255;
private static final Set<String> FINAL_OUTCOMES = Set.of(
TeamRunStatus.COMPLETED, TeamRunStatus.PARTIAL, TeamRunStatus.FAILED);
private final TeamRunMapper runMapper;
private final TeamTaskMapper taskMapper;
private final TeamService teamService;
private final TeamRunStateMachine stateMachine;
public TeamRunService(TeamRunMapper runMapper, TeamTaskMapper taskMapper, TeamService teamService) {
this.runMapper = runMapper;
this.taskMapper = taskMapper;
this.teamService = teamService;
this.stateMachine = new TeamRunStateMachine();
}
public TeamRunEntity startRun(TeamRunCreateCommand command) {
validateCreate(command);
TeamRunEntity existing = findByOrigin(command.getWorkspaceId(), command.getLeadConversationId(),
command.getOriginMessageId());
if (existing != null) {
return existing;
}
TeamRunEntity run = new TeamRunEntity();
run.setTeamId(command.getTeamId());
run.setWorkspaceId(command.getWorkspaceId());
run.setLeadAgentId(command.getLeadAgentId());
run.setLeadConversationId(command.getLeadConversationId());
run.setOriginMessageId(command.getOriginMessageId());
run.setTitle(deriveTitle(command));
run.setObjective(command.getObjective().trim());
run.setStatus(TeamRunStatus.PLANNING);
run.setMetadata(command.getMetadata());
try {
runMapper.insert(run);
return run;
} catch (DuplicateKeyException duplicate) {
TeamRunEntity winner = findByOrigin(command.getWorkspaceId(), command.getLeadConversationId(),
command.getOriginMessageId());
if (winner != null) {
return winner;
}
throw duplicate;
}
}
public TeamRunEntity requireRun(Long runId, Long workspaceId) {
TeamRunEntity run = runMapper.selectById(runId);
if (run == null || workspaceId == null || !workspaceId.equals(run.getWorkspaceId())) {
throw new IllegalArgumentException("team run not found in workspace: " + runId);
}
return run;
}
public Set<Long> findPlanningRunIds(Collection<Long> runIds) {
if (runIds == null || runIds.isEmpty()) {
return Set.of();
}
return runMapper.selectBatchIds(runIds).stream()
.filter(run -> TeamRunStatus.PLANNING.equals(run.getStatus()))
.map(TeamRunEntity::getId)
.collect(Collectors.toSet());
}
public TeamRunView getRun(Long runId, Long workspaceId) {
return buildView(requireRun(runId, workspaceId));
}
public List<TeamRunView> listTeamRuns(Long teamId, Long workspaceId) {
return runMapper.selectList(Wrappers.<TeamRunEntity>lambdaQuery()
.eq(TeamRunEntity::getTeamId, teamId)
.eq(TeamRunEntity::getWorkspaceId, workspaceId)
.orderByDesc(TeamRunEntity::getCreateTime))
.stream().map(this::buildView).toList();
}
public List<TeamRunView> listConversationRuns(String conversationId, Long workspaceId) {
return runMapper.selectList(Wrappers.<TeamRunEntity>lambdaQuery()
.eq(TeamRunEntity::getLeadConversationId, conversationId)
.eq(TeamRunEntity::getWorkspaceId, workspaceId)
.orderByDesc(TeamRunEntity::getCreateTime))
.stream().map(this::buildView).toList();
}
@Transactional
public TeamRunEntity sealRun(Long runId, Long workspaceId) {
return sealRunWithResult(runId, workspaceId).run();
}
@Transactional
public SealResult sealRunWithResult(Long runId, Long workspaceId) {
TeamRunEntity run = requireRun(runId, workspaceId);
if (!TeamRunStatus.PLANNING.equals(run.getStatus())) {
return new SealResult(run, false);
}
long taskCount = taskMapper.selectCount(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getRunId, runId));
if (taskCount == 0) {
throw new IllegalStateException("cannot seal a team run without tasks");
}
LocalDateTime startedAt = LocalDateTime.now();
int changed = runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, runId)
.eq(TeamRunEntity::getStatus, TeamRunStatus.PLANNING)
.set(TeamRunEntity::getStatus, TeamRunStatus.RUNNING)
.set(TeamRunEntity::getStartedAt, startedAt));
if (changed == 1) {
run.setStatus(TeamRunStatus.RUNNING);
run.setStartedAt(startedAt);
return new SealResult(run, true);
}
TeamRunEntity current = requireRun(runId, workspaceId);
if (!TeamRunStatus.PLANNING.equals(current.getStatus())) {
return new SealResult(current, false);
}
throw new IllegalStateException("failed to seal team run: " + runId);
}
@Transactional
public TeamRunEntity markFinalized(Long runId, Long workspaceId, String finalSummary) {
TeamRunEntity run = requireRun(runId, workspaceId);
if (TeamRunStatus.isTerminal(run.getStatus())) {
return run;
}
if (!TeamRunStatus.FINALIZING.equals(run.getStatus())) {
throw new IllegalStateException("team run is not finalizing: " + runId);
}
String outcome = metadata(run.getMetadata()).getStr("projectedOutcome");
if (!FINAL_OUTCOMES.contains(outcome)) {
throw new IllegalStateException("team run has no valid projected outcome: " + runId);
}
LocalDateTime completedAt = LocalDateTime.now();
int changed = runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, runId)
.eq(TeamRunEntity::getStatus, TeamRunStatus.FINALIZING)
.set(TeamRunEntity::getStatus, outcome)
.set(TeamRunEntity::getFinalSummary, finalSummary)
.set(TeamRunEntity::getCompletedAt, completedAt));
if (changed == 1) {
run.setStatus(outcome);
run.setFinalSummary(finalSummary);
run.setCompletedAt(completedAt);
return run;
}
TeamRunEntity current = requireRun(runId, workspaceId);
if (TeamRunStatus.isTerminal(current.getStatus())) {
return current;
}
throw new IllegalStateException("failed to finalize team run: " + runId);
}
@Transactional
public TeamRunEntity cancelRun(Long runId, Long workspaceId, String reason) {
return cancelRunWithResult(runId, workspaceId, reason).run();
}
@Transactional
public CancelResult cancelRunWithResult(Long runId, Long workspaceId, String reason) {
TeamRunEntity run = requireRun(runId, workspaceId);
if (TeamRunStatus.isTerminal(run.getStatus())) {
return new CancelResult(run, false);
}
LocalDateTime completedAt = LocalDateTime.now();
int changed = runMapper.update(null, Wrappers.<TeamRunEntity>lambdaUpdate()
.eq(TeamRunEntity::getId, runId)
.notIn(TeamRunEntity::getStatus, TeamRunStatus.TERMINAL)
.set(TeamRunEntity::getStatus, TeamRunStatus.CANCELLED)
.set(TeamRunEntity::getStopReason, reason)
.set(TeamRunEntity::getCompletedAt, completedAt));
if (changed == 1) {
run.setStatus(TeamRunStatus.CANCELLED);
run.setStopReason(reason);
run.setCompletedAt(completedAt);
return new CancelResult(run, true);
}
return new CancelResult(requireRun(runId, workspaceId), false);
}
public TeamRunView buildView(TeamRunEntity run) {
List<TeamTaskEntity> tasks = tasksForRun(run.getId());
TeamRunStateMachine.Projection projection = stateMachine.project(run, tasks);
return new TeamRunView(run.getId(), run.getTeamId(), run.getWorkspaceId(), run.getLeadAgentId(),
run.getLeadConversationId(), run.getOriginMessageId(), run.getTitle(), run.getObjective(),
projection.status(), run.getFinalSummary(), run.getStopReason(), run.getMetadata(),
run.getStartedAt(), run.getCompletedAt(), run.getCreateTime(), run.getUpdateTime(),
projection.progress(), tasks.stream().map(TeamRunView.Task::from).toList());
}
private List<TeamTaskEntity> tasksForRun(Long runId) {
return taskMapper.selectList(Wrappers.<TeamTaskEntity>lambdaQuery()
.eq(TeamTaskEntity::getRunId, runId)
.orderByAsc(TeamTaskEntity::getTaskNumber));
}
private void validateCreate(TeamRunCreateCommand command) {
if (command == null || command.getTeamId() == null || command.getWorkspaceId() == null
|| command.getLeadAgentId() == null) {
throw new IllegalArgumentException("team, workspace, and lead are required");
}
AgentTeamEntity team = teamService.getTeam(command.getTeamId());
if (team == null || !TeamService.STATUS_ACTIVE.equals(team.getStatus())) {
throw new IllegalArgumentException("team not found or not active: " + command.getTeamId());
}
if (!command.getWorkspaceId().equals(team.getWorkspaceId())) {
throw new IllegalArgumentException("team is not in workspace: " + command.getWorkspaceId());
}
if (!command.getLeadAgentId().equals(team.getLeadAgentId())) {
throw new IllegalArgumentException("agent is not the team lead: " + command.getLeadAgentId());
}
if (command.getLeadConversationId() == null || command.getLeadConversationId().isBlank()) {
throw new IllegalArgumentException("lead conversation is required");
}
if (command.getObjective() == null || command.getObjective().isBlank()) {
throw new IllegalArgumentException("objective is required");
}
}
private TeamRunEntity findByOrigin(Long workspaceId, String conversationId, Long originMessageId) {
if (originMessageId == null) {
return null;
}
return runMapper.selectOne(Wrappers.<TeamRunEntity>lambdaQuery()
.eq(TeamRunEntity::getWorkspaceId, workspaceId)
.eq(TeamRunEntity::getLeadConversationId, conversationId)
.eq(TeamRunEntity::getOriginMessageId, originMessageId));
}
private String deriveTitle(TeamRunCreateCommand command) {
String title = command.getTitle() == null || command.getTitle().isBlank()
? command.getObjective().trim() : command.getTitle().trim();
return title.length() <= MAX_TITLE_LENGTH ? title : title.substring(0, MAX_TITLE_LENGTH);
}
private JSONObject metadata(String value) {
if (value == null || value.isBlank()) {
return new JSONObject();
}
try {
return JSONUtil.parseObj(value);
} catch (RuntimeException invalidJson) {
return new JSONObject();
}
}
}