Files
mosaic/packages/brain/src/storage/collections.ts
Jason Woltje 1aa11c4ee8
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
ci/woodpecker/pull_request_closed/woodpecker Pipeline failed
feat(brain): @mosaic/brain structured data service (#10)
Implement @mosaic/brain — typed structured data service with MCP + REST API,
JSON file backend, and schema validation via Zod.

Collections: tasks, projects, events, agents, tickets, appreciations,
missions, mission_tasks.

MCP tools: brain_tasks, brain_projects, brain_events, brain_agents,
brain_tickets, brain_today, brain_stale, brain_stats, brain_search,
brain_audit, brain_missions, brain_mission, brain_mission_tasks,
plus mutation tools for all collections.

REST API mirrors MCP 1:1 at /v1/*.
Bearer token auth with timing-safe comparison.
Fastify server with per-request MCP instances (stateless HTTP transport).
JSON file storage with proper-lockfile for concurrent access.

Also adds Brain* types to @mosaic/types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:10:12 -05:00

537 lines
21 KiB
TypeScript

import type {
BrainTask, BrainProject, BrainEvent, BrainAgent,
BrainTicket, BrainAppreciation, BrainMission, BrainMissionTask,
BrainTaskFilters, BrainProjectFilters, BrainEventFilters,
BrainMissionFilters, BrainMissionTaskFilters,
BrainMissionSummary, BrainTodaySummary, BrainStats,
BrainStaleReport, BrainAuditResult, BrainSearchResult,
TaskPriority,
} from '@mosaic/types';
import { JsonStore } from './json-store.js';
function today(): string {
return new Date().toISOString().slice(0, 10);
}
interface TasksFile { version: string; tasks: BrainTask[] }
interface ProjectsFile { version: string; projects: BrainProject[] }
interface EventsFile { version: string; events: BrainEvent[] }
interface AgentsFile { version: string; agents: BrainAgent[] }
interface TicketsFile { version: string; tickets: BrainTicket[] }
interface AppreciationsFile { version: string; appreciations: BrainAppreciation[] }
interface MissionsFile { version: string; missions: BrainMission[] }
interface MissionTasksFile { version: string; mission_tasks: BrainMissionTask[] }
const DEFAULT_TASKS: TasksFile = { version: '2.0', tasks: [] };
const DEFAULT_PROJECTS: ProjectsFile = { version: '2.0', projects: [] };
const DEFAULT_EVENTS: EventsFile = { version: '2.0', events: [] };
const DEFAULT_AGENTS: AgentsFile = { version: '2.0', agents: [] };
const DEFAULT_TICKETS: TicketsFile = { version: '2.0', tickets: [] };
const DEFAULT_APPRECIATIONS: AppreciationsFile = { version: '2.0', appreciations: [] };
const DEFAULT_MISSIONS: MissionsFile = { version: '2.0', missions: [] };
const DEFAULT_MISSION_TASKS: MissionTasksFile = { version: '2.0', mission_tasks: [] };
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
function matchesFilter<T>(item: T, filters: object): boolean {
for (const [key, value] of Object.entries(filters as Record<string, unknown>)) {
if (value === undefined || value === null) continue;
if (key === 'limit') continue;
if (key === 'due_before') {
if (!(item as Record<string, unknown>)['due'] || (item as Record<string, unknown>)['due']! > value) return false;
continue;
}
if (key === 'due_after') {
if (!(item as Record<string, unknown>)['due'] || (item as Record<string, unknown>)['due']! < value) return false;
continue;
}
if (key === 'date_from') {
if ((item as Record<string, unknown>)['date']! < value) return false;
continue;
}
if (key === 'date_to') {
if ((item as Record<string, unknown>)['date']! > value) return false;
continue;
}
if (key === 'priority_min') {
if (((item as Record<string, unknown>)['priority'] as number) < (value as number)) return false;
continue;
}
if (key === 'priority_max') {
if (((item as Record<string, unknown>)['priority'] as number) > (value as number)) return false;
continue;
}
if ((item as Record<string, unknown>)[key] !== value) return false;
}
return true;
}
function applyLimit<T>(items: T[], limit?: number): T[] {
return limit != null && limit > 0 ? items.slice(0, limit) : items;
}
export class Collections {
constructor(private readonly store: JsonStore) {}
// === Tasks ===
async getTasks(filters: BrainTaskFilters = {}): Promise<BrainTask[]> {
const file = await this.store.read('tasks', DEFAULT_TASKS);
const filtered = file.tasks.filter(t => matchesFilter(t, filters));
filtered.sort((a, b) =>
(PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9)
|| (a.due ?? '9999').localeCompare(b.due ?? '9999'),
);
return applyLimit(filtered, filters.limit);
}
async getTask(id: string): Promise<BrainTask | null> {
const file = await this.store.read('tasks', DEFAULT_TASKS);
return file.tasks.find(t => t.id === id) ?? null;
}
async addTask(task: BrainTask): Promise<BrainTask> {
const result = await this.store.modify('tasks', DEFAULT_TASKS, (file) => {
if (file.tasks.some(t => t.id === task.id)) {
throw new Error(`Task '${task.id}' already exists`);
}
return { ...file, tasks: [...file.tasks, task] };
});
return result.tasks.find(t => t.id === task.id)!;
}
async updateTask(id: string, updates: Partial<BrainTask>): Promise<BrainTask> {
let updated: BrainTask | undefined;
await this.store.modify('tasks', DEFAULT_TASKS, (file) => {
const idx = file.tasks.findIndex(t => t.id === id);
if (idx === -1) throw new Error(`Task '${id}' not found`);
const tasks = [...file.tasks];
updated = { ...tasks[idx]!, ...updates, id, updated: today() } as BrainTask;
tasks[idx] = updated;
return { ...file, tasks };
});
return updated!;
}
// === Projects ===
async getProjects(filters: BrainProjectFilters = {}): Promise<BrainProject[]> {
const file = await this.store.read('projects', DEFAULT_PROJECTS);
const filtered = file.projects.filter(p => matchesFilter(p, filters));
filtered.sort((a, b) => a.priority - b.priority);
return applyLimit(filtered, filters.limit);
}
async getProject(id: string): Promise<BrainProject | null> {
const file = await this.store.read('projects', DEFAULT_PROJECTS);
return file.projects.find(p => p.id === id) ?? null;
}
async addProject(project: BrainProject): Promise<BrainProject> {
const result = await this.store.modify('projects', DEFAULT_PROJECTS, (file) => {
if (file.projects.some(p => p.id === project.id)) {
throw new Error(`Project '${project.id}' already exists`);
}
return { ...file, projects: [...file.projects, project] };
});
return result.projects.find(p => p.id === project.id)!;
}
async updateProject(id: string, updates: Partial<BrainProject>): Promise<BrainProject> {
let updated: BrainProject | undefined;
await this.store.modify('projects', DEFAULT_PROJECTS, (file) => {
const idx = file.projects.findIndex(p => p.id === id);
if (idx === -1) throw new Error(`Project '${id}' not found`);
const projects = [...file.projects];
updated = { ...projects[idx]!, ...updates, id, updated: today() } as BrainProject;
projects[idx] = updated;
return { ...file, projects };
});
return updated!;
}
// === Events ===
async getEvents(filters: BrainEventFilters = {}): Promise<BrainEvent[]> {
const file = await this.store.read('events', DEFAULT_EVENTS);
const filtered = file.events.filter(e => matchesFilter(e, filters));
filtered.sort((a, b) => a.date.localeCompare(b.date) || (a.time ?? '').localeCompare(b.time ?? ''));
return applyLimit(filtered, filters.limit);
}
async getEvent(id: string): Promise<BrainEvent | null> {
const file = await this.store.read('events', DEFAULT_EVENTS);
return file.events.find(e => e.id === id) ?? null;
}
async addEvent(event: BrainEvent): Promise<BrainEvent> {
const result = await this.store.modify('events', DEFAULT_EVENTS, (file) => {
if (file.events.some(e => e.id === event.id)) {
throw new Error(`Event '${event.id}' already exists`);
}
return { ...file, events: [...file.events, event] };
});
return result.events.find(e => e.id === event.id)!;
}
async updateEvent(id: string, updates: Partial<BrainEvent>): Promise<BrainEvent> {
let updated: BrainEvent | undefined;
await this.store.modify('events', DEFAULT_EVENTS, (file) => {
const idx = file.events.findIndex(e => e.id === id);
if (idx === -1) throw new Error(`Event '${id}' not found`);
const events = [...file.events];
updated = { ...events[idx]!, ...updates, id } as BrainEvent;
events[idx] = updated;
return { ...file, events };
});
return updated!;
}
// === Agents ===
async getAgents(filters: { status?: string; project?: string } = {}): Promise<BrainAgent[]> {
const file = await this.store.read('agents', DEFAULT_AGENTS);
return file.agents.filter(a => matchesFilter(a, filters));
}
async updateAgent(id: string, updates: Partial<BrainAgent>): Promise<BrainAgent> {
let updated: BrainAgent | undefined;
await this.store.modify('agents', DEFAULT_AGENTS, (file) => {
const idx = file.agents.findIndex(a => a.id === id);
if (idx === -1) {
// Auto-create agent entries
updated = { id, project: '', status: 'active', updated: new Date().toISOString(), ...updates } as BrainAgent;
return { ...file, agents: [...file.agents, updated] };
}
const agents = [...file.agents];
updated = { ...agents[idx]!, ...updates, id, updated: new Date().toISOString() } as BrainAgent;
agents[idx] = updated;
return { ...file, agents };
});
return updated!;
}
// === Tickets ===
async getTickets(filters: { status?: number; priority?: number; limit?: number } = {}): Promise<BrainTicket[]> {
const file = await this.store.read('tickets', DEFAULT_TICKETS);
let filtered = file.tickets;
if (filters.status !== undefined) filtered = filtered.filter(t => t.status === filters.status);
if (filters.priority !== undefined) filtered = filtered.filter(t => t.priority === filters.priority);
return applyLimit(filtered, filters.limit);
}
// === Appreciations ===
async getAppreciations(): Promise<BrainAppreciation[]> {
const file = await this.store.read('appreciations', DEFAULT_APPRECIATIONS);
return file.appreciations;
}
async addAppreciation(appreciation: BrainAppreciation): Promise<BrainAppreciation> {
await this.store.modify('appreciations', DEFAULT_APPRECIATIONS, (file) => ({
...file,
appreciations: [...file.appreciations, appreciation],
}));
return appreciation;
}
// === Missions ===
async getMissions(filters: BrainMissionFilters = {}): Promise<BrainMission[]> {
const file = await this.store.read('missions', DEFAULT_MISSIONS);
const filtered = file.missions.filter(m => matchesFilter(m, filters));
return applyLimit(filtered, filters.limit);
}
async getMission(id: string): Promise<BrainMission | null> {
const file = await this.store.read('missions', DEFAULT_MISSIONS);
return file.missions.find(m => m.id === id) ?? null;
}
async addMission(mission: BrainMission): Promise<BrainMission> {
const result = await this.store.modify('missions', DEFAULT_MISSIONS, (file) => {
if (file.missions.some(m => m.id === mission.id)) {
throw new Error(`Mission '${mission.id}' already exists`);
}
return { ...file, missions: [...file.missions, mission] };
});
return result.missions.find(m => m.id === mission.id)!;
}
async updateMission(id: string, updates: Partial<BrainMission>): Promise<BrainMission> {
let updated: BrainMission | undefined;
await this.store.modify('missions', DEFAULT_MISSIONS, (file) => {
const idx = file.missions.findIndex(m => m.id === id);
if (idx === -1) throw new Error(`Mission '${id}' not found`);
const missions = [...file.missions];
updated = { ...missions[idx]!, ...updates, id, updated: today() } as BrainMission;
missions[idx] = updated;
return { ...file, missions };
});
return updated!;
}
async getMissionSummary(id: string): Promise<BrainMissionSummary | null> {
const mission = await this.getMission(id);
if (!mission) return null;
const tasks = await this.getMissionTasks({ mission_id: id });
const completed = tasks.filter(t => t.status === 'done');
const completedIds = new Set(completed.map(t => t.id));
const nextAvailable = tasks.filter(t =>
t.status !== 'done' && t.status !== 'cancelled' && t.status !== 'blocked'
&& t.dependencies.every(dep => completedIds.has(dep))
&& !t.assigned_to,
);
const blockedTasks = tasks.filter(t =>
t.status === 'blocked'
|| (t.status !== 'done' && t.status !== 'cancelled'
&& !t.dependencies.every(dep => completedIds.has(dep))),
);
return {
...mission,
task_count: tasks.length,
completed_count: completed.length,
progress: tasks.length > 0 ? Math.round((completed.length / tasks.length) * 100) : 0,
next_available: nextAvailable,
blocked_tasks: blockedTasks,
};
}
// === Mission Tasks ===
async getMissionTasks(filters: BrainMissionTaskFilters): Promise<BrainMissionTask[]> {
const file = await this.store.read('mission_tasks', DEFAULT_MISSION_TASKS);
const filtered = file.mission_tasks.filter(t => matchesFilter(t, filters));
filtered.sort((a, b) => a.order - b.order);
return filtered;
}
async addMissionTask(task: BrainMissionTask): Promise<BrainMissionTask> {
const result = await this.store.modify('mission_tasks', DEFAULT_MISSION_TASKS, (file) => {
if (file.mission_tasks.some(t => t.id === task.id && t.mission_id === task.mission_id)) {
throw new Error(`Mission task '${task.id}' already exists in mission '${task.mission_id}'`);
}
return { ...file, mission_tasks: [...file.mission_tasks, task] };
});
return result.mission_tasks.find(t => t.id === task.id && t.mission_id === task.mission_id)!;
}
async updateMissionTask(missionId: string, taskId: string, updates: Partial<BrainMissionTask>): Promise<BrainMissionTask> {
let updated: BrainMissionTask | undefined;
await this.store.modify('mission_tasks', DEFAULT_MISSION_TASKS, (file) => {
const idx = file.mission_tasks.findIndex(t => t.id === taskId && t.mission_id === missionId);
if (idx === -1) throw new Error(`Mission task '${taskId}' not found in mission '${missionId}'`);
const tasks = [...file.mission_tasks];
updated = { ...tasks[idx]!, ...updates, id: taskId, mission_id: missionId, updated: today() } as BrainMissionTask;
if (updates.status === 'done' && !updated.completed_at) {
updated = { ...updated, completed_at: new Date().toISOString() };
}
tasks[idx] = updated;
return { ...file, mission_tasks: tasks };
});
return updated!;
}
// === Computed: Today ===
async getToday(date?: string): Promise<BrainTodaySummary> {
const d = date ?? today();
const weekFromNow = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
const staleCutoff = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
const [tasks, projects, events, agents, missions] = await Promise.all([
this.getTasks(),
this.getProjects(),
this.getEvents(),
this.getAgents(),
this.getMissions({ status: 'active' }),
]);
const activeTasks = tasks.filter(t => t.status !== 'done' && t.status !== 'cancelled');
const eventsToday = events.filter(e => e.date === d);
const eventsUpcoming = events.filter(e => e.date > d && e.date <= weekFromNow);
const tasksNearTerm = activeTasks.filter(t => t.due && t.due >= d && t.due <= weekFromNow);
const tasksBlocked = activeTasks.filter(t => t.status === 'blocked');
const tasksStale = activeTasks.filter(t => t.status === 'in-progress' && t.updated < staleCutoff);
const tasksAlmostDone = activeTasks.filter(t => t.progress != null && t.progress >= 80);
const missionSummaries: BrainMissionSummary[] = [];
for (const m of missions) {
const summary = await this.getMissionSummary(m.id);
if (summary) missionSummaries.push(summary);
}
const tasksByStatus: Record<string, number> = {};
const tasksByDomain: Record<string, number> = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
tasksByDomain[t.domain] = (tasksByDomain[t.domain] ?? 0) + 1;
}
const projectsByStatus: Record<string, number> = {};
for (const p of projects) {
projectsByStatus[p.status] = (projectsByStatus[p.status] ?? 0) + 1;
}
return {
date: d,
events_today: eventsToday,
events_upcoming: eventsUpcoming,
tasks_near_term: tasksNearTerm,
tasks_blocked: tasksBlocked,
tasks_stale: tasksStale,
tasks_almost_done: tasksAlmostDone,
active_missions: missionSummaries,
stats: {
tasks: tasks.length,
projects: projects.length,
events: events.length,
agents: agents.length,
tickets: (await this.getTickets()).length,
missions: (await this.getMissions()).length,
tasks_by_status: tasksByStatus,
tasks_by_domain: tasksByDomain,
projects_by_status: projectsByStatus,
},
};
}
// === Computed: Stale ===
async getStale(days: number = 7): Promise<BrainStaleReport> {
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const tasks = await this.getTasks();
const projects = await this.getProjects();
return {
days,
tasks: tasks.filter(t => t.status === 'in-progress' && t.updated < cutoff),
projects: projects.filter(p => p.status === 'active' && p.updated < cutoff),
};
}
// === Computed: Stats ===
async getStats(): Promise<BrainStats> {
const [tasks, projects, events, agents, tickets, missions] = await Promise.all([
this.getTasks(),
this.getProjects(),
this.getEvents(),
this.getAgents(),
this.getTickets(),
this.getMissions(),
]);
const tasksByStatus: Record<string, number> = {};
const tasksByDomain: Record<string, number> = {};
for (const t of tasks) {
tasksByStatus[t.status] = (tasksByStatus[t.status] ?? 0) + 1;
tasksByDomain[t.domain] = (tasksByDomain[t.domain] ?? 0) + 1;
}
const projectsByStatus: Record<string, number> = {};
for (const p of projects) {
projectsByStatus[p.status] = (projectsByStatus[p.status] ?? 0) + 1;
}
return {
tasks: tasks.length,
projects: projects.length,
events: events.length,
agents: agents.length,
tickets: tickets.length,
missions: missions.length,
tasks_by_status: tasksByStatus,
tasks_by_domain: tasksByDomain,
projects_by_status: projectsByStatus,
};
}
// === Computed: Search ===
async search(query: string, collection?: string): Promise<BrainSearchResult[]> {
const q = query.toLowerCase();
const results: BrainSearchResult[] = [];
const searchIn = (items: Array<{ id: string; title: string; notes?: string | null }>, collectionName: string) => {
for (const item of items) {
const titleMatch = item.title.toLowerCase().includes(q);
const notesMatch = item.notes?.toLowerCase().includes(q);
if (titleMatch || notesMatch) {
results.push({
collection: collectionName,
id: item.id,
title: item.title,
match_context: titleMatch ? item.title : (item.notes?.substring(0, 200) ?? ''),
score: titleMatch ? 1.0 : 0.5,
});
}
}
};
if (!collection || collection === 'tasks') searchIn(await this.getTasks(), 'tasks');
if (!collection || collection === 'projects') {
const projects = await this.getProjects();
for (const p of projects) {
const titleMatch = p.name.toLowerCase().includes(q);
const notesMatch = p.notes?.toLowerCase().includes(q);
if (titleMatch || notesMatch) {
results.push({
collection: 'projects',
id: p.id,
title: p.name,
match_context: titleMatch ? p.name : (p.notes?.substring(0, 200) ?? ''),
score: titleMatch ? 1.0 : 0.5,
});
}
}
}
if (!collection || collection === 'events') searchIn(await this.getEvents(), 'events');
if (!collection || collection === 'missions') searchIn(await this.getMissions(), 'missions');
results.sort((a, b) => b.score - a.score);
return results;
}
// === Computed: Audit ===
async audit(): Promise<BrainAuditResult> {
const tasks = await this.getTasks();
const projects = await this.getProjects();
const taskIds = new Set(tasks.map(t => t.id));
const projectIds = new Set(projects.map(p => p.id));
const allIds = new Set([...taskIds, ...projectIds]);
const orphanRefs: string[] = [];
const brokenDependencies: string[] = [];
const missingRequiredFields: string[] = [];
const duplicateIds: string[] = [];
// Check for duplicate task IDs
const seenTaskIds = new Set<string>();
for (const t of tasks) {
if (seenTaskIds.has(t.id)) duplicateIds.push(`task:${t.id}`);
seenTaskIds.add(t.id);
}
// Check task references
for (const t of tasks) {
if (t.project && !projectIds.has(t.project)) {
orphanRefs.push(`task:${t.id} -> project:${t.project}`);
}
for (const dep of t.blocked_by ?? []) {
if (!allIds.has(dep)) brokenDependencies.push(`task:${t.id} blocked_by:${dep}`);
}
for (const dep of t.blocks ?? []) {
if (!allIds.has(dep)) brokenDependencies.push(`task:${t.id} blocks:${dep}`);
}
if (!t.title) missingRequiredFields.push(`task:${t.id} missing title`);
}
return { orphan_refs: orphanRefs, broken_dependencies: brokenDependencies, missing_required_fields: missingRequiredFields, duplicate_ids: duplicateIds };
}
}