orchestry-mcp
Version:
Orchestry MCP Server for multi-session task management
626 lines (563 loc) • 19.2 kB
text/typescript
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,
};
}
}