diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java index 2d568b47..95ca4ec3 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -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 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 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 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 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 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 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 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 approve(@PathVariable Long id, @PathVariable Long taskId) { - requireTask(id, taskId); - List released = taskService.approveTask(taskId); - if (!released.isEmpty()) { - dispatchService.requestDispatch(id); - } - return R.ok(toTaskVO(taskService.getTask(taskId))); + public R approve(@PathVariable Long id, @PathVariable Long taskId, + Principal principal) { + return guarded(() -> { + requireTask(id, taskId); + List 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 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 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 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 cancel(@PathVariable Long id, @PathVariable Long taskId, - @RequestBody(required = false) ReasonRequest req) { - TeamTaskEntity task = requireTask(id, taskId); - List 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 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> 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 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 R guarded(Supplier> 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)) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEventEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEventEntity.java new file mode 100644 index 00000000..abb7a23e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/model/TeamTaskEventEntity.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskEventMapper.java b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskEventMapper.java new file mode 100644 index 00000000..1b43aeb5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/repository/TeamTaskEventMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index 0e369429..b89211ef 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -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 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 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 blockerIds = TeamTaskService.parseIdArray(task.getBlockedBy()); + if (blockerIds.isEmpty()) { return; } - try { - Map 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 extra) { + eventChannel.publishTaskEvent(task, event, extra); } private static String truncate(String s, int max) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java new file mode 100644 index 00000000..9c726fde --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamEventChannel.java @@ -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 extra) { + if (task == null) { + return; + } + try { + Map 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java index 37109cde..a8861d06 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java @@ -46,6 +46,7 @@ public class TeamService { public AgentTeamEntity createTeam(String name, String description, Long leadAgentId, List 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. diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index d47f3fa3..51097c48 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -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.lambdaUpdate() + boolean failed = taskMapper.update(null, Wrappers.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.lambdaUpdate() + boolean updated = taskMapper.update(null, Wrappers.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 listEvents(Long taskId) { + return eventMapper.selectList(Wrappers.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.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()); diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java index 55768ac9..2a13cd1a 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -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 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 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."; } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V174__team_task_event_timeline.sql b/mateclaw-server/src/main/resources/db/migration/h2/V174__team_task_event_timeline.sql new file mode 100644 index 00000000..28f5da5d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V174__team_task_event_timeline.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V174__team_task_event_timeline.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V174__team_task_event_timeline.sql new file mode 100644 index 00000000..4ea6e5fb --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V174__team_task_event_timeline.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V174__team_task_event_timeline.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V174__team_task_event_timeline.sql new file mode 100644 index 00000000..2a12c607 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V174__team_task_event_timeline.sql @@ -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) +); diff --git a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java new file mode 100644 index 00000000..75a7e5ff --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 r = controller.getTask(TEAM_ID, TASK_ID); + + assertEquals(500, r.getCode()); + assertEquals("task not found on this team's board", r.getMsg()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index 0563aa34..fd0d7d48 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -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() { diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java new file mode 100644 index 00000000..b34af7ec --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java @@ -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)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java index b0523268..9bb485cb 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamTaskServiceTest.java @@ -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); diff --git a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java index 1960715e..6a1b9eb1 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java @@ -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); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 61439ee2..fd41c690 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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 }), diff --git a/mateclaw-ui/src/composables/useTeamEvents.ts b/mateclaw-ui/src/composables/useTeamEvents.ts new file mode 100644 index 00000000..8121a0ab --- /dev/null +++ b/mateclaw-ui/src/composables/useTeamEvents.ts @@ -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 +} + +export function subscribeTeamEvents( + teamId: string, + onEvent: (e: TeamBoardEvent) => void, +): () => void { + const controller = new AbortController() + + const run = async () => { + const headers: Record = { 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() +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 72e1df9b..b74f499c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index c24ccc4d..4fbcc0af 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '进行中', diff --git a/mateclaw-ui/src/views/Teams.vue b/mateclaw-ui/src/views/Teams.vue index 95953ca5..c8804ac5 100644 --- a/mateclaw-ui/src/views/Teams.vue +++ b/mateclaw-ui/src/views/Teams.vue @@ -78,6 +78,9 @@ {{ store.currentTeam.leadName }} + + ⚠ {{ t('teams.leadTypeWarning') }} +
@@ -102,6 +105,18 @@
+ + +
+ {{ item.text }} +
+
+
@@ -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))" > @@ -198,6 +215,7 @@ {{ agent.name }}
+

{{ t('teams.leadReactHint') }}

@@ -408,6 +426,21 @@
+
+
{{ t('teams.timeline') }}
+
+
+ + {{ (ev.createTime || '').slice(5, 16) }} + {{ t(`teams.eventType.${ev.eventType}`, ev.eventType) }} + + {{ agentStore.agents.find(a => String(a.id) === String(ev.actorId))?.name || ev.actorId }} + + {{ ev.actorId }} + {{ ev.detail }} +
+
+
{{ t('teams.comments') }}
@@ -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 | null = null + +function onBoardEvent(e: { event: string; data: Record }) { + 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 | 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([]) + 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;