feat: enhance review and messaging functionalities
- Added requestReview method to facilitate task review requests, integrating with kanban and messaging systems. - Updated taskStore to manage review states, ensuring compatibility with kanban columns. - Enhanced message handling by introducing appendSentMessage for better tracking of sent messages. - Improved task management by normalizing review states and integrating them into task creation and retrieval processes. - Updated tests to cover new review and messaging features, ensuring robust functionality across components.
This commit is contained in:
parent
4cf330e8cc
commit
95d610f43b
33 changed files with 750 additions and 157 deletions
|
|
@ -15,7 +15,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally.</sub>
|
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const taskStore = require('./taskStore.js');
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
|
|
@ -80,6 +81,10 @@ function setKanbanColumn(paths, teamName, taskId, column) {
|
||||||
? { column: 'review', reviewer: null, movedAt: nowIso() }
|
? { column: 'review', reviewer: null, movedAt: nowIso() }
|
||||||
: { column: 'approved', movedAt: nowIso() };
|
: { column: 'approved', movedAt: nowIso() };
|
||||||
writeKanbanState(paths, teamName, state);
|
writeKanbanState(paths, teamName, state);
|
||||||
|
taskStore.updateTask(paths, String(taskId), (task) => ({
|
||||||
|
...task,
|
||||||
|
reviewState: column,
|
||||||
|
}));
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +92,10 @@ function clearKanban(paths, teamName, taskId) {
|
||||||
const state = readKanbanState(paths, teamName);
|
const state = readKanbanState(paths, teamName);
|
||||||
delete state.tasks[String(taskId)];
|
delete state.tasks[String(taskId)];
|
||||||
writeKanbanState(paths, teamName, state);
|
writeKanbanState(paths, teamName, state);
|
||||||
|
taskStore.updateTask(paths, String(taskId), (task) => ({
|
||||||
|
...task,
|
||||||
|
reviewState: 'none',
|
||||||
|
}));
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
144
agent-teams-controller/src/internal/messageStore.js
Normal file
144
agent-teams-controller/src/internal/messageStore.js
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dirPath) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath, fallbackValue) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath, value) {
|
||||||
|
ensureDir(path.dirname(filePath));
|
||||||
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
|
||||||
|
fs.renameSync(tempPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInboxPath(paths, memberName) {
|
||||||
|
return path.join(paths.teamDir, 'inboxes', `${String(memberName).trim()}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSentMessagesPath(paths) {
|
||||||
|
return path.join(paths.teamDir, 'sentMessages.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachments(attachments) {
|
||||||
|
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = attachments
|
||||||
|
.filter((item) => item && typeof item === 'object')
|
||||||
|
.map((item) => ({
|
||||||
|
id: String(item.id || '').trim(),
|
||||||
|
filename: String(item.filename || '').trim(),
|
||||||
|
mimeType: String(item.mimeType || '').trim(),
|
||||||
|
size: Number(item.size || 0),
|
||||||
|
}))
|
||||||
|
.filter((item) => item.id && item.filename && item.mimeType && Number.isFinite(item.size));
|
||||||
|
|
||||||
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessage(flags, defaults) {
|
||||||
|
const timestamp =
|
||||||
|
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
|
||||||
|
const messageId =
|
||||||
|
typeof flags.messageId === 'string' && flags.messageId.trim()
|
||||||
|
? flags.messageId.trim()
|
||||||
|
: crypto.randomUUID();
|
||||||
|
const attachments = normalizeAttachments(flags.attachments);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from:
|
||||||
|
typeof flags.from === 'string' && flags.from.trim()
|
||||||
|
? flags.from.trim()
|
||||||
|
: defaults.from || 'user',
|
||||||
|
...(defaults.to ? { to: defaults.to } : {}),
|
||||||
|
text: String(flags.text || ''),
|
||||||
|
timestamp,
|
||||||
|
read: defaults.read,
|
||||||
|
...(typeof flags.summary === 'string' && flags.summary.trim()
|
||||||
|
? { summary: flags.summary.trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof flags.source === 'string' && flags.source.trim() ? { source: flags.source.trim() } : {}),
|
||||||
|
...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
|
||||||
|
? { leadSessionId: flags.leadSessionId.trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof flags.color === 'string' && flags.color.trim() ? { color: flags.color.trim() } : {}),
|
||||||
|
...(typeof flags.toolSummary === 'string' && flags.toolSummary.trim()
|
||||||
|
? { toolSummary: flags.toolSummary.trim() }
|
||||||
|
: {}),
|
||||||
|
...(Array.isArray(flags.toolCalls) && flags.toolCalls.length > 0
|
||||||
|
? {
|
||||||
|
toolCalls: flags.toolCalls
|
||||||
|
.filter((item) => item && typeof item === 'object' && typeof item.name === 'string')
|
||||||
|
.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
...(typeof item.preview === 'string' ? { preview: item.preview } : {}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(attachments ? { attachments } : {}),
|
||||||
|
messageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRow(filePath, row) {
|
||||||
|
const current = readJson(filePath, []);
|
||||||
|
const list = Array.isArray(current) ? current : [];
|
||||||
|
list.push(row);
|
||||||
|
writeJson(filePath, list);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInboxMessage(paths, flags) {
|
||||||
|
const memberName =
|
||||||
|
typeof flags.member === 'string' && flags.member.trim()
|
||||||
|
? flags.member.trim()
|
||||||
|
: typeof flags.to === 'string' && flags.to.trim()
|
||||||
|
? flags.to.trim()
|
||||||
|
: '';
|
||||||
|
if (!memberName) {
|
||||||
|
throw new Error('Missing recipient');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildMessage(flags, {
|
||||||
|
from: 'user',
|
||||||
|
to: memberName,
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
appendRow(getInboxPath(paths, memberName), payload);
|
||||||
|
return {
|
||||||
|
deliveredToInbox: true,
|
||||||
|
messageId: payload.messageId,
|
||||||
|
message: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSentMessage(paths, flags) {
|
||||||
|
const payload = buildMessage(flags, {
|
||||||
|
from: 'team-lead',
|
||||||
|
to: typeof flags.to === 'string' && flags.to.trim() ? flags.to.trim() : undefined,
|
||||||
|
read: true,
|
||||||
|
});
|
||||||
|
appendRow(getSentMessagesPath(paths), payload);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
appendSentMessage,
|
||||||
|
sendInboxMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
const legacy = require('../legacy/teamctl.cli.js');
|
const messageStore = require('./messageStore.js');
|
||||||
|
|
||||||
function sendMessage(context, flags) {
|
function sendMessage(context, flags) {
|
||||||
return legacy.sendInboxMessage(context.paths, context.teamName, flags);
|
return messageStore.sendInboxMessage(context.paths, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSentMessage(context, flags) {
|
||||||
|
return messageStore.appendSentMessage(context.paths, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
appendSentMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,19 @@ function registerProcess(paths, flags) {
|
||||||
|
|
||||||
const list = readProcesses(paths);
|
const list = readProcesses(paths);
|
||||||
const existingActiveIndex = list.findIndex((entry) => entry.pid === pid && !entry.stoppedAt);
|
const existingActiveIndex = list.findIndex((entry) => entry.pid === pid && !entry.stoppedAt);
|
||||||
const existingActive = existingActiveIndex >= 0 ? list[existingActiveIndex] : null;
|
const existingActive =
|
||||||
|
existingActiveIndex >= 0
|
||||||
|
? {
|
||||||
|
...list[existingActiveIndex],
|
||||||
|
...(legacy.isProcessAlive(pid) ? {} : { stoppedAt: nowIso() }),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (existingActiveIndex >= 0 && existingActive && existingActive.stoppedAt) {
|
||||||
|
list[existingActiveIndex] = existingActive;
|
||||||
|
}
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const entry = {
|
const entry = {
|
||||||
id: existingActive ? existingActive.id : crypto.randomUUID(),
|
id: existingActive && !existingActive.stoppedAt ? existingActive.id : crypto.randomUUID(),
|
||||||
label,
|
label,
|
||||||
pid,
|
pid,
|
||||||
...(flags.port != null ? { port: Number(flags.port) } : {}),
|
...(flags.port != null ? { port: Number(flags.port) } : {}),
|
||||||
|
|
@ -94,10 +103,10 @@ function registerProcess(paths, flags) {
|
||||||
...(typeof flags.command === 'string' && flags.command.trim()
|
...(typeof flags.command === 'string' && flags.command.trim()
|
||||||
? { command: flags.command.trim() }
|
? { command: flags.command.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
registeredAt: existingActive ? existingActive.registeredAt : now,
|
registeredAt: existingActive && !existingActive.stoppedAt ? existingActive.registeredAt : now,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingActiveIndex >= 0) {
|
if (existingActiveIndex >= 0 && existingActive && !existingActive.stoppedAt) {
|
||||||
list[existingActiveIndex] = entry;
|
list[existingActiveIndex] = entry;
|
||||||
} else {
|
} else {
|
||||||
list.push(entry);
|
list.push(entry);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,57 @@ const kanban = require('./kanban.js');
|
||||||
const messages = require('./messages.js');
|
const messages = require('./messages.js');
|
||||||
const tasks = require('./tasks.js');
|
const tasks = require('./tasks.js');
|
||||||
|
|
||||||
|
function getReviewer(context, flags) {
|
||||||
|
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
|
||||||
|
return flags.reviewer.trim();
|
||||||
|
}
|
||||||
|
const state = kanban.getKanbanState(context);
|
||||||
|
return typeof state.reviewers[0] === 'string' && state.reviewers[0].trim()
|
||||||
|
? state.reviewers[0].trim()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestReview(context, taskId, flags = {}) {
|
||||||
|
const task = tasks.getTask(context, taskId);
|
||||||
|
if (task.status !== 'completed') {
|
||||||
|
throw new Error(`Task #${task.displayId || task.id} must be completed before review`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from =
|
||||||
|
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||||
|
const reviewer = getReviewer(context, flags);
|
||||||
|
|
||||||
|
try {
|
||||||
|
kanban.setKanbanColumn(context, task.id, 'review');
|
||||||
|
if (!reviewer) {
|
||||||
|
return tasks.getTask(context, task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.sendMessage(context, {
|
||||||
|
to: reviewer,
|
||||||
|
from,
|
||||||
|
text:
|
||||||
|
`Please review task #${task.displayId || task.id}.\n\n` +
|
||||||
|
'<agent-block>\n' +
|
||||||
|
`When approved, use MCP tool review_approve:\n` +
|
||||||
|
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
|
||||||
|
`If changes are needed, use MCP tool review_request_changes:\n` +
|
||||||
|
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }\n` +
|
||||||
|
'</agent-block>',
|
||||||
|
summary: `Review request for #${task.displayId || task.id}`,
|
||||||
|
source: 'system_notification',
|
||||||
|
});
|
||||||
|
return tasks.getTask(context, task.id);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
kanban.clearKanban(context, task.id);
|
||||||
|
} catch {
|
||||||
|
// Best-effort rollback: keep the original error.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function approveReview(context, taskId, flags = {}) {
|
function approveReview(context, taskId, flags = {}) {
|
||||||
const task = tasks.getTask(context, taskId);
|
const task = tasks.getTask(context, taskId);
|
||||||
const from =
|
const from =
|
||||||
|
|
@ -66,5 +117,6 @@ function requestChanges(context, taskId, flags = {}) {
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
approveReview,
|
approveReview,
|
||||||
|
requestReview,
|
||||||
requestChanges,
|
requestChanges,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
||||||
|
const REVIEW_STATES = new Set(['none', 'review', 'approved']);
|
||||||
const UUID_TASK_ID_PATTERN =
|
const UUID_TASK_ID_PATTERN =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
|
@ -61,15 +62,43 @@ function normalizeTask(rawTask, filePath) {
|
||||||
typeof rawTask.displayId === 'string' && rawTask.displayId.trim()
|
typeof rawTask.displayId === 'string' && rawTask.displayId.trim()
|
||||||
? rawTask.displayId.trim()
|
? rawTask.displayId.trim()
|
||||||
: deriveDisplayId(id),
|
: deriveDisplayId(id),
|
||||||
|
reviewState: normalizeTaskReviewState(rawTask.reviewState),
|
||||||
};
|
};
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTaskReviewState(value) {
|
||||||
|
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverlayReviewState(overlayTasks, taskId) {
|
||||||
|
if (!overlayTasks || typeof overlayTasks !== 'object') {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = overlayTasks[String(taskId)];
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.column === 'review' || entry.column === 'approved' ? entry.column : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCompatibleReviewState(task, overlayTasks) {
|
||||||
|
const explicit = normalizeTaskReviewState(task.reviewState);
|
||||||
|
return explicit === 'none' ? { ...task, reviewState: getOverlayReviewState(overlayTasks, task.id) } : task;
|
||||||
|
}
|
||||||
|
|
||||||
function listRawTasks(paths) {
|
function listRawTasks(paths) {
|
||||||
ensureDir(paths.tasksDir);
|
ensureDir(paths.tasksDir);
|
||||||
const entries = fs.readdirSync(paths.tasksDir);
|
const entries = fs.readdirSync(paths.tasksDir);
|
||||||
const out = [];
|
const out = [];
|
||||||
|
const overlayState = readJson(paths.kanbanPath, null);
|
||||||
|
const overlayTasks =
|
||||||
|
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
|
||||||
|
? overlayState.tasks
|
||||||
|
: null;
|
||||||
|
|
||||||
for (const fileName of entries) {
|
for (const fileName of entries) {
|
||||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||||
|
|
@ -78,7 +107,7 @@ function listRawTasks(paths) {
|
||||||
if (!rawTask) continue;
|
if (!rawTask) continue;
|
||||||
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
||||||
try {
|
try {
|
||||||
out.push(normalizeTask(rawTask, filePath));
|
out.push(withCompatibleReviewState(normalizeTask(rawTask, filePath), overlayTasks));
|
||||||
} catch {
|
} catch {
|
||||||
// Skip unreadable task rows.
|
// Skip unreadable task rows.
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +165,12 @@ function readTask(paths, taskRef, options = {}) {
|
||||||
if (!rawTask) {
|
if (!rawTask) {
|
||||||
throw new Error(`Task not found: ${String(taskRef)}`);
|
throw new Error(`Task not found: ${String(taskRef)}`);
|
||||||
}
|
}
|
||||||
return normalizeTask(rawTask, taskPath);
|
const overlayState = readJson(paths.kanbanPath, null);
|
||||||
|
const overlayTasks =
|
||||||
|
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
|
||||||
|
? overlayState.tasks
|
||||||
|
: null;
|
||||||
|
return withCompatibleReviewState(normalizeTask(rawTask, taskPath), overlayTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatusTransition(history, from, to, actor, timestamp) {
|
function createStatusTransition(history, from, to, actor, timestamp) {
|
||||||
|
|
@ -291,6 +325,7 @@ function createTask(paths, input = {}) {
|
||||||
input.needsClarification === 'lead' || input.needsClarification === 'user'
|
input.needsClarification === 'lead' || input.needsClarification === 'user'
|
||||||
? input.needsClarification
|
? input.needsClarification
|
||||||
: undefined,
|
: undefined,
|
||||||
|
reviewState: normalizeTaskReviewState(input.reviewState),
|
||||||
deletedAt:
|
deletedAt:
|
||||||
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
|
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
|
||||||
attachments: Array.isArray(input.attachments) ? input.attachments : undefined,
|
attachments: Array.isArray(input.attachments) ? input.attachments : undefined,
|
||||||
|
|
@ -591,8 +626,14 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
||||||
for (const task of activeTasks) {
|
for (const task of activeTasks) {
|
||||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||||
const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : '';
|
const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : '';
|
||||||
|
const effectiveReviewState =
|
||||||
|
normalizeTaskReviewState(task.reviewState) !== 'none'
|
||||||
|
? normalizeTaskReviewState(task.reviewState)
|
||||||
|
: reviewState
|
||||||
|
? String(kanbanEntry.column)
|
||||||
|
: 'none';
|
||||||
lines.push(
|
lines.push(
|
||||||
`${buildTaskReference(task)} [status=${task.status}${reviewState}] ${task.subject}`
|
`${buildTaskReference(task)} [status=${task.status}${effectiveReviewState !== 'none' ? `, review=${effectiveReviewState}` : ''}] ${task.subject}`
|
||||||
);
|
);
|
||||||
if (task.description) lines.push(` Description: ${task.description}`);
|
if (task.description) lines.push(` Description: ${task.description}`);
|
||||||
if (task.blockedBy && task.blockedBy.length > 0) {
|
if (task.blockedBy && task.blockedBy.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,29 @@ describe('agent-teams-controller API', () => {
|
||||||
);
|
);
|
||||||
expect(created.displayId).toHaveLength(8);
|
expect(created.displayId).toHaveLength(8);
|
||||||
expect(created.status).toBe('pending');
|
expect(created.status).toBe('pending');
|
||||||
|
expect(created.reviewState).toBe('none');
|
||||||
expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
|
expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
|
||||||
expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
|
expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
|
||||||
|
|
||||||
controller.kanban.addReviewer('alice');
|
controller.kanban.addReviewer('alice');
|
||||||
controller.kanban.setKanbanColumn(created.id, 'review');
|
controller.tasks.completeTask(created.id, 'bob');
|
||||||
|
controller.review.requestReview(created.id, { from: 'alice' });
|
||||||
controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
|
controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
|
||||||
|
|
||||||
const kanbanState = controller.kanban.getKanbanState();
|
const kanbanState = controller.kanban.getKanbanState();
|
||||||
expect(kanbanState.reviewers).toEqual(['alice']);
|
expect(kanbanState.reviewers).toEqual(['alice']);
|
||||||
expect(kanbanState.tasks[created.id].column).toBe('approved');
|
expect(kanbanState.tasks[created.id].column).toBe('approved');
|
||||||
|
expect(controller.tasks.getTask(created.id).reviewState).toBe('approved');
|
||||||
|
|
||||||
|
const sent = controller.messages.appendSentMessage({
|
||||||
|
from: 'team-lead',
|
||||||
|
to: 'user',
|
||||||
|
text: 'All good',
|
||||||
|
leadSessionId: 'session-1',
|
||||||
|
source: 'lead_process',
|
||||||
|
attachments: [{ id: 'a1', filename: 'diff.txt', mimeType: 'text/plain', size: 12 }],
|
||||||
|
});
|
||||||
|
expect(sent.leadSessionId).toBe('session-1');
|
||||||
|
|
||||||
const proc = controller.processes.registerProcess({
|
const proc = controller.processes.registerProcess({
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
|
|
@ -65,4 +78,37 @@ describe('agent-teams-controller API', () => {
|
||||||
const stopped = controller.processes.stopProcess({ pid: process.pid });
|
const stopped = controller.processes.stopProcess({ pid: process.pid });
|
||||||
expect(typeof stopped.stoppedAt).toBe('string');
|
expect(typeof stopped.stoppedAt).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates a fresh registry entry when an old pid was recycled without stoppedAt', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
processesPath,
|
||||||
|
JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'old-entry',
|
||||||
|
pid: 999999,
|
||||||
|
label: 'stale',
|
||||||
|
registeredAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const registered = controller.processes.registerProcess({
|
||||||
|
pid: 999999,
|
||||||
|
label: 'fresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registered.id).not.toBe('old-entry');
|
||||||
|
const rows = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].stoppedAt).toBeTruthy();
|
||||||
|
expect(rows[1].id).toBe(registered.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
2
mcp-server/src/agent-teams-controller.d.ts
vendored
2
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -39,11 +39,13 @@ declare module 'agent-teams-controller' {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerReviewApi {
|
export interface ControllerReviewApi {
|
||||||
|
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerMessageApi {
|
export interface ControllerMessageApi {
|
||||||
|
appendSentMessage(flags: Record<string, unknown>): unknown;
|
||||||
sendMessage(flags: Record<string, unknown>): unknown;
|
sendMessage(flags: Record<string, unknown>): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,39 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
summary: z.string().optional(),
|
summary: z.string().optional(),
|
||||||
|
source: z.string().optional(),
|
||||||
|
leadSessionId: z.string().optional(),
|
||||||
|
attachments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
filename: z.string().min(1),
|
||||||
|
mimeType: z.string().min(1),
|
||||||
|
size: z.number().nonnegative(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, to, text, from, summary }) =>
|
execute: async ({
|
||||||
|
teamName,
|
||||||
|
claudeDir,
|
||||||
|
to,
|
||||||
|
text,
|
||||||
|
from,
|
||||||
|
summary,
|
||||||
|
source,
|
||||||
|
leadSessionId,
|
||||||
|
attachments,
|
||||||
|
}) =>
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).messages.sendMessage({
|
getController(teamName, claudeDir).messages.sendMessage({
|
||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
...(from ? { from } : {}),
|
...(from ? { from } : {}),
|
||||||
...(summary ? { summary } : {}),
|
...(summary ? { summary } : {}),
|
||||||
|
...(source ? { source } : {}),
|
||||||
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
...(attachments?.length ? { attachments } : {}),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,24 @@ const toolContextSchema = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
server.addTool({
|
||||||
|
name: 'review_request',
|
||||||
|
description: 'Move a completed task into review and notify reviewer',
|
||||||
|
parameters: z.object({
|
||||||
|
...toolContextSchema,
|
||||||
|
taskId: z.string().min(1),
|
||||||
|
from: z.string().optional(),
|
||||||
|
reviewer: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async ({ teamName, claudeDir, taskId, from, reviewer }) =>
|
||||||
|
jsonTextContent(
|
||||||
|
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||||
|
...(from ? { from } : {}),
|
||||||
|
...(reviewer ? { reviewer } : {}),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'review_approve',
|
name: 'review_approve',
|
||||||
description: 'Approve task review and move kanban state accordingly',
|
description: 'Approve task review and move kanban state accordingly',
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,25 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(loadedTask.needsClarification).toBe('user');
|
expect(loadedTask.needsClarification).toBe('user');
|
||||||
expect(loadedTask.comments).toHaveLength(1);
|
expect(loadedTask.comments).toHaveLength(1);
|
||||||
expect(loadedTask.comments[0].attachments).toHaveLength(1);
|
expect(loadedTask.comments[0].attachments).toHaveLength(1);
|
||||||
|
|
||||||
|
await getTool('task_set_status').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: createdTask.id,
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewRequested = parseJsonToolResult(
|
||||||
|
await getTool('review_request').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: createdTask.id,
|
||||||
|
from: 'lead',
|
||||||
|
reviewer: 'alice',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reviewRequested.reviewState).toBe('review');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('covers process register/list/stop without legacy stdout leaking into results', async () => {
|
it('covers process register/list/stop without legacy stdout leaking into results', async () => {
|
||||||
|
|
@ -141,4 +160,30 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(stopped.pid).toBe(pid);
|
expect(stopped.pid).toBe(pid);
|
||||||
expect(typeof stopped.stoppedAt).toBe('string');
|
expect(typeof stopped.stoppedAt).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists full message metadata through message_send', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const teamName = 'gamma';
|
||||||
|
|
||||||
|
const sent = parseJsonToolResult(
|
||||||
|
await getTool('message_send').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
to: 'alice',
|
||||||
|
text: 'Check this',
|
||||||
|
from: 'lead',
|
||||||
|
summary: 'Metadata test',
|
||||||
|
source: 'system_notification',
|
||||||
|
leadSessionId: 'session-42',
|
||||||
|
attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sent.deliveredToInbox).toBe(true);
|
||||||
|
const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||||
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
|
expect(rows[0].source).toBe('system_notification');
|
||||||
|
expect(rows[0].leadSessionId).toBe('session-42');
|
||||||
|
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,14 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
|
|
||||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
if (row.type === 'inbox') {
|
if (row.type === 'inbox') {
|
||||||
|
if (teamDataService) {
|
||||||
|
void teamDataService
|
||||||
|
.reconcileTeamArtifacts(teamName)
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-relay ONLY lead-inbox changes into the live lead process.
|
// Auto-relay ONLY lead-inbox changes into the live lead process.
|
||||||
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
|
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
|
||||||
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
|
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
|
||||||
|
|
@ -479,6 +487,12 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
|
|
||||||
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
||||||
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
||||||
|
void teamDataService
|
||||||
|
.reconcileTeamArtifacts(teamName)
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
|
||||||
|
);
|
||||||
|
|
||||||
const taskId = detail.replace('.json', '');
|
const taskId = detail.replace('.json', '');
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
|
||||||
import {
|
import {
|
||||||
encodePath,
|
encodePath,
|
||||||
extractBaseDir,
|
extractBaseDir,
|
||||||
|
|
@ -8,7 +7,6 @@ import {
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
import { isProcessAlive } from '@main/utils/processHealth';
|
|
||||||
import { killProcessByPid } from '@main/utils/processKill';
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import {
|
import {
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
|
|
@ -17,6 +15,7 @@ import {
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
|
|
@ -46,7 +45,6 @@ import type {
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
KanbanState,
|
KanbanState,
|
||||||
KanbanTaskState,
|
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
|
@ -73,7 +71,6 @@ const logger = createLogger('Service:TeamDataService');
|
||||||
const MIN_TEXT_LENGTH = 30;
|
const MIN_TEXT_LENGTH = 30;
|
||||||
const MAX_LEAD_TEXTS = 150;
|
const MAX_LEAD_TEXTS = 150;
|
||||||
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||||
const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024;
|
|
||||||
const TASK_MAP_YIELD_EVERY = 250;
|
const TASK_MAP_YIELD_EVERY = 250;
|
||||||
|
|
||||||
export class TeamDataService {
|
export class TeamDataService {
|
||||||
|
|
@ -86,7 +83,7 @@ export class TeamDataService {
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||||
private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(),
|
_inboxWriter: TeamInboxWriter = new TeamInboxWriter(),
|
||||||
private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(),
|
private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(),
|
||||||
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
|
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
|
||||||
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
|
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
|
||||||
|
|
@ -108,6 +105,28 @@ export class TeamDataService {
|
||||||
return formatTaskDisplayLabel(task);
|
return formatTaskDisplayLabel(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveTaskReviewState(
|
||||||
|
task: Pick<TeamTask, 'id' | 'reviewState'>,
|
||||||
|
kanbanState?: Pick<KanbanState, 'tasks'>
|
||||||
|
): 'none' | 'review' | 'approved' {
|
||||||
|
const explicit = normalizeReviewState(task.reviewState);
|
||||||
|
if (explicit !== 'none') {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = kanbanState?.tasks?.[task.id]?.column;
|
||||||
|
return overlay === 'review' || overlay === 'approved' ? overlay : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachKanbanCompatibility(task: TeamTask, kanbanState?: KanbanState): TeamTaskWithKanban {
|
||||||
|
const reviewState = this.resolveTaskReviewState(task, kanbanState);
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
reviewState,
|
||||||
|
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async listTeams(): Promise<TeamSummary[]> {
|
async listTeams(): Promise<TeamSummary[]> {
|
||||||
return this.configReader.listTeams();
|
return this.configReader.listTeams();
|
||||||
}
|
}
|
||||||
|
|
@ -153,11 +172,8 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
const info = teamInfoMap.get(task.teamName)!;
|
const info = teamInfoMap.get(task.teamName)!;
|
||||||
const kanban = kanbanByTeam.get(task.teamName);
|
const kanban = kanbanByTeam.get(task.teamName);
|
||||||
const kanbanEntry = kanban?.tasks[task.id];
|
const reviewState = this.resolveTaskReviewState(task, kanban);
|
||||||
const kanbanColumn =
|
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||||
kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved'
|
|
||||||
? kanbanEntry.column
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||||
// Return a "light" task object and defer heavy details to team/task detail views.
|
// Return a "light" task object and defer heavy details to team/task detail views.
|
||||||
|
|
@ -176,6 +192,7 @@ export class TeamDataService {
|
||||||
projectPath,
|
projectPath,
|
||||||
needsClarification: task.needsClarification,
|
needsClarification: task.needsClarification,
|
||||||
deletedAt: task.deletedAt,
|
deletedAt: task.deletedAt,
|
||||||
|
reviewState,
|
||||||
// Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small
|
// Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small
|
||||||
kanbanColumn,
|
kanbanColumn,
|
||||||
teamName: task.teamName,
|
teamName: task.teamName,
|
||||||
|
|
@ -258,12 +275,10 @@ export class TeamDataService {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
let tasks: TeamTask[] = [];
|
let tasks: TeamTask[] = [];
|
||||||
let tasksLoaded = true;
|
|
||||||
try {
|
try {
|
||||||
tasks = await this.taskReader.getTasks(teamName);
|
tasks = await this.taskReader.getTasks(teamName);
|
||||||
} catch {
|
} catch {
|
||||||
warnings.push('Tasks failed to load');
|
warnings.push('Tasks failed to load');
|
||||||
tasksLoaded = false;
|
|
||||||
}
|
}
|
||||||
mark('tasks');
|
mark('tasks');
|
||||||
|
|
||||||
|
|
@ -407,21 +422,11 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
mark('kanbanState');
|
mark('kanbanState');
|
||||||
|
|
||||||
if (canRunKanbanGc && tasksLoaded) {
|
|
||||||
try {
|
|
||||||
await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id)));
|
|
||||||
kanbanState = await this.kanbanManager.getState(teamName);
|
|
||||||
} catch {
|
|
||||||
warnings.push('Kanban state cleanup failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mark('kanbanGc');
|
mark('kanbanGc');
|
||||||
|
|
||||||
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => {
|
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||||
const col = kanbanState.tasks[task.id]?.column;
|
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
|
||||||
const kanbanColumn = col === 'review' || col === 'approved' ? col : undefined;
|
);
|
||||||
return { ...task, kanbanColumn };
|
|
||||||
});
|
|
||||||
|
|
||||||
const members = this.memberResolver.resolveMembers(
|
const members = this.memberResolver.resolveMembers(
|
||||||
config,
|
config,
|
||||||
|
|
@ -436,25 +441,11 @@ export class TeamDataService {
|
||||||
await this.enrichMemberBranches(members, config);
|
await this.enrichMemberBranches(members, config);
|
||||||
mark('enrichBranches');
|
mark('enrichBranches');
|
||||||
|
|
||||||
// Auto-sync: create comments from task-related inbox messages
|
|
||||||
if (tasksLoaded && messages.length > 0) {
|
|
||||||
try {
|
|
||||||
const didSync = await this.syncLinkedComments(teamName, tasks, messages);
|
|
||||||
if (didSync) {
|
|
||||||
// Re-read tasks only if new comments were actually written
|
|
||||||
tasks = await this.taskReader.getTasks(teamName);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
warnings.push('Comment sync from messages failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mark('syncComments');
|
mark('syncComments');
|
||||||
|
|
||||||
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => {
|
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||||
const col = kanbanState.tasks[task.id]?.column;
|
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
|
||||||
const kanbanColumn = col === 'review' || col === 'approved' ? col : undefined;
|
);
|
||||||
return { ...task, kanbanColumn };
|
|
||||||
});
|
|
||||||
|
|
||||||
let processes: TeamProcess[] = [];
|
let processes: TeamProcess[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -1090,7 +1081,15 @@ export class TeamDataService {
|
||||||
// non-critical
|
// non-critical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.inboxWriter.sendMessage(teamName, enrichedRequest);
|
return this.getController(teamName).messages.sendMessage({
|
||||||
|
member: enrichedRequest.member,
|
||||||
|
from: enrichedRequest.from,
|
||||||
|
text: enrichedRequest.text,
|
||||||
|
summary: enrichedRequest.summary,
|
||||||
|
source: enrichedRequest.source,
|
||||||
|
leadSessionId: enrichedRequest.leadSessionId,
|
||||||
|
attachments: enrichedRequest.attachments,
|
||||||
|
}) as SendMessageResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||||
|
|
@ -1121,8 +1120,6 @@ export class TeamDataService {
|
||||||
summary?: string,
|
summary?: string,
|
||||||
attachments?: AttachmentMeta[]
|
attachments?: AttachmentMeta[]
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
const messageId = randomUUID();
|
|
||||||
|
|
||||||
let leadSessionId: string | undefined;
|
let leadSessionId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const config = await this.configReader.getConfig(teamName);
|
||||||
|
|
@ -1131,20 +1128,20 @@ export class TeamDataService {
|
||||||
// non-critical — proceed without sessionId
|
// non-critical — proceed without sessionId
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg: InboxMessage = {
|
const msg = this.getController(teamName).messages.appendSentMessage({
|
||||||
from: 'user',
|
from: 'user',
|
||||||
to: leadName,
|
to: leadName,
|
||||||
text,
|
text,
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
read: true,
|
|
||||||
summary,
|
summary,
|
||||||
messageId,
|
|
||||||
source: 'user_sent',
|
source: 'user_sent',
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
|
}) as InboxMessage;
|
||||||
|
return {
|
||||||
|
deliveredToInbox: false,
|
||||||
|
deliveredViaStdin: true,
|
||||||
|
messageId: msg.messageId ?? randomUUID(),
|
||||||
};
|
};
|
||||||
await this.sentMessagesStore.appendMessage(teamName, msg);
|
|
||||||
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeadMemberName(teamName: string): Promise<string | null> {
|
async getLeadMemberName(teamName: string): Promise<string | null> {
|
||||||
|
|
@ -1175,43 +1172,8 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||||
const tasks = await this.taskReader.getTasks(teamName);
|
|
||||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
||||||
if (!task) {
|
|
||||||
throw new Error(`Task not found: ${taskId}`);
|
|
||||||
}
|
|
||||||
if (task.status !== 'completed') {
|
|
||||||
throw new Error(`Task ${this.getTaskLabel(task)} must be completed before review`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.getController(teamName).kanban.setKanbanColumn(taskId, 'review');
|
|
||||||
|
|
||||||
const state = await this.kanbanManager.getState(teamName);
|
|
||||||
const reviewer = state.reviewers[0];
|
|
||||||
if (!reviewer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const leadName = await this.resolveLeadName(teamName);
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
this.getController(teamName).review.requestReview(taskId, { from: leadName });
|
||||||
member: reviewer,
|
|
||||||
from: leadName,
|
|
||||||
text:
|
|
||||||
`Please review task ${this.getTaskLabel(task)}.\n\n` +
|
|
||||||
`${AGENT_BLOCK_OPEN}\n` +
|
|
||||||
`When approved, use MCP tool review_approve:\n` +
|
|
||||||
`{ teamName: "${teamName}", taskId: "${taskId}", notifyOwner: true }\n\n` +
|
|
||||||
`If changes are needed, use MCP tool review_request_changes:\n` +
|
|
||||||
`{ teamName: "${teamName}", taskId: "${taskId}", comment: "..." }\n` +
|
|
||||||
AGENT_BLOCK_CLOSE,
|
|
||||||
summary: `Review request for ${this.getTaskLabel(task)}`,
|
|
||||||
source: 'system_notification',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.getController(teamName).kanban.clearKanban(taskId);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTeamConfig(request: TeamCreateConfigRequest): Promise<void> {
|
async createTeamConfig(request: TeamCreateConfigRequest): Promise<void> {
|
||||||
|
|
@ -1267,6 +1229,18 @@ export class TeamDataService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reconcileTeamArtifacts(teamName: string): Promise<void> {
|
||||||
|
const tasks = await this.taskReader.getTasks(teamName);
|
||||||
|
await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id)));
|
||||||
|
|
||||||
|
const messages = await this.inboxReader.getMessages(teamName);
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncLinkedComments(teamName, tasks, messages);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scans inbox messages for task-related discussions and auto-creates
|
* Scans inbox messages for task-related discussions and auto-creates
|
||||||
* linked comments on disk. Uses deterministic comment ID for dedup.
|
* linked comments on disk. Uses deterministic comment ID for dedup.
|
||||||
|
|
@ -1329,14 +1303,15 @@ export class TeamDataService {
|
||||||
if (existing.some((c) => c.id === commentId)) continue;
|
if (existing.some((c) => c.id === commentId)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.taskWriter.addComment(teamName, task.id, msg.text, {
|
this.getController(teamName).tasks.addTaskComment(task.id, {
|
||||||
|
text: msg.text,
|
||||||
id: commentId,
|
id: commentId,
|
||||||
author: msg.from,
|
from: msg.from,
|
||||||
createdAt: msg.timestamp,
|
createdAt: msg.timestamp,
|
||||||
});
|
});
|
||||||
synced = true;
|
synced = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort — don't fail getTeamData() on sync errors
|
// Best-effort — don't fail reconciliation on sync errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1511,7 +1486,8 @@ export class TeamDataService {
|
||||||
|
|
||||||
if (patch.op === 'set_column') {
|
if (patch.op === 'set_column') {
|
||||||
if (patch.column === 'review') {
|
if (patch.column === 'review') {
|
||||||
controller.kanban.setKanbanColumn(taskId, 'review');
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
|
controller.review.requestReview(taskId, { from: leadName });
|
||||||
} else {
|
} else {
|
||||||
const leadName = await this.resolveLeadName(teamName);
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
controller.review.approveReview(taskId, {
|
controller.review.approveReview(taskId, {
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,21 @@ export class TeamInboxReader {
|
||||||
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
|
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
|
||||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||||
|
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||||
|
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
|
||||||
|
toolCalls: Array.isArray(row.toolCalls)
|
||||||
|
? (row.toolCalls as unknown[])
|
||||||
|
.filter(
|
||||||
|
(tc): tc is { name: string; preview?: string } =>
|
||||||
|
tc != null &&
|
||||||
|
typeof tc === 'object' &&
|
||||||
|
typeof (tc as Record<string, unknown>).name === 'string'
|
||||||
|
)
|
||||||
|
.map((tc) => ({
|
||||||
|
name: tc.name,
|
||||||
|
preview: typeof tc.preview === 'string' ? tc.preview : undefined,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
@ -58,6 +59,7 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamProvisioning');
|
const logger = createLogger('Service:TeamProvisioning');
|
||||||
|
const { createController } = agentTeamsControllerModule;
|
||||||
const RUN_TIMEOUT_MS = 300_000;
|
const RUN_TIMEOUT_MS = 300_000;
|
||||||
const VERIFY_TIMEOUT_MS = 15_000;
|
const VERIFY_TIMEOUT_MS = 15_000;
|
||||||
const VERIFY_POLL_MS = 500;
|
const VERIFY_POLL_MS = 500;
|
||||||
|
|
@ -1083,7 +1085,7 @@ export class TeamProvisioningService {
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
_sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore(),
|
||||||
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
|
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -1197,6 +1199,30 @@ export class TeamProvisioningService {
|
||||||
this.teamChangeEmitter = emitter;
|
this.teamChangeEmitter = emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||||
|
try {
|
||||||
|
createController({
|
||||||
|
teamName,
|
||||||
|
claudeDir: getClaudeBasePath(),
|
||||||
|
}).messages.appendSentMessage({
|
||||||
|
from: message.from,
|
||||||
|
to: message.to,
|
||||||
|
text: message.text,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
summary: message.summary,
|
||||||
|
messageId: message.messageId,
|
||||||
|
source: message.source,
|
||||||
|
leadSessionId: message.leadSessionId,
|
||||||
|
attachments: message.attachments,
|
||||||
|
color: message.color,
|
||||||
|
toolSummary: message.toolSummary,
|
||||||
|
toolCalls: message.toolCalls,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[${teamName}] sent-message persist failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
|
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
|
||||||
|
|
||||||
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
|
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
|
||||||
|
|
@ -2611,11 +2637,7 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
this.pushLiveLeadProcessMessage(teamName, relayMsg);
|
this.pushLiveLeadProcessMessage(teamName, relayMsg);
|
||||||
// Persist to disk so relayed replies survive app restart and trigger FileWatcher
|
// Persist to disk so relayed replies survive app restart and trigger FileWatcher
|
||||||
void this.sentMessagesStore
|
this.persistSentMessage(teamName, relayMsg);
|
||||||
.appendMessage(teamName, relayMsg)
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
logger.warn(`[${teamName}] sentMessagesStore persist failed: ${String(e)}`)
|
|
||||||
);
|
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -2831,13 +2853,7 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.pushLiveLeadProcessMessage(run.teamName, msg);
|
this.pushLiveLeadProcessMessage(run.teamName, msg);
|
||||||
void this.sentMessagesStore
|
this.persistSentMessage(run.teamName, msg);
|
||||||
.appendMessage(run.teamName, msg)
|
|
||||||
.catch((e: unknown) =>
|
|
||||||
logger.warn(
|
|
||||||
`[${run.teamName}] sentMessagesStore persist (SendMessage capture) failed: ${String(e)}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -275,6 +276,7 @@ export class TeamTaskReader {
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
}))
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
reviewState: normalizeReviewState(parsed.reviewState),
|
||||||
} satisfies Record<keyof TeamTask, unknown>;
|
} satisfies Record<keyof TeamTask, unknown>;
|
||||||
if (task.status === 'deleted') {
|
if (task.status === 'deleted') {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -376,6 +378,7 @@ export class TeamTaskReader {
|
||||||
status: 'deleted',
|
status: 'deleted',
|
||||||
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
||||||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||||
|
reviewState: normalizeReviewState(parsed.reviewState),
|
||||||
};
|
};
|
||||||
|
|
||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ interface ParsedTask {
|
||||||
projectPath?: unknown;
|
projectPath?: unknown;
|
||||||
comments?: unknown;
|
comments?: unknown;
|
||||||
needsClarification?: unknown;
|
needsClarification?: unknown;
|
||||||
|
reviewState?: unknown;
|
||||||
metadata?: { _internal?: unknown };
|
metadata?: { _internal?: unknown };
|
||||||
workIntervals?: unknown;
|
workIntervals?: unknown;
|
||||||
statusHistory?: unknown;
|
statusHistory?: unknown;
|
||||||
|
|
@ -600,6 +601,12 @@ async function readTasksDirForTeam(
|
||||||
parsed.needsClarification === 'lead' || parsed.needsClarification === 'user'
|
parsed.needsClarification === 'lead' || parsed.needsClarification === 'user'
|
||||||
? (parsed.needsClarification as string)
|
? (parsed.needsClarification as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const reviewState =
|
||||||
|
parsed.reviewState === 'review' ||
|
||||||
|
parsed.reviewState === 'approved' ||
|
||||||
|
parsed.reviewState === 'none'
|
||||||
|
? parsed.reviewState
|
||||||
|
: 'none';
|
||||||
|
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
@ -635,6 +642,7 @@ async function readTasksDirForTeam(
|
||||||
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
||||||
comments: normalizeComments(parsed),
|
comments: normalizeComments(parsed),
|
||||||
needsClarification,
|
needsClarification,
|
||||||
|
reviewState,
|
||||||
deletedAt: undefined,
|
deletedAt: undefined,
|
||||||
attachments: Array.isArray(parsed.attachments)
|
attachments: Array.isArray(parsed.attachments)
|
||||||
? (parsed.attachments as unknown[])
|
? (parsed.attachments as unknown[])
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ import { NotificationTriggerSettings } from '../NotificationTriggerSettings';
|
||||||
|
|
||||||
import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig';
|
import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig';
|
||||||
import type { NotificationTrigger } from '@renderer/types/data';
|
import type { NotificationTrigger } from '@renderer/types/data';
|
||||||
import type { TeamTaskStatus } from '@shared/types';
|
import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
/** Statuses available for notification filtering — real status values + kanban-only 'approved'. */
|
/** Notification targets span workflow status plus the explicit review axis. */
|
||||||
type NotifiableStatus = TeamTaskStatus | 'approved';
|
type NotifiableStatus = TeamTaskStatus | Extract<TeamReviewState, 'approved'>;
|
||||||
|
|
||||||
// Snooze duration options
|
// Snooze duration options
|
||||||
const SNOOZE_OPTIONS = [
|
const SNOOZE_OPTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
||||||
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -103,10 +104,11 @@ export const SidebarTaskItem = ({
|
||||||
}
|
}
|
||||||
}, [isRenaming, displaySubject]);
|
}, [isRenaming, displaySubject]);
|
||||||
|
|
||||||
|
const reviewColumn = getTaskKanbanColumn(task);
|
||||||
const cfg =
|
const cfg =
|
||||||
task.kanbanColumn === 'approved'
|
reviewColumn === 'approved'
|
||||||
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
|
||||||
: task.kanbanColumn === 'review'
|
: reviewColumn === 'review'
|
||||||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||||
const StatusIcon = cfg.icon;
|
const StatusIcon = cfg.icon;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
|
||||||
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
||||||
|
|
||||||
|
|
@ -25,17 +26,22 @@ export const defaultTaskFiltersState = (): TaskFiltersState => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
export function taskMatchesStatus(
|
export function taskMatchesStatus(
|
||||||
task: { status: string; kanbanColumn?: 'review' | 'approved' },
|
task: {
|
||||||
|
status: string;
|
||||||
|
reviewState?: 'none' | 'review' | 'approved';
|
||||||
|
kanbanColumn?: 'review' | 'approved';
|
||||||
|
},
|
||||||
statusIds: Set<TaskStatusFilterId>
|
statusIds: Set<TaskStatusFilterId>
|
||||||
): boolean {
|
): boolean {
|
||||||
if (statusIds.size === 0) return false;
|
if (statusIds.size === 0) return false;
|
||||||
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
|
if (statusIds.size === STATUS_OPTIONS.length) return task.status !== 'deleted';
|
||||||
|
|
||||||
const inTodo = task.status === 'pending' && !task.kanbanColumn;
|
const kanbanColumn = getTaskKanbanColumn(task);
|
||||||
const inProgress = task.status === 'in_progress' && !task.kanbanColumn;
|
const inTodo = task.status === 'pending' && !kanbanColumn;
|
||||||
const inDone = task.status === 'completed' && !task.kanbanColumn;
|
const inProgress = task.status === 'in_progress' && !kanbanColumn;
|
||||||
const inReview = task.kanbanColumn === 'review';
|
const inDone = task.status === 'completed' && !kanbanColumn;
|
||||||
const inApproved = task.kanbanColumn === 'approved';
|
const inReview = kanbanColumn === 'review';
|
||||||
|
const inApproved = kanbanColumn === 'approved';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(statusIds.has('todo') && inTodo) ||
|
(statusIds.has('todo') && inTodo) ||
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -25,7 +26,8 @@ const STATUS_COLORS: Record<string, { text: string; bg: string }> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
function getEffectiveColumn(task: TeamTaskWithKanban): string {
|
||||||
if (task.kanbanColumn) return task.kanbanColumn;
|
const reviewColumn = getTaskKanbanColumn(task);
|
||||||
|
if (reviewColumn) return reviewColumn;
|
||||||
if (task.status === 'pending') return 'todo';
|
if (task.status === 'pending') return 'todo';
|
||||||
if (task.status === 'completed') return 'done';
|
if (task.status === 'completed') return 'done';
|
||||||
return task.status;
|
return task.status;
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,9 @@ import { useStore } from '@renderer/store';
|
||||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { AlertTriangle, Search } from 'lucide-react';
|
import { AlertTriangle, Search } from 'lucide-react';
|
||||||
|
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
|
|
@ -146,7 +147,7 @@ export const CreateTaskDialog = ({
|
||||||
|
|
||||||
// Only show non-internal, non-deleted tasks as candidates for blocking
|
// Only show non-internal, non-deleted tasks as candidates for blocking
|
||||||
const availableTasks = tasks.filter(
|
const availableTasks = tasks.filter(
|
||||||
(t) => t.status !== 'deleted' && t.kanbanColumn !== 'approved'
|
(t) => t.status !== 'deleted' && getTaskKanbanColumn(t) !== 'approved'
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleBlockedBy = (taskId: string): void => {
|
const toggleBlockedBy = (taskId: string): void => {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import { Textarea } from '@renderer/components/ui/textarea';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
|
||||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
import {
|
import {
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
|
|
@ -30,6 +29,8 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
|
|
@ -287,7 +288,12 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kanbanColumn = kanbanTaskState?.column ?? currentTask.kanbanColumn;
|
const kanbanColumn =
|
||||||
|
kanbanTaskState?.column ??
|
||||||
|
getTaskKanbanColumn({
|
||||||
|
reviewState: currentTask.reviewState,
|
||||||
|
kanbanColumn: currentTask.kanbanColumn,
|
||||||
|
});
|
||||||
const status = currentTask.status;
|
const status = currentTask.status;
|
||||||
const statusStyle =
|
const statusStyle =
|
||||||
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
|
@ -105,9 +106,12 @@ const COLUMNS: { id: KanbanColumnId; title: string }[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId | null {
|
function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId | null {
|
||||||
const explicit = kanbanState.tasks[task.id];
|
const explicit = getTaskKanbanColumn({
|
||||||
if (explicit?.column) {
|
reviewState: task.reviewState,
|
||||||
return explicit.column;
|
kanbanColumn: kanbanState.tasks[task.id]?.column,
|
||||||
|
});
|
||||||
|
if (explicit) {
|
||||||
|
return explicit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.status === 'pending') {
|
if (task.status === 'pending') {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -42,7 +43,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
||||||
<div className="max-h-[320px] overflow-y-auto">
|
<div className="max-h-[320px] overflow-y-auto">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{visibleTasks.map((task) => {
|
{visibleTasks.map((task) => {
|
||||||
const col = task.kanbanColumn;
|
const col = getTaskKanbanColumn(task);
|
||||||
const style =
|
const style =
|
||||||
col && KANBAN_COLUMN_DISPLAY[col]
|
col && KANBAN_COLUMN_DISPLAY[col]
|
||||||
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
|
? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
|
import { KANBAN_COLUMN_DISPLAY, TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -10,6 +11,7 @@ interface TaskRowProps {
|
||||||
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
||||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||||
|
const kanbanColumn = getTaskKanbanColumn(task);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-t border-[var(--color-border)]">
|
<tr className="border-t border-[var(--color-border)]">
|
||||||
|
|
@ -21,8 +23,8 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
||||||
{task.owner ?? 'Unassigned'}
|
{task.owner ?? 'Unassigned'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
|
{kanbanColumn && kanbanColumn in KANBAN_COLUMN_DISPLAY
|
||||||
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label
|
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
|
||||||
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
|
: (TASK_STATUS_LABELS[task.status] ?? task.status)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs">
|
<td className="px-3 py-2 text-xs">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { api } from '@renderer/api';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
@ -155,7 +156,9 @@ function detectStatusChangeNotifications(
|
||||||
if (!oldTask) continue;
|
if (!oldTask) continue;
|
||||||
|
|
||||||
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
|
// Detect kanbanColumn change to 'approved' (status stays 'completed', column changes)
|
||||||
const becameApproved = task.kanbanColumn === 'approved' && oldTask.kanbanColumn !== 'approved';
|
const taskKanbanColumn = getTaskKanbanColumn(task);
|
||||||
|
const oldTaskKanbanColumn = getTaskKanbanColumn(oldTask);
|
||||||
|
const becameApproved = taskKanbanColumn === 'approved' && oldTaskKanbanColumn !== 'approved';
|
||||||
|
|
||||||
const statusChanged = oldTask.status !== task.status;
|
const statusChanged = oldTask.status !== task.status;
|
||||||
if (!statusChanged && !becameApproved) continue;
|
if (!statusChanged && !becameApproved) continue;
|
||||||
|
|
@ -511,7 +514,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
notifiedClarificationTaskKeys.add(`${task.teamName}:${task.id}`);
|
||||||
}
|
}
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:${task.status}`);
|
||||||
if (task.kanbanColumn === 'approved') {
|
if (getTaskKanbanColumn(task) === 'approved') {
|
||||||
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
notifiedStatusChangeKeys.add(`${task.teamName}:${task.id}:approved`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export interface TeamSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
||||||
|
export type TeamReviewState = 'none' | 'review' | 'approved';
|
||||||
|
|
||||||
export interface TaskWorkInterval {
|
export interface TaskWorkInterval {
|
||||||
/** ISO timestamp when task entered in_progress */
|
/** ISO timestamp when task entered in_progress */
|
||||||
|
|
@ -129,6 +130,8 @@ export interface TeamTask {
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
/** Attachments associated with this task. Metadata only — actual files stored on disk. */
|
/** Attachments associated with this task. Metadata only — actual files stored on disk. */
|
||||||
attachments?: TaskAttachmentMeta[];
|
attachments?: TaskAttachmentMeta[];
|
||||||
|
/** Separate review lifecycle axis. Persisted on modern tasks, derived for legacy rows when needed. */
|
||||||
|
reviewState?: TeamReviewState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
||||||
|
|
|
||||||
42
src/shared/utils/reviewState.ts
Normal file
42
src/shared/utils/reviewState.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { TeamReviewState } from '@shared/types';
|
||||||
|
|
||||||
|
interface ReviewStateLike {
|
||||||
|
reviewState?: TeamReviewState | null;
|
||||||
|
kanbanColumn?: 'review' | 'approved' | null;
|
||||||
|
status?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeReviewState(value: unknown): TeamReviewState {
|
||||||
|
return value === 'review' || value === 'approved' ? value : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
|
||||||
|
const explicit = normalizeReviewState(task.reviewState);
|
||||||
|
if (explicit !== 'none') {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||||
|
return task.kanbanColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKanbanColumnFromReviewState(
|
||||||
|
reviewState: TeamReviewState
|
||||||
|
): 'review' | 'approved' | undefined {
|
||||||
|
return reviewState === 'review' || reviewState === 'approved' ? reviewState : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskKanbanColumn(task: ReviewStateLike): 'review' | 'approved' | undefined {
|
||||||
|
return getKanbanColumnFromReviewState(getReviewStateFromTask(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApprovedTask(task: ReviewStateLike): boolean {
|
||||||
|
return getReviewStateFromTask(task) === 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReviewTask(task: ReviewStateLike): boolean {
|
||||||
|
return getReviewStateFromTask(task) === 'review';
|
||||||
|
}
|
||||||
2
src/types/agent-teams-controller.d.ts
vendored
2
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -39,11 +39,13 @@ declare module 'agent-teams-controller' {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerReviewApi {
|
export interface ControllerReviewApi {
|
||||||
|
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerMessageApi {
|
export interface ControllerMessageApi {
|
||||||
|
appendSentMessage(flags: Record<string, unknown>): unknown;
|
||||||
sendMessage(flags: Record<string, unknown>): unknown;
|
sendMessage(flags: Record<string, unknown>): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
|
||||||
import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team';
|
import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team';
|
||||||
|
|
||||||
describe('TeamDataService', () => {
|
describe('TeamDataService', () => {
|
||||||
it('runs kanban garbage-collect only after tasks are loaded', async () => {
|
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const tasks: TeamTask[] = [
|
const tasks: TeamTask[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -44,10 +44,10 @@ describe('TeamDataService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.getTeamData('my-team');
|
await service.getTeamData('my-team');
|
||||||
expect(order).toEqual(['tasks', 'gc']);
|
expect(order).toEqual(['tasks']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not sync automated comment notifications into task comments', async () => {
|
it('reconciles linked comments outside getTeamData and skips automated notifications', async () => {
|
||||||
const tasks: TeamTask[] = [
|
const tasks: TeamTask[] = [
|
||||||
{
|
{
|
||||||
id: '12',
|
id: '12',
|
||||||
|
|
@ -106,14 +106,20 @@ describe('TeamDataService', () => {
|
||||||
} as never,
|
} as never,
|
||||||
{
|
{
|
||||||
readMessages: vi.fn(async () => []),
|
readMessages: vi.fn(async () => []),
|
||||||
} as never
|
} as never,
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
tasks: {
|
||||||
|
addTaskComment: addComment,
|
||||||
|
},
|
||||||
|
}) as never
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.getTeamData('my-team');
|
await service.reconcileTeamArtifacts('my-team');
|
||||||
expect(addComment).not.toHaveBeenCalled();
|
expect(addComment).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips kanban garbage-collect when tasks fail to load', async () => {
|
it('skips reconcile writes when tasks fail to load', async () => {
|
||||||
const garbageCollect = vi.fn(async () => undefined);
|
const garbageCollect = vi.fn(async () => undefined);
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
|
|
@ -140,9 +146,8 @@ describe('TeamDataService', () => {
|
||||||
} as never
|
} as never
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.getTeamData('my-team');
|
await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('tasks failed');
|
||||||
expect(garbageCollect).not.toHaveBeenCalled();
|
expect(garbageCollect).not.toHaveBeenCalled();
|
||||||
expect(result.warnings).toContain('Tasks failed to load');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes projectPath from config when creating a task', async () => {
|
it('includes projectPath from config when creating a task', async () => {
|
||||||
|
|
@ -292,4 +297,79 @@ describe('TeamDataService', () => {
|
||||||
expect(result.related).toEqual(['1', '2']);
|
expect(result.related).toEqual(['1', '2']);
|
||||||
expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] }));
|
expect(createTaskMock).toHaveBeenCalledWith(expect.objectContaining({ related: ['1', '2'] }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes durable inbox writes through controller message API', async () => {
|
||||||
|
const sendMessageMock = vi.fn(() => ({ deliveredToInbox: true, messageId: 'm-1' }));
|
||||||
|
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{
|
||||||
|
listTeams: vi.fn(),
|
||||||
|
getConfig: vi.fn(async () => ({ name: 'My team', members: [], leadSessionId: 'lead-1' })),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
messages: {
|
||||||
|
sendMessage: sendMessageMock,
|
||||||
|
},
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.sendMessage('my-team', {
|
||||||
|
member: 'alice',
|
||||||
|
text: 'hello',
|
||||||
|
summary: 'ping',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ deliveredToInbox: true, messageId: 'm-1' });
|
||||||
|
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
member: 'alice',
|
||||||
|
text: 'hello',
|
||||||
|
summary: 'ping',
|
||||||
|
leadSessionId: 'lead-1',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates review entry to controller review API', async () => {
|
||||||
|
const requestReviewMock = vi.fn();
|
||||||
|
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{
|
||||||
|
listTeams: vi.fn(),
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: 'My team',
|
||||||
|
members: [{ name: 'lead', role: 'team lead' }],
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
review: {
|
||||||
|
requestReview: requestReviewMock,
|
||||||
|
},
|
||||||
|
}) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.requestReview('my-team', 'task-1');
|
||||||
|
|
||||||
|
expect(requestReviewMock).toHaveBeenCalledWith('task-1', { from: 'lead' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,14 @@ const hoisted = vi.hoisted(() => {
|
||||||
stat,
|
stat,
|
||||||
readFile,
|
readFile,
|
||||||
atomicWrite,
|
atomicWrite,
|
||||||
|
appendSentMessage: vi.fn((teamName: string, message: Record<string, unknown>) => {
|
||||||
|
const sentMessagesPath = `/mock/teams/${teamName}/sentMessages.json`;
|
||||||
|
const current = files.get(sentMessagesPath);
|
||||||
|
const rows = current ? (JSON.parse(current) as unknown[]) : [];
|
||||||
|
rows.push(message);
|
||||||
|
files.set(sentMessagesPath, JSON.stringify(rows));
|
||||||
|
return message;
|
||||||
|
}),
|
||||||
setAtomicWriteShouldFail: (next: boolean) => {
|
setAtomicWriteShouldFail: (next: boolean) => {
|
||||||
atomicWriteShouldFail = next;
|
atomicWriteShouldFail = next;
|
||||||
},
|
},
|
||||||
|
|
@ -72,6 +80,15 @@ vi.mock('../../../../src/main/utils/pathDecoder', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('agent-teams-controller', () => ({
|
||||||
|
createController: ({ teamName }: { teamName: string }) => ({
|
||||||
|
messages: {
|
||||||
|
appendSentMessage: (message: Record<string, unknown>) =>
|
||||||
|
hoisted.appendSentMessage(teamName, message),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
|
|
||||||
function seedConfig(teamName: string): void {
|
function seedConfig(teamName: string): void {
|
||||||
|
|
@ -139,6 +156,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
hoisted.files.clear();
|
hoisted.files.clear();
|
||||||
hoisted.readFile.mockClear();
|
hoisted.readFile.mockClear();
|
||||||
hoisted.atomicWrite.mockClear();
|
hoisted.atomicWrite.mockClear();
|
||||||
|
hoisted.appendSentMessage.mockClear();
|
||||||
hoisted.setAtomicWriteShouldFail(false);
|
hoisted.setAtomicWriteShouldFail(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -210,14 +228,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
expect(first).toBe(1);
|
expect(first).toBe(1);
|
||||||
expect(second).toBe(0);
|
expect(second).toBe(0);
|
||||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
||||||
// Relay now also persists to sentMessages.json via appendMessage() which uses
|
|
||||||
// atomicWriteAsync — expected to fail here since atomicWriteShouldFail=true.
|
|
||||||
expect(console.error).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('TeamSentMessagesStore'),
|
|
||||||
expect.stringContaining('Failed to append sent message')
|
|
||||||
);
|
|
||||||
vi.mocked(console.error).mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not mark as relayed when stdin is not writable', async () => {
|
it('does not mark as relayed when stdin is not writable', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue