manager.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package planning
  6
  7import (
  8	"fmt"
  9	"log/slog"
 10	"sort"
 11	"strings"
 12	"sync"
 13	"time"
 14
 15	"git.sr.ht/~amolith/planning-mcp-server/internal/config"
 16)
 17
 18// Manager handles planning operations
 19type Manager struct {
 20	config *config.Config
 21	logger *slog.Logger
 22
 23	// Thread-safe storage
 24	mu    sync.RWMutex
 25	goal  *Goal
 26	tasks map[string]*Task
 27}
 28
 29// New creates a new planning manager
 30func New(cfg *config.Config, logger *slog.Logger) *Manager {
 31	return &Manager{
 32		config: cfg,
 33		logger: logger,
 34		tasks:  make(map[string]*Task),
 35	}
 36}
 37
 38// UpdateGoal sets or updates the overarching goal (kept for compatibility)
 39func (m *Manager) UpdateGoal(goalText string) error {
 40	if len(goalText) > m.config.Planning.MaxGoalLength {
 41		return fmt.Errorf("goal too long (max %d characters)", m.config.Planning.MaxGoalLength)
 42	}
 43
 44	m.mu.Lock()
 45	defer m.mu.Unlock()
 46
 47	m.goal = &Goal{
 48		Text:      strings.TrimSpace(goalText),
 49		UpdatedAt: time.Now(),
 50	}
 51
 52	m.logger.Info("Goal updated", "goal", goalText)
 53	return nil
 54}
 55
 56// SetGoal sets the initial goal (returns error if already set)
 57func (m *Manager) SetGoal(title, description string) error {
 58	if len(title) > m.config.Planning.MaxGoalLength {
 59		return fmt.Errorf("goal title too long (max %d characters)", m.config.Planning.MaxGoalLength)
 60	}
 61	if len(description) > m.config.Planning.MaxGoalLength {
 62		return fmt.Errorf("goal description too long (max %d characters)", m.config.Planning.MaxGoalLength)
 63	}
 64
 65	m.mu.Lock()
 66	defer m.mu.Unlock()
 67
 68	if m.goal != nil && m.goal.Text != "" {
 69		return fmt.Errorf("goal already set; use change_goal to update it")
 70	}
 71
 72	goalText := title
 73	if description != "" {
 74		goalText = fmt.Sprintf("%s: %s", title, description)
 75	}
 76
 77	m.goal = &Goal{
 78		Text:      strings.TrimSpace(goalText),
 79		UpdatedAt: time.Now(),
 80	}
 81
 82	m.logger.Info("Goal set", "title", title, "description", description)
 83	return nil
 84}
 85
 86// ChangeGoal changes an existing goal (requires a reason)
 87func (m *Manager) ChangeGoal(title, description, reason string) error {
 88	if len(title) > m.config.Planning.MaxGoalLength {
 89		return fmt.Errorf("goal title too long (max %d characters)", m.config.Planning.MaxGoalLength)
 90	}
 91	if len(description) > m.config.Planning.MaxGoalLength {
 92		return fmt.Errorf("goal description too long (max %d characters)", m.config.Planning.MaxGoalLength)
 93	}
 94	if reason == "" {
 95		return fmt.Errorf("reason is required when changing the goal")
 96	}
 97
 98	m.mu.Lock()
 99	defer m.mu.Unlock()
100
101	if m.goal == nil || m.goal.Text == "" {
102		return fmt.Errorf("no goal set; use set_goal to create an initial goal")
103	}
104
105	goalText := title
106	if description != "" {
107		goalText = fmt.Sprintf("%s: %s", title, description)
108	}
109
110	oldGoal := m.goal.Text
111	m.goal = &Goal{
112		Text:      strings.TrimSpace(goalText),
113		UpdatedAt: time.Now(),
114	}
115
116	m.logger.Info("Goal changed", "old", oldGoal, "new", goalText, "reason", reason)
117	return nil
118}
119
120// AddTasksResult contains the result of adding tasks
121type AddTasksResult struct {
122	AddedTasks       []*Task
123	HadExistingTasks bool
124}
125
126// AddTasks adds one or more tasks
127func (m *Manager) AddTasks(tasks []TaskInput) (*AddTasksResult, error) {
128	m.mu.Lock()
129	defer m.mu.Unlock()
130
131	// Check if there were existing tasks before adding new ones
132	hadExistingTasks := len(m.tasks) > 0
133
134	// Check task limits
135	if len(m.tasks)+len(tasks) > m.config.Planning.MaxTasks {
136		return nil, fmt.Errorf("too many tasks (max %d)", m.config.Planning.MaxTasks)
137	}
138
139	addedTasks := make([]*Task, 0, len(tasks))
140
141	for _, taskInput := range tasks {
142		// Validate task input
143		if taskInput.Title == "" {
144			return nil, fmt.Errorf("task title cannot be empty")
145		}
146
147		if len(taskInput.Title) > m.config.Planning.MaxTaskLength {
148			return nil, fmt.Errorf("task title too long (max %d characters)", m.config.Planning.MaxTaskLength)
149		}
150
151		if len(taskInput.Description) > m.config.Planning.MaxTaskLength {
152			return nil, fmt.Errorf("task description too long (max %d characters)", m.config.Planning.MaxTaskLength)
153		}
154
155		// Create task
156		task := NewTask(taskInput.Title, taskInput.Description)
157
158		// Check if task already exists (by ID)
159		if _, exists := m.tasks[task.ID]; exists {
160			m.logger.Warn("Task already exists, skipping", "id", task.ID, "title", task.Title)
161			continue
162		}
163
164		m.tasks[task.ID] = task
165		addedTasks = append(addedTasks, task)
166	}
167
168	m.logger.Info("Tasks added", "count", len(addedTasks))
169	return &AddTasksResult{
170		AddedTasks:       addedTasks,
171		HadExistingTasks: hadExistingTasks,
172	}, nil
173}
174
175// formatTaskList returns a markdown-formatted list of all tasks
176// This method assumes the mutex is already locked
177func (m *Manager) formatTaskList() string {
178	return m.formatTaskListWithFilter(nil)
179}
180
181// GetTasks returns a markdown-formatted list of all tasks
182func (m *Manager) GetTasks() string {
183	m.mu.RLock()
184	defer m.mu.RUnlock()
185	return m.formatTaskList()
186}
187
188// GetTasksByStatus returns a markdown-formatted list of tasks filtered by status
189func (m *Manager) GetTasksByStatus(statusFilter string) string {
190	m.mu.RLock()
191	defer m.mu.RUnlock()
192
193	// Parse the filter
194	var filterStatus *TaskStatus
195	if statusFilter != "" && statusFilter != "all" {
196		status := ParseStatus(statusFilter)
197		filterStatus = &status
198	}
199
200	return m.formatTaskListWithFilter(filterStatus)
201}
202
203// formatTaskListWithFilter returns a markdown-formatted list of tasks with optional status filter
204func (m *Manager) formatTaskListWithFilter(filterStatus *TaskStatus) string {
205	var lines []string
206
207	// Add goal if it exists
208	if m.goal != nil && m.goal.Text != "" {
209		lines = append(lines, fmt.Sprintf("**Goal:** %s", m.goal.Text))
210		lines = append(lines, "")
211	}
212
213	// Filter tasks if needed
214	filteredTasks := make(map[string]*Task)
215	for id, task := range m.tasks {
216		if filterStatus == nil || task.Status == *filterStatus {
217			filteredTasks[id] = task
218		}
219	}
220
221	if len(filteredTasks) == 0 {
222		if filterStatus != nil {
223			statusName := "pending"
224			switch *filterStatus {
225			case StatusInProgress:
226				statusName = "in progress"
227			case StatusCompleted:
228				statusName = "completed"
229			case StatusFailed:
230				statusName = "failed"
231			case StatusCancelled:
232				statusName = "cancelled"
233			}
234			lines = append(lines, fmt.Sprintf("No %s tasks.", statusName))
235		} else if len(lines) > 0 {
236			// We have a goal but no tasks
237			lines = append(lines, "No tasks defined yet.")
238		} else {
239			lines = append(lines, "No tasks defined yet.")
240		}
241		return strings.Join(lines, "\n")
242	}
243
244	// Sort tasks by creation time for consistent output
245	taskList := make([]*Task, 0, len(filteredTasks))
246	for _, task := range filteredTasks {
247		taskList = append(taskList, task)
248	}
249
250	// Check if there are any failed or cancelled tasks
251	hasFailed := false
252	hasCancelled := false
253	for _, task := range taskList {
254		if task.Status == StatusFailed {
255			hasFailed = true
256		}
257		if task.Status == StatusCancelled {
258			hasCancelled = true
259		}
260	}
261
262	// Add legend
263	legend := "Legend: ☐ pending  ⟳ in progress  ☑ completed"
264	if hasCancelled {
265		legend += "  ⊗ cancelled"
266	}
267	if hasFailed {
268		legend += "  ☒ failed"
269	}
270	lines = append(lines, legend)
271
272	// Sort tasks by creation time (oldest first for consistent ordering)
273	sort.Slice(taskList, func(i, j int) bool {
274		return taskList[i].CreatedAt.Before(taskList[j].CreatedAt)
275	})
276
277	for _, task := range taskList {
278		line := fmt.Sprintf("%s %s [%s]", task.Status.String(), task.Title, task.ID)
279		lines = append(lines, line)
280		if task.Description != "" {
281			lines = append(lines, fmt.Sprintf("  %s", task.Description))
282		}
283	}
284
285	return strings.Join(lines, "\n")
286}
287
288// UpdateTaskStatus updates the status of a specific task
289func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error {
290	m.mu.Lock()
291	defer m.mu.Unlock()
292
293	task, exists := m.tasks[taskID]
294	if !exists {
295		return fmt.Errorf("task not found: %s", taskID)
296	}
297
298	task.UpdateStatus(status)
299	m.logger.Info("Task status updated", "id", taskID, "status", status.String())
300	return nil
301}
302
303// UpdateTasks updates the status of multiple tasks in a single operation
304func (m *Manager) UpdateTasks(updates []TaskUpdate) error {
305	m.mu.Lock()
306	defer m.mu.Unlock()
307
308	// First validate all task IDs exist
309	for _, update := range updates {
310		if _, exists := m.tasks[update.TaskID]; !exists {
311			return fmt.Errorf("task not found: %s", update.TaskID)
312		}
313	}
314
315	// If all validations pass, apply all updates
316	for _, update := range updates {
317		task := m.tasks[update.TaskID]
318		task.UpdateStatus(update.Status)
319		m.logger.Info("Task status updated", "id", update.TaskID, "status", update.Status.String())
320	}
321
322	return nil
323}
324
325// GetGoal returns the current goal
326func (m *Manager) GetGoal() *Goal {
327	m.mu.RLock()
328	defer m.mu.RUnlock()
329	return m.goal
330}
331
332// GetTaskByID returns a task by its ID
333func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
334	m.mu.RLock()
335	defer m.mu.RUnlock()
336	task, exists := m.tasks[taskID]
337	return task, exists
338}
339
340// TaskInput represents input for creating a task
341type TaskInput struct {
342	Title       string `json:"title"`
343	Description string `json:"description"`
344}
345
346// TaskUpdate represents a task status update
347type TaskUpdate struct {
348	TaskID string     `json:"task_id"`
349	Status TaskStatus `json:"status"`
350}
351
352// DeleteTasks deletes one or more tasks by their IDs
353func (m *Manager) DeleteTasks(taskIDs []string) error {
354	m.mu.Lock()
355	defer m.mu.Unlock()
356
357	if len(taskIDs) == 0 {
358		return fmt.Errorf("at least one task ID is required")
359	}
360
361	// First validate all task IDs exist
362	notFound := make([]string, 0)
363	for _, taskID := range taskIDs {
364		if _, exists := m.tasks[taskID]; !exists {
365			notFound = append(notFound, taskID)
366		}
367	}
368
369	if len(notFound) > 0 {
370		return fmt.Errorf("task(s) not found: %s", strings.Join(notFound, ", "))
371	}
372
373	// If all validations pass, delete all tasks
374	for _, taskID := range taskIDs {
375		delete(m.tasks, taskID)
376		m.logger.Info("Task deleted", "id", taskID)
377	}
378
379	m.logger.Info("Tasks deleted", "count", len(taskIDs))
380	return nil
381}