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