feat(tasks): update team task status timeline
This commit is contained in:
parent
9e1abb0332
commit
3992ab0dab
12 changed files with 193 additions and 17 deletions
|
|
@ -475,13 +475,34 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
});
|
||||
}
|
||||
|
||||
function setTaskOwner(paths, taskRef, owner) {
|
||||
function normalizeOwnerValue(owner) {
|
||||
if (owner == null || owner === 'clear' || owner === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = String(owner).trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function setTaskOwner(paths, taskRef, owner, actor) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
if (owner == null || owner === 'clear' || owner === 'none') {
|
||||
delete task.owner;
|
||||
const previousOwner = normalizeOwnerValue(task.owner);
|
||||
const nextOwner = normalizeOwnerValue(owner);
|
||||
|
||||
if (nextOwner) {
|
||||
task.owner = nextOwner;
|
||||
} else {
|
||||
task.owner = String(owner).trim();
|
||||
delete task.owner;
|
||||
}
|
||||
|
||||
if (previousOwner !== nextOwner) {
|
||||
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
||||
type: 'owner_changed',
|
||||
...(previousOwner ? { from: previousOwner } : {}),
|
||||
...(nextOwner ? { to: nextOwner } : {}),
|
||||
...(actor ? { actor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -472,13 +472,13 @@ function restoreTask(context, taskId, actor) {
|
|||
});
|
||||
}
|
||||
|
||||
function setTaskOwner(context, taskId, owner) {
|
||||
function setTaskOwner(context, taskId, owner, actor) {
|
||||
const { previousTask, updatedTask } = withTeamBoardLock(context.paths, () => {
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const nextOwner = isClearOwnerValue(owner)
|
||||
? owner
|
||||
: assertKnownTaskActor(context, owner, 'task owner');
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner, normalizeActorName(actor) || undefined);
|
||||
return {
|
||||
previousTask: before,
|
||||
updatedTask: after,
|
||||
|
|
@ -707,7 +707,7 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa
|
|||
- Human-facing summaries should use the short display label like #abcd1234 for readability.
|
||||
1. If you are about to do implementation/fix work on a task yourself, make sure the owner reflects the actual implementer:
|
||||
- If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>" }
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>", actor: "<your-name>" }
|
||||
- Do this only when you are genuinely taking over the work.
|
||||
- Reviewing, approving, or leaving comments does NOT require changing ownership.
|
||||
2. Use MCP tool task_start to mark task started:
|
||||
|
|
|
|||
|
|
@ -717,6 +717,27 @@ describe('agent-teams-controller API', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('tracks owner assignment history without duplicate same-owner events', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Owner history' });
|
||||
|
||||
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
|
||||
controller.tasks.setTaskOwner(task.id, 'bob', 'team-lead');
|
||||
controller.tasks.setTaskOwner(task.id, 'alice', 'team-lead');
|
||||
controller.tasks.setTaskOwner(task.id, null, 'team-lead');
|
||||
|
||||
const ownerEvents = controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'owner_changed');
|
||||
|
||||
expect(ownerEvents).toHaveLength(3);
|
||||
expect(ownerEvents[0]).toMatchObject({ to: 'bob', actor: 'team-lead' });
|
||||
expect(ownerEvents[1]).toMatchObject({ from: 'bob', to: 'alice', actor: 'team-lead' });
|
||||
expect(ownerEvents[2]).toMatchObject({ from: 'alice', actor: 'team-lead' });
|
||||
expect(ownerEvents[2].to).toBeUndefined();
|
||||
});
|
||||
|
||||
it('wraps review instructions in the canonical agent block format used by the UI', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
2
mcp-server/src/agent-teams-controller.d.ts
vendored
2
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
|
|||
completeTask(taskId: string, actor?: string): unknown;
|
||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||
restoreTask(taskId: string, actor?: string): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown;
|
||||
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
|
|
|
|||
|
|
@ -413,18 +413,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
name: 'task_set_owner',
|
||||
description: 'Assign or clear task owner',
|
||||
description: 'Assign, reassign, or clear task owner',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
owner: z.string().nullable(),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
||||
execute: async ({ teamName, claudeDir, taskId, owner, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
|
@ -624,7 +625,13 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||
includeActiveProcesses: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider, includeActiveProcesses }) => {
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
memberName,
|
||||
runtimeProvider,
|
||||
includeActiveProcesses,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
|
|
|
|||
|
|
@ -601,15 +601,36 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id);
|
||||
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
owner: null,
|
||||
actor: 'lead',
|
||||
});
|
||||
|
||||
const owned = parseJsonToolResult(
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
owner: 'alice',
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(owned.owner).toBe('alice');
|
||||
const ownedFull = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
})
|
||||
);
|
||||
expect(ownedFull.historyEvents.at(-1)).toMatchObject({
|
||||
type: 'owner_changed',
|
||||
to: 'alice',
|
||||
actor: 'lead',
|
||||
});
|
||||
|
||||
const commented = parseJsonToolResult(
|
||||
await getTool('task_add_comment').execute({
|
||||
|
|
|
|||
|
|
@ -2137,7 +2137,7 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner);
|
||||
this.getController(teamName).tasks.setTaskOwner(taskId, owner, 'user');
|
||||
this.invalidateGlobalTaskProjectionCache();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -378,11 +378,25 @@ export class TeamTaskWriter {
|
|||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
if (owner) {
|
||||
task.owner = owner;
|
||||
const previousOwner =
|
||||
typeof task.owner === 'string' && task.owner.trim() ? task.owner.trim() : undefined;
|
||||
const nextOwner = typeof owner === 'string' && owner.trim() ? owner.trim() : undefined;
|
||||
if (nextOwner) {
|
||||
task.owner = nextOwner;
|
||||
} else {
|
||||
delete task.owner;
|
||||
}
|
||||
if (previousOwner !== nextOwner) {
|
||||
task.historyEvents = appendHistoryEvent(
|
||||
Array.isArray(task.historyEvents) ? task.historyEvents : undefined,
|
||||
{
|
||||
type: 'owner_changed',
|
||||
...(previousOwner ? { from: previousOwner } : {}),
|
||||
...(nextOwner ? { to: nextOwner } : {}),
|
||||
actor: 'user',
|
||||
} as Omit<TaskHistoryEvent, 'id' | 'timestamp'>
|
||||
);
|
||||
}
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck } from 'lucide-react';
|
||||
import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react';
|
||||
|
||||
import type { TaskHistoryEvent, TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -107,6 +107,52 @@ const EventContent = ({
|
|||
<StatusBadge status={event.to} />
|
||||
</span>
|
||||
);
|
||||
case 'owner_changed':
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserRound size={10} className="text-cyan-400" />
|
||||
{event.from && event.to ? (
|
||||
<>
|
||||
Reassigned
|
||||
<MemberBadge
|
||||
name={event.from}
|
||||
color={memberColorMap?.get(event.from)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
<ArrowRight size={10} className="text-[var(--color-text-muted)]" />
|
||||
<MemberBadge
|
||||
name={event.to}
|
||||
color={memberColorMap?.get(event.to)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
</>
|
||||
) : event.to ? (
|
||||
<>
|
||||
Assigned to
|
||||
<MemberBadge
|
||||
name={event.to}
|
||||
color={memberColorMap?.get(event.to)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
</>
|
||||
) : event.from ? (
|
||||
<>
|
||||
Unassigned from
|
||||
<MemberBadge
|
||||
name={event.from}
|
||||
color={memberColorMap?.get(event.from)}
|
||||
size="sm"
|
||||
hideAvatar
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
'Owner changed'
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
case 'review_requested':
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
@ -181,6 +227,8 @@ function dotColor(event: TaskHistoryEvent): string {
|
|||
return dotColorForStatus(event.status);
|
||||
case 'status_changed':
|
||||
return dotColorForStatus(event.to);
|
||||
case 'owner_changed':
|
||||
return 'bg-cyan-400';
|
||||
case 'review_requested':
|
||||
return 'bg-purple-400';
|
||||
case 'review_started':
|
||||
|
|
|
|||
|
|
@ -126,6 +126,12 @@ export interface TaskStatusChangedEvent extends TaskHistoryEventBase {
|
|||
to: TeamTaskStatus;
|
||||
}
|
||||
|
||||
export interface TaskOwnerChangedEvent extends TaskHistoryEventBase {
|
||||
type: 'owner_changed';
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface TaskReviewRequestedEvent extends TaskHistoryEventBase {
|
||||
type: 'review_requested';
|
||||
from: TeamReviewState;
|
||||
|
|
@ -157,6 +163,7 @@ export interface TaskReviewStartedEvent extends TaskHistoryEventBase {
|
|||
export type TaskHistoryEvent =
|
||||
| TaskCreatedEvent
|
||||
| TaskStatusChangedEvent
|
||||
| TaskOwnerChangedEvent
|
||||
| TaskReviewRequestedEvent
|
||||
| TaskReviewChangesRequestedEvent
|
||||
| TaskReviewApprovedEvent
|
||||
|
|
|
|||
2
src/types/agent-teams-controller.d.ts
vendored
2
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -31,7 +31,7 @@ declare module 'agent-teams-controller' {
|
|||
completeTask(taskId: string, actor?: string): unknown;
|
||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||
restoreTask(taskId: string, actor?: string): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null, actor?: string): unknown;
|
||||
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
|
|
|
|||
|
|
@ -332,5 +332,42 @@ describe('TeamTaskWriter', () => {
|
|||
actor: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateOwner appends owner_changed event', async () => {
|
||||
hoisted.files.set(
|
||||
taskPath,
|
||||
JSON.stringify({
|
||||
id: '12',
|
||||
subject: 'task',
|
||||
owner: 'alice',
|
||||
status: 'pending',
|
||||
historyEvents: [
|
||||
{ type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await writer.updateOwner('my-team', '12', 'bob');
|
||||
await writer.updateOwner('my-team', '12', 'bob');
|
||||
await writer.updateOwner('my-team', '12', null);
|
||||
|
||||
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||
const ownerEvents = persisted.historyEvents.filter(
|
||||
(event: Record<string, unknown>) => event.type === 'owner_changed'
|
||||
);
|
||||
expect(ownerEvents).toHaveLength(2);
|
||||
expect(ownerEvents[0]).toMatchObject({
|
||||
type: 'owner_changed',
|
||||
from: 'alice',
|
||||
to: 'bob',
|
||||
actor: 'user',
|
||||
});
|
||||
expect(ownerEvents[1]).toMatchObject({
|
||||
type: 'owner_changed',
|
||||
from: 'bob',
|
||||
actor: 'user',
|
||||
});
|
||||
expect(ownerEvents[1].to).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue