mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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.team.model.AgentTeamEntity;
|
||||
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.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskEventEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
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.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -44,6 +48,7 @@ public class TeamController {
|
||||
private final TeamTaskService taskService;
|
||||
private final TeamDispatchService dispatchService;
|
||||
private final TeamAnnounceService announceService;
|
||||
private final TeamEventChannel eventChannel;
|
||||
private final AgentMapper agentMapper;
|
||||
|
||||
// ==================== team CRUD ====================
|
||||
@ -77,24 +82,28 @@ public class TeamController {
|
||||
@Operation(summary = "创建团队")
|
||||
@PostMapping
|
||||
public R<TeamVO> create(@RequestBody CreateTeamRequest req, Principal principal) {
|
||||
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
|
||||
req.getLeadAgentId(), req.getMemberAgentIds(),
|
||||
principal != null ? principal.getName() : "admin");
|
||||
return R.ok(toVO(team));
|
||||
return guarded(() -> {
|
||||
AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(),
|
||||
req.getLeadAgentId(), req.getMemberAgentIds(),
|
||||
principal != null ? principal.getName() : "admin");
|
||||
return R.ok(toVO(team));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "更新团队")
|
||||
@PutMapping("/{id}")
|
||||
public R<TeamVO> update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) {
|
||||
return R.ok(toVO(teamService.updateTeam(id, req.getName(), req.getDescription(),
|
||||
req.getSettings())));
|
||||
return guarded(() -> R.ok(toVO(teamService.updateTeam(id, req.getName(),
|
||||
req.getDescription(), req.getSettings()))));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除团队")
|
||||
@DeleteMapping("/{id}")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
teamService.deleteTeam(id);
|
||||
return R.ok(null);
|
||||
return guarded(() -> {
|
||||
teamService.deleteTeam(id);
|
||||
return R.ok(null);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== membership ====================
|
||||
@ -102,15 +111,19 @@ public class TeamController {
|
||||
@Operation(summary = "添加成员")
|
||||
@PostMapping("/{id}/members")
|
||||
public R<Void> addMember(@PathVariable Long id, @RequestBody MemberRequest req) {
|
||||
teamService.addMember(id, req.getAgentId(), req.getRole());
|
||||
return R.ok(null);
|
||||
return guarded(() -> {
|
||||
teamService.addMember(id, req.getAgentId(), req.getRole());
|
||||
return R.ok(null);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "移除成员")
|
||||
@DeleteMapping("/{id}/members/{agentId}")
|
||||
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long agentId) {
|
||||
teamService.removeMember(id, agentId);
|
||||
return R.ok(null);
|
||||
return guarded(() -> {
|
||||
teamService.removeMember(id, agentId);
|
||||
return R.ok(null);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== task board ====================
|
||||
@ -125,89 +138,146 @@ public class TeamController {
|
||||
@Operation(summary = "任务详情(含评论)")
|
||||
@GetMapping("/{id}/tasks/{taskId}")
|
||||
public R<TaskDetailVO> getTask(@PathVariable Long id, @PathVariable Long taskId) {
|
||||
TeamTaskEntity task = requireTask(id, taskId);
|
||||
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
|
||||
return guarded(() -> {
|
||||
TeamTaskEntity task = requireTask(id, taskId);
|
||||
return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId)));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "手动创建任务")
|
||||
@PostMapping("/{id}/tasks")
|
||||
public R<TaskVO> createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req,
|
||||
Principal principal) {
|
||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||
.teamId(id)
|
||||
.subject(req.getSubject())
|
||||
.description(req.getDescription())
|
||||
.assigneeAgentId(req.getAssigneeAgentId())
|
||||
.priority(req.getPriority())
|
||||
.blockedBy(req.getBlockedBy())
|
||||
.requireApproval(Boolean.TRUE.equals(req.getRequireApproval()))
|
||||
.username(principal != null ? principal.getName() : null)
|
||||
.channel("dashboard")
|
||||
.build());
|
||||
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
||||
dispatchService.requestDispatch(id);
|
||||
}
|
||||
return R.ok(toTaskVO(task));
|
||||
return guarded(() -> {
|
||||
TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder()
|
||||
.teamId(id)
|
||||
.subject(req.getSubject())
|
||||
.description(req.getDescription())
|
||||
.assigneeAgentId(req.getAssigneeAgentId())
|
||||
.priority(req.getPriority())
|
||||
.blockedBy(req.getBlockedBy())
|
||||
.requireApproval(Boolean.TRUE.equals(req.getRequireApproval()))
|
||||
.username(principal != null ? principal.getName() : null)
|
||||
.channel("dashboard")
|
||||
.build());
|
||||
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
|
||||
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
||||
dispatchService.requestDispatch(id);
|
||||
}
|
||||
return R.ok(toTaskVO(task));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "批准 in_review 任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/approve")
|
||||
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId) {
|
||||
requireTask(id, taskId);
|
||||
List<Long> released = taskService.approveTask(taskId);
|
||||
if (!released.isEmpty()) {
|
||||
dispatchService.requestDispatch(id);
|
||||
}
|
||||
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
||||
public R<TaskVO> approve(@PathVariable Long id, @PathVariable Long taskId,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTask(id, taskId);
|
||||
List<Long> released = taskService.approveTask(taskId);
|
||||
recordUserEvent(id, taskId, TeamTaskEventEntity.APPROVED, principal, null);
|
||||
publishBoardEvent(taskId, "team_task_approved");
|
||||
if (!released.isEmpty()) {
|
||||
dispatchService.requestDispatch(id);
|
||||
}
|
||||
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "驳回 in_review 任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/reject")
|
||||
public R<TaskVO> reject(@PathVariable Long id, @PathVariable Long taskId,
|
||||
@RequestBody(required = false) ReasonRequest req) {
|
||||
requireTask(id, taskId);
|
||||
taskService.rejectTask(taskId, req == null ? null : req.getReason());
|
||||
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));
|
||||
@RequestBody(required = false) ReasonRequest req,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTask(id, taskId);
|
||||
taskService.rejectTask(taskId, req == null ? null : req.getReason());
|
||||
recordUserEvent(id, taskId, TeamTaskEventEntity.REJECTED, principal,
|
||||
req == null ? null : req.getReason());
|
||||
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 任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/retry")
|
||||
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId) {
|
||||
requireTask(id, taskId);
|
||||
if (!taskService.retryTask(taskId)) {
|
||||
return R.fail("only failed or stale tasks can be retried");
|
||||
}
|
||||
dispatchService.requestDispatch(id);
|
||||
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
||||
public R<TaskVO> retry(@PathVariable Long id, @PathVariable Long taskId,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
requireTask(id, taskId);
|
||||
if (!taskService.retryTask(taskId)) {
|
||||
return R.fail("only failed or stale tasks can be retried");
|
||||
}
|
||||
recordUserEvent(id, taskId, TeamTaskEventEntity.RETRIED, principal, null);
|
||||
publishBoardEvent(taskId, "team_task_retried");
|
||||
dispatchService.requestDispatch(id);
|
||||
return R.ok(toTaskVO(taskService.getTask(taskId)));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "取消任务")
|
||||
@PostMapping("/{id}/tasks/{taskId}/cancel")
|
||||
public R<TaskVO> cancel(@PathVariable Long id, @PathVariable Long taskId,
|
||||
@RequestBody(required = false) ReasonRequest req) {
|
||||
TeamTaskEntity task = requireTask(id, taskId);
|
||||
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
|
||||
// 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)));
|
||||
@RequestBody(required = false) ReasonRequest req,
|
||||
Principal principal) {
|
||||
return guarded(() -> {
|
||||
TeamTaskEntity task = requireTask(id, taskId);
|
||||
List<Long> released = taskService.cancelTask(taskId, req == null ? null : req.getReason());
|
||||
recordUserEvent(id, taskId, TeamTaskEventEntity.CANCELLED, principal,
|
||||
req == null ? null : req.getReason());
|
||||
publishBoardEvent(taskId, "team_task_cancelled");
|
||||
// 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 = "添加评论")
|
||||
@PostMapping("/{id}/tasks/{taskId}/comments")
|
||||
public R<Void> comment(@PathVariable Long id, @PathVariable Long taskId,
|
||||
@RequestBody CommentRequest req, Principal principal) {
|
||||
requireTask(id, taskId);
|
||||
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
|
||||
principal != null ? principal.getName() : "admin",
|
||||
TeamTaskService.COMMENT_NOTE, req.getContent());
|
||||
return R.ok(null);
|
||||
return guarded(() -> {
|
||||
requireTask(id, taskId);
|
||||
taskService.addComment(taskId, TeamTaskService.AUTHOR_USER,
|
||||
principal != null ? principal.getName() : "admin",
|
||||
TeamTaskService.COMMENT_NOTE, req.getContent());
|
||||
return R.ok(null);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "任务状态统计(看板列头)")
|
||||
@ -219,6 +289,20 @@ public class TeamController {
|
||||
|
||||
// ==================== 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) {
|
||||
TeamTaskEntity task = taskService.getTask(taskId);
|
||||
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.team.model.AgentTeamEntity;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskEventEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
@ -71,6 +72,7 @@ public class TeamDispatchService {
|
||||
private final ConversationService conversationService;
|
||||
private final ChatStreamTracker streamTracker;
|
||||
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). */
|
||||
private final Set<Long> runningMembers = ConcurrentHashMap.newKeySet();
|
||||
@ -125,6 +127,8 @@ public class TeamDispatchService {
|
||||
if (!taskService.assignTask(task.getId(), assignee)) {
|
||||
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())) {
|
||||
// Circuit breaker tripped; the task was auto-failed — the lead
|
||||
// must hear about it or the work silently disappears.
|
||||
@ -252,6 +256,10 @@ public class TeamDispatchService {
|
||||
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. */
|
||||
private String buildDispatchContent(TeamTaskEntity task) {
|
||||
StringBuilder sb = new StringBuilder(1024);
|
||||
@ -261,6 +269,7 @@ public class TeamDispatchService {
|
||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||
sb.append("\n").append(task.getDescription()).append('\n');
|
||||
}
|
||||
appendPrerequisiteResults(sb, task);
|
||||
sb.append("""
|
||||
|
||||
[Instructions]
|
||||
@ -272,22 +281,47 @@ public class TeamDispatchService {
|
||||
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) {
|
||||
if (task.getLeadConversationId() == null) {
|
||||
/**
|
||||
* Hand the member everything its prerequisites produced: result summaries
|
||||
* 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;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> payload = new HashMap<>(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()));
|
||||
streamTracker.broadcastObject(task.getLeadConversationId(), event, payload);
|
||||
} catch (Exception e) {
|
||||
log.debug("Team task event broadcast skipped: {}", e.getMessage());
|
||||
StringBuilder section = new StringBuilder();
|
||||
for (Long blockerId : blockerIds) {
|
||||
TeamTaskEntity blocker = taskService.getTask(blockerId);
|
||||
if (blocker == null) {
|
||||
continue;
|
||||
}
|
||||
section.append("- #").append(blocker.getTaskNumber())
|
||||
.append(" \"").append(blocker.getSubject()).append("\" (")
|
||||
.append(blocker.getStatus()).append(')');
|
||||
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) {
|
||||
|
||||
@ -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,
|
||||
List<Long> memberAgentIds, String createdBy) {
|
||||
requireAgentExists(leadAgentId, "lead");
|
||||
requireReactLead(leadAgentId);
|
||||
requireNotInAnyTeam(leadAgentId);
|
||||
if (memberAgentIds != null) {
|
||||
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) {
|
||||
// Membership check ignores team status on purpose: an agent parked in a
|
||||
// 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.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.team.model.TeamTaskEventEntity;
|
||||
import vip.mate.team.repository.TeamTaskCommentMapper;
|
||||
import vip.mate.team.repository.TeamTaskEventMapper;
|
||||
import vip.mate.team.repository.TeamTaskMapper;
|
||||
|
||||
import java.net.URI;
|
||||
@ -51,6 +53,7 @@ public class TeamTaskService {
|
||||
|
||||
private final TeamTaskMapper taskMapper;
|
||||
private final TeamTaskCommentMapper commentMapper;
|
||||
private final TeamTaskEventMapper eventMapper;
|
||||
private final TeamService teamService;
|
||||
|
||||
// ==================== creation ====================
|
||||
@ -112,6 +115,12 @@ public class TeamTaskService {
|
||||
task.setChannel(cmd.getChannel());
|
||||
task.setMetadata(cmd.getMetadata());
|
||||
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={}",
|
||||
cmd.getTeamId(), task.getTaskNumber(), task.getId(), assignee, task.getStatus());
|
||||
return task;
|
||||
@ -186,6 +195,10 @@ public class TeamTaskService {
|
||||
throw new IllegalStateException("task #" + task.getTaskNumber()
|
||||
+ " 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);
|
||||
}
|
||||
|
||||
@ -220,13 +233,19 @@ public class TeamTaskService {
|
||||
|
||||
/** Fail a task (blocker escalation, runner error, circuit breaker). Does NOT release dependents. */
|
||||
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)
|
||||
.in(TeamTaskEntity::getStatus,
|
||||
TeamTaskStatus.PENDING, TeamTaskStatus.IN_PROGRESS, TeamTaskStatus.STALE)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.FAILED)
|
||||
.set(TeamTaskEntity::getReason, reason)
|
||||
.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. */
|
||||
@ -262,13 +281,21 @@ public class TeamTaskService {
|
||||
|
||||
/** Update progress and renew the execution lease in one shot. */
|
||||
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::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.eq(agentId != null, TeamTaskEntity::getOwnerAgentId, agentId)
|
||||
.set(percent != null, TeamTaskEntity::getProgressPercent, percent)
|
||||
.set(step != null, TeamTaskEntity::getProgressStep, step)
|
||||
.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). */
|
||||
@ -298,6 +325,10 @@ public class TeamTaskService {
|
||||
comment.setCommentType(commentType == null ? COMMENT_NOTE : commentType);
|
||||
comment.setContent(content);
|
||||
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())) {
|
||||
boolean failed = failTask(taskId, "blocked: " + content);
|
||||
@ -316,6 +347,42 @@ public class TeamTaskService {
|
||||
.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 ====================
|
||||
|
||||
/** 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()
|
||||
.eq(TeamTaskEntity::getId, taskId)
|
||||
.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());
|
||||
}
|
||||
|
||||
@ -479,6 +548,8 @@ public class TeamTaskService {
|
||||
.eq(TeamTaskEntity::getStatus, TeamTaskStatus.IN_PROGRESS)
|
||||
.set(TeamTaskEntity::getStatus, TeamTaskStatus.STALE)
|
||||
.set(TeamTaskEntity::getReason, "execution lease expired"));
|
||||
recordEvent(task.getTeamId(), task.getId(), TeamTaskEventEntity.STALE,
|
||||
AUTHOR_SYSTEM, null, "execution lease expired");
|
||||
}
|
||||
if (!expired.isEmpty()) {
|
||||
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.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskEventEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
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 vip.mate.tool.builtin.ToolExecutionContext;
|
||||
@ -22,7 +24,9 @@ import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@ -44,6 +48,7 @@ public class TeamTasksTool {
|
||||
private final TeamService teamService;
|
||||
private final TeamTaskService taskService;
|
||||
private final TeamDispatchService dispatchService;
|
||||
private final TeamEventChannel eventChannel;
|
||||
private final ConversationService conversationService;
|
||||
private final AgentMapper agentMapper;
|
||||
|
||||
@ -119,8 +124,8 @@ public class TeamTasksTool {
|
||||
case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step);
|
||||
case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text);
|
||||
case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url);
|
||||
case "cancel" -> cancel(team, isLead, parseId(taskId, "taskId"), text);
|
||||
case "retry" -> retry(team, isLead, parseId(taskId, "taskId"));
|
||||
case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text);
|
||||
case "retry" -> retry(team, agentId, isLead, parseId(taskId, "taskId"));
|
||||
default -> "Error: unknown action '" + action
|
||||
+ "'. Use one of: list, get, create, complete, progress, comment, attach, cancel, retry.";
|
||||
};
|
||||
@ -154,6 +159,7 @@ public class TeamTasksTool {
|
||||
.requireApproval(Boolean.TRUE.equals(requireApproval))
|
||||
.leadConversationId(conversationId)
|
||||
.build());
|
||||
eventChannel.publishTaskEvent(task, "team_task_created", Map.of());
|
||||
if (TeamTaskStatus.PENDING.equals(task.getStatus())) {
|
||||
dispatchService.requestDispatch(team.getId());
|
||||
}
|
||||
@ -193,6 +199,16 @@ public class TeamTasksTool {
|
||||
return "Error: percent must be between 0 and 100.";
|
||||
}
|
||||
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."
|
||||
: "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.";
|
||||
}
|
||||
|
||||
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) {
|
||||
return "Error: only the team lead can cancel tasks.";
|
||||
}
|
||||
TeamTaskEntity task = requireTaskInTeam(team, taskId);
|
||||
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.
|
||||
dispatchService.interruptRun(task);
|
||||
if (!released.isEmpty()) {
|
||||
@ -231,7 +251,7 @@ public class TeamTasksTool {
|
||||
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) {
|
||||
return "Error: only the team lead can retry tasks.";
|
||||
}
|
||||
@ -239,6 +259,9 @@ public class TeamTasksTool {
|
||||
if (!taskService.retryTask(taskId)) {
|
||||
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());
|
||||
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 ChatStreamTracker streamTracker;
|
||||
private TeamAnnounceService announceService;
|
||||
private TeamEventChannel eventChannel;
|
||||
private TeamDispatchService service;
|
||||
|
||||
@BeforeEach
|
||||
@ -47,8 +48,9 @@ class TeamDispatchServiceTest {
|
||||
conversationService = mock(ConversationService.class);
|
||||
streamTracker = mock(ChatStreamTracker.class);
|
||||
announceService = mock(TeamAnnounceService.class);
|
||||
eventChannel = mock(TeamEventChannel.class);
|
||||
service = new TeamDispatchService(teamService, taskService, agentService,
|
||||
conversationService, streamTracker, announceService);
|
||||
conversationService, streamTracker, announceService, eventChannel);
|
||||
}
|
||||
|
||||
private TeamTaskEntity task(Long id, Long assignee) {
|
||||
@ -145,7 +147,7 @@ class TeamDispatchServiceTest {
|
||||
service.settleOutcome(running, "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);
|
||||
}
|
||||
|
||||
@ -160,7 +162,7 @@ class TeamDispatchServiceTest {
|
||||
service.settleOutcome(failed, "irrelevant reply");
|
||||
|
||||
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 ====================
|
||||
@ -200,8 +202,8 @@ class TeamDispatchServiceTest {
|
||||
|
||||
service.runTask(TEAM_ID, assigned);
|
||||
|
||||
verify(streamTracker, never())
|
||||
.broadcastObject(anyString(), eq("team_task_failed"), any());
|
||||
verify(eventChannel, never())
|
||||
.publishTaskEvent(any(), eq("team_task_failed"), any());
|
||||
verify(announceService, never()).announceTaskSettled(any());
|
||||
// Tracking still ends cleanly.
|
||||
verify(streamTracker).complete(startsWith("team-task-"));
|
||||
@ -221,7 +223,7 @@ class TeamDispatchServiceTest {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -241,6 +243,36 @@ class TeamDispatchServiceTest {
|
||||
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
|
||||
@DisplayName("an oversized member reply is truncated before persisting")
|
||||
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.TeamTaskCreateCommand;
|
||||
import vip.mate.team.model.TeamTaskEntity;
|
||||
import vip.mate.team.model.TeamTaskEventEntity;
|
||||
import vip.mate.team.model.TeamTaskStatus;
|
||||
import vip.mate.team.repository.TeamTaskCommentMapper;
|
||||
import vip.mate.team.repository.TeamTaskEventMapper;
|
||||
import vip.mate.team.repository.TeamTaskMapper;
|
||||
|
||||
import java.util.List;
|
||||
@ -37,6 +39,7 @@ class TeamTaskServiceTest {
|
||||
|
||||
private TeamTaskMapper taskMapper;
|
||||
private TeamTaskCommentMapper commentMapper;
|
||||
private TeamTaskEventMapper eventMapper;
|
||||
private TeamService teamService;
|
||||
private TeamTaskService service;
|
||||
|
||||
@ -48,14 +51,16 @@ class TeamTaskServiceTest {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, TeamTaskEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TeamTaskCommentEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TeamTaskEventEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
taskMapper = mock(TeamTaskMapper.class);
|
||||
commentMapper = mock(TeamTaskCommentMapper.class);
|
||||
eventMapper = mock(TeamTaskEventMapper.class);
|
||||
teamService = mock(TeamService.class);
|
||||
service = new TeamTaskService(taskMapper, commentMapper, teamService);
|
||||
service = new TeamTaskService(taskMapper, commentMapper, eventMapper, teamService);
|
||||
|
||||
AgentTeamEntity team = new AgentTeamEntity();
|
||||
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.TeamTaskStatus;
|
||||
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 vip.mate.tool.builtin.ToolExecutionContext;
|
||||
@ -42,6 +43,7 @@ class TeamTasksToolTest {
|
||||
private TeamService teamService;
|
||||
private TeamTaskService taskService;
|
||||
private TeamDispatchService dispatchService;
|
||||
private TeamEventChannel eventChannel;
|
||||
private ConversationService conversationService;
|
||||
private AgentMapper agentMapper;
|
||||
private TeamTasksTool tool;
|
||||
@ -52,10 +54,11 @@ class TeamTasksToolTest {
|
||||
teamService = mock(TeamService.class);
|
||||
taskService = mock(TeamTaskService.class);
|
||||
dispatchService = mock(TeamDispatchService.class);
|
||||
eventChannel = mock(TeamEventChannel.class);
|
||||
conversationService = mock(ConversationService.class);
|
||||
agentMapper = mock(AgentMapper.class);
|
||||
tool = new TeamTasksTool(teamService, taskService, dispatchService,
|
||||
conversationService, agentMapper);
|
||||
eventChannel, conversationService, agentMapper);
|
||||
|
||||
team = new AgentTeamEntity();
|
||||
team.setId(TEAM_ID);
|
||||
|
||||
@ -880,6 +880,17 @@ export interface TeamTaskComment {
|
||||
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 = {
|
||||
list: () => http.get('/teams'),
|
||||
get: (id: string) => http.get(`/teams/${id}`),
|
||||
@ -909,6 +920,7 @@ export const teamApi = {
|
||||
requireApproval?: boolean
|
||||
},
|
||||
) => 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`),
|
||||
rejectTask: (id: string, taskId: string, reason?: string) =>
|
||||
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',
|
||||
deliverables: 'Deliverables',
|
||||
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: {
|
||||
todo: 'To Do',
|
||||
in_progress: 'In Progress',
|
||||
|
||||
@ -51,6 +51,25 @@ export default {
|
||||
taskCreateIncomplete: '请填写任务标题并选择执行成员',
|
||||
deliverables: '交付物',
|
||||
viewRun: '查看执行过程',
|
||||
timeline: '时间线',
|
||||
leadReactHint: 'Lead 需为 ReAct 型员工:计划执行型员工走自身的串行委派管线,不会使用团队任务板',
|
||||
leadTypeWarning: 'Lead 是计划执行型,团队协同不会生效',
|
||||
eventType: {
|
||||
created: '创建',
|
||||
dispatched: '派发',
|
||||
progress: '进度',
|
||||
comment: '评论',
|
||||
blocker: '阻塞上报',
|
||||
deliverable: '交付物',
|
||||
completed: '完成',
|
||||
in_review: '待审核',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
approved: '批准',
|
||||
rejected: '驳回',
|
||||
retried: '重试',
|
||||
stale: '失联',
|
||||
},
|
||||
column: {
|
||||
todo: '待处理',
|
||||
in_progress: '进行中',
|
||||
|
||||
@ -78,6 +78,9 @@
|
||||
</span>
|
||||
{{ store.currentTeam.leadName }}
|
||||
</span>
|
||||
<span v-if="leadIsPlanExecute" class="lead-warning" :title="t('teams.leadReactHint')">
|
||||
⚠ {{ t('teams.leadTypeWarning') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-header__right">
|
||||
<div class="view-switch">
|
||||
@ -102,6 +105,18 @@
|
||||
</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 -->
|
||||
<div v-if="activeTab === 'board'" class="board-grid">
|
||||
<div v-for="col in boardColumns" :key="col.key" class="board-col">
|
||||
@ -190,6 +205,8 @@
|
||||
:key="String(agent.id)"
|
||||
class="agent-pill"
|
||||
: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))"
|
||||
>
|
||||
<span class="agent-pill__icon" :style="{ color: agentIconColor(agent.icon) }">
|
||||
@ -198,6 +215,7 @@
|
||||
{{ agent.name }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-hint">{{ t('teams.leadReactHint') }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('teams.membersField') }} <i>*</i></label>
|
||||
@ -408,6 +426,21 @@
|
||||
</a>
|
||||
</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__label">{{ t('teams.comments') }}</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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 { agentIconColor } from '@/utils/agentIconColor'
|
||||
import { useAgentStore } from '@/stores/useAgentStore'
|
||||
@ -518,6 +552,50 @@ function agentIcon(agentId?: string | null, apiIcon?: string | null): string {
|
||||
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 ====================
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
@ -541,7 +619,15 @@ function stopPolling() {
|
||||
|
||||
watch(
|
||||
() => store.currentTeam,
|
||||
(team) => (team ? startPolling() : stopPolling()),
|
||||
(team) => {
|
||||
if (team) {
|
||||
startPolling()
|
||||
startEventSubscription(String(team.team.id))
|
||||
} else {
|
||||
stopPolling()
|
||||
stopEventSubscription()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@ -551,7 +637,10 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => stopPolling())
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
stopEventSubscription()
|
||||
})
|
||||
|
||||
// ==================== team actions ====================
|
||||
|
||||
@ -596,6 +685,18 @@ const memberCandidates = computed(() =>
|
||||
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() {
|
||||
createForm.name = ''
|
||||
createForm.description = ''
|
||||
@ -764,6 +865,8 @@ function openTaskRun() {
|
||||
})
|
||||
}
|
||||
|
||||
const taskEvents = ref<TeamTaskEvent[]>([])
|
||||
|
||||
async function openTask(vo: TeamTaskVO) {
|
||||
if (!store.currentTeam) return
|
||||
try {
|
||||
@ -771,6 +874,13 @@ async function openTask(vo: TeamTaskVO) {
|
||||
currentTask.value = res.data?.task || vo
|
||||
comments.value = res.data?.comments || []
|
||||
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) {
|
||||
ElMessage.error(e?.message || 'failed')
|
||||
}
|
||||
@ -1507,6 +1617,88 @@ async function cancelTask() {
|
||||
font-size: 12px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user