mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(team): collaboration observability — live event channel, task timeline, prerequisite hand-off and readable validation errors
This commit is contained in:
parent
251a3288dd
commit
c15e51b34b
@ -10,18 +10,22 @@ import vip.mate.agent.repository.AgentMapper;
|
|||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.team.model.AgentTeamEntity;
|
import vip.mate.team.model.AgentTeamEntity;
|
||||||
import vip.mate.team.model.AgentTeamMemberEntity;
|
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
import vip.mate.team.model.TeamTaskCommentEntity;
|
import vip.mate.team.model.TeamTaskCommentEntity;
|
||||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||||
import vip.mate.team.model.TeamTaskEntity;
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
|
import vip.mate.team.model.TeamTaskEventEntity;
|
||||||
import vip.mate.team.model.TeamTaskStatus;
|
import vip.mate.team.model.TeamTaskStatus;
|
||||||
import vip.mate.team.service.TeamAnnounceService;
|
import vip.mate.team.service.TeamAnnounceService;
|
||||||
import vip.mate.team.service.TeamDispatchService;
|
import vip.mate.team.service.TeamDispatchService;
|
||||||
|
import vip.mate.team.service.TeamEventChannel;
|
||||||
import vip.mate.team.service.TeamService;
|
import vip.mate.team.service.TeamService;
|
||||||
import vip.mate.team.service.TeamTaskService;
|
import vip.mate.team.service.TeamTaskService;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,6 +48,7 @@ public class TeamController {
|
|||||||
private final TeamTaskService taskService;
|
private final TeamTaskService taskService;
|
||||||
private final TeamDispatchService dispatchService;
|
private final TeamDispatchService dispatchService;
|
||||||
private final TeamAnnounceService announceService;
|
private final TeamAnnounceService announceService;
|
||||||
|
private final TeamEventChannel eventChannel;
|
||||||
private final AgentMapper agentMapper;
|
private final AgentMapper agentMapper;
|
||||||
|
|
||||||
// ==================== team CRUD ====================
|
// ==================== team CRUD ====================
|
||||||
@ -77,24 +82,28 @@ public class TeamController {
|
|||||||
@Operation(summary = "创建团队")
|
@Operation(summary = "创建团队")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public R<TeamVO> create(@RequestBody CreateTeamRequest req, Principal principal) {
|
public R<TeamVO> create(@RequestBody CreateTeamRequest req, Principal principal) {
|
||||||
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
|
return guarded(() -> {
|
||||||
req.getLeadAgentId(), req.getMemberAgentIds(),
|
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
|
||||||
principal != null ? principal.getName() : "admin");
|
req.getLeadAgentId(), req.getMemberAgentIds(),
|
||||||
return R.ok(toVO(team));
|
principal != null ? principal.getName() : "admin");
|
||||||
|
return R.ok(toVO(team));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "更新团队")
|
@Operation(summary = "更新团队")
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public R<TeamVO> update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) {
|
public R<TeamVO> update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) {
|
||||||
return R.ok(toVO(teamService.updateTeam(id, req.getName(), req.getDescription(),
|
return guarded(() -> R.ok(toVO(teamService.updateTeam(id, req.getName(),
|
||||||
req.getSettings())));
|
req.getDescription(), req.getSettings()))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除团队")
|
@Operation(summary = "删除团队")
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
public R<Void> delete(@PathVariable Long id) {
|
public R<Void> delete(@PathVariable Long id) {
|
||||||
teamService.deleteTeam(id);
|
return guarded(() -> {
|
||||||
return R.ok(null);
|
teamService.deleteTeam(id);
|
||||||
|
return R.ok(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== membership ====================
|
// ==================== membership ====================
|
||||||
@ -102,15 +111,19 @@ public class TeamController {
|
|||||||
@Operation(summary = "添加成员")
|
@Operation(summary = "添加成员")
|
||||||
@PostMapping("/{id}/members")
|
@PostMapping("/{id}/members")
|
||||||
public R<Void> addMember(@PathVariable Long id, @RequestBody MemberRequest req) {
|
public R<Void> addMember(@PathVariable Long id, @RequestBody MemberRequest req) {
|
||||||
teamService.addMember(id, req.getAgentId(), req.getRole());
|
return guarded(() -> {
|
||||||
return R.ok(null);
|
teamService.addMember(id, req.getAgentId(), req.getRole());
|
||||||
|
return R.ok(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "移除成员")
|
@Operation(summary = "移除成员")
|
||||||
@DeleteMapping("/{id}/members/{agentId}")
|
@DeleteMapping("/{id}/members/{agentId}")
|
||||||
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long agentId) {
|
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long agentId) {
|
||||||
teamService.removeMember(id, agentId);
|
return guarded(() -> {
|
||||||
return R.ok(null);
|
teamService.removeMember(id, agentId);
|
||||||
|
return R.ok(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== task board ====================
|
// ==================== task board ====================
|
||||||
@ -125,89 +138,146 @@ public class TeamController {
|
|||||||
@Operation(summary = "任务详情(含评论)")
|
@Operation(summary = "任务详情(含评论)")
|
||||||
@GetMapping("/{id}/tasks/{taskId}")
|
@GetMapping("/{id}/tasks/{taskId}")
|
||||||
public R<TaskDetailVO> getTask(@PathVariable Long id, @PathVariable Long taskId) {
|
public R<TaskDetailVO> getTask(@PathVariable Long id, @PathVariable Long taskId) {
|
||||||
TeamTaskEntity task = requireTask(id, taskId);
|
return guarded(() -> {
|
||||||
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
|
TeamTaskEntity task = requireTask(id, taskId);
|
||||||
|
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "手动创建任务")
|
@Operation(summary = "手动创建任务")
|
||||||
@PostMapping("/{id}/tasks")
|
@PostMapping("/{id}/tasks")
|
||||||
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
|
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
|
||||||
Principal principal) {
|
Principal principal) {
|
||||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
return guarded(() -> {
|
||||||
.teamId(id)
|
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||||
.subject(req.getSubject())
|
.teamId(id)
|
||||||
.description(req.getDescription())
|
.subject(req.getSubject())
|
||||||
.assigneeAgentId(req.getAssigneeAgentId())
|
.description(req.getDescription())
|
||||||
.priority(req.getPriority())
|
.assigneeAgentId(req.getAssigneeAgentId())
|
||||||
.blockedBy(req.getBlockedBy())
|
.priority(req.getPriority())
|
||||||
.requireApproval(Boolean.TRUE.equals(req.getRequireApproval()))
|
.blockedBy(req.getBlockedBy())
|
||||||
.username(principal != null ? principal.getName() : null)
|
.requireApproval(Boolean.TRUE.equals(req.getRequireApproval()))
|
||||||
.channel("dashboard")
|
.username(principal != null ? principal.getName() : null)
|
||||||
.build());
|
.channel("dashboard")
|
||||||
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
.build());
|
||||||
dispatchService.requestDispatch(id);
|
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
|
||||||
}
|
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
||||||
return R.ok(toTaskVO(task));
|
dispatchService.requestDispatch(id);
|
||||||
|
}
|
||||||
|
return R.ok(toTaskVO(task));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "批准 in_review 任务")
|
@Operation(summary = "批准 in_review 任务")
|
||||||
@PostMapping("/{id}/tasks/{taskId}/approve")
|
@PostMapping("/{id}/tasks/{taskId}/approve")
|
||||||
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId) {
|
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId,
|
||||||
requireTask(id, taskId);
|
Principal principal) {
|
||||||
List<Long> released = taskService.approveTask(taskId);
|
return guarded(() -> {
|
||||||
if (!released.isEmpty()) {
|
requireTask(id, taskId);
|
||||||
dispatchService.requestDispatch(id);
|
List<Long> released = taskService.approveTask(taskId);
|
||||||
}
|
recordUserEvent(id, taskId, TeamTaskEventEntity.APPROVED, principal, null);
|
||||||
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
publishBoardEvent(taskId, "team_task_approved");
|
||||||
|
if (!released.isEmpty()) {
|
||||||
|
dispatchService.requestDispatch(id);
|
||||||
|
}
|
||||||
|
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "驳回 in_review 任务")
|
@Operation(summary = "驳回 in_review 任务")
|
||||||
@PostMapping("/{id}/tasks/{taskId}/reject")
|
@PostMapping("/{id}/tasks/{taskId}/reject")
|
||||||
public R<TaskVO> reject(@PathVariable Long id, @PathVariable Long taskId,
|
public R<TaskVO> reject(@PathVariable Long id, @PathVariable Long taskId,
|
||||||
@RequestBody(required = false) ReasonRequest req) {
|
@RequestBody(required = false) ReasonRequest req,
|
||||||
requireTask(id, taskId);
|
Principal principal) {
|
||||||
taskService.rejectTask(taskId, req == null ? null : req.getReason());
|
return guarded(() -> {
|
||||||
TeamTaskEntity task = taskService.getTask(taskId);
|
requireTask(id, taskId);
|
||||||
// The lead must hear about the rejection to re-plan or retry.
|
taskService.rejectTask(taskId, req == null ? null : req.getReason());
|
||||||
announceService.announceTaskSettled(task);
|
recordUserEvent(id, taskId, TeamTaskEventEntity.REJECTED, principal,
|
||||||
dispatchService.requestDispatch(id);
|
req == null ? null : req.getReason());
|
||||||
return R.ok(toTaskVO(task));
|
publishBoardEvent(taskId, "team_task_rejected");
|
||||||
|
TeamTaskEntity task = taskService.getTask(taskId);
|
||||||
|
// The lead must hear about the rejection to re-plan or retry.
|
||||||
|
announceService.announceTaskSettled(task);
|
||||||
|
dispatchService.requestDispatch(id);
|
||||||
|
return R.ok(toTaskVO(task));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "重试 failed/stale 任务")
|
@Operation(summary = "重试 failed/stale 任务")
|
||||||
@PostMapping("/{id}/tasks/{taskId}/retry")
|
@PostMapping("/{id}/tasks/{taskId}/retry")
|
||||||
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId) {
|
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId,
|
||||||
requireTask(id, taskId);
|
Principal principal) {
|
||||||
if (!taskService.retryTask(taskId)) {
|
return guarded(() -> {
|
||||||
return R.fail("only failed or stale tasks can be retried");
|
requireTask(id, taskId);
|
||||||
}
|
if (!taskService.retryTask(taskId)) {
|
||||||
dispatchService.requestDispatch(id);
|
return R.fail("only failed or stale tasks can be retried");
|
||||||
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
}
|
||||||
|
recordUserEvent(id, taskId, TeamTaskEventEntity.RETRIED, principal, null);
|
||||||
|
publishBoardEvent(taskId, "team_task_retried");
|
||||||
|
dispatchService.requestDispatch(id);
|
||||||
|
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "取消任务")
|
@Operation(summary = "取消任务")
|
||||||
@PostMapping("/{id}/tasks/{taskId}/cancel")
|
@PostMapping("/{id}/tasks/{taskId}/cancel")
|
||||||
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
|
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
|
||||||
@RequestBody(required = false) ReasonRequest req) {
|
@RequestBody(required = false) ReasonRequest req,
|
||||||
TeamTaskEntity task = requireTask(id, taskId);
|
Principal principal) {
|
||||||
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
|
return guarded(() -> {
|
||||||
// Stop the member run mid-flight instead of letting it burn to the end.
|
TeamTaskEntity task = requireTask(id, taskId);
|
||||||
dispatchService.interruptRun(task);
|
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
|
||||||
if (!released.isEmpty()) {
|
recordUserEvent(id, taskId, TeamTaskEventEntity.CANCELLED, principal,
|
||||||
dispatchService.requestDispatch(id);
|
req == null ? null : req.getReason());
|
||||||
}
|
publishBoardEvent(taskId, "team_task_cancelled");
|
||||||
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
// Stop the member run mid-flight instead of letting it burn to the end.
|
||||||
|
dispatchService.interruptRun(task);
|
||||||
|
if (!released.isEmpty()) {
|
||||||
|
dispatchService.requestDispatch(id);
|
||||||
|
}
|
||||||
|
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "任务时间线")
|
||||||
|
@GetMapping("/{id}/tasks/{taskId}/events")
|
||||||
|
public R<List<TeamTaskEventEntity>> taskEvents(@PathVariable Long id, @PathVariable Long taskId) {
|
||||||
|
return guarded(() -> {
|
||||||
|
requireTask(id, taskId);
|
||||||
|
return R.ok(taskService.listEvents(taskId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "团队事件流(SSE)")
|
||||||
|
@GetMapping("/{id}/events")
|
||||||
|
public SseEmitter events(@PathVariable Long id,
|
||||||
|
@RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) {
|
||||||
|
SseEmitter emitter = new SseEmitter(0L);
|
||||||
|
eventChannel.attach(id, emitter, lastEventId == null ? 0L : lastEventId);
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordUserEvent(Long teamId, Long taskId, String eventType,
|
||||||
|
Principal principal, String detail) {
|
||||||
|
taskService.recordEvent(teamId, taskId, eventType, TeamTaskService.AUTHOR_USER,
|
||||||
|
principal != null ? principal.getName() : null, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishBoardEvent(Long taskId, String event) {
|
||||||
|
eventChannel.publishTaskEvent(taskService.getTask(taskId), event, Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "添加评论")
|
@Operation(summary = "添加评论")
|
||||||
@PostMapping("/{id}/tasks/{taskId}/comments")
|
@PostMapping("/{id}/tasks/{taskId}/comments")
|
||||||
public R<Void> comment(@PathVariable Long id, @PathVariable Long taskId,
|
public R<Void> comment(@PathVariable Long id, @PathVariable Long taskId,
|
||||||
@RequestBody CommentRequest req, Principal principal) {
|
@RequestBody CommentRequest req, Principal principal) {
|
||||||
requireTask(id, taskId);
|
return guarded(() -> {
|
||||||
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
|
requireTask(id, taskId);
|
||||||
principal != null ? principal.getName() : "admin",
|
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
|
||||||
TeamTaskService.COMMENT_NOTE, req.getContent());
|
principal != null ? principal.getName() : "admin",
|
||||||
return R.ok(null);
|
TeamTaskService.COMMENT_NOTE, req.getContent());
|
||||||
|
return R.ok(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "任务状态统计(看板列头)")
|
@Operation(summary = "任务状态统计(看板列头)")
|
||||||
@ -219,6 +289,20 @@ public class TeamController {
|
|||||||
|
|
||||||
// ==================== helpers / DTOs ====================
|
// ==================== helpers / DTOs ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an endpoint body whose service layer reports validation verdicts
|
||||||
|
* (unknown assignee, wrong task status, cross-team task id…) via
|
||||||
|
* IllegalArgumentException / IllegalStateException. Those must reach the
|
||||||
|
* client as readable text in the R envelope, not the catch-all 500 handler.
|
||||||
|
*/
|
||||||
|
private <T> R<T> guarded(Supplier<R<T>> action) {
|
||||||
|
try {
|
||||||
|
return action.get();
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
return R.fail(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private TeamTaskEntity requireTask(Long teamId, Long taskId) {
|
private TeamTaskEntity requireTask(Long teamId, Long taskId) {
|
||||||
TeamTaskEntity task = taskService.getTask(taskId);
|
TeamTaskEntity task = taskService.getTask(taskId);
|
||||||
if (task == null || !task.getTeamId().equals(teamId)) {
|
if (task == null || !task.getTeamId().equals(teamId)) {
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
package vip.mate.team.model;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One moment in a team task's lifecycle (created, dispatched, progress,
|
||||||
|
* comment, deliverable, settlement, approval actions). Append-only side
|
||||||
|
* channel rendered as the task's collaboration timeline; recording failures
|
||||||
|
* never affect the task itself.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("mate_team_task_event")
|
||||||
|
public class TeamTaskEventEntity {
|
||||||
|
|
||||||
|
// event_type values; kept as plain constants (no enum) so new moments can
|
||||||
|
// be recorded without a schema or code migration.
|
||||||
|
public static final String CREATED = "created";
|
||||||
|
public static final String DISPATCHED = "dispatched";
|
||||||
|
public static final String PROGRESS = "progress";
|
||||||
|
public static final String COMMENT = "comment";
|
||||||
|
public static final String BLOCKER = "blocker";
|
||||||
|
public static final String DELIVERABLE = "deliverable";
|
||||||
|
public static final String COMPLETED = "completed";
|
||||||
|
public static final String IN_REVIEW = "in_review";
|
||||||
|
public static final String FAILED = "failed";
|
||||||
|
public static final String CANCELLED = "cancelled";
|
||||||
|
public static final String APPROVED = "approved";
|
||||||
|
public static final String REJECTED = "rejected";
|
||||||
|
public static final String RETRIED = "retried";
|
||||||
|
public static final String STALE = "stale";
|
||||||
|
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Denormalized team id for team-level activity queries. */
|
||||||
|
private Long teamId;
|
||||||
|
|
||||||
|
private Long taskId;
|
||||||
|
|
||||||
|
private String eventType;
|
||||||
|
|
||||||
|
/** Actor kind: agent / user / system. */
|
||||||
|
private String actorType;
|
||||||
|
|
||||||
|
/** Agent id or username depending on actorType; null for system moments. */
|
||||||
|
private String actorId;
|
||||||
|
|
||||||
|
/** Human-readable one-liner: progress step, failure reason, file name… */
|
||||||
|
private String detail;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package vip.mate.team.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import vip.mate.team.model.TeamTaskEventEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapper for the team task event timeline.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
public interface TeamTaskEventMapper extends BaseMapper<TeamTaskEventEntity> {
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import vip.mate.agent.AgentService;
|
|||||||
import vip.mate.channel.web.ChatStreamTracker;
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
import vip.mate.team.model.AgentTeamEntity;
|
import vip.mate.team.model.AgentTeamEntity;
|
||||||
import vip.mate.team.model.TeamTaskEntity;
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
|
import vip.mate.team.model.TeamTaskEventEntity;
|
||||||
import vip.mate.team.model.TeamTaskStatus;
|
import vip.mate.team.model.TeamTaskStatus;
|
||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
@ -71,6 +72,7 @@ public class TeamDispatchService {
|
|||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
private final TeamAnnounceService announceService;
|
private final TeamAnnounceService announceService;
|
||||||
|
private final TeamEventChannel eventChannel;
|
||||||
|
|
||||||
/** Members with a run currently in flight in this JVM (belt-and-braces on top of hasActiveTask). */
|
/** Members with a run currently in flight in this JVM (belt-and-braces on top of hasActiveTask). */
|
||||||
private final Set<Long> runningMembers = ConcurrentHashMap.newKeySet();
|
private final Set<Long> runningMembers = ConcurrentHashMap.newKeySet();
|
||||||
@ -125,6 +127,8 @@ public class TeamDispatchService {
|
|||||||
if (!taskService.assignTask(task.getId(), assignee)) {
|
if (!taskService.assignTask(task.getId(), assignee)) {
|
||||||
continue; // another sweep won the race, or status moved on
|
continue; // another sweep won the race, or status moved on
|
||||||
}
|
}
|
||||||
|
taskService.recordEvent(teamId, task.getId(), TeamTaskEventEntity.DISPATCHED,
|
||||||
|
TeamTaskService.AUTHOR_SYSTEM, null, "agent " + assignee);
|
||||||
if (!taskService.tryAcquireDispatch(task.getId())) {
|
if (!taskService.tryAcquireDispatch(task.getId())) {
|
||||||
// Circuit breaker tripped; the task was auto-failed — the lead
|
// Circuit breaker tripped; the task was auto-failed — the lead
|
||||||
// must hear about it or the work silently disappears.
|
// must hear about it or the work silently disappears.
|
||||||
@ -252,6 +256,10 @@ public class TeamDispatchService {
|
|||||||
announceService.announceTaskSettled(current);
|
announceService.announceTaskSettled(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-prerequisite and whole-section caps keeping the envelope bounded. */
|
||||||
|
static final int MAX_PREREQ_RESULT_CHARS = 1500;
|
||||||
|
static final int MAX_PREREQ_SECTION_CHARS = 6000;
|
||||||
|
|
||||||
/** The full instruction envelope the member receives; it cannot see the lead's conversation. */
|
/** The full instruction envelope the member receives; it cannot see the lead's conversation. */
|
||||||
private String buildDispatchContent(TeamTaskEntity task) {
|
private String buildDispatchContent(TeamTaskEntity task) {
|
||||||
StringBuilder sb = new StringBuilder(1024);
|
StringBuilder sb = new StringBuilder(1024);
|
||||||
@ -261,6 +269,7 @@ public class TeamDispatchService {
|
|||||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||||
sb.append("\n").append(task.getDescription()).append('\n');
|
sb.append("\n").append(task.getDescription()).append('\n');
|
||||||
}
|
}
|
||||||
|
appendPrerequisiteResults(sb, task);
|
||||||
sb.append("""
|
sb.append("""
|
||||||
|
|
||||||
[Instructions]
|
[Instructions]
|
||||||
@ -272,22 +281,47 @@ public class TeamDispatchService {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Push a task event onto the lead conversation's SSE stream (UI + observability). */
|
/**
|
||||||
private void broadcast(TeamTaskEntity task, String event, Map<String, Object> extra) {
|
* Hand the member everything its prerequisites produced: result summaries
|
||||||
if (task.getLeadConversationId() == null) {
|
* and deliverable links, so upstream output flows downstream without the
|
||||||
|
* lead re-typing it. Bounded by per-item and whole-section caps — the
|
||||||
|
* member can fetch the full record with team_tasks(action="get").
|
||||||
|
*/
|
||||||
|
void appendPrerequisiteResults(StringBuilder sb, TeamTaskEntity task) {
|
||||||
|
List<Long> blockerIds = TeamTaskService.parseIdArray(task.getBlockedBy());
|
||||||
|
if (blockerIds.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
StringBuilder section = new StringBuilder();
|
||||||
Map<String, Object> payload = new HashMap<>(extra);
|
for (Long blockerId : blockerIds) {
|
||||||
payload.put("taskId", String.valueOf(task.getId()));
|
TeamTaskEntity blocker = taskService.getTask(blockerId);
|
||||||
payload.put("taskNumber", task.getTaskNumber());
|
if (blocker == null) {
|
||||||
payload.put("subject", task.getSubject());
|
continue;
|
||||||
payload.put("teamId", String.valueOf(task.getTeamId()));
|
}
|
||||||
payload.put("assigneeAgentId", String.valueOf(task.getAssigneeAgentId()));
|
section.append("- #").append(blocker.getTaskNumber())
|
||||||
streamTracker.broadcastObject(task.getLeadConversationId(), event, payload);
|
.append(" \"").append(blocker.getSubject()).append("\" (")
|
||||||
} catch (Exception e) {
|
.append(blocker.getStatus()).append(')');
|
||||||
log.debug("Team task event broadcast skipped: {}", e.getMessage());
|
if (blocker.getResult() != null && !blocker.getResult().isBlank()) {
|
||||||
|
section.append(": ").append(truncate(blocker.getResult().strip(),
|
||||||
|
MAX_PREREQ_RESULT_CHARS));
|
||||||
|
}
|
||||||
|
section.append('\n');
|
||||||
|
for (TeamTaskService.Deliverable file : taskService.listDeliverables(blocker)) {
|
||||||
|
section.append(" File: ").append(file.name()).append(" → ")
|
||||||
|
.append(file.url()).append('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (section.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sb.append("\n[Prerequisite results]\n")
|
||||||
|
.append(truncate(section.toString(), MAX_PREREQ_SECTION_CHARS))
|
||||||
|
.append("Use team_tasks(action=\"get\", taskId=...) for any full record.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a task event onto the team channel and the lead conversation's stream. */
|
||||||
|
private void broadcast(TeamTaskEntity task, String event, Map<String, Object> extra) {
|
||||||
|
eventChannel.publishTaskEvent(task, event, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String truncate(String s, int max) {
|
private static String truncate(String s, int max) {
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
package vip.mate.team.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import vip.mate.channel.web.ChatStreamTracker;
|
||||||
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Team-scoped SSE event channel, backed by a synthetic conversation id on the
|
||||||
|
* existing stream tracker so registration, ring buffering, replay-by-last-id
|
||||||
|
* and heartbeats are all inherited instead of re-invented. One channel per
|
||||||
|
* team, alive for the application's lifetime; publishing lazily (re)registers,
|
||||||
|
* so a recycled channel heals on the next event and subscribers simply
|
||||||
|
* reconnect.
|
||||||
|
*
|
||||||
|
* Task events are additionally mirrored onto the originating lead
|
||||||
|
* conversation's stream (when the task has one) for in-chat observability.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TeamEventChannel {
|
||||||
|
|
||||||
|
static final String CHANNEL_PREFIX = "team-events-";
|
||||||
|
|
||||||
|
private final ChatStreamTracker streamTracker;
|
||||||
|
|
||||||
|
/** Publish a task lifecycle event to the team channel (+ lead stream if any). */
|
||||||
|
public void publishTaskEvent(TeamTaskEntity task, String event, Map<String, Object> extra) {
|
||||||
|
if (task == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> payload = new HashMap<>(extra == null ? Map.of() : extra);
|
||||||
|
payload.put("taskId", String.valueOf(task.getId()));
|
||||||
|
payload.put("taskNumber", task.getTaskNumber());
|
||||||
|
payload.put("subject", task.getSubject());
|
||||||
|
payload.put("teamId", String.valueOf(task.getTeamId()));
|
||||||
|
payload.put("assigneeAgentId", String.valueOf(task.getAssigneeAgentId()));
|
||||||
|
|
||||||
|
String channelId = channelId(task.getTeamId());
|
||||||
|
streamTracker.register(channelId);
|
||||||
|
streamTracker.broadcastObject(channelId, event, payload);
|
||||||
|
|
||||||
|
if (task.getLeadConversationId() != null) {
|
||||||
|
streamTracker.broadcastObject(task.getLeadConversationId(), event, payload);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Events are a side channel — never let them affect the task flow.
|
||||||
|
log.debug("Team event '{}' broadcast skipped: {}", event, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach a subscriber, replaying buffered events newer than lastEventId. */
|
||||||
|
public boolean attach(Long teamId, SseEmitter emitter, long lastEventId) {
|
||||||
|
String channelId = channelId(teamId);
|
||||||
|
streamTracker.register(channelId);
|
||||||
|
return streamTracker.attach(channelId, emitter, lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String channelId(Long teamId) {
|
||||||
|
return CHANNEL_PREFIX + teamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,6 +46,7 @@ public class TeamService {
|
|||||||
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
|
public AgentTeamEntity createTeam(String name, String description, Long leadAgentId,
|
||||||
List<Long> memberAgentIds, String createdBy) {
|
List<Long> memberAgentIds, String createdBy) {
|
||||||
requireAgentExists(leadAgentId, "lead");
|
requireAgentExists(leadAgentId, "lead");
|
||||||
|
requireReactLead(leadAgentId);
|
||||||
requireNotInAnyTeam(leadAgentId);
|
requireNotInAnyTeam(leadAgentId);
|
||||||
if (memberAgentIds != null) {
|
if (memberAgentIds != null) {
|
||||||
for (Long memberId : memberAgentIds) {
|
for (Long memberId : memberAgentIds) {
|
||||||
@ -224,6 +225,22 @@ public class TeamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead must run the ReAct graph. A plan-execute lead orchestrates
|
||||||
|
* through its own serial per-step delegation pipeline, which bypasses the
|
||||||
|
* team board entirely — tasks are never created, members never run in
|
||||||
|
* parallel, and the collaboration silently degrades to solo delegation.
|
||||||
|
*/
|
||||||
|
private void requireReactLead(Long agentId) {
|
||||||
|
AgentEntity agent = agentMapper.selectById(agentId);
|
||||||
|
if (agent != null && "plan_execute".equals(agent.getAgentType())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"lead agent must be a ReAct agent: a plan-execute lead plans serial steps "
|
||||||
|
+ "through its own delegation pipeline and never uses the team board. "
|
||||||
|
+ "Switch the agent's type to ReAct, or pick a different lead.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void requireNotInAnyTeam(Long agentId) {
|
private void requireNotInAnyTeam(Long agentId) {
|
||||||
// Membership check ignores team status on purpose: an agent parked in a
|
// Membership check ignores team status on purpose: an agent parked in a
|
||||||
// paused team must not silently join a second one.
|
// paused team must not silently join a second one.
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import vip.mate.team.model.TeamTaskCommentEntity;
|
|||||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||||
import vip.mate.team.model.TeamTaskEntity;
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
import vip.mate.team.model.TeamTaskStatus;
|
import vip.mate.team.model.TeamTaskStatus;
|
||||||
|
import vip.mate.team.model.TeamTaskEventEntity;
|
||||||
import vip.mate.team.repository.TeamTaskCommentMapper;
|
import vip.mate.team.repository.TeamTaskCommentMapper;
|
||||||
|
import vip.mate.team.repository.TeamTaskEventMapper;
|
||||||
import vip.mate.team.repository.TeamTaskMapper;
|
import vip.mate.team.repository.TeamTaskMapper;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@ -51,6 +53,7 @@ public class TeamTaskService {
|
|||||||
|
|
||||||
private final TeamTaskMapper taskMapper;
|
private final TeamTaskMapper taskMapper;
|
||||||
private final TeamTaskCommentMapper commentMapper;
|
private final TeamTaskCommentMapper commentMapper;
|
||||||
|
private final TeamTaskEventMapper eventMapper;
|
||||||
private final TeamService teamService;
|
private final TeamService teamService;
|
||||||
|
|
||||||
// ==================== creation ====================
|
// ==================== creation ====================
|
||||||
@ -112,6 +115,12 @@ public class TeamTaskService {
|
|||||||
task.setChannel(cmd.getChannel());
|
task.setChannel(cmd.getChannel());
|
||||||
task.setMetadata(cmd.getMetadata());
|
task.setMetadata(cmd.getMetadata());
|
||||||
taskMapper.insert(task);
|
taskMapper.insert(task);
|
||||||
|
recordEvent(cmd.getTeamId(), task.getId(), TeamTaskEventEntity.CREATED,
|
||||||
|
cmd.getCreatedByAgentId() != null ? AUTHOR_AGENT
|
||||||
|
: cmd.getUsername() != null ? AUTHOR_USER : AUTHOR_SYSTEM,
|
||||||
|
cmd.getCreatedByAgentId() != null ? String.valueOf(cmd.getCreatedByAgentId())
|
||||||
|
: cmd.getUsername(),
|
||||||
|
"assignee: agent " + assignee);
|
||||||
log.info("Team {} task #{} created ({}), assignee={} status={}",
|
log.info("Team {} task #{} created ({}), assignee={} status={}",
|
||||||
cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus());
|
cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus());
|
||||||
return task;
|
return task;
|
||||||
@ -186,6 +195,10 @@ public class TeamTaskService {
|
|||||||
throw new IllegalStateException("task #" + task.getTaskNumber()
|
throw new IllegalStateException("task #" + task.getTaskNumber()
|
||||||
+ " is " + task.getStatus() + " and cannot be completed");
|
+ " is " + task.getStatus() + " and cannot be completed");
|
||||||
}
|
}
|
||||||
|
recordEvent(task.getTeamId(), taskId,
|
||||||
|
toReview ? TeamTaskEventEntity.IN_REVIEW : TeamTaskEventEntity.COMPLETED,
|
||||||
|
agentId != null ? AUTHOR_AGENT : AUTHOR_SYSTEM,
|
||||||
|
agentId != null ? String.valueOf(agentId) : null, null);
|
||||||
return toReview ? List.of() : releaseDependents(task);
|
return toReview ? List.of() : releaseDependents(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,13 +233,19 @@ public class TeamTaskService {
|
|||||||
|
|
||||||
/** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */
|
/** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */
|
||||||
public boolean failTask(Long taskId, String reason) {
|
public boolean failTask(Long taskId, String reason) {
|
||||||
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
boolean failed = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||||
.eq(TeamTaskEntity::getId, taskId)
|
.eq(TeamTaskEntity::getId, taskId)
|
||||||
.in(TeamTaskEntity::getStatus,
|
.in(TeamTaskEntity::getStatus,
|
||||||
TeamTaskStatus.PENDING, TeamTaskStatus.IN_PROGRESS, TeamTaskStatus.STALE)
|
TeamTaskStatus.PENDING, TeamTaskStatus.IN_PROGRESS, TeamTaskStatus.STALE)
|
||||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED)
|
.set(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED)
|
||||||
.set(TeamTaskEntity::getReason, reason)
|
.set(TeamTaskEntity::getReason, reason)
|
||||||
.set(TeamTaskEntity::getLockExpiresAt, null)) == 1;
|
.set(TeamTaskEntity::getLockExpiresAt, null)) == 1;
|
||||||
|
if (failed) {
|
||||||
|
TeamTaskEntity task = taskMapper.selectById(taskId);
|
||||||
|
recordEvent(task == null ? null : task.getTeamId(), taskId,
|
||||||
|
TeamTaskEventEntity.FAILED, AUTHOR_SYSTEM, null, reason);
|
||||||
|
}
|
||||||
|
return failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel a non-terminal task; releases dependents so siblings are not deadlocked. */
|
/** Cancel a non-terminal task; releases dependents so siblings are not deadlocked. */
|
||||||
@ -262,13 +281,21 @@ public class TeamTaskService {
|
|||||||
|
|
||||||
/** Update progress and renew the execution lease in one shot. */
|
/** Update progress and renew the execution lease in one shot. */
|
||||||
public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) {
|
public boolean updateProgress(Long taskId, Long agentId, Integer percent, String step) {
|
||||||
return taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
boolean updated = taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||||
.eq(TeamTaskEntity::getId, taskId)
|
.eq(TeamTaskEntity::getId, taskId)
|
||||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||||
.eq(agentId != null, TeamTaskEntity::getOwnerAgentId, agentId)
|
.eq(agentId != null, TeamTaskEntity::getOwnerAgentId, agentId)
|
||||||
.set(percent != null, TeamTaskEntity::getProgressPercent, percent)
|
.set(percent != null, TeamTaskEntity::getProgressPercent, percent)
|
||||||
.set(step != null, TeamTaskEntity::getProgressStep, step)
|
.set(step != null, TeamTaskEntity::getProgressStep, step)
|
||||||
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
|
.set(TeamTaskEntity::getLockExpiresAt, newLease())) == 1;
|
||||||
|
if (updated) {
|
||||||
|
TeamTaskEntity task = taskMapper.selectById(taskId);
|
||||||
|
recordEvent(task == null ? null : task.getTeamId(), taskId,
|
||||||
|
TeamTaskEventEntity.PROGRESS, AUTHOR_AGENT,
|
||||||
|
agentId != null ? String.valueOf(agentId) : null,
|
||||||
|
(percent != null ? percent + "%" : "") + (step != null ? " — " + step : ""));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extend the execution lease (runner heartbeat). */
|
/** Extend the execution lease (runner heartbeat). */
|
||||||
@ -298,6 +325,10 @@ public class TeamTaskService {
|
|||||||
comment.setCommentType(commentType == null ? COMMENT_NOTE : commentType);
|
comment.setCommentType(commentType == null ? COMMENT_NOTE : commentType);
|
||||||
comment.setContent(content);
|
comment.setContent(content);
|
||||||
commentMapper.insert(comment);
|
commentMapper.insert(comment);
|
||||||
|
recordEvent(task.getTeamId(), taskId,
|
||||||
|
COMMENT_BLOCKER.equals(comment.getCommentType())
|
||||||
|
? TeamTaskEventEntity.BLOCKER : TeamTaskEventEntity.COMMENT,
|
||||||
|
authorType, authorId, content);
|
||||||
|
|
||||||
if (COMMENT_BLOCKER.equals(comment.getCommentType())) {
|
if (COMMENT_BLOCKER.equals(comment.getCommentType())) {
|
||||||
boolean failed = failTask(taskId, "blocked: " + content);
|
boolean failed = failTask(taskId, "blocked: " + content);
|
||||||
@ -316,6 +347,42 @@ public class TeamTaskService {
|
|||||||
.orderByAsc(TeamTaskCommentEntity::getCreateTime));
|
.orderByAsc(TeamTaskCommentEntity::getCreateTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== timeline events ====================
|
||||||
|
|
||||||
|
/** Timeline detail cap, matching the column width. */
|
||||||
|
static final int MAX_EVENT_DETAIL_CHARS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a lifecycle moment on the task's timeline. Best-effort side
|
||||||
|
* channel: any failure is logged and swallowed — a missing timeline row
|
||||||
|
* is acceptable, a task transition broken by the audit trail is not.
|
||||||
|
*/
|
||||||
|
public void recordEvent(Long teamId, Long taskId, String eventType,
|
||||||
|
String actorType, String actorId, String detail) {
|
||||||
|
try {
|
||||||
|
TeamTaskEventEntity event = new TeamTaskEventEntity();
|
||||||
|
event.setTeamId(teamId);
|
||||||
|
event.setTaskId(taskId);
|
||||||
|
event.setEventType(eventType);
|
||||||
|
event.setActorType(actorType);
|
||||||
|
event.setActorId(actorId);
|
||||||
|
event.setDetail(detail == null || detail.length() <= MAX_EVENT_DETAIL_CHARS
|
||||||
|
? detail : detail.substring(0, MAX_EVENT_DETAIL_CHARS));
|
||||||
|
eventMapper.insert(event);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Team task {} timeline event '{}' not recorded: {}",
|
||||||
|
taskId, eventType, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The task's timeline, oldest first. */
|
||||||
|
public List<TeamTaskEventEntity> listEvents(Long taskId) {
|
||||||
|
return eventMapper.selectList(Wrappers.<TeamTaskEventEntity>lambdaQuery()
|
||||||
|
.eq(TeamTaskEventEntity::getTaskId, taskId)
|
||||||
|
.orderByAsc(TeamTaskEventEntity::getCreateTime)
|
||||||
|
.orderByAsc(TeamTaskEventEntity::getId));
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== deliverables ====================
|
// ==================== deliverables ====================
|
||||||
|
|
||||||
/** Maximum deliverables per task; the board is a summary surface, not a file store. */
|
/** Maximum deliverables per task; the board is a summary surface, not a file store. */
|
||||||
@ -377,6 +444,8 @@ public class TeamTaskService {
|
|||||||
taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
taskMapper.update(null, Wrappers.<TeamTaskEntity>lambdaUpdate()
|
||||||
.eq(TeamTaskEntity::getId, taskId)
|
.eq(TeamTaskEntity::getId, taskId)
|
||||||
.set(TeamTaskEntity::getMetadata, metadata.toString()));
|
.set(TeamTaskEntity::getMetadata, metadata.toString()));
|
||||||
|
recordEvent(task.getTeamId(), taskId, TeamTaskEventEntity.DELIVERABLE,
|
||||||
|
AUTHOR_AGENT, agentId != null ? String.valueOf(agentId) : null, name.trim());
|
||||||
log.info("Team task {} deliverable attached: {}", taskId, name.trim());
|
log.info("Team task {} deliverable attached: {}", taskId, name.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,6 +548,8 @@ public class TeamTaskService {
|
|||||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE)
|
.set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE)
|
||||||
.set(TeamTaskEntity::getReason, "execution lease expired"));
|
.set(TeamTaskEntity::getReason, "execution lease expired"));
|
||||||
|
recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE,
|
||||||
|
AUTHOR_SYSTEM, null, "execution lease expired");
|
||||||
}
|
}
|
||||||
if (!expired.isEmpty()) {
|
if (!expired.isEmpty()) {
|
||||||
log.warn("Marked {} team task(s) stale after lease expiry", expired.size());
|
log.warn("Marked {} team task(s) stale after lease expiry", expired.size());
|
||||||
|
|||||||
@ -13,8 +13,10 @@ import vip.mate.team.model.AgentTeamEntity;
|
|||||||
import vip.mate.team.model.TeamTaskCommentEntity;
|
import vip.mate.team.model.TeamTaskCommentEntity;
|
||||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||||
import vip.mate.team.model.TeamTaskEntity;
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
|
import vip.mate.team.model.TeamTaskEventEntity;
|
||||||
import vip.mate.team.model.TeamTaskStatus;
|
import vip.mate.team.model.TeamTaskStatus;
|
||||||
import vip.mate.team.service.TeamDispatchService;
|
import vip.mate.team.service.TeamDispatchService;
|
||||||
|
import vip.mate.team.service.TeamEventChannel;
|
||||||
import vip.mate.team.service.TeamService;
|
import vip.mate.team.service.TeamService;
|
||||||
import vip.mate.team.service.TeamTaskService;
|
import vip.mate.team.service.TeamTaskService;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
@ -22,7 +24,9 @@ import vip.mate.workspace.conversation.ConversationService;
|
|||||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,6 +48,7 @@ public class TeamTasksTool {
|
|||||||
private final TeamService teamService;
|
private final TeamService teamService;
|
||||||
private final TeamTaskService taskService;
|
private final TeamTaskService taskService;
|
||||||
private final TeamDispatchService dispatchService;
|
private final TeamDispatchService dispatchService;
|
||||||
|
private final TeamEventChannel eventChannel;
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final AgentMapper agentMapper;
|
private final AgentMapper agentMapper;
|
||||||
|
|
||||||
@ -119,8 +124,8 @@ public class TeamTasksTool {
|
|||||||
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
|
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
|
||||||
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
|
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
|
||||||
case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url);
|
case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url);
|
||||||
case "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text);
|
case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text);
|
||||||
case "retry" -> retry(team, isLead, parseId(taskId, "taskId"));
|
case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId"));
|
||||||
default -> "Error: unknown action '" + action
|
default -> "Error: unknown action '" + action
|
||||||
+ "'. Use one of: list, get, create, complete, progress, comment, attach, cancel, retry.";
|
+ "'. Use one of: list, get, create, complete, progress, comment, attach, cancel, retry.";
|
||||||
};
|
};
|
||||||
@ -154,6 +159,7 @@ public class TeamTasksTool {
|
|||||||
.requireApproval(Boolean.TRUE.equals(requireApproval))
|
.requireApproval(Boolean.TRUE.equals(requireApproval))
|
||||||
.leadConversationId(conversationId)
|
.leadConversationId(conversationId)
|
||||||
.build());
|
.build());
|
||||||
|
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
|
||||||
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
||||||
dispatchService.requestDispatch(team.getId());
|
dispatchService.requestDispatch(team.getId());
|
||||||
}
|
}
|
||||||
@ -193,6 +199,16 @@ public class TeamTasksTool {
|
|||||||
return "Error: percent must be between 0 and 100.";
|
return "Error: percent must be between 0 and 100.";
|
||||||
}
|
}
|
||||||
boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
|
boolean ok = taskService.updateProgress(taskId, agentId, percent, step);
|
||||||
|
if (ok) {
|
||||||
|
Map<String, Object> extra = new HashMap<>();
|
||||||
|
if (percent != null) {
|
||||||
|
extra.put("progressPercent", percent);
|
||||||
|
}
|
||||||
|
if (step != null) {
|
||||||
|
extra.put("progressStep", step);
|
||||||
|
}
|
||||||
|
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra);
|
||||||
|
}
|
||||||
return ok ? "✓ Progress recorded."
|
return ok ? "✓ Progress recorded."
|
||||||
: "Error: task is not in progress under your ownership; progress not recorded.";
|
: "Error: task is not in progress under your ownership; progress not recorded.";
|
||||||
}
|
}
|
||||||
@ -217,12 +233,16 @@ public class TeamTasksTool {
|
|||||||
+ ". It now shows on the task card; keep your result a summary instead of pasting file contents.";
|
+ ". It now shows on the task card; keep your result a summary instead of pasting file contents.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String cancel(AgentTeamEntity team, boolean isLead, Long taskId, String reason) {
|
private String cancel(AgentTeamEntity team, Long agentId, boolean isLead,
|
||||||
|
Long taskId, String reason) {
|
||||||
if (!isLead) {
|
if (!isLead) {
|
||||||
return "Error: only the team lead can cancel tasks.";
|
return "Error: only the team lead can cancel tasks.";
|
||||||
}
|
}
|
||||||
TeamTaskEntity task = requireTaskInTeam(team, taskId);
|
TeamTaskEntity task = requireTaskInTeam(team, taskId);
|
||||||
List<Long> released = taskService.cancelTask(taskId, reason);
|
List<Long> released = taskService.cancelTask(taskId, reason);
|
||||||
|
taskService.recordEvent(team.getId(), taskId, TeamTaskEventEntity.CANCELLED,
|
||||||
|
TeamTaskService.AUTHOR_AGENT, String.valueOf(agentId), reason);
|
||||||
|
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_cancelled", Map.of());
|
||||||
// Stop the member run mid-flight instead of letting it burn to the end.
|
// Stop the member run mid-flight instead of letting it burn to the end.
|
||||||
dispatchService.interruptRun(task);
|
dispatchService.interruptRun(task);
|
||||||
if (!released.isEmpty()) {
|
if (!released.isEmpty()) {
|
||||||
@ -231,7 +251,7 @@ public class TeamTasksTool {
|
|||||||
return "✓ Task cancelled.";
|
return "✓ Task cancelled.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String retry(AgentTeamEntity team, boolean isLead, Long taskId) {
|
private String retry(AgentTeamEntity team, Long agentId, boolean isLead, Long taskId) {
|
||||||
if (!isLead) {
|
if (!isLead) {
|
||||||
return "Error: only the team lead can retry tasks.";
|
return "Error: only the team lead can retry tasks.";
|
||||||
}
|
}
|
||||||
@ -239,6 +259,9 @@ public class TeamTasksTool {
|
|||||||
if (!taskService.retryTask(taskId)) {
|
if (!taskService.retryTask(taskId)) {
|
||||||
return "Error: only failed or stale tasks can be retried.";
|
return "Error: only failed or stale tasks can be retried.";
|
||||||
}
|
}
|
||||||
|
taskService.recordEvent(team.getId(), taskId, TeamTaskEventEntity.RETRIED,
|
||||||
|
TeamTaskService.AUTHOR_AGENT, String.valueOf(agentId), null);
|
||||||
|
eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_retried", Map.of());
|
||||||
dispatchService.requestDispatch(team.getId());
|
dispatchService.requestDispatch(team.getId());
|
||||||
return "✓ Task reset to pending; it will be re-dispatched.";
|
return "✓ Task reset to pending; it will be re-dispatched.";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
-- V174: Team task event timeline — an append-only audit trail of task lifecycle
|
||||||
|
-- moments (created, dispatched, progress, comments, deliverables, settlement,
|
||||||
|
-- approval actions), rendered as the task's collaboration timeline in the UI.
|
||||||
|
-- Written as a side channel: failures to record never affect the task itself.
|
||||||
|
-- (H2 dialect)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_team_task_event (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL,
|
||||||
|
task_id BIGINT NOT NULL,
|
||||||
|
event_type VARCHAR(32) NOT NULL,
|
||||||
|
actor_type VARCHAR(16),
|
||||||
|
actor_id VARCHAR(64),
|
||||||
|
detail VARCHAR(1000),
|
||||||
|
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_task_event_task ON mate_team_task_event(task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_task_event_team ON mate_team_task_event(team_id, create_time);
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- V174: Team task event timeline — an append-only audit trail of task lifecycle
|
||||||
|
-- moments. (KingbaseES / PostgreSQL dialect). See h2/V174 for design notes.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_team_task_event (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL,
|
||||||
|
task_id BIGINT NOT NULL,
|
||||||
|
event_type VARCHAR(32) NOT NULL,
|
||||||
|
actor_type VARCHAR(16),
|
||||||
|
actor_id VARCHAR(64),
|
||||||
|
detail VARCHAR(1000),
|
||||||
|
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_task_event_task ON mate_team_task_event(task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_task_event_team ON mate_team_task_event(team_id, create_time);
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- V174: Team task event timeline — an append-only audit trail of task lifecycle
|
||||||
|
-- moments. (MySQL dialect). See h2/V174 for design notes.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_team_task_event (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL,
|
||||||
|
task_id BIGINT NOT NULL,
|
||||||
|
event_type VARCHAR(32) NOT NULL,
|
||||||
|
actor_type VARCHAR(16),
|
||||||
|
actor_id VARCHAR(64),
|
||||||
|
detail VARCHAR(1000),
|
||||||
|
create_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted INT DEFAULT 0,
|
||||||
|
KEY idx_team_task_event_task (task_id),
|
||||||
|
KEY idx_team_task_event_team (team_id, create_time)
|
||||||
|
);
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
package vip.mate.team.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||||
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
|
import vip.mate.team.service.TeamAnnounceService;
|
||||||
|
import vip.mate.team.service.TeamDispatchService;
|
||||||
|
import vip.mate.team.service.TeamEventChannel;
|
||||||
|
import vip.mate.team.service.TeamService;
|
||||||
|
import vip.mate.team.service.TeamTaskService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that service-layer validation verdicts (IllegalArgumentException /
|
||||||
|
* IllegalStateException) surface through the R envelope as readable messages
|
||||||
|
* instead of escaping to the global catch-all 500 handler.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TeamControllerTest {
|
||||||
|
|
||||||
|
private static final Long TEAM_ID = 1L;
|
||||||
|
private static final Long TASK_ID = 100L;
|
||||||
|
|
||||||
|
@Mock private TeamService teamService;
|
||||||
|
@Mock private TeamTaskService taskService;
|
||||||
|
@Mock private TeamDispatchService dispatchService;
|
||||||
|
@Mock private TeamAnnounceService announceService;
|
||||||
|
@Mock private TeamEventChannel eventChannel;
|
||||||
|
@Mock private AgentMapper agentMapper;
|
||||||
|
|
||||||
|
private TeamController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
controller = new TeamController(teamService, taskService, dispatchService,
|
||||||
|
announceService, eventChannel, agentMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TeamTaskEntity task(Long teamId, String status) {
|
||||||
|
TeamTaskEntity task = new TeamTaskEntity();
|
||||||
|
task.setId(TASK_ID);
|
||||||
|
task.setTeamId(teamId);
|
||||||
|
task.setTaskNumber(7);
|
||||||
|
task.setStatus(status);
|
||||||
|
task.setSubject("subject");
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== team / membership ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createTeamSurfacesMembershipConflictAsReadableFailure() {
|
||||||
|
when(teamService.createTeam(any(), any(), any(), any(), any()))
|
||||||
|
.thenThrow(new IllegalStateException(
|
||||||
|
"agent 5 already belongs to team 2; an agent can join only one team"));
|
||||||
|
TeamController.CreateTeamRequest req = new TeamController.CreateTeamRequest();
|
||||||
|
req.setName("t");
|
||||||
|
req.setLeadAgentId(5L);
|
||||||
|
|
||||||
|
R<TeamController.TeamVO> r = controller.create(req, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("agent 5 already belongs to team 2; an agent can join only one team", r.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateTeamSurfacesUnknownTeamAsReadableFailure() {
|
||||||
|
when(teamService.updateTeam(eq(TEAM_ID), any(), any(), any()))
|
||||||
|
.thenThrow(new IllegalArgumentException("team not found: " + TEAM_ID));
|
||||||
|
|
||||||
|
R<TeamController.TeamVO> r = controller.update(TEAM_ID, new TeamController.UpdateTeamRequest());
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("team not found: 1", r.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addMemberSurfacesValidationAsReadableFailure() {
|
||||||
|
doThrow(new IllegalArgumentException("agent is already the team lead"))
|
||||||
|
.when(teamService).addMember(TEAM_ID, 5L, "member");
|
||||||
|
TeamController.MemberRequest req = new TeamController.MemberRequest();
|
||||||
|
req.setAgentId(5L);
|
||||||
|
req.setRole("member");
|
||||||
|
|
||||||
|
R<Void> r = controller.addMember(TEAM_ID, req);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("agent is already the team lead", r.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeMemberSurfacesLeadProtectionAsReadableFailure() {
|
||||||
|
doThrow(new IllegalArgumentException("cannot remove the team lead; delete the team instead"))
|
||||||
|
.when(teamService).removeMember(TEAM_ID, 5L);
|
||||||
|
|
||||||
|
R<Void> r = controller.removeMember(TEAM_ID, 5L);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("cannot remove the team lead; delete the team instead", r.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== task board ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createTaskSurfacesUnknownAssigneeAsReadableFailure() {
|
||||||
|
when(taskService.createTask(any(TeamTaskCreateCommand.class)))
|
||||||
|
.thenThrow(new IllegalArgumentException("assignee 9 is not a member of this team"));
|
||||||
|
TeamController.CreateTaskRequest req = new TeamController.CreateTaskRequest();
|
||||||
|
req.setSubject("do the thing");
|
||||||
|
req.setAssigneeAgentId(9L);
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.createTask(TEAM_ID, req, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("assignee 9 is not a member of this team", r.getMsg());
|
||||||
|
verify(eventChannel, never()).publishTaskEvent(any(), anyString(), any());
|
||||||
|
verify(dispatchService, never()).requestDispatch(anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveRejectsTaskFromAnotherTeamsBoard() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(task(2L, "in_review"));
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.approve(TEAM_ID, TASK_ID, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("task not found on this team's board", r.getMsg());
|
||||||
|
verify(taskService, never()).approveTask(anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveSurfacesWrongStatusAsReadableFailure() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "pending"));
|
||||||
|
when(taskService.approveTask(TASK_ID))
|
||||||
|
.thenThrow(new IllegalStateException("task #7 is not awaiting review"));
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.approve(TEAM_ID, TASK_ID, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("task #7 is not awaiting review", r.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveHappyPathStillReturnsTask() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "in_review"));
|
||||||
|
when(taskService.approveTask(TASK_ID)).thenReturn(List.of(200L));
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.approve(TEAM_ID, TASK_ID, null);
|
||||||
|
|
||||||
|
assertEquals(200, r.getCode());
|
||||||
|
assertNotNull(r.getData());
|
||||||
|
verify(dispatchService).requestDispatch(TEAM_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectSurfacesWrongStatusAsReadableFailure() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "pending"));
|
||||||
|
doThrow(new IllegalStateException("task #7 is not awaiting review"))
|
||||||
|
.when(taskService).rejectTask(TASK_ID, null);
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.reject(TEAM_ID, TASK_ID, null, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("task #7 is not awaiting review", r.getMsg());
|
||||||
|
verify(announceService, never()).announceTaskSettled(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retrySurfacesTaskFromAnotherTeamAsReadableFailure() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(task(2L, "failed"));
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.retry(TEAM_ID, TASK_ID, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("task not found on this team's board", r.getMsg());
|
||||||
|
verify(taskService, never()).retryTask(anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryOnNonRetryableStatusKeepsReadableFailure() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "done"));
|
||||||
|
when(taskService.retryTask(TASK_ID)).thenReturn(false);
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.retry(TEAM_ID, TASK_ID, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("only failed or stale tasks can be retried", r.getMsg());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelSurfacesTerminalTaskAsReadableFailure() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(task(TEAM_ID, "done"));
|
||||||
|
when(taskService.cancelTask(TASK_ID, null))
|
||||||
|
.thenThrow(new IllegalStateException("task #7 is already terminal"));
|
||||||
|
|
||||||
|
R<TeamController.TaskVO> r = controller.cancel(TEAM_ID, TASK_ID, null, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("task #7 is already terminal", r.getMsg());
|
||||||
|
verify(dispatchService, never()).interruptRun(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commentSurfacesUnknownTaskAsReadableFailure() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(null);
|
||||||
|
TeamController.CommentRequest req = new TeamController.CommentRequest();
|
||||||
|
req.setContent("hi");
|
||||||
|
|
||||||
|
R<Void> r = controller.comment(TEAM_ID, TASK_ID, req, null);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("task not found on this team's board", r.getMsg());
|
||||||
|
verify(taskService, never()).addComment(anyLong(), anyString(), any(), anyString(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getTaskSurfacesUnknownTaskAsReadableFailure() {
|
||||||
|
when(taskService.getTask(TASK_ID)).thenReturn(null);
|
||||||
|
|
||||||
|
R<TeamController.TaskDetailVO> r = controller.getTask(TEAM_ID, TASK_ID);
|
||||||
|
|
||||||
|
assertEquals(500, r.getCode());
|
||||||
|
assertEquals("task not found on this team's board", r.getMsg());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ class TeamDispatchServiceTest {
|
|||||||
private ConversationService conversationService;
|
private ConversationService conversationService;
|
||||||
private ChatStreamTracker streamTracker;
|
private ChatStreamTracker streamTracker;
|
||||||
private TeamAnnounceService announceService;
|
private TeamAnnounceService announceService;
|
||||||
|
private TeamEventChannel eventChannel;
|
||||||
private TeamDispatchService service;
|
private TeamDispatchService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@ -47,8 +48,9 @@ class TeamDispatchServiceTest {
|
|||||||
conversationService = mock(ConversationService.class);
|
conversationService = mock(ConversationService.class);
|
||||||
streamTracker = mock(ChatStreamTracker.class);
|
streamTracker = mock(ChatStreamTracker.class);
|
||||||
announceService = mock(TeamAnnounceService.class);
|
announceService = mock(TeamAnnounceService.class);
|
||||||
|
eventChannel = mock(TeamEventChannel.class);
|
||||||
service = new TeamDispatchService(teamService, taskService, agentService,
|
service = new TeamDispatchService(teamService, taskService, agentService,
|
||||||
conversationService, streamTracker, announceService);
|
conversationService, streamTracker, announceService, eventChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TeamTaskEntity task(Long id, Long assignee) {
|
private TeamTaskEntity task(Long id, Long assignee) {
|
||||||
@ -145,7 +147,7 @@ class TeamDispatchServiceTest {
|
|||||||
service.settleOutcome(running, "analysis finished");
|
service.settleOutcome(running, "analysis finished");
|
||||||
|
|
||||||
verify(taskService).completeTask(1L, null, "analysis finished");
|
verify(taskService).completeTask(1L, null, "analysis finished");
|
||||||
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_completed"), any());
|
verify(eventChannel).publishTaskEvent(any(), eq("team_task_completed"), any());
|
||||||
verify(announceService).announceTaskSettled(done);
|
verify(announceService).announceTaskSettled(done);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +162,7 @@ class TeamDispatchServiceTest {
|
|||||||
service.settleOutcome(failed, "irrelevant reply");
|
service.settleOutcome(failed, "irrelevant reply");
|
||||||
|
|
||||||
verify(taskService, never()).completeTask(any(), any(), anyString());
|
verify(taskService, never()).completeTask(any(), any(), anyString());
|
||||||
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any());
|
verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== run tracking & interrupt ====================
|
// ==================== run tracking & interrupt ====================
|
||||||
@ -200,8 +202,8 @@ class TeamDispatchServiceTest {
|
|||||||
|
|
||||||
service.runTask(TEAM_ID, assigned);
|
service.runTask(TEAM_ID, assigned);
|
||||||
|
|
||||||
verify(streamTracker, never())
|
verify(eventChannel, never())
|
||||||
.broadcastObject(anyString(), eq("team_task_failed"), any());
|
.publishTaskEvent(any(), eq("team_task_failed"), any());
|
||||||
verify(announceService, never()).announceTaskSettled(any());
|
verify(announceService, never()).announceTaskSettled(any());
|
||||||
// Tracking still ends cleanly.
|
// Tracking still ends cleanly.
|
||||||
verify(streamTracker).complete(startsWith("team-task-"));
|
verify(streamTracker).complete(startsWith("team-task-"));
|
||||||
@ -221,7 +223,7 @@ class TeamDispatchServiceTest {
|
|||||||
|
|
||||||
service.runTask(TEAM_ID, assigned);
|
service.runTask(TEAM_ID, assigned);
|
||||||
|
|
||||||
verify(streamTracker).broadcastObject(eq("lead-conv"), eq("team_task_failed"), any());
|
verify(eventChannel).publishTaskEvent(any(), eq("team_task_failed"), any());
|
||||||
verify(announceService).announceTaskSettled(failed);
|
verify(announceService).announceTaskSettled(failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +243,36 @@ class TeamDispatchServiceTest {
|
|||||||
verifyNoMoreInteractions(streamTracker);
|
verifyNoMoreInteractions(streamTracker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== prerequisite hand-off ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("the dispatch envelope carries prerequisite results and deliverables")
|
||||||
|
void envelopeCarriesPrerequisiteResults() {
|
||||||
|
TeamTaskEntity dependent = task(3L, MEMBER_B);
|
||||||
|
dependent.setBlockedBy("[\"1\",\"2\"]");
|
||||||
|
TeamTaskEntity done = task(1L, MEMBER_A);
|
||||||
|
done.setStatus(TeamTaskStatus.COMPLETED);
|
||||||
|
done.setResult("pricing collected: 3 competitors");
|
||||||
|
when(taskService.getTask(1L)).thenReturn(done);
|
||||||
|
when(taskService.getTask(2L)).thenReturn(null); // vanished blocker is skipped
|
||||||
|
when(taskService.listDeliverables(done)).thenReturn(List.of(
|
||||||
|
new TeamTaskService.Deliverable("prices.xlsx", "/api/v1/files/generated/x", null)));
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
service.appendPrerequisiteResults(sb, dependent);
|
||||||
|
String section = sb.toString();
|
||||||
|
|
||||||
|
assertTrue(section.contains("[Prerequisite results]"));
|
||||||
|
assertTrue(section.contains("pricing collected: 3 competitors"));
|
||||||
|
assertTrue(section.contains("prices.xlsx → /api/v1/files/generated/x"));
|
||||||
|
assertFalse(section.contains("#2"), "vanished blockers leave no trace");
|
||||||
|
|
||||||
|
// No blockers → no section at all.
|
||||||
|
StringBuilder plain = new StringBuilder();
|
||||||
|
service.appendPrerequisiteResults(plain, task(4L, MEMBER_A));
|
||||||
|
assertEquals(0, plain.length());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("an oversized member reply is truncated before persisting")
|
@DisplayName("an oversized member reply is truncated before persisting")
|
||||||
void settleTruncatesLongReply() {
|
void settleTruncatesLongReply() {
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
package vip.mate.team.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.agent.repository.AgentMapper;
|
||||||
|
import vip.mate.team.model.AgentTeamEntity;
|
||||||
|
import vip.mate.team.model.AgentTeamMemberEntity;
|
||||||
|
import vip.mate.team.repository.AgentTeamMapper;
|
||||||
|
import vip.mate.team.repository.AgentTeamMemberMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins team creation guards — most importantly that a plan-execute agent can
|
||||||
|
* never become a lead: its own serial delegation pipeline bypasses the team
|
||||||
|
* board, so the collaboration would silently degrade to solo delegation.
|
||||||
|
*/
|
||||||
|
class TeamServiceTest {
|
||||||
|
|
||||||
|
private static final Long LEAD_ID = 1L;
|
||||||
|
private static final Long MEMBER_ID = 2L;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initTableInfo() {
|
||||||
|
// Lambda wrappers resolve columns from the static TableInfo cache;
|
||||||
|
// plain Mockito tests must seed it manually.
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, AgentTeamEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, AgentTeamMemberEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentTeamMapper teamMapper;
|
||||||
|
private AgentTeamMemberMapper memberMapper;
|
||||||
|
private AgentMapper agentMapper;
|
||||||
|
private TeamService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
teamMapper = mock(AgentTeamMapper.class);
|
||||||
|
memberMapper = mock(AgentTeamMemberMapper.class);
|
||||||
|
agentMapper = mock(AgentMapper.class);
|
||||||
|
service = new TeamService(teamMapper, memberMapper, agentMapper,
|
||||||
|
mock(ApplicationEventPublisher.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentEntity agent(Long id, String agentType) {
|
||||||
|
AgentEntity a = new AgentEntity();
|
||||||
|
a.setId(id);
|
||||||
|
a.setName("agent-" + id);
|
||||||
|
a.setAgentType(agentType);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("a plan-execute agent is rejected as lead with an actionable message")
|
||||||
|
void planExecuteLeadRejected() {
|
||||||
|
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "plan_execute"));
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> service.createTeam("组", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
|
||||||
|
|
||||||
|
assertTrue(e.getMessage().contains("ReAct"));
|
||||||
|
verify(teamMapper, never()).insert(any(AgentTeamEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("members may be plan-execute — only the lead role is restricted")
|
||||||
|
void planExecuteMemberAllowedByTypeGuard() {
|
||||||
|
when(agentMapper.selectById(LEAD_ID)).thenReturn(agent(LEAD_ID, "react"));
|
||||||
|
when(agentMapper.selectById(MEMBER_ID)).thenReturn(agent(MEMBER_ID, "plan_execute"));
|
||||||
|
when(memberMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() ->
|
||||||
|
service.createTeam("组", null, LEAD_ID, List.of(MEMBER_ID), "admin"));
|
||||||
|
verify(teamMapper).insert(any(AgentTeamEntity.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,8 +13,10 @@ import vip.mate.team.model.AgentTeamEntity;
|
|||||||
import vip.mate.team.model.TeamTaskCommentEntity;
|
import vip.mate.team.model.TeamTaskCommentEntity;
|
||||||
import vip.mate.team.model.TeamTaskCreateCommand;
|
import vip.mate.team.model.TeamTaskCreateCommand;
|
||||||
import vip.mate.team.model.TeamTaskEntity;
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
|
import vip.mate.team.model.TeamTaskEventEntity;
|
||||||
import vip.mate.team.model.TeamTaskStatus;
|
import vip.mate.team.model.TeamTaskStatus;
|
||||||
import vip.mate.team.repository.TeamTaskCommentMapper;
|
import vip.mate.team.repository.TeamTaskCommentMapper;
|
||||||
|
import vip.mate.team.repository.TeamTaskEventMapper;
|
||||||
import vip.mate.team.repository.TeamTaskMapper;
|
import vip.mate.team.repository.TeamTaskMapper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -37,6 +39,7 @@ class TeamTaskServiceTest {
|
|||||||
|
|
||||||
private TeamTaskMapper taskMapper;
|
private TeamTaskMapper taskMapper;
|
||||||
private TeamTaskCommentMapper commentMapper;
|
private TeamTaskCommentMapper commentMapper;
|
||||||
|
private TeamTaskEventMapper eventMapper;
|
||||||
private TeamService teamService;
|
private TeamService teamService;
|
||||||
private TeamTaskService service;
|
private TeamTaskService service;
|
||||||
|
|
||||||
@ -48,14 +51,16 @@ class TeamTaskServiceTest {
|
|||||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
|
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
|
||||||
TableInfoHelper.initTableInfo(assistant, TeamTaskCommentEntity.class);
|
TableInfoHelper.initTableInfo(assistant, TeamTaskCommentEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TeamTaskEventEntity.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
taskMapper = mock(TeamTaskMapper.class);
|
taskMapper = mock(TeamTaskMapper.class);
|
||||||
commentMapper = mock(TeamTaskCommentMapper.class);
|
commentMapper = mock(TeamTaskCommentMapper.class);
|
||||||
|
eventMapper = mock(TeamTaskEventMapper.class);
|
||||||
teamService = mock(TeamService.class);
|
teamService = mock(TeamService.class);
|
||||||
service = new TeamTaskService(taskMapper, commentMapper, teamService);
|
service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService);
|
||||||
|
|
||||||
AgentTeamEntity team = new AgentTeamEntity();
|
AgentTeamEntity team = new AgentTeamEntity();
|
||||||
team.setId(TEAM_ID);
|
team.setId(TEAM_ID);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import vip.mate.team.model.TeamTaskCreateCommand;
|
|||||||
import vip.mate.team.model.TeamTaskEntity;
|
import vip.mate.team.model.TeamTaskEntity;
|
||||||
import vip.mate.team.model.TeamTaskStatus;
|
import vip.mate.team.model.TeamTaskStatus;
|
||||||
import vip.mate.team.service.TeamDispatchService;
|
import vip.mate.team.service.TeamDispatchService;
|
||||||
|
import vip.mate.team.service.TeamEventChannel;
|
||||||
import vip.mate.team.service.TeamService;
|
import vip.mate.team.service.TeamService;
|
||||||
import vip.mate.team.service.TeamTaskService;
|
import vip.mate.team.service.TeamTaskService;
|
||||||
import vip.mate.tool.builtin.ToolExecutionContext;
|
import vip.mate.tool.builtin.ToolExecutionContext;
|
||||||
@ -42,6 +43,7 @@ class TeamTasksToolTest {
|
|||||||
private TeamService teamService;
|
private TeamService teamService;
|
||||||
private TeamTaskService taskService;
|
private TeamTaskService taskService;
|
||||||
private TeamDispatchService dispatchService;
|
private TeamDispatchService dispatchService;
|
||||||
|
private TeamEventChannel eventChannel;
|
||||||
private ConversationService conversationService;
|
private ConversationService conversationService;
|
||||||
private AgentMapper agentMapper;
|
private AgentMapper agentMapper;
|
||||||
private TeamTasksTool tool;
|
private TeamTasksTool tool;
|
||||||
@ -52,10 +54,11 @@ class TeamTasksToolTest {
|
|||||||
teamService = mock(TeamService.class);
|
teamService = mock(TeamService.class);
|
||||||
taskService = mock(TeamTaskService.class);
|
taskService = mock(TeamTaskService.class);
|
||||||
dispatchService = mock(TeamDispatchService.class);
|
dispatchService = mock(TeamDispatchService.class);
|
||||||
|
eventChannel = mock(TeamEventChannel.class);
|
||||||
conversationService = mock(ConversationService.class);
|
conversationService = mock(ConversationService.class);
|
||||||
agentMapper = mock(AgentMapper.class);
|
agentMapper = mock(AgentMapper.class);
|
||||||
tool = new TeamTasksTool(teamService, taskService, dispatchService,
|
tool = new TeamTasksTool(teamService, taskService, dispatchService,
|
||||||
conversationService, agentMapper);
|
eventChannel, conversationService, agentMapper);
|
||||||
|
|
||||||
team = new AgentTeamEntity();
|
team = new AgentTeamEntity();
|
||||||
team.setId(TEAM_ID);
|
team.setId(TEAM_ID);
|
||||||
|
|||||||
@ -880,6 +880,17 @@ export interface TeamTaskComment {
|
|||||||
createTime?: string
|
createTime?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamTaskEvent {
|
||||||
|
id: string
|
||||||
|
teamId: string
|
||||||
|
taskId: string
|
||||||
|
eventType: string
|
||||||
|
actorType: string | null
|
||||||
|
actorId: string | null
|
||||||
|
detail: string | null
|
||||||
|
createTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const teamApi = {
|
export const teamApi = {
|
||||||
list: () => http.get('/teams'),
|
list: () => http.get('/teams'),
|
||||||
get: (id: string) => http.get(`/teams/${id}`),
|
get: (id: string) => http.get(`/teams/${id}`),
|
||||||
@ -909,6 +920,7 @@ export const teamApi = {
|
|||||||
requireApproval?: boolean
|
requireApproval?: boolean
|
||||||
},
|
},
|
||||||
) => http.post(`/teams/${id}/tasks`, data),
|
) => http.post(`/teams/${id}/tasks`, data),
|
||||||
|
listTaskEvents: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}/events`),
|
||||||
approveTask: (id: string, taskId: string) => http.post(`/teams/${id}/tasks/${taskId}/approve`),
|
approveTask: (id: string, taskId: string) => http.post(`/teams/${id}/tasks/${taskId}/approve`),
|
||||||
rejectTask: (id: string, taskId: string, reason?: string) =>
|
rejectTask: (id: string, taskId: string, reason?: string) =>
|
||||||
http.post(`/teams/${id}/tasks/${taskId}/reject`, { reason }),
|
http.post(`/teams/${id}/tasks/${taskId}/reject`, { reason }),
|
||||||
|
|||||||
64
mateclaw-ui/src/composables/useTeamEvents.ts
Normal file
64
mateclaw-ui/src/composables/useTeamEvents.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Team board event subscription over SSE.
|
||||||
|
*
|
||||||
|
* EventSource cannot carry the Authorization header, so this reads the SSE
|
||||||
|
* body through fetch + ReadableStream (same approach as the chat stream).
|
||||||
|
* No auto-reconnect: the board keeps its polling fallback, so a dropped
|
||||||
|
* subscription degrades gracefully instead of stacking retry loops.
|
||||||
|
*/
|
||||||
|
export interface TeamBoardEvent {
|
||||||
|
event: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeTeamEvents(
|
||||||
|
teamId: string,
|
||||||
|
onEvent: (e: TeamBoardEvent) => void,
|
||||||
|
): () => void {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const headers: Record<string, string> = { Accept: 'text/event-stream' }
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/teams/${teamId}/events`, {
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok || !res.body) return
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
// SSE frames are separated by a blank line.
|
||||||
|
let sep: number
|
||||||
|
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
||||||
|
const frame = buffer.slice(0, sep)
|
||||||
|
buffer = buffer.slice(sep + 2)
|
||||||
|
let event = 'message'
|
||||||
|
let data = ''
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim()
|
||||||
|
else if (line.startsWith('data:')) data += line.slice(5).trim()
|
||||||
|
}
|
||||||
|
if (event === 'heartbeat' || !data) continue
|
||||||
|
try {
|
||||||
|
onEvent({ event, data: JSON.parse(data) })
|
||||||
|
} catch {
|
||||||
|
// Non-JSON payloads are not board events; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(() => {
|
||||||
|
// Aborted or dropped — the board's polling fallback takes over.
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}
|
||||||
@ -51,6 +51,25 @@ export default {
|
|||||||
taskCreateIncomplete: 'Subject and assignee are required',
|
taskCreateIncomplete: 'Subject and assignee are required',
|
||||||
deliverables: 'Deliverables',
|
deliverables: 'Deliverables',
|
||||||
viewRun: 'View execution',
|
viewRun: 'View execution',
|
||||||
|
timeline: 'Timeline',
|
||||||
|
leadReactHint: 'The lead must be a ReAct agent: plan-execute agents orchestrate through their own serial delegation pipeline and never use the team board',
|
||||||
|
leadTypeWarning: 'Lead is plan-execute — team collaboration will not engage',
|
||||||
|
eventType: {
|
||||||
|
created: 'Created',
|
||||||
|
dispatched: 'Dispatched',
|
||||||
|
progress: 'Progress',
|
||||||
|
comment: 'Comment',
|
||||||
|
blocker: 'Blocker',
|
||||||
|
deliverable: 'Deliverable',
|
||||||
|
completed: 'Completed',
|
||||||
|
in_review: 'In review',
|
||||||
|
failed: 'Failed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
retried: 'Retried',
|
||||||
|
stale: 'Stale',
|
||||||
|
},
|
||||||
column: {
|
column: {
|
||||||
todo: 'To Do',
|
todo: 'To Do',
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
|
|||||||
@ -51,6 +51,25 @@ export default {
|
|||||||
taskCreateIncomplete: '请填写任务标题并选择执行成员',
|
taskCreateIncomplete: '请填写任务标题并选择执行成员',
|
||||||
deliverables: '交付物',
|
deliverables: '交付物',
|
||||||
viewRun: '查看执行过程',
|
viewRun: '查看执行过程',
|
||||||
|
timeline: '时间线',
|
||||||
|
leadReactHint: 'Lead 需为 ReAct 型员工:计划执行型员工走自身的串行委派管线,不会使用团队任务板',
|
||||||
|
leadTypeWarning: 'Lead 是计划执行型,团队协同不会生效',
|
||||||
|
eventType: {
|
||||||
|
created: '创建',
|
||||||
|
dispatched: '派发',
|
||||||
|
progress: '进度',
|
||||||
|
comment: '评论',
|
||||||
|
blocker: '阻塞上报',
|
||||||
|
deliverable: '交付物',
|
||||||
|
completed: '完成',
|
||||||
|
in_review: '待审核',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
approved: '批准',
|
||||||
|
rejected: '驳回',
|
||||||
|
retried: '重试',
|
||||||
|
stale: '失联',
|
||||||
|
},
|
||||||
column: {
|
column: {
|
||||||
todo: '待处理',
|
todo: '待处理',
|
||||||
in_progress: '进行中',
|
in_progress: '进行中',
|
||||||
|
|||||||
@ -78,6 +78,9 @@
|
|||||||
</span>
|
</span>
|
||||||
{{ store.currentTeam.leadName }}
|
{{ store.currentTeam.leadName }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="leadIsPlanExecute" class="lead-warning" :title="t('teams.leadReactHint')">
|
||||||
|
⚠ {{ t('teams.leadTypeWarning') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-header__right">
|
<div class="detail-header__right">
|
||||||
<div class="view-switch">
|
<div class="view-switch">
|
||||||
@ -102,6 +105,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live activity feed -->
|
||||||
|
<transition-group
|
||||||
|
v-if="activeTab === 'board' && activityFeed.length > 0"
|
||||||
|
name="activity"
|
||||||
|
tag="div"
|
||||||
|
class="activity-feed"
|
||||||
|
>
|
||||||
|
<div v-for="item in activityFeed" :key="item.key" class="activity-line">
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
|
||||||
<!-- Kanban board -->
|
<!-- Kanban board -->
|
||||||
<div v-if="activeTab === 'board'" class="board-grid">
|
<div v-if="activeTab === 'board'" class="board-grid">
|
||||||
<div v-for="col in boardColumns" :key="col.key" class="board-col">
|
<div v-for="col in boardColumns" :key="col.key" class="board-col">
|
||||||
@ -190,6 +205,8 @@
|
|||||||
:key="String(agent.id)"
|
:key="String(agent.id)"
|
||||||
class="agent-pill"
|
class="agent-pill"
|
||||||
:class="{ 'is-selected is-lead': createForm.leadAgentId === String(agent.id) }"
|
:class="{ 'is-selected is-lead': createForm.leadAgentId === String(agent.id) }"
|
||||||
|
:disabled="agent.agentType === 'plan_execute'"
|
||||||
|
:title="agent.agentType === 'plan_execute' ? t('teams.leadReactHint') : undefined"
|
||||||
@click="selectLead(String(agent.id))"
|
@click="selectLead(String(agent.id))"
|
||||||
>
|
>
|
||||||
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
|
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
|
||||||
@ -198,6 +215,7 @@
|
|||||||
{{ agent.name }}
|
{{ agent.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="form-hint">{{ t('teams.leadReactHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('teams.membersField') }} <i>*</i></label>
|
<label>{{ t('teams.membersField') }} <i>*</i></label>
|
||||||
@ -408,6 +426,21 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="taskEvents.length > 0" class="task-detail__block">
|
||||||
|
<div class="task-detail__label">{{ t('teams.timeline') }}</div>
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-for="ev in taskEvents" :key="ev.id" class="timeline-row">
|
||||||
|
<span class="timeline-row__dot" :class="`dot-ev--${ev.eventType}`"></span>
|
||||||
|
<span class="timeline-row__time">{{ (ev.createTime || '').slice(5, 16) }}</span>
|
||||||
|
<span class="timeline-row__type">{{ t(`teams.eventType.${ev.eventType}`, ev.eventType) }}</span>
|
||||||
|
<span v-if="ev.actorType === 'agent'" class="timeline-row__actor">
|
||||||
|
{{ agentStore.agents.find(a => String(a.id) === String(ev.actorId))?.name || ev.actorId }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="ev.actorId" class="timeline-row__actor">{{ ev.actorId }}</span>
|
||||||
|
<span v-if="ev.detail" class="timeline-row__detail">{{ ev.detail }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="task-detail__block">
|
<div class="task-detail__block">
|
||||||
<div class="task-detail__label">{{ t('teams.comments') }}</div>
|
<div class="task-detail__label">{{ t('teams.comments') }}</div>
|
||||||
<div v-if="comments.length === 0" class="task-detail__muted">—</div>
|
<div v-if="comments.length === 0" class="task-detail__muted">—</div>
|
||||||
@ -470,7 +503,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { teamApi } from '@/api/index'
|
import { teamApi } from '@/api/index'
|
||||||
import type { TeamMemberVO, TeamTaskComment, TeamTaskDeliverable, TeamTaskVO } from '@/api/index'
|
import type { TeamMemberVO, TeamTaskComment, TeamTaskDeliverable, TeamTaskEvent, TeamTaskVO } from '@/api/index'
|
||||||
|
import { subscribeTeamEvents } from '@/composables/useTeamEvents'
|
||||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||||
import { agentIconColor } from '@/utils/agentIconColor'
|
import { agentIconColor } from '@/utils/agentIconColor'
|
||||||
import { useAgentStore } from '@/stores/useAgentStore'
|
import { useAgentStore } from '@/stores/useAgentStore'
|
||||||
@ -518,6 +552,50 @@ function agentIcon(agentId?: string | null, apiIcon?: string | null): string {
|
|||||||
return agent?.icon || 'pi:user'
|
return agent?.icon || 'pi:user'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== live board events ====================
|
||||||
|
|
||||||
|
/** Recent activity lines shown above the board; each expires after a few seconds. */
|
||||||
|
const activityFeed = ref<{ key: number; text: string }[]>([])
|
||||||
|
let activityKey = 0
|
||||||
|
let unsubscribeEvents: (() => void) | null = null
|
||||||
|
let refreshDebounce: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function onBoardEvent(e: { event: string; data: Record<string, unknown> }) {
|
||||||
|
if (!e.event.startsWith('team_task_')) return
|
||||||
|
// Event-driven refresh, debounced so bursts collapse into one fetch.
|
||||||
|
if (refreshDebounce) clearTimeout(refreshDebounce)
|
||||||
|
refreshDebounce = setTimeout(() => {
|
||||||
|
refreshDebounce = null
|
||||||
|
refreshBoard()
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
const type = e.event.slice('team_task_'.length)
|
||||||
|
const subject = String(e.data.subject ?? '')
|
||||||
|
const taskNumber = e.data.taskNumber != null ? `#${e.data.taskNumber} ` : ''
|
||||||
|
const key = ++activityKey
|
||||||
|
activityFeed.value.push({
|
||||||
|
key,
|
||||||
|
text: `${taskNumber}${subject} · ${t(`teams.eventType.${type}`, type)}`,
|
||||||
|
})
|
||||||
|
if (activityFeed.value.length > 3) activityFeed.value.shift()
|
||||||
|
setTimeout(() => {
|
||||||
|
activityFeed.value = activityFeed.value.filter((item) => item.key !== key)
|
||||||
|
}, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEventSubscription(teamId: string) {
|
||||||
|
stopEventSubscription()
|
||||||
|
unsubscribeEvents = subscribeTeamEvents(teamId, onBoardEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopEventSubscription() {
|
||||||
|
if (unsubscribeEvents) {
|
||||||
|
unsubscribeEvents()
|
||||||
|
unsubscribeEvents = null
|
||||||
|
}
|
||||||
|
activityFeed.value = []
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== polling ====================
|
// ==================== polling ====================
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
@ -541,7 +619,15 @@ function stopPolling() {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.currentTeam,
|
() => store.currentTeam,
|
||||||
(team) => (team ? startPolling() : stopPolling()),
|
(team) => {
|
||||||
|
if (team) {
|
||||||
|
startPolling()
|
||||||
|
startEventSubscription(String(team.team.id))
|
||||||
|
} else {
|
||||||
|
stopPolling()
|
||||||
|
stopEventSubscription()
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -551,7 +637,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => stopPolling())
|
onBeforeUnmount(() => {
|
||||||
|
stopPolling()
|
||||||
|
stopEventSubscription()
|
||||||
|
})
|
||||||
|
|
||||||
// ==================== team actions ====================
|
// ==================== team actions ====================
|
||||||
|
|
||||||
@ -596,6 +685,18 @@ const memberCandidates = computed(() =>
|
|||||||
agentStore.agents.filter((a) => String(a.id) !== createForm.leadAgentId),
|
agentStore.agents.filter((a) => String(a.id) !== createForm.leadAgentId),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plan-execute lead orchestrates through its own serial delegation pipeline
|
||||||
|
* and never touches the team board — surface a standing warning so a lead
|
||||||
|
* whose type was switched after team creation doesn't fail silently.
|
||||||
|
*/
|
||||||
|
const leadIsPlanExecute = computed(() => {
|
||||||
|
const leadId = store.currentTeam?.team.leadAgentId
|
||||||
|
if (!leadId) return false
|
||||||
|
const agent = agentStore.agents.find((a) => String(a.id) === String(leadId))
|
||||||
|
return agent?.agentType === 'plan_execute'
|
||||||
|
})
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
createForm.name = ''
|
createForm.name = ''
|
||||||
createForm.description = ''
|
createForm.description = ''
|
||||||
@ -764,6 +865,8 @@ function openTaskRun() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const taskEvents = ref<TeamTaskEvent[]>([])
|
||||||
|
|
||||||
async function openTask(vo: TeamTaskVO) {
|
async function openTask(vo: TeamTaskVO) {
|
||||||
if (!store.currentTeam) return
|
if (!store.currentTeam) return
|
||||||
try {
|
try {
|
||||||
@ -771,6 +874,13 @@ async function openTask(vo: TeamTaskVO) {
|
|||||||
currentTask.value = res.data?.task || vo
|
currentTask.value = res.data?.task || vo
|
||||||
comments.value = res.data?.comments || []
|
comments.value = res.data?.comments || []
|
||||||
taskDialogVisible.value = true
|
taskDialogVisible.value = true
|
||||||
|
// Timeline loads after the dialog opens; a failure just leaves it empty.
|
||||||
|
taskEvents.value = []
|
||||||
|
teamApi.listTaskEvents(store.currentTeam.team.id, vo.task.id)
|
||||||
|
.then((eventsRes: any) => {
|
||||||
|
taskEvents.value = eventsRes.data || []
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e?.message || 'failed')
|
ElMessage.error(e?.message || 'failed')
|
||||||
}
|
}
|
||||||
@ -1507,6 +1617,88 @@ async function cancelTask() {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--mc-text-tertiary);
|
color: var(--mc-text-tertiary);
|
||||||
}
|
}
|
||||||
|
.agent-pill:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.lead-warning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-danger, #d9534f);
|
||||||
|
background: rgba(217, 83, 79, 0.08);
|
||||||
|
border: 1px solid rgba(217, 83, 79, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.activity-feed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.activity-line {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
background: var(--mc-bg-sunken);
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
.activity-enter-active,
|
||||||
|
.activity-leave-active {
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
.activity-enter-from,
|
||||||
|
.activity-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.timeline-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
}
|
||||||
|
.timeline-row__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mc-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.dot-ev--completed,
|
||||||
|
.dot-ev--approved { background: var(--mc-success, #67c23a); }
|
||||||
|
.dot-ev--failed,
|
||||||
|
.dot-ev--blocker,
|
||||||
|
.dot-ev--cancelled,
|
||||||
|
.dot-ev--rejected { background: var(--mc-danger, #d9534f); }
|
||||||
|
.dot-ev--dispatched,
|
||||||
|
.dot-ev--progress { background: var(--mc-primary); }
|
||||||
|
.timeline-row__time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.timeline-row__type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.timeline-row__actor {
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.timeline-row__detail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.deliverable-list {
|
.deliverable-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user