357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
/**
|
||
* 任务管理工具(1 个)
|
||
*
|
||
* task_manager — 创建/更新/列出/完成任务
|
||
*
|
||
* 支持 Agent 在执行复杂任务时将任务分解为子任务并跟踪状态。
|
||
* 任务持久化到 SQLite tasks 表。
|
||
*
|
||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html
|
||
*/
|
||
|
||
import { nanoid } from 'nanoid';
|
||
import type Database from 'better-sqlite3';
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
|
||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'cancelled';
|
||
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||
|
||
export interface Task {
|
||
id: string;
|
||
sessionId: string;
|
||
title: string;
|
||
description: string;
|
||
status: TaskStatus;
|
||
priority: TaskPriority;
|
||
parentId: string | null;
|
||
assignedTo: string | null;
|
||
order: number;
|
||
createdAt: number;
|
||
updatedAt: number;
|
||
completedAt: number | null;
|
||
}
|
||
|
||
/** 从数据库行映射到 Task 对象 */
|
||
function mapRow(row: Record<string, unknown>): Task {
|
||
return {
|
||
id: row.id as string,
|
||
sessionId: row.session_id as string,
|
||
title: row.title as string,
|
||
description: row.description as string,
|
||
status: row.status as TaskStatus,
|
||
priority: row.priority as TaskPriority,
|
||
parentId: (row.parent_id as string) ?? null,
|
||
assignedTo: (row.assigned_to as string) ?? null,
|
||
order: row.order_idx as number,
|
||
createdAt: row.created_at as number,
|
||
updatedAt: row.updated_at as number,
|
||
completedAt: (row.completed_at as number) ?? null,
|
||
};
|
||
}
|
||
|
||
// T3.5: 合法枚举值
|
||
const VALID_STATUSES: TaskStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'];
|
||
const VALID_PRIORITIES: TaskPriority[] = ['low', 'medium', 'high', 'critical'];
|
||
|
||
export class TaskManagerTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'task_manager',
|
||
description: 'Manage tasks for complex multi-step work. Supports operations: create, update, list, complete, delete. Tasks are persisted per session and can have parent-child relationships.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
operation: {
|
||
type: 'string',
|
||
description: 'Task operation',
|
||
enum: ['create', 'update', 'list', 'complete', 'delete', 'get'],
|
||
},
|
||
title: { type: 'string', description: 'Task title (for create)' },
|
||
description: { type: 'string', description: 'Task description (for create/update)' },
|
||
task_id: { type: 'string', description: 'Task ID (for update/complete/delete/get)' },
|
||
status: { type: 'string', description: 'New status (for update)', enum: ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'] },
|
||
priority: { type: 'string', description: 'Priority (for create/update)', enum: ['low', 'medium', 'high', 'critical'] },
|
||
parent_id: { type: 'string', description: 'Parent task ID (for create, establishes subtask relationship)' },
|
||
},
|
||
required: ['operation'],
|
||
},
|
||
category: MetonaToolCategory.DATABASE,
|
||
riskLevel: MetonaRiskLevel.LOW,
|
||
requiresPermission: false,
|
||
timeoutMs: 10_000,
|
||
};
|
||
|
||
constructor(
|
||
private getDB: () => Database.Database,
|
||
// P2(v0.3.13): 写入后回调,用于通知 UI 刷新(避免工具层依赖 Electron)
|
||
private onTaskChanged?: (sessionId: string) => void,
|
||
) {}
|
||
|
||
/** 写入后触发回调(仅对修改操作) */
|
||
private notifyChanged(sessionId: string): void {
|
||
if (this.onTaskChanged) {
|
||
try {
|
||
this.onTaskChanged(sessionId);
|
||
} catch {
|
||
// 回调失败不影响工具主流程
|
||
}
|
||
}
|
||
}
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const operation = args.operation as string;
|
||
|
||
switch (operation) {
|
||
case 'create':
|
||
return this.createTask(args, context);
|
||
case 'update':
|
||
return this.updateTask(args, context);
|
||
case 'list':
|
||
return this.listTasks(args, context);
|
||
case 'complete':
|
||
return this.completeTask(args, context);
|
||
case 'delete':
|
||
return this.deleteTask(args, context);
|
||
case 'get':
|
||
return this.getTask(args, context);
|
||
default:
|
||
return { success: false, error: `Unknown operation: ${operation}` };
|
||
}
|
||
} catch (err) {
|
||
return { success: false, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
private createTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||
const db = this.getDB();
|
||
const id = `task_${nanoid(12)}`;
|
||
const now = Date.now();
|
||
const sessionId = context.sessionId;
|
||
const title = args.title as string;
|
||
const description = (args.description as string) ?? '';
|
||
const priority = (args.priority as TaskPriority) ?? 'medium';
|
||
const parentId = (args.parent_id as string) ?? null;
|
||
|
||
if (!title) {
|
||
return { success: false, error: 'title is required for create operation' };
|
||
}
|
||
|
||
// T3.5: 校验 priority 枚举值
|
||
if (!VALID_PRIORITIES.includes(priority)) {
|
||
return { success: false, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
||
}
|
||
|
||
// 获取同 session 同 parent 下的最大 order
|
||
const maxOrderRow = db.prepare(
|
||
'SELECT MAX(order_idx) as max_order FROM tasks WHERE session_id = ? AND parent_id IS ?',
|
||
).get(sessionId, parentId) as { max_order: number | null } | undefined;
|
||
const order = (maxOrderRow?.max_order ?? -1) + 1;
|
||
|
||
db.prepare(`
|
||
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, assigned_to, order_idx, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now);
|
||
|
||
// P2: 通知 UI 刷新
|
||
this.notifyChanged(sessionId);
|
||
|
||
return {
|
||
success: true,
|
||
task: {
|
||
id, sessionId, title, description,
|
||
status: 'pending', priority, parentId,
|
||
assignedTo: null, order,
|
||
createdAt: now, updatedAt: now, completedAt: null,
|
||
},
|
||
};
|
||
}
|
||
|
||
private updateTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||
const db = this.getDB();
|
||
const taskId = args.task_id as string;
|
||
if (!taskId) {
|
||
return { success: false, error: 'task_id is required for update operation' };
|
||
}
|
||
|
||
// T3.1: 校验任务属于当前会话
|
||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId);
|
||
if (!existing) {
|
||
return { success: false, error: `Task not found: ${taskId}` };
|
||
}
|
||
|
||
// T3.5: 校验枚举值
|
||
if (args.status !== undefined && !VALID_STATUSES.includes(args.status as TaskStatus)) {
|
||
return { success: false, error: `Invalid status: ${args.status}. Must be one of: ${VALID_STATUSES.join(', ')}` };
|
||
}
|
||
if (args.priority !== undefined && !VALID_PRIORITIES.includes(args.priority as TaskPriority)) {
|
||
return { success: false, error: `Invalid priority: ${args.priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
||
}
|
||
|
||
const updates: string[] = [];
|
||
const values: unknown[] = [];
|
||
|
||
if (args.title !== undefined) {
|
||
updates.push('title = ?');
|
||
values.push(args.title);
|
||
}
|
||
if (args.description !== undefined) {
|
||
updates.push('description = ?');
|
||
values.push(args.description);
|
||
}
|
||
if (args.status !== undefined) {
|
||
updates.push('status = ?');
|
||
values.push(args.status);
|
||
if (args.status === 'completed') {
|
||
updates.push('completed_at = ?');
|
||
values.push(Date.now());
|
||
}
|
||
}
|
||
if (args.priority !== undefined) {
|
||
updates.push('priority = ?');
|
||
values.push(args.priority);
|
||
}
|
||
|
||
if (updates.length === 0) {
|
||
return { success: false, error: 'No fields to update' };
|
||
}
|
||
|
||
updates.push('updated_at = ?');
|
||
values.push(Date.now());
|
||
values.push(taskId);
|
||
values.push(context.sessionId);
|
||
|
||
// T3.1: UPDATE 语句加 AND session_id = ? 防止越权
|
||
db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
|
||
|
||
// P2: 通知 UI 刷新
|
||
this.notifyChanged(context.sessionId);
|
||
|
||
const updated = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record<string, unknown>;
|
||
return { success: true, task: mapRow(updated) };
|
||
}
|
||
|
||
private listTasks(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||
const db = this.getDB();
|
||
const sessionId = context.sessionId;
|
||
const status = args.status as string | undefined;
|
||
|
||
let sql = 'SELECT * FROM tasks WHERE session_id = ?';
|
||
const values: unknown[] = [sessionId];
|
||
|
||
if (status) {
|
||
sql += ' AND status = ?';
|
||
values.push(status);
|
||
}
|
||
|
||
sql += ' ORDER BY order_idx ASC, created_at ASC';
|
||
|
||
const rows = db.prepare(sql).all(...values) as Array<Record<string, unknown>>;
|
||
const tasks = rows.map(mapRow);
|
||
|
||
return {
|
||
success: true,
|
||
tasks,
|
||
count: tasks.length,
|
||
by_status: {
|
||
pending: tasks.filter((t) => t.status === 'pending').length,
|
||
in_progress: tasks.filter((t) => t.status === 'in_progress').length,
|
||
completed: tasks.filter((t) => t.status === 'completed').length,
|
||
blocked: tasks.filter((t) => t.status === 'blocked').length,
|
||
cancelled: tasks.filter((t) => t.status === 'cancelled').length,
|
||
},
|
||
};
|
||
}
|
||
|
||
private completeTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||
const db = this.getDB();
|
||
const taskId = args.task_id as string;
|
||
if (!taskId) {
|
||
return { success: false, error: 'task_id is required for complete operation' };
|
||
}
|
||
|
||
// T3.1: 校验任务属于当前会话
|
||
const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId);
|
||
if (!existing) {
|
||
return { success: false, error: `Task not found: ${taskId}` };
|
||
}
|
||
|
||
const now = Date.now();
|
||
// T3.1: UPDATE 语句加 AND session_id = ? 防止越权
|
||
const result = db.prepare(
|
||
'UPDATE tasks SET status = ?, completed_at = ?, updated_at = ? WHERE id = ? AND session_id = ?',
|
||
).run('completed', now, now, taskId, context.sessionId);
|
||
|
||
if (result.changes === 0) {
|
||
return { success: false, error: `Task not found: ${taskId}` };
|
||
}
|
||
|
||
// P2: 通知 UI 刷新
|
||
this.notifyChanged(context.sessionId);
|
||
|
||
return { success: true, task_id: taskId, completed_at: now };
|
||
}
|
||
|
||
private deleteTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||
const db = this.getDB();
|
||
const taskId = args.task_id as string;
|
||
if (!taskId) {
|
||
return { success: false, error: 'task_id is required for delete operation' };
|
||
}
|
||
|
||
// T3.1: 校验任务属于当前会话
|
||
const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId);
|
||
if (!existing) {
|
||
return { success: false, error: `Task not found: ${taskId}` };
|
||
}
|
||
|
||
// T3.2/T3.3: 递归 CTE 查出所有后代任务 ID
|
||
const descendantIds = db.prepare(`
|
||
WITH RECURSIVE descendants AS (
|
||
SELECT id FROM tasks WHERE id = ?
|
||
UNION ALL
|
||
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
|
||
)
|
||
SELECT id FROM descendants
|
||
`).all(taskId) as Array<{ id: string }>;
|
||
|
||
const allIds = descendantIds.map((r) => r.id);
|
||
|
||
// 事务包裹批量删除
|
||
const deleteMany = db.transaction(() => {
|
||
const placeholders = allIds.map(() => '?').join(',');
|
||
db.prepare(`DELETE FROM tasks WHERE id IN (${placeholders})`).run(...allIds);
|
||
});
|
||
deleteMany();
|
||
|
||
// P2: 通知 UI 刷新
|
||
this.notifyChanged(context.sessionId);
|
||
|
||
return { success: true, task_id: taskId, deleted: allIds.length };
|
||
}
|
||
|
||
private getTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||
const db = this.getDB();
|
||
const taskId = args.task_id as string;
|
||
if (!taskId) {
|
||
return { success: false, error: 'task_id is required for get operation' };
|
||
}
|
||
|
||
// T3.1: 校验任务属于当前会话
|
||
const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record<string, unknown> | undefined;
|
||
if (!row) {
|
||
return { success: false, error: `Task not found: ${taskId}` };
|
||
}
|
||
|
||
// 获取子任务(同样限制在当前会话内)
|
||
const subtasks = db.prepare('SELECT * FROM tasks WHERE parent_id = ? AND session_id = ? ORDER BY order_idx ASC').all(taskId, context.sessionId) as Array<Record<string, unknown>>;
|
||
|
||
return {
|
||
success: true,
|
||
task: mapRow(row),
|
||
subtasks: subtasks.map(mapRow),
|
||
};
|
||
}
|
||
}
|