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	var lines []string
104
105	// Add goal if it exists
106	if m.goal != nil && m.goal.Text != "" {
107		lines = append(lines, fmt.Sprintf("**Goal:** %s", m.goal.Text))
108		lines = append(lines, "")
109	}
110
111	if len(m.tasks) == 0 {
112		if len(lines) > 0 {
113			// We have a goal but no tasks
114			lines = append(lines, "No tasks defined yet.")
115			return strings.Join(lines, "\n")
116		}
117		return "No tasks defined yet."
118	}
119
120	// Sort tasks by creation time for consistent output
121	taskList := make([]*Task, 0, len(m.tasks))
122	for _, task := range m.tasks {
123		taskList = append(taskList, task)
124	}
125
126	// Check if there are any failed tasks
127	hasFailed := false
128	for _, task := range taskList {
129		if task.Status == StatusFailed {
130			hasFailed = true
131			break
132		}
133	}
134
135	// Add legend
136	legend := "Legend: ☐ pending  ⟳ in progress  ☑ completed"
137	if hasFailed {
138		legend += "  ☒ failed"
139	}
140	lines = append(lines, legend)
141
142	// Simple sort by creation time (newest first could be changed if needed)
143	for i := range len(taskList) {
144		for j := i + 1; j < len(taskList); j++ {
145			if taskList[i].CreatedAt.After(taskList[j].CreatedAt) {
146				taskList[i], taskList[j] = taskList[j], taskList[i]
147			}
148		}
149	}
150
151	for _, task := range taskList {
152		line := fmt.Sprintf("%s %s [%s]", task.Status.String(), task.Title, task.ID)
153		lines = append(lines, line)
154		if task.Description != "" {
155			lines = append(lines, fmt.Sprintf("  %s", task.Description))
156		}
157	}
158
159	return strings.Join(lines, "\n")
160}
161
162// UpdateTaskStatus updates the status of a specific task
163func (m *Manager) UpdateTaskStatus(taskID string, status TaskStatus) error {
164	m.mu.Lock()
165	defer m.mu.Unlock()
166
167	task, exists := m.tasks[taskID]
168	if !exists {
169		return fmt.Errorf("task not found: %s", taskID)
170	}
171
172	task.UpdateStatus(status)
173	m.logger.Info("Task status updated", "id", taskID, "status", status.String())
174	return nil
175}
176
177// GetGoal returns the current goal
178func (m *Manager) GetGoal() *Goal {
179	m.mu.RLock()
180	defer m.mu.RUnlock()
181	return m.goal
182}
183
184// GetTaskByID returns a task by its ID
185func (m *Manager) GetTaskByID(taskID string) (*Task, bool) {
186	m.mu.RLock()
187	defer m.mu.RUnlock()
188	task, exists := m.tasks[taskID]
189	return task, exists
190}
191
192// TaskInput represents input for creating a task
193type TaskInput struct {
194	Title       string `json:"title"`
195	Description string `json:"description"`
196}