Files
metona-ai-desktop/electron/harness/tools/built-in/task-manager.ts
T

357 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 任务管理工具(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,
// P2v0.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),
};
}
}