feat: enhance task status management with history tracking
- Introduced a new `statusHistory` feature to record every status transition of tasks, including the previous status, new status, timestamp, and actor responsible for the change. - Updated task creation and status update methods to append transitions to the status history, ensuring a comprehensive audit trail. - Enhanced UI components to display the status history, providing better visibility into task progress and changes over time. - Refactored related services to support the new status history functionality, improving overall task management practices.
This commit is contained in:
parent
f654c5f356
commit
887c7406d1
16 changed files with 709 additions and 179 deletions
|
|
@ -2084,7 +2084,8 @@
|
||||||
"cache_read_input_token_cost": 3.3e-7,
|
"cache_read_input_token_cost": 3.3e-7,
|
||||||
"litellm_provider": "deepinfra",
|
"litellm_provider": "deepinfra",
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"supports_tool_choice": true
|
"supports_tool_choice": true,
|
||||||
|
"supports_function_calling": true
|
||||||
},
|
},
|
||||||
"deepinfra/anthropic/claude-4-opus": {
|
"deepinfra/anthropic/claude-4-opus": {
|
||||||
"max_tokens": 200000,
|
"max_tokens": 200000,
|
||||||
|
|
@ -2094,7 +2095,8 @@
|
||||||
"output_cost_per_token": 0.0000825,
|
"output_cost_per_token": 0.0000825,
|
||||||
"litellm_provider": "deepinfra",
|
"litellm_provider": "deepinfra",
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"supports_tool_choice": true
|
"supports_tool_choice": true,
|
||||||
|
"supports_function_calling": true
|
||||||
},
|
},
|
||||||
"deepinfra/anthropic/claude-4-sonnet": {
|
"deepinfra/anthropic/claude-4-sonnet": {
|
||||||
"max_tokens": 200000,
|
"max_tokens": 200000,
|
||||||
|
|
@ -2104,7 +2106,8 @@
|
||||||
"output_cost_per_token": 0.0000165,
|
"output_cost_per_token": 0.0000165,
|
||||||
"litellm_provider": "deepinfra",
|
"litellm_provider": "deepinfra",
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
"supports_tool_choice": true
|
"supports_tool_choice": true,
|
||||||
|
"supports_function_calling": true
|
||||||
},
|
},
|
||||||
"eu.anthropic.claude-3-5-haiku-20241022-v1:0": {
|
"eu.anthropic.claude-3-5-haiku-20241022-v1:0": {
|
||||||
"input_cost_per_token": 2.5e-7,
|
"input_cost_per_token": 2.5e-7,
|
||||||
|
|
|
||||||
|
|
@ -440,7 +440,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
|
|
||||||
// Auto-relay direct messages to live team lead process (no UI dependency).
|
// Auto-relay direct messages to live team lead process (no UI dependency).
|
||||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||||
void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined);
|
void teamProvisioningService
|
||||||
|
.relayLeadInboxMessages(teamName)
|
||||||
|
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show native OS notification for new inbox messages (debounced per inbox).
|
// Show native OS notification for new inbox messages (debounced per inbox).
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,9 @@ async function handleGetData(
|
||||||
const isAlive = provisioning.isTeamAlive(tn);
|
const isAlive = provisioning.isTeamAlive(tn);
|
||||||
|
|
||||||
if (isAlive) {
|
if (isAlive) {
|
||||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
void provisioning
|
||||||
|
.relayLeadInboxMessages(tn)
|
||||||
|
.catch((e: unknown) => logger.warn(`Relay failed for ${tn}: ${e}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = data.config.name || tn;
|
const displayName = data.config.name || tn;
|
||||||
|
|
@ -891,9 +893,18 @@ async function handleSendMessage(
|
||||||
if (isLeadRecipient && isAlive) {
|
if (isLeadRecipient && isAlive) {
|
||||||
// Separate try blocks: stdin delivery vs persistence
|
// Separate try blocks: stdin delivery vs persistence
|
||||||
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
|
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
|
||||||
|
// Wrap with instructions so lead responds with visible text (not just agent-only blocks)
|
||||||
|
const wrappedText = [
|
||||||
|
`You received a direct message from the user.`,
|
||||||
|
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
|
||||||
|
``,
|
||||||
|
`Message from user:`,
|
||||||
|
payload.text!,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
let stdinSent = false;
|
let stdinSent = false;
|
||||||
try {
|
try {
|
||||||
await provisioning.sendMessageToTeam(tn, payload.text!, validatedAttachments);
|
await provisioning.sendMessageToTeam(tn, wrappedText, validatedAttachments);
|
||||||
stdinSent = true;
|
stdinSent = true;
|
||||||
} catch (stdinError: unknown) {
|
} catch (stdinError: unknown) {
|
||||||
// Stdin failed (process died between check and write)
|
// Stdin failed (process died between check and write)
|
||||||
|
|
@ -963,7 +974,9 @@ async function handleSendMessage(
|
||||||
|
|
||||||
// Best-effort relay for lead via inbox
|
// Best-effort relay for lead via inbox
|
||||||
if (isLeadRecipient && isAlive) {
|
if (isLeadRecipient && isAlive) {
|
||||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
void provisioning
|
||||||
|
.relayLeadInboxMessages(tn)
|
||||||
|
.catch((e: unknown) => logger.warn(`Relay after sendMessage failed for ${tn}: ${e}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
|
|
@ -243,13 +243,22 @@ function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now
|
||||||
else delete task.workIntervals;
|
else delete task.workIntervals;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTaskStatus(paths, taskId, status) {
|
function appendStatusTransition(task, fromStatus, toStatus, timestamp, actor) {
|
||||||
|
var entry = { from: fromStatus, to: toStatus, timestamp: timestamp };
|
||||||
|
if (actor) entry.actor = actor;
|
||||||
|
var history = Array.isArray(task.statusHistory) ? task.statusHistory.slice() : [];
|
||||||
|
history.push(entry);
|
||||||
|
task.statusHistory = history;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTaskStatus(paths, taskId, status, actor) {
|
||||||
const normalized = normalizeStatus(status);
|
const normalized = normalizeStatus(status);
|
||||||
if (!normalized) die('Invalid status: ' + String(status));
|
if (!normalized) die('Invalid status: ' + String(status));
|
||||||
const { taskPath, task } = readTask(paths, taskId);
|
const { taskPath, task } = readTask(paths, taskId);
|
||||||
var prev = task.status;
|
var prev = task.status;
|
||||||
var now = nowIso();
|
var now = nowIso();
|
||||||
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
|
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
|
||||||
|
appendStatusTransition(task, prev, normalized, now, actor);
|
||||||
task.status = normalized;
|
task.status = normalized;
|
||||||
writeTask(taskPath, task);
|
writeTask(taskPath, task);
|
||||||
}
|
}
|
||||||
|
|
@ -503,6 +512,7 @@ function createTask(paths, flags) {
|
||||||
status,
|
status,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
|
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
|
||||||
|
statusHistory: [{ from: null, to: status, timestamp: createdAt, actor: from }],
|
||||||
blocks: [],
|
blocks: [],
|
||||||
blockedBy: blockedByIds,
|
blockedBy: blockedByIds,
|
||||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||||
|
|
@ -664,7 +674,9 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
||||||
|
|
||||||
clearKanban(paths, teamName, taskId);
|
clearKanban(paths, teamName, taskId);
|
||||||
var now = nowIso();
|
var now = nowIso();
|
||||||
applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now);
|
var prevStatus = task.status;
|
||||||
|
applyWorkIntervalsForStatusTransition(task, prevStatus, 'in_progress', now);
|
||||||
|
appendStatusTransition(task, prevStatus, 'in_progress', now, from);
|
||||||
task.status = 'in_progress';
|
task.status = 'in_progress';
|
||||||
|
|
||||||
// Record review comment in task.comments
|
// Record review comment in task.comments
|
||||||
|
|
@ -957,27 +969,30 @@ async function main() {
|
||||||
|
|
||||||
const teamName = getTeamName(args.flags);
|
const teamName = getTeamName(args.flags);
|
||||||
const paths = getPaths(args.flags, teamName);
|
const paths = getPaths(args.flags, teamName);
|
||||||
|
var actor = typeof args.flags.from === 'string' && args.flags.from.trim()
|
||||||
|
? args.flags.from.trim()
|
||||||
|
: inferLeadName(paths);
|
||||||
|
|
||||||
if (domain === 'task') {
|
if (domain === 'task') {
|
||||||
if (action === 'set-status') {
|
if (action === 'set-status') {
|
||||||
const id = rest[0] || args.flags.id;
|
const id = rest[0] || args.flags.id;
|
||||||
const status = rest[1] || args.flags.status;
|
const status = rest[1] || args.flags.status;
|
||||||
if (!id || !status) die('Usage: task set-status <id> <status>');
|
if (!id || !status) die('Usage: task set-status <id> <status>');
|
||||||
setTaskStatus(paths, String(id), status);
|
setTaskStatus(paths, String(id), status, actor);
|
||||||
process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n');
|
process.stdout.write('OK task #' + String(id) + ' status=' + String(status) + '\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'complete' || action === 'done') {
|
if (action === 'complete' || action === 'done') {
|
||||||
const id = rest[0] || args.flags.id;
|
const id = rest[0] || args.flags.id;
|
||||||
if (!id) die('Usage: task complete <id>');
|
if (!id) die('Usage: task complete <id>');
|
||||||
setTaskStatus(paths, String(id), 'completed');
|
setTaskStatus(paths, String(id), 'completed', actor);
|
||||||
process.stdout.write('OK task #' + String(id) + ' status=completed\n');
|
process.stdout.write('OK task #' + String(id) + ' status=completed\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'start') {
|
if (action === 'start') {
|
||||||
const id = rest[0] || args.flags.id;
|
const id = rest[0] || args.flags.id;
|
||||||
if (!id) die('Usage: task start <id>');
|
if (!id) die('Usage: task start <id>');
|
||||||
setTaskStatus(paths, String(id), 'in_progress');
|
setTaskStatus(paths, String(id), 'in_progress', actor);
|
||||||
process.stdout.write('OK task #' + String(id) + ' status=in_progress\n');
|
process.stdout.write('OK task #' + String(id) + ' status=in_progress\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -826,7 +826,7 @@ export class TeamDataService {
|
||||||
throw new Error(`Task #${taskId} is not pending (current: ${task.status})`);
|
throw new Error(`Task #${taskId} is not pending (current: ${task.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'user');
|
||||||
|
|
||||||
if (task.owner) {
|
if (task.owner) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -856,16 +856,21 @@ export class TeamDataService {
|
||||||
return { notifiedOwner: !!task.owner };
|
return { notifiedOwner: !!task.owner };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTaskStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
async updateTaskStatus(
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, status);
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
status: TeamTaskStatus,
|
||||||
|
actor?: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.taskWriter.updateStatus(teamName, taskId, status, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
||||||
await this.taskWriter.softDelete(teamName, taskId);
|
await this.taskWriter.softDelete(teamName, taskId, 'user');
|
||||||
}
|
}
|
||||||
|
|
||||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
||||||
await this.taskWriter.restoreTask(teamName, taskId);
|
await this.taskWriter.restoreTask(teamName, taskId, 'user');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||||
|
|
@ -929,8 +934,7 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task?.owner && !this.isLeadOwner(task.owner, leadName)) {
|
if (task?.owner && !this.isLeadOwner(task.owner, leadName)) {
|
||||||
// UX: don't echo a user comment as an inbox notification "from the lead" when the
|
// Notify non-lead task owner via inbox (lead → member message)
|
||||||
// task is already owned by the lead. This creates confusing self-notifications.
|
|
||||||
const parts = [
|
const parts = [
|
||||||
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
|
`Comment on task #${taskId} "${task.subject}":\n\n${text}`,
|
||||||
`\n${AGENT_BLOCK_OPEN}`,
|
`\n${AGENT_BLOCK_OPEN}`,
|
||||||
|
|
@ -944,6 +948,22 @@ export class TeamDataService {
|
||||||
text: parts.join('\n'),
|
text: parts.join('\n'),
|
||||||
summary: `Comment on #${taskId}`,
|
summary: `Comment on #${taskId}`,
|
||||||
});
|
});
|
||||||
|
} else if (task?.owner && this.isLeadOwner(task.owner, leadName)) {
|
||||||
|
// Notify lead about user's comment on their own task.
|
||||||
|
// Write to lead's inbox — relay delivers to stdin when process is alive.
|
||||||
|
const parts = [
|
||||||
|
`New comment from user on your task #${taskId} "${task.subject}":\n\n${text}`,
|
||||||
|
`\n${AGENT_BLOCK_OPEN}`,
|
||||||
|
`Reply to this comment using:`,
|
||||||
|
`node "${toolPath}" --team ${teamName} task comment ${taskId} --text "<your reply>" --from "${leadName}"`,
|
||||||
|
AGENT_BLOCK_CLOSE,
|
||||||
|
];
|
||||||
|
await this.sendMessage(teamName, {
|
||||||
|
member: leadName,
|
||||||
|
from: 'user',
|
||||||
|
text: parts.join('\n'),
|
||||||
|
summary: `Comment on #${taskId}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Notification is best-effort — don't fail comment save
|
// Notification is best-effort — don't fail comment save
|
||||||
|
|
@ -1269,7 +1289,7 @@ export class TeamDataService {
|
||||||
await this.kanbanManager.updateTask(teamName, taskId, { op: 'remove' });
|
await this.kanbanManager.updateTask(teamName, taskId, { op: 'remove' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress');
|
await this.taskWriter.updateStatus(teamName, taskId, 'in_progress', 'reviewer');
|
||||||
const leadName = await this.resolveLeadName(teamName);
|
const leadName = await this.resolveLeadName(teamName);
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: task.owner,
|
member: task.owner,
|
||||||
|
|
@ -1281,7 +1301,9 @@ export class TeamDataService {
|
||||||
summary: `Fix request for #${taskId}`,
|
summary: `Fix request for #${taskId}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, previousStatus).catch(() => undefined);
|
await this.taskWriter
|
||||||
|
.updateStatus(teamName, taskId, previousStatus, 'system')
|
||||||
|
.catch(() => undefined);
|
||||||
if (previousKanbanEntry) {
|
if (previousKanbanEntry) {
|
||||||
await this.kanbanManager
|
await this.kanbanManager
|
||||||
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
|
.updateTask(teamName, taskId, { op: 'set_column', column: previousKanbanEntry.column })
|
||||||
|
|
|
||||||
|
|
@ -1959,7 +1959,13 @@ export class TeamProvisioningService {
|
||||||
content: contentBlocks,
|
content: contentBlocks,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
run.child.stdin.write(payload + '\n');
|
const stdin = run.child.stdin;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stdin.write(payload + '\n', (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
this.setLeadActivity(run, 'active');
|
this.setLeadActivity(run, 'active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2145,7 +2151,11 @@ 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.appendMessage(teamName, relayMsg).catch(() => undefined);
|
void this.sentMessagesStore
|
||||||
|
.appendMessage(teamName, relayMsg)
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
logger.warn(`[${teamName}] sentMessagesStore persist failed: ${e}`)
|
||||||
|
);
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -2457,7 +2467,32 @@ export class TeamProvisioningService {
|
||||||
// Persist to disk so replies survive app restart
|
// Persist to disk so replies survive app restart
|
||||||
void this.sentMessagesStore
|
void this.sentMessagesStore
|
||||||
.appendMessage(run.teamName, replyMsg)
|
.appendMessage(run.teamName, replyMsg)
|
||||||
.catch(() => undefined);
|
.catch((e: unknown) =>
|
||||||
|
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
|
||||||
|
);
|
||||||
|
this.teamChangeEmitter?.({
|
||||||
|
type: 'inbox',
|
||||||
|
teamName: run.teamName,
|
||||||
|
detail: 'lead-direct-reply',
|
||||||
|
});
|
||||||
|
} else if (rawReply.length > 0) {
|
||||||
|
// Lead responded but only with agent-only content — send generic acknowledgment
|
||||||
|
const fallbackMsg: InboxMessage = {
|
||||||
|
from: leadName,
|
||||||
|
to: 'user',
|
||||||
|
text: '(Message received and processed)',
|
||||||
|
timestamp: nowIso(),
|
||||||
|
read: true,
|
||||||
|
summary: 'Message processed',
|
||||||
|
messageId: `lead-direct-${run.runId}-${Date.now()}`,
|
||||||
|
source: 'lead_process',
|
||||||
|
};
|
||||||
|
this.pushLiveLeadProcessMessage(run.teamName, fallbackMsg);
|
||||||
|
void this.sentMessagesStore
|
||||||
|
.appendMessage(run.teamName, fallbackMsg)
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
logger.warn(`[${run.teamName}] sentMessagesStore persist failed: ${e}`)
|
||||||
|
);
|
||||||
this.teamChangeEmitter?.({
|
this.teamChangeEmitter?.({
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
|
|
@ -2572,7 +2607,9 @@ export class TeamProvisioningService {
|
||||||
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
||||||
|
|
||||||
// Pick up any direct messages that arrived before/while reconnecting.
|
// Pick up any direct messages that arrived before/while reconnecting.
|
||||||
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
|
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||||
|
logger.warn(`[${run.teamName}] post-reconnect relay failed: ${e}`)
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2613,7 +2650,9 @@ export class TeamProvisioningService {
|
||||||
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
||||||
|
|
||||||
// Pick up any direct messages that arrived during provisioning.
|
// Pick up any direct messages that arrived during provisioning.
|
||||||
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
|
void this.relayLeadInboxMessages(run.teamName).catch((e: unknown) =>
|
||||||
|
logger.warn(`[${run.teamName}] post-provisioning relay failed: ${e}`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ import * as path from 'path';
|
||||||
|
|
||||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
|
|
||||||
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
|
import type {
|
||||||
|
StatusTransition,
|
||||||
|
TaskComment,
|
||||||
|
TaskWorkInterval,
|
||||||
|
TeamTask,
|
||||||
|
TeamTaskStatus,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamTaskReader');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
@ -107,6 +113,26 @@ export class TeamTaskReader {
|
||||||
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
||||||
// safety: if a field is added to TeamTask but not mapped here,
|
// safety: if a field is added to TeamTask but not mapped here,
|
||||||
// TypeScript will error. This prevents silently dropping new fields.
|
// TypeScript will error. This prevents silently dropping new fields.
|
||||||
|
const statusHistory: StatusTransition[] | undefined = Array.isArray(parsed.statusHistory)
|
||||||
|
? (parsed.statusHistory as unknown[])
|
||||||
|
.filter(
|
||||||
|
(e): e is { from: string | null; to: string; timestamp: string; actor?: string } =>
|
||||||
|
Boolean(e) &&
|
||||||
|
typeof e === 'object' &&
|
||||||
|
((e as Record<string, unknown>).from === null ||
|
||||||
|
typeof (e as Record<string, unknown>).from === 'string') &&
|
||||||
|
typeof (e as Record<string, unknown>).to === 'string' &&
|
||||||
|
typeof (e as Record<string, unknown>).timestamp === 'string' &&
|
||||||
|
((e as Record<string, unknown>).actor === undefined ||
|
||||||
|
typeof (e as Record<string, unknown>).actor === 'string')
|
||||||
|
)
|
||||||
|
.map((e) => ({
|
||||||
|
from: e.from as TeamTaskStatus | null,
|
||||||
|
to: e.to as TeamTaskStatus,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
...(e.actor ? { actor: e.actor } : {}),
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
||||||
? (parsed.workIntervals as unknown[])
|
? (parsed.workIntervals as unknown[])
|
||||||
.filter(
|
.filter(
|
||||||
|
|
@ -136,6 +162,7 @@ export class TeamTaskReader {
|
||||||
? (parsed.status as TeamTask['status'])
|
? (parsed.status as TeamTask['status'])
|
||||||
: 'pending',
|
: 'pending',
|
||||||
workIntervals,
|
workIntervals,
|
||||||
|
statusHistory,
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
||||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
||||||
related: Array.isArray(parsed.related)
|
related: Array.isArray(parsed.related)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,13 @@ import * as path from 'path';
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
import type { TaskComment, TaskCommentType, TeamTask, TeamTaskStatus } from '@shared/types';
|
import type {
|
||||||
|
StatusTransition,
|
||||||
|
TaskComment,
|
||||||
|
TaskCommentType,
|
||||||
|
TeamTask,
|
||||||
|
TeamTaskStatus,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
const taskWriteLocks = new Map<string, Promise<void>>();
|
const taskWriteLocks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
|
@ -27,6 +33,18 @@ async function withTaskLock<T>(taskPath: string, fn: () => Promise<T>): Promise<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendTransition(
|
||||||
|
history: StatusTransition[] | undefined,
|
||||||
|
from: TeamTaskStatus | null,
|
||||||
|
to: TeamTaskStatus,
|
||||||
|
timestamp: string,
|
||||||
|
actor?: string
|
||||||
|
): StatusTransition[] {
|
||||||
|
const entry: StatusTransition = { from, to, timestamp };
|
||||||
|
if (actor) entry.actor = actor;
|
||||||
|
return [...(history ?? []), entry];
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamTaskWriter {
|
export class TeamTaskWriter {
|
||||||
async createTask(teamName: string, task: TeamTask): Promise<void> {
|
async createTask(teamName: string, task: TeamTask): Promise<void> {
|
||||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
|
|
@ -63,6 +81,13 @@ export class TeamTaskWriter {
|
||||||
: [{ startedAt: createdAt }]),
|
: [{ startedAt: createdAt }]),
|
||||||
]
|
]
|
||||||
: task.workIntervals,
|
: task.workIntervals,
|
||||||
|
statusHistory: appendTransition(
|
||||||
|
task.statusHistory,
|
||||||
|
null,
|
||||||
|
task.status,
|
||||||
|
createdAt,
|
||||||
|
task.createdBy
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
||||||
|
|
@ -272,7 +297,12 @@ export class TeamTaskWriter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
async updateStatus(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
status: TeamTaskStatus,
|
||||||
|
actor?: string
|
||||||
|
): Promise<void> {
|
||||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
|
|
||||||
await withTaskLock(taskPath, async () => {
|
await withTaskLock(taskPath, async () => {
|
||||||
|
|
@ -310,6 +340,13 @@ export class TeamTaskWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
task.workIntervals = intervals.length > 0 ? intervals : undefined;
|
task.workIntervals = intervals.length > 0 ? intervals : undefined;
|
||||||
|
task.statusHistory = appendTransition(
|
||||||
|
Array.isArray(task.statusHistory) ? task.statusHistory : undefined,
|
||||||
|
prevStatus,
|
||||||
|
status,
|
||||||
|
nowIso,
|
||||||
|
actor
|
||||||
|
);
|
||||||
task.status = status;
|
task.status = status;
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
|
@ -345,7 +382,7 @@ export class TeamTaskWriter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async softDelete(teamName: string, taskId: string): Promise<void> {
|
async softDelete(teamName: string, taskId: string, actor?: string): Promise<void> {
|
||||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
|
|
||||||
await withTaskLock(taskPath, async () => {
|
await withTaskLock(taskPath, async () => {
|
||||||
|
|
@ -360,6 +397,7 @@ export class TeamTaskWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = JSON.parse(raw) as TeamTask;
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
|
const prevStatus = task.status;
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
// Ensure any open in_progress interval is closed on delete.
|
// Ensure any open in_progress interval is closed on delete.
|
||||||
|
|
@ -374,6 +412,13 @@ export class TeamTaskWriter {
|
||||||
|
|
||||||
task.status = 'deleted';
|
task.status = 'deleted';
|
||||||
task.deletedAt = nowIso;
|
task.deletedAt = nowIso;
|
||||||
|
task.statusHistory = appendTransition(
|
||||||
|
Array.isArray(task.statusHistory) ? task.statusHistory : undefined,
|
||||||
|
prevStatus,
|
||||||
|
'deleted',
|
||||||
|
nowIso,
|
||||||
|
actor
|
||||||
|
);
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
||||||
|
|
@ -384,7 +429,7 @@ export class TeamTaskWriter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async restoreTask(teamName: string, taskId: string): Promise<void> {
|
async restoreTask(teamName: string, taskId: string, actor?: string): Promise<void> {
|
||||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
|
|
||||||
await withTaskLock(taskPath, async () => {
|
await withTaskLock(taskPath, async () => {
|
||||||
|
|
@ -399,6 +444,15 @@ export class TeamTaskWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = JSON.parse(raw) as TeamTask;
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
|
const prevStatus = task.status;
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
task.statusHistory = appendTransition(
|
||||||
|
Array.isArray(task.statusHistory) ? task.statusHistory : undefined,
|
||||||
|
prevStatus,
|
||||||
|
'pending',
|
||||||
|
nowIso,
|
||||||
|
actor ?? 'user'
|
||||||
|
);
|
||||||
task.status = 'pending';
|
task.status = 'pending';
|
||||||
delete task.deletedAt;
|
delete task.deletedAt;
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ interface ParsedTask {
|
||||||
needsClarification?: unknown;
|
needsClarification?: unknown;
|
||||||
metadata?: { _internal?: unknown };
|
metadata?: { _internal?: unknown };
|
||||||
workIntervals?: unknown;
|
workIntervals?: unknown;
|
||||||
|
statusHistory?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawWorkInterval {
|
interface RawWorkInterval {
|
||||||
|
|
@ -116,6 +117,13 @@ interface RawWorkInterval {
|
||||||
completedAt?: unknown;
|
completedAt?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RawStatusTransition {
|
||||||
|
from?: unknown;
|
||||||
|
to?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
actor?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawComment {
|
interface RawComment {
|
||||||
id?: unknown;
|
id?: unknown;
|
||||||
author?: unknown;
|
author?: unknown;
|
||||||
|
|
@ -436,6 +444,28 @@ function normalizeWorkIntervals(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStatusHistory(
|
||||||
|
parsed: ParsedTask
|
||||||
|
): { from: string | null; to: string; timestamp: string; actor?: string }[] | undefined {
|
||||||
|
if (!Array.isArray(parsed.statusHistory)) return undefined;
|
||||||
|
return (parsed.statusHistory as unknown[])
|
||||||
|
.filter(
|
||||||
|
(i): i is RawStatusTransition =>
|
||||||
|
Boolean(i) &&
|
||||||
|
typeof i === 'object' &&
|
||||||
|
((i as RawStatusTransition).from === null ||
|
||||||
|
typeof (i as RawStatusTransition).from === 'string') &&
|
||||||
|
typeof (i as RawStatusTransition).to === 'string' &&
|
||||||
|
typeof (i as RawStatusTransition).timestamp === 'string'
|
||||||
|
)
|
||||||
|
.map((i) => ({
|
||||||
|
from: i.from as string | null,
|
||||||
|
to: i.to as string,
|
||||||
|
timestamp: i.timestamp as string,
|
||||||
|
...(typeof i.actor === 'string' ? { actor: i.actor } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||||
if (!Array.isArray(parsed.comments)) return undefined;
|
if (!Array.isArray(parsed.comments)) return undefined;
|
||||||
return (parsed.comments as unknown[])
|
return (parsed.comments as unknown[])
|
||||||
|
|
@ -554,6 +584,7 @@ async function readTasksDirForTeam(
|
||||||
? (parsed.status as string)
|
? (parsed.status as string)
|
||||||
: 'pending',
|
: 'pending',
|
||||||
workIntervals: normalizeWorkIntervals(parsed),
|
workIntervals: normalizeWorkIntervals(parsed),
|
||||||
|
statusHistory: normalizeStatusHistory(parsed),
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
||||||
related: Array.isArray(parsed.related)
|
related: Array.isArray(parsed.related)
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,11 @@ export const CollapsibleTeamSection = ({
|
||||||
}, [handleNavigate]);
|
}, [handleNavigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
||||||
ref={sectionRef}
|
<div className="relative -mx-4 flex min-h-9 w-[calc(100%+2rem)] items-stretch py-1.5">
|
||||||
data-section-id={sectionId}
|
|
||||||
className="min-w-0 overflow-hidden border-b border-[var(--color-border)] pb-3 last:border-b-0"
|
|
||||||
>
|
|
||||||
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-0 z-0 cursor-pointer rounded-md transition-colors hover:bg-[var(--color-surface-raised)]"
|
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-white/[0.07] hover:bg-white/[0.1]' : 'rounded-md bg-white/[0.04] hover:bg-white/[0.08]'}`}
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
||||||
/>
|
/>
|
||||||
|
|
@ -97,7 +93,7 @@ export const CollapsibleTeamSection = ({
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && <div className="mt-2 min-w-0 overflow-hidden">{children}</div>}
|
{isOpen && <div className="mt-1.5 min-w-0 overflow-x-hidden pb-2 pl-2.5">{children}</div>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
114
src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
Normal file
114
src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
|
||||||
|
import { ArrowRight, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { StatusTransition, TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
|
interface StatusHistoryTimelineProps {
|
||||||
|
history: StatusTransition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusHistoryTimeline = ({ history }: StatusHistoryTimelineProps) => {
|
||||||
|
if (history.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
No status history recorded
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0 px-3 py-2">
|
||||||
|
{history.map((transition, idx) => {
|
||||||
|
const isLast = idx === history.length - 1;
|
||||||
|
const time = formatTime(transition.timestamp);
|
||||||
|
const isCreation = transition.from === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${transition.timestamp}-${idx}`} className="flex">
|
||||||
|
{/* Timeline line + dot */}
|
||||||
|
<div className="flex w-5 shrink-0 flex-col items-center">
|
||||||
|
<div className={cn('mt-1.5 size-2 shrink-0 rounded-full', dotColor(transition.to))} />
|
||||||
|
{!isLast && <div className="w-px flex-1 bg-zinc-700" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="mb-1.5 flex w-full items-center gap-2 rounded px-1.5 py-1 text-xs text-[var(--color-text-secondary)]">
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{time}
|
||||||
|
</span>
|
||||||
|
{isCreation ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Plus size={10} />
|
||||||
|
Created as
|
||||||
|
<StatusBadge status={transition.to} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<StatusBadge status={transition.from!} />
|
||||||
|
<ArrowRight size={10} className="text-[var(--color-text-muted)]" />
|
||||||
|
<StatusBadge status={transition.to} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{transition.actor ? (
|
||||||
|
<span className="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
by {transition.actor}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{new Date(transition.timestamp).toLocaleString()}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }: { status: TeamTaskStatus }) => {
|
||||||
|
const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
|
||||||
|
const label = TASK_STATUS_LABELS[status] ?? status;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('rounded-full px-1.5 py-0.5 text-[10px] font-medium', style.bg, style.text)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function dotColor(status: TeamTaskStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-zinc-500';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'bg-blue-400';
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-emerald-400';
|
||||||
|
case 'deleted':
|
||||||
|
return 'bg-red-400';
|
||||||
|
default:
|
||||||
|
return 'bg-zinc-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
if (isNaN(date.getTime())) return '??:??';
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '??:??';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ import {
|
||||||
FileCode,
|
FileCode,
|
||||||
FileDiff,
|
FileDiff,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
History,
|
||||||
Link2,
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
|
@ -54,6 +55,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { StatusHistoryTimeline } from './StatusHistoryTimeline';
|
||||||
import { TaskCommentInput } from './TaskCommentInput';
|
import { TaskCommentInput } from './TaskCommentInput';
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
||||||
|
|
@ -337,7 +339,7 @@ export const TaskDetailDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{canReassign ? (
|
{canReassign ? (
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -405,6 +407,20 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
: null}
|
: null}
|
||||||
|
{onDeleteTask && currentTask ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto h-6 gap-1 text-xs text-[var(--color-text-muted)] hover:text-red-400"
|
||||||
|
onClick={() => {
|
||||||
|
onDeleteTask(currentTask.id);
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clarification banner */}
|
{/* Clarification banner */}
|
||||||
|
|
@ -615,128 +631,146 @@ export const TaskDetailDialog = ({
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mb-3 space-y-2">
|
{blockedByIds.length > 0 ||
|
||||||
{/* Dependencies */}
|
blocksIds.length > 0 ||
|
||||||
{blockedByIds.length > 0 ? (
|
relatedIds.length > 0 ||
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
relatedByIds.length > 0 ||
|
||||||
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
|
kanbanTaskState ? (
|
||||||
<ArrowLeftFromLine size={12} />
|
<div className="space-y-2">
|
||||||
Blocked by
|
{/* Dependencies */}
|
||||||
</span>
|
{blockedByIds.length > 0 ? (
|
||||||
{blockedByIds.map((id) => {
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
const depTask = taskMap.get(id);
|
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
|
||||||
const isCompleted = depTask?.status === 'completed';
|
<ArrowLeftFromLine size={12} />
|
||||||
return (
|
Blocked by
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
|
||||||
isCompleted
|
|
||||||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
|
||||||
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
|
|
||||||
} cursor-pointer`}
|
|
||||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
||||||
onClick={() => handleDependencyClick(id)}
|
|
||||||
>
|
|
||||||
#{id}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{blocksIds.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
|
|
||||||
<ArrowRightFromLine size={12} />
|
|
||||||
Blocks
|
|
||||||
</span>
|
|
||||||
{blocksIds.map((id) => {
|
|
||||||
const depTask = taskMap.get(id);
|
|
||||||
const isCompleted = depTask?.status === 'completed';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
|
||||||
isCompleted
|
|
||||||
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
|
||||||
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
|
||||||
} cursor-pointer`}
|
|
||||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
||||||
onClick={() => handleDependencyClick(id)}
|
|
||||||
>
|
|
||||||
#{id}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Related tasks (explicit) */}
|
|
||||||
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
|
||||||
<Link2 size={12} />
|
|
||||||
Related tasks
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{relatedIds.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
|
|
||||||
{relatedIds.map((id) => {
|
|
||||||
const depTask = taskMap.get(id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`related:${currentTask.id}:${id}`}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
|
|
||||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
||||||
onClick={() => handleDependencyClick(id)}
|
|
||||||
>
|
|
||||||
#{id}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{relatedByIds.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
|
|
||||||
{relatedByIds.map((id) => {
|
|
||||||
const depTask = taskMap.get(id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`related-by:${currentTask.id}:${id}`}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
|
|
||||||
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
||||||
onClick={() => handleDependencyClick(id)}
|
|
||||||
>
|
|
||||||
#{id}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Review info */}
|
|
||||||
{kanbanTaskState ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{kanbanTaskState.reviewer ? (
|
|
||||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
|
||||||
Reviewer: {kanbanTaskState.reviewer}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{blockedByIds.map((id) => {
|
||||||
{kanbanTaskState.errorDescription ? (
|
const depTask = taskMap.get(id);
|
||||||
<span className="text-xs text-red-400">{kanbanTaskState.errorDescription}</span>
|
const isCompleted = depTask?.status === 'completed';
|
||||||
) : null}
|
return (
|
||||||
</div>
|
<button
|
||||||
) : null}
|
key={id}
|
||||||
</div>
|
type="button"
|
||||||
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||||
|
isCompleted
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||||
|
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
|
||||||
|
} cursor-pointer`}
|
||||||
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||||
|
onClick={() => handleDependencyClick(id)}
|
||||||
|
>
|
||||||
|
#{id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{blocksIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
|
||||||
|
<ArrowRightFromLine size={12} />
|
||||||
|
Blocks
|
||||||
|
</span>
|
||||||
|
{blocksIds.map((id) => {
|
||||||
|
const depTask = taskMap.get(id);
|
||||||
|
const isCompleted = depTask?.status === 'completed';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
||||||
|
isCompleted
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
||||||
|
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
||||||
|
} cursor-pointer`}
|
||||||
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||||
|
onClick={() => handleDependencyClick(id)}
|
||||||
|
>
|
||||||
|
#{id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Related tasks (explicit) */}
|
||||||
|
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
||||||
|
<Link2 size={12} />
|
||||||
|
Related tasks
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{relatedIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
|
||||||
|
{relatedIds.map((id) => {
|
||||||
|
const depTask = taskMap.get(id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`related:${currentTask.id}:${id}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
|
||||||
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||||
|
onClick={() => handleDependencyClick(id)}
|
||||||
|
>
|
||||||
|
#{id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{relatedByIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
|
||||||
|
{relatedByIds.map((id) => {
|
||||||
|
const depTask = taskMap.get(id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`related-by:${currentTask.id}:${id}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
|
||||||
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||||
|
onClick={() => handleDependencyClick(id)}
|
||||||
|
>
|
||||||
|
#{id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Review info */}
|
||||||
|
{kanbanTaskState ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{kanbanTaskState.reviewer ? (
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||||
|
Reviewer: {kanbanTaskState.reviewer}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{kanbanTaskState.errorDescription ? (
|
||||||
|
<span className="text-xs text-red-400">{kanbanTaskState.errorDescription}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Status History */}
|
||||||
|
{currentTask.statusHistory && currentTask.statusHistory.length > 0 ? (
|
||||||
|
<CollapsibleTeamSection
|
||||||
|
title="Status History"
|
||||||
|
icon={<History size={14} />}
|
||||||
|
badge={currentTask.statusHistory.length}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
||||||
|
</CollapsibleTeamSection>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
|
@ -767,22 +801,7 @@ export const TaskDetailDialog = ({
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
<DialogFooter className="flex items-center justify-end sm:justify-end">
|
||||||
{onDeleteTask && currentTask ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
onDeleteTask(currentTask.id);
|
|
||||||
handleClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} className="mr-1" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button variant="outline" onClick={handleClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,18 @@ export interface TaskWorkInterval {
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Records a single status transition for audit/timeline display. */
|
||||||
|
export interface StatusTransition {
|
||||||
|
/** Previous status (null for initial creation). */
|
||||||
|
from: TeamTaskStatus | null;
|
||||||
|
/** New status after the transition. */
|
||||||
|
to: TeamTaskStatus;
|
||||||
|
/** ISO timestamp when the transition occurred. */
|
||||||
|
timestamp: string;
|
||||||
|
/** Who triggered the change: member name, 'user', or undefined if unknown. */
|
||||||
|
actor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
|
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
|
||||||
|
|
||||||
export interface TaskComment {
|
export interface TaskComment {
|
||||||
|
|
@ -89,6 +101,12 @@ export interface TeamTask {
|
||||||
* We persist intervals for reliable log attribution without relying on heuristics.
|
* We persist intervals for reliable log attribution without relying on heuristics.
|
||||||
*/
|
*/
|
||||||
workIntervals?: TaskWorkInterval[];
|
workIntervals?: TaskWorkInterval[];
|
||||||
|
/**
|
||||||
|
* Chronological record of every status change.
|
||||||
|
* Append-only — each transition records from, to, timestamp, actor.
|
||||||
|
* Optional for backwards compatibility with pre-existing tasks.
|
||||||
|
*/
|
||||||
|
statusHistory?: StatusTransition[];
|
||||||
blocks?: string[];
|
blocks?: string[];
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { spawnCli } from '@main/utils/childProcess';
|
import { spawnCli } from '@main/utils/childProcess';
|
||||||
|
|
||||||
function createFakeChild() {
|
function createFakeChild() {
|
||||||
const writeSpy = vi.fn();
|
const writeSpy = vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => {
|
||||||
|
if (typeof cb === 'function') cb(null);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const endSpy = vi.fn();
|
const endSpy = vi.fn();
|
||||||
const child = Object.assign(new EventEmitter(), {
|
const child = Object.assign(new EventEmitter(), {
|
||||||
pid: 12345,
|
pid: 12345,
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,10 @@ function attachAliveRun(
|
||||||
opts?: { writable?: boolean }
|
opts?: { writable?: boolean }
|
||||||
): { writeSpy: ReturnType<typeof vi.fn> } {
|
): { writeSpy: ReturnType<typeof vi.fn> } {
|
||||||
const runId = 'run-1';
|
const runId = 'run-1';
|
||||||
const writeSpy = vi.fn();
|
const writeSpy = vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => {
|
||||||
|
if (typeof cb === 'function') cb(null);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
const writable = opts?.writable ?? true;
|
const writable = opts?.writable ?? true;
|
||||||
|
|
||||||
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
|
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
|
||||||
|
|
|
||||||
|
|
@ -155,4 +155,175 @@ describe('TeamTaskWriter', () => {
|
||||||
'Task status update verification failed: 12'
|
'Task status update verification failed: 12'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('statusHistory', () => {
|
||||||
|
it('createTask records initial statusHistory entry', async () => {
|
||||||
|
await writer.createTask('my-team', {
|
||||||
|
id: '10',
|
||||||
|
subject: 'New task',
|
||||||
|
status: 'pending',
|
||||||
|
createdBy: 'alice',
|
||||||
|
});
|
||||||
|
|
||||||
|
const writtenPath = '/mock/tasks/my-team/10.json';
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(1);
|
||||||
|
expect(persisted.statusHistory[0]).toMatchObject({
|
||||||
|
from: null,
|
||||||
|
to: 'pending',
|
||||||
|
actor: 'alice',
|
||||||
|
});
|
||||||
|
expect(typeof persisted.statusHistory[0].timestamp).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createTask with in_progress records initial transition', async () => {
|
||||||
|
await writer.createTask('my-team', {
|
||||||
|
id: '11',
|
||||||
|
subject: 'Start immediately',
|
||||||
|
status: 'in_progress',
|
||||||
|
createdBy: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
|
const writtenPath = '/mock/tasks/my-team/11.json';
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(1);
|
||||||
|
expect(persisted.statusHistory[0]).toMatchObject({
|
||||||
|
from: null,
|
||||||
|
to: 'in_progress',
|
||||||
|
actor: 'bob',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createTask without createdBy omits actor', async () => {
|
||||||
|
await writer.createTask('my-team', {
|
||||||
|
id: '13',
|
||||||
|
subject: 'No author',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const writtenPath = '/mock/tasks/my-team/13.json';
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(writtenPath) ?? '{}');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(1);
|
||||||
|
expect(persisted.statusHistory[0].actor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatus appends transition to statusHistory', async () => {
|
||||||
|
hoisted.files.set(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({
|
||||||
|
id: '12',
|
||||||
|
subject: 'task',
|
||||||
|
status: 'pending',
|
||||||
|
statusHistory: [
|
||||||
|
{ from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z', actor: 'user' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await writer.updateStatus('my-team', '12', 'in_progress', 'alice');
|
||||||
|
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(2);
|
||||||
|
expect(persisted.statusHistory[1]).toMatchObject({
|
||||||
|
from: 'pending',
|
||||||
|
to: 'in_progress',
|
||||||
|
actor: 'alice',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateStatus works on legacy task without statusHistory', async () => {
|
||||||
|
hoisted.files.set(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({
|
||||||
|
id: '12',
|
||||||
|
subject: 'legacy task',
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await writer.updateStatus('my-team', '12', 'in_progress');
|
||||||
|
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(1);
|
||||||
|
expect(persisted.statusHistory[0]).toMatchObject({
|
||||||
|
from: 'pending',
|
||||||
|
to: 'in_progress',
|
||||||
|
});
|
||||||
|
expect(persisted.statusHistory[0].actor).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('softDelete appends deleted transition', async () => {
|
||||||
|
hoisted.files.set(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({
|
||||||
|
id: '12',
|
||||||
|
subject: 'task',
|
||||||
|
status: 'in_progress',
|
||||||
|
statusHistory: [
|
||||||
|
{ from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
|
||||||
|
{ from: 'pending', to: 'in_progress', timestamp: '2024-01-01T00:01:00.000Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await writer.softDelete('my-team', '12', 'user');
|
||||||
|
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(3);
|
||||||
|
expect(persisted.statusHistory[2]).toMatchObject({
|
||||||
|
from: 'in_progress',
|
||||||
|
to: 'deleted',
|
||||||
|
actor: 'user',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restoreTask appends pending transition', async () => {
|
||||||
|
hoisted.files.set(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({
|
||||||
|
id: '12',
|
||||||
|
subject: 'task',
|
||||||
|
status: 'deleted',
|
||||||
|
deletedAt: '2024-01-01T00:02:00.000Z',
|
||||||
|
statusHistory: [
|
||||||
|
{ from: null, to: 'pending', timestamp: '2024-01-01T00:00:00.000Z' },
|
||||||
|
{ from: 'pending', to: 'deleted', timestamp: '2024-01-01T00:02:00.000Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await writer.restoreTask('my-team', '12', 'user');
|
||||||
|
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
|
expect(persisted.status).toBe('pending');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(3);
|
||||||
|
expect(persisted.statusHistory[2]).toMatchObject({
|
||||||
|
from: 'deleted',
|
||||||
|
to: 'pending',
|
||||||
|
actor: 'user',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restoreTask defaults actor to user when not provided', async () => {
|
||||||
|
hoisted.files.set(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({
|
||||||
|
id: '12',
|
||||||
|
subject: 'task',
|
||||||
|
status: 'deleted',
|
||||||
|
deletedAt: '2024-01-01T00:02:00.000Z',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await writer.restoreTask('my-team', '12');
|
||||||
|
|
||||||
|
const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}');
|
||||||
|
expect(persisted.statusHistory).toHaveLength(1);
|
||||||
|
expect(persisted.statusHistory[0]).toMatchObject({
|
||||||
|
from: 'deleted',
|
||||||
|
to: 'pending',
|
||||||
|
actor: 'user',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue