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	"strings"
 11	"sync"
 12	"time"
 13
 14	"git.sr.ht/~amolith/planning-mcp-server/internal/config"
 15)
 16
 17// Manager handles planning operations
 18type Manager struct {
 19	config *config.Config
 20	logger *slog.Logger
 21
 22	// Thread-safe storage
 23	mu    sync.RWMutex
 24	goal  *Goal
 25	tasks map[string]*Task
 26}
 27
 28// New creates a new planning manager
 29func New(cfg *config.Config, logger *slog.Logger) *Manager {
 30	return &Manager{
 31		config: cfg,
 32		logger: logger,
 33		tasks:  make(map[string]*Task),
 34	}
 35}
 36
 37// UpdateGoal sets or updates the overarching goal
 38func (m *Manager) UpdateGoal(goalText string) error {
 39	if len(goalText) > m.config.Planning.MaxGoalLength {
 40		return fmt.Errorf("goal too long (max %d characters)", m.config.Planning.MaxGoalLength)
 41	}
 42
 43	m.mu.Lock()
 44	defer m.mu.Unlock()
 45
 46	m.goal = &Goal{
 47		Text:      strings.TrimSpace(goalText),
 48		UpdatedAt: time.Now(),
 49	}
 50
 51	m.logger.Info("Goal updated", "goal", goalText)
 52	return nil
 53}
 54
 55// AddTasks adds one or more tasks
 56func (m *Manager) AddTasks(tasks []TaskInput) error {
 57	m.mu.Lock()
 58	defer m.mu.Unlock()
 59
 60	// Check task limits
 61	if len(m.tasks)+len(tasks) > m.config.Planning.MaxTasks {
 62		return fmt.Errorf("too many tasks (max %d)", m.config.Planning.MaxTasks)
 63	}
 64
 65	addedTasks := make([]*Task, 0, len(tasks))
 66
 67	for _, taskInput := range tasks {
 68		// Validate task input
 69		if taskInput.Title == "" {
 70			return fmt.Errorf("task title cannot be empty")
 71		}
 72
 73		if len(taskInput.Title) > m.config.Planning.MaxTaskLength {
 74			return fmt.Errorf("task title too long (max %d characters)", m.config.Planning.MaxTaskLength)
 75		}
 76
 77		if len(taskInput.Description) > m.config.Planning.MaxTaskLength {
 78			return fmt.Errorf("task description too long (max %d characters)", m.config.Planning.MaxTaskLength)
 79		}
 80
 81		// Create task
 82		task := NewTask(taskInput.Title, taskInput.Description)
 83
 84		// Check if task already exists (by ID)
 85		if _, exists := m.tasks[task.ID]; exists {
 86			m.logger.Warn("Task already exists, skipping", "id", task.ID, "title", task.Title)
 87			continue
 88		}
 89
 90		m.tasks[task.ID] = task
 91		addedTasks = append(addedTasks, task)
 92	}
 93
 94	m.logger.Info("Tasks added", "count", len(addedTasks))
 95	return nil
 96}
 97
 98// GetTasks returns a markdown-formatted list of tasks
 99func (m *Manager) GetTasks() string {
100	m.mu.RLock()
101	defer m.mu.RUnlock()
102
103	if len(m.tasks) == 0 {
104		return "No tasks defined yet."
105	}
106
107	var lines []string
108
109	// Sort tasks by creation time for consistent output
110	taskList := make([]*Task, 0, len(m.tasks))
111	for _, task := range m.tasks {
112		taskList = append(taskList, task)
113	}
114
115	// Check if there are any failed tasks
116	hasFailed := false
117	for _, task := range taskList {
118		if task.Status == StatusFailed {
119			hasFailed = true
120			break
121		}
122	}
123
124	// Add legend
125	legend := "Legend: ☐ pending  ⟳ in progress  ☑ completed"
126	if hasFailed {
127		legend += "  ☒ failed"
128	}
129	lines = append(lines, legend)
130
131	// Simple sort by creation time (newest first could be changed if needed)
132	for i := range len(taskList) {
133		for j := i + 1; j < len(taskList); j++ {
134			if taskList[i].CreatedAt.After(taskList[j].CreatedAt) {
135				taskList[i], taskList[j] = taskList[j], taskList[i]
136			}
137		}
138	}
139
140	for _, task := range taskList {
141		line := fmt.Sprintf("%s %s [%s]", task.Status.String(), task.Title, task.ID)
142		lines = append(lines, line)
143		if task.Description != "" {
144			lines = append(lines, fmt.Sprintf("  %s", task.Description))
145		}
146	}
147
148	return strings.Join(lines, "\n")
149}
150
151// UpdateTaskStatus updates the status of a specific task
152func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error {
153	m.mu.Lock()
154	defer m.mu.Unlock()
155
156	task, exists := m.tasks[taskID]
157	if !exists {
158		return fmt.Errorf("task not found: %s", taskID)
159	}
160
161	task.UpdateStatus(status)
162	m.logger.Info("Task status updated", "id", taskID, "status", status.String())
163	return nil
164}
165
166// GetGoal returns the current goal
167func (m *Manager) GetGoal() *Goal {
168	m.mu.RLock()
169	defer m.mu.RUnlock()
170	return m.goal
171}
172
173// GetTaskByID returns a task by its ID
174func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
175	m.mu.RLock()
176	defer m.mu.RUnlock()
177	task, exists := m.tasks[taskID]
178	return task, exists
179}
180
181// TaskInput represents input for creating a task
182type TaskInput struct {
183	Title       string `json:"title"`
184	Description string `json:"description"`
185}