UNPKG

orchestry-mcp

Version:

Orchestry MCP Server for multi-session task management

626 lines (563 loc) 19.2 kB
import BetterSqlite3 from 'better-sqlite3'; import { v4 as uuidv4 } from 'uuid'; import path from 'path'; import { fileURLToPath } from 'url'; import type { Project, Workspace, Goal, Task, Document, TeamMember, Comment, TaskStatus, Priority, Session, ProjectStats, } from '../shared/types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export class Database { private db: BetterSqlite3.Database; constructor(dbPath?: string) { const defaultPath = path.join(__dirname, '..', 'orchestry.db'); this.db = new BetterSqlite3(dbPath || defaultPath); this.db.pragma('journal_mode = WAL'); } async initialize() { // Projects table this.db.exec(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, session_id TEXT, llm_context TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, data TEXT NOT NULL ) `); // Workspaces table this.db.exec(` CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, color TEXT, icon TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, data TEXT NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ) `); // Goals table this.db.exec(` CREATE TABLE IF NOT EXISTS goals ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT, priority TEXT DEFAULT 'medium', target_date DATE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, data TEXT NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE ) `); // Tasks table this.db.exec(` CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, goal_id TEXT NOT NULL, parent_task_id TEXT, title TEXT NOT NULL, description TEXT, status TEXT DEFAULT 'backlog', priority TEXT DEFAULT 'medium', assignee_id TEXT, due_date DATE, start_date DATE, completed_date DATE, estimated_hours REAL, actual_hours REAL, session_notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, data TEXT NOT NULL, FOREIGN KEY (goal_id) REFERENCES goals(id) ON DELETE CASCADE, FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ON DELETE CASCADE ) `); // Sessions table this.db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT DEFAULT 'human', llm_model TEXT, context TEXT, is_active BOOLEAN DEFAULT 1, started_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_activity_at DATETIME DEFAULT CURRENT_TIMESTAMP, ended_at DATETIME, data TEXT NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ) `); // Documents table this.db.exec(` CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, entity_type TEXT NOT NULL, type TEXT NOT NULL, title TEXT NOT NULL, content TEXT, url TEXT, version INTEGER DEFAULT 1, author_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, data TEXT NOT NULL ) `); // Comments table this.db.exec(` CREATE TABLE IF NOT EXISTS comments ( id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, entity_type TEXT NOT NULL, author_id TEXT NOT NULL, content TEXT NOT NULL, session_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, data TEXT NOT NULL ) `); // Team members table this.db.exec(` CREATE TABLE IF NOT EXISTS team_members ( id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, role TEXT, avatar TEXT, is_bot BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, data TEXT NOT NULL ) `); // Create indexes this.db.exec(` CREATE INDEX IF NOT EXISTS idx_workspaces_project ON workspaces(project_id); CREATE INDEX IF NOT EXISTS idx_goals_workspace ON goals(workspace_id); CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks(goal_id); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id); CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(is_active); CREATE INDEX IF NOT EXISTS idx_documents_entity ON documents(entity_id, entity_type); CREATE INDEX IF NOT EXISTS idx_comments_entity ON comments(entity_id, entity_type); `); } // Project CRUD createProject(name: string, description: string, sessionId?: string): Project { const project: Project = { id: uuidv4(), name, description, sessionId, createdAt: new Date(), updatedAt: new Date(), workspaces: [], tags: [], team: [], }; const stmt = this.db.prepare(` INSERT INTO projects (id, name, description, session_id, data) VALUES (?, ?, ?, ?, ?) `); stmt.run(project.id, name, description, sessionId, JSON.stringify(project)); return project; } getProject(id: string): Project | null { const row = this.db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as { data: string } | undefined; if (!row) return null; const project = JSON.parse(row.data); project.workspaces = this.getWorkspacesByProject(id); return project; } getAllProjects(): Project[] { const rows = this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as Array<{ data: string }>; return rows.map(row => { const project = JSON.parse(row.data); project.workspaces = this.getWorkspacesByProject(project.id); return project; }); } // Workspace CRUD - supports both signatures createWorkspace(data: { projectId: string; name: string; description?: string }): Workspace; createWorkspace(projectId: string, data: Partial<Workspace>): Workspace; createWorkspace(projectIdOrData: string | any, data?: Partial<Workspace>): Workspace { // Handle both call signatures let projectId: string; let workspaceData: Partial<Workspace>; if (typeof projectIdOrData === 'string') { projectId = projectIdOrData; workspaceData = data || {}; } else { projectId = projectIdOrData.projectId; workspaceData = { name: projectIdOrData.name, description: projectIdOrData.description, }; } const workspace: Workspace = { id: uuidv4(), projectId, name: workspaceData.name || 'New Workspace', description: workspaceData.description || '', color: workspaceData.color, icon: workspaceData.icon, goals: [], createdAt: new Date(), updatedAt: new Date(), }; const stmt = this.db.prepare(` INSERT INTO workspaces (id, project_id, name, description, color, icon, data) VALUES (?, ?, ?, ?, ?, ?, ?) `); stmt.run( workspace.id, projectId, workspace.name, workspace.description, workspace.color, workspace.icon, JSON.stringify(workspace) ); return workspace; } getWorkspacesByProject(projectId: string): Workspace[] { const rows = this.db.prepare('SELECT * FROM workspaces WHERE project_id = ?').all(projectId) as Array<{ data: string }>; return rows.map(row => { const workspace = JSON.parse(row.data); workspace.goals = this.getGoalsByWorkspace(workspace.id); return workspace; }); } // Goal CRUD - supports both signatures createGoal(data: { workspaceId: string; title: string; description?: string }): Goal; createGoal(workspaceId: string, data: Partial<Goal>): Goal; createGoal(workspaceIdOrData: string | any, data?: Partial<Goal>): Goal { let workspaceId: string; let goalData: Partial<Goal>; if (typeof workspaceIdOrData === 'string') { workspaceId = workspaceIdOrData; goalData = data || {}; } else { workspaceId = workspaceIdOrData.workspaceId; goalData = { title: workspaceIdOrData.title, description: workspaceIdOrData.description, }; } const goal: Goal = { id: uuidv4(), workspaceId, title: goalData.title || '', description: goalData.description || '', priority: goalData.priority || 'medium' as Priority, targetDate: goalData.targetDate, successCriteria: goalData.successCriteria || [], tasks: [], documents: [], createdAt: new Date(), updatedAt: new Date(), }; const stmt = this.db.prepare(` INSERT INTO goals (id, workspace_id, title, description, priority, target_date, data) VALUES (?, ?, ?, ?, ?, ?, ?) `); stmt.run( goal.id, workspaceId, goal.title, goal.description, goal.priority, goal.targetDate?.toISOString(), JSON.stringify(goal) ); return goal; } getGoalsByWorkspace(workspaceId: string): Goal[] { const rows = this.db.prepare('SELECT * FROM goals WHERE workspace_id = ?').all(workspaceId) as Array<{ data: string }>; return rows.map(row => { const goal = JSON.parse(row.data); goal.tasks = this.getTasksByGoal(goal.id); return goal; }); } // Task CRUD - supports both signatures createTask(data: { goalId: string; title: string; description?: string; priority?: Priority }): Task; createTask(goalId: string, data: Partial<Task>): Task; createTask(goalIdOrData: string | any, data?: Partial<Task>): Task { let goalId: string; let taskData: Partial<Task>; if (typeof goalIdOrData === 'string') { goalId = goalIdOrData; taskData = data || {}; } else { goalId = goalIdOrData.goalId; taskData = { title: goalIdOrData.title, description: goalIdOrData.description, priority: goalIdOrData.priority, }; } const task: Task = { id: uuidv4(), goalId, parentTaskId: taskData.parentTaskId, title: taskData.title || '', description: taskData.description || '', status: taskData.status || 'backlog' as TaskStatus, priority: taskData.priority || 'medium' as Priority, assignee: taskData.assignee, dueDate: taskData.dueDate, startDate: taskData.startDate, completedDate: taskData.completedDate, estimatedHours: taskData.estimatedHours, actualHours: taskData.actualHours, checklist: taskData.checklist || [], dependencies: taskData.dependencies || [], comments: [], attachments: [], tags: taskData.tags || [], sessionNotes: taskData.sessionNotes, llmSuggestions: taskData.llmSuggestions || [], createdAt: new Date(), updatedAt: new Date(), }; const stmt = this.db.prepare(` INSERT INTO tasks ( id, goal_id, parent_task_id, title, description, status, priority, assignee_id, due_date, start_date, completed_date, estimated_hours, actual_hours, session_notes, data ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( task.id, goalId, task.parentTaskId, task.title, task.description, task.status, task.priority, task.assignee?.id, task.dueDate?.toISOString(), task.startDate?.toISOString(), task.completedDate?.toISOString(), task.estimatedHours, task.actualHours, task.sessionNotes, JSON.stringify(task) ); return task; } getTasksByGoal(goalId: string): Task[] { const rows = this.db.prepare('SELECT * FROM tasks WHERE goal_id = ? AND parent_task_id IS NULL').all(goalId) as Array<{ data: string }>; return rows.map(row => { const task = JSON.parse(row.data); // Get subtasks task.subtasks = this.getSubtasks(task.id); return task; }); } getSubtasks(parentTaskId: string): Task[] { const rows = this.db.prepare('SELECT * FROM tasks WHERE parent_task_id = ?').all(parentTaskId) as Array<{ data: string }>; return rows.map(row => JSON.parse(row.data)); } getTask(id: string): Task | null { const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as { data: string } | undefined; if (!row) return null; return JSON.parse(row.data); } updateTaskStatus(id: string, status: TaskStatus): void { const stmt = this.db.prepare(` UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `); stmt.run(status, id); // Update data column too const task = this.getTask(id); if (task) { task.status = status; task.updatedAt = new Date(); if (status === 'done' && !task.completedDate) { task.completedDate = new Date(); } this.updateTaskData(id, task); } } private updateTaskData(id: string, task: Task): void { const stmt = this.db.prepare(` UPDATE tasks SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `); stmt.run(JSON.stringify(task), id); } // Session management - supports both signatures createSession(data: { projectId: string; name: string }): Session; createSession(projectId: string, data: Partial<Session>): Session; createSession(projectIdOrData: string | any, data?: Partial<Session>): Session { let projectId: string; let sessionData: Partial<Session>; if (typeof projectIdOrData === 'string') { projectId = projectIdOrData; sessionData = data || {}; } else { projectId = projectIdOrData.projectId; sessionData = { name: projectIdOrData.name, }; } const session: Session = { id: uuidv4(), projectId, name: sessionData.name || 'New Session', type: sessionData.type || 'human', llmModel: sessionData.llmModel, context: sessionData.context, isActive: true, startedAt: new Date(), lastActivityAt: new Date(), }; const stmt = this.db.prepare(` INSERT INTO sessions ( id, project_id, name, type, llm_model, context, is_active, data ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( session.id, projectId, session.name, session.type, session.llmModel, session.context, session.isActive ? 1 : 0, JSON.stringify(session) ); return session; } getActiveSessions(projectId: string): Session[] { const rows = this.db.prepare('SELECT * FROM sessions WHERE project_id = ? AND is_active = 1').all(projectId) as Array<{ data: string }>; return rows.map(row => JSON.parse(row.data)); } // Kanban board data getKanbanBoard(projectId: string): Record<TaskStatus, Task[]> { const project = this.getProject(projectId); if (!project) { return { backlog: [], todo: [], in_progress: [], review: [], done: [], blocked: [], }; } const allTasks: Task[] = []; project.workspaces.forEach(workspace => { workspace.goals.forEach(goal => { allTasks.push(...goal.tasks); // Include subtasks goal.tasks.forEach(task => { if (task.subtasks) { allTasks.push(...task.subtasks); } }); }); }); // Group by status const columns = { backlog: allTasks.filter(t => t.status === 'backlog'), todo: allTasks.filter(t => t.status === 'todo'), in_progress: allTasks.filter(t => t.status === 'in_progress'), review: allTasks.filter(t => t.status === 'review'), done: allTasks.filter(t => t.status === 'done'), blocked: allTasks.filter(t => t.status === 'blocked'), }; return columns; } // Get project with all details async getProjectWithDetails(projectId: string): Promise<Project | null> { return this.getProject(projectId); } // Search tasks across the project async searchTasks(query: string, filters?: { status?: TaskStatus; priority?: Priority; }): Promise<Task[]> { const allProjects = this.getAllProjects(); const tasks: Task[] = []; for (const project of allProjects) { project.workspaces.forEach(workspace => { workspace.goals.forEach(goal => { goal.tasks.forEach(task => { const matchesQuery = !query || task.title.toLowerCase().includes(query.toLowerCase()) || (task.description && task.description.toLowerCase().includes(query.toLowerCase())); const matchesStatus = !filters?.status || task.status === filters.status; const matchesPriority = !filters?.priority || task.priority === filters.priority; if (matchesQuery && matchesStatus && matchesPriority) { tasks.push(task); } }); }); }); } return tasks; } // Project statistics getProjectStats(projectId: string): ProjectStats | null { const project = this.getProject(projectId); if (!project) return null; let totalTasks = 0; let completedTasks = 0; let inProgressTasks = 0; let overdueTasks = 0; project.workspaces.forEach(workspace => { workspace.goals.forEach(goal => { goal.tasks.forEach(task => { totalTasks++; if (task.status === 'done') completedTasks++; if (task.status === 'in_progress') inProgressTasks++; if (task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done') { overdueTasks++; } }); }); }); const sessions = this.db.prepare('SELECT COUNT(*) as total FROM sessions WHERE project_id = ?').get(projectId) as { total: number }; const activeSessions = this.db.prepare('SELECT COUNT(*) as total FROM sessions WHERE project_id = ? AND is_active = 1').get(projectId) as { total: number }; return { totalWorkspaces: project.workspaces.length, totalGoals: project.workspaces.reduce((acc, w) => acc + w.goals.length, 0), totalTasks, completedTasks, inProgressTasks, overdueTasks, totalSessions: sessions.total, activeSessions: activeSessions.total, }; } }