u.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package t
  6
  7import (
  8	"errors"
  9	"fmt"
 10	"strings"
 11	"time"
 12
 13	"git.secluded.site/np/cmd/shared"
 14	"git.secluded.site/np/internal/cli"
 15	"git.secluded.site/np/internal/db"
 16	"git.secluded.site/np/internal/event"
 17	"git.secluded.site/np/internal/task"
 18	"github.com/spf13/cobra"
 19)
 20
 21var uCmd = &cobra.Command{
 22	Use:   "u",
 23	Short: "Update tasks",
 24	Long:  `Update one or more tasks' status, title, or description`,
 25	RunE:  runUpdateTask,
 26}
 27
 28func init() {
 29	TCmd.AddCommand(uCmd)
 30
 31	uCmd.Flags().StringArrayP("id", "i", []string{}, "Task ID (required, repeatable for batch updates)")
 32	uCmd.Flags().StringArrayP("status", "s", []string{}, "New task status (repeatable, must match number of IDs)")
 33	uCmd.Flags().StringP("title", "t", "", "New task title (only for single task updates)")
 34	uCmd.Flags().StringP("description", "d", "", "New task description (only for single task updates)")
 35	uCmd.Flags().StringP("reason", "r", "", "Reason for update (required for title/description changes and status=cancelled/failed)")
 36	_ = uCmd.MarkFlagRequired("id")
 37}
 38
 39func runUpdateTask(cmd *cobra.Command, _ []string) error {
 40	env, err := shared.Environment(cmd)
 41	if err != nil {
 42		return err
 43	}
 44
 45	sessionDoc, found, err := shared.ActiveSession(cmd, env)
 46	if err != nil {
 47		return err
 48	}
 49	if !found {
 50		return nil
 51	}
 52
 53	taskIDs, err := cmd.Flags().GetStringArray("id")
 54	if err != nil {
 55		return err
 56	}
 57	statuses, err := cmd.Flags().GetStringArray("status")
 58	if err != nil {
 59		return err
 60	}
 61
 62	// Trim and validate IDs
 63	var cleanIDs []string
 64	for _, id := range taskIDs {
 65		cleaned := strings.TrimSpace(id)
 66		if cleaned != "" {
 67			cleanIDs = append(cleanIDs, cleaned)
 68		}
 69	}
 70	taskIDs = cleanIDs
 71
 72	if len(taskIDs) == 0 {
 73		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one task ID with -i/--id.")
 74		return nil
 75	}
 76
 77	// Check if this is a batch status update
 78	isBatchStatusUpdate := len(statuses) > 0 && len(statuses) == len(taskIDs)
 79	isSingleTask := len(taskIDs) == 1
 80
 81	// Title/description only allowed for single task
 82	titleInput, _ := cmd.Flags().GetString("title")
 83	descInput, _ := cmd.Flags().GetString("description")
 84	reasonInput, _ := cmd.Flags().GetString("reason")
 85	titleChanged := cmd.Flags().Changed("title")
 86	descChanged := cmd.Flags().Changed("description")
 87
 88	if (titleChanged || descChanged) && !isSingleTask {
 89		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Title and description updates are only supported for single task updates.")
 90		return nil
 91	}
 92
 93	// Handle batch status updates
 94	if isBatchStatusUpdate && !titleChanged && !descChanged {
 95		return runBatchStatusUpdate(cmd, env, sessionDoc.SID, taskIDs, statuses, reasonInput)
 96	}
 97
 98	// Handle single task update (possibly with title/description)
 99	if !isSingleTask {
100		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Multiple task IDs provided but status count doesn't match. For batch updates, provide equal -i and -s flags.")
101		return nil
102	}
103
104	return runSingleTaskUpdate(cmd, env, sessionDoc.SID, taskIDs[0], statuses, titleInput, descInput, reasonInput, titleChanged, descChanged)
105}
106
107func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskIDs []string, statuses []string, reasonInput string) error {
108	type updatePair struct {
109		id     string
110		status task.Status
111	}
112
113	// Parse and validate all statuses first
114	var pairs []updatePair
115	for i, taskID := range taskIDs {
116		statusStr := strings.TrimSpace(statuses[i])
117		newStatus, err := task.ParseStatus(statusStr)
118		if err != nil {
119			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q for task %s. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr, taskID)
120			return nil
121		}
122		pairs = append(pairs, updatePair{id: taskID, status: newStatus})
123	}
124
125	reason := strings.TrimSpace(reasonInput)
126	// Check if any status requires a reason
127	reasonRequired := false
128	for _, pair := range pairs {
129		if pair.status == task.StatusCancelled || pair.status == task.StatusFailed {
130			reasonRequired = true
131			break
132		}
133	}
134
135	if reasonRequired && reason == "" {
136		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required when changing status to cancelled or failed.")
137		return nil
138	}
139
140	var lastUpdatedAt time.Time
141	var updatedCount int
142
143	err := env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
144		taskTxn := env.TaskStore.WithTxn(txn)
145		eventTxn := env.EventStore.WithTxn(txn)
146		sessionTxn := env.SessionStore.WithTxn(txn)
147
148		for _, pair := range pairs {
149			before, err := taskTxn.Get(sid, pair.id)
150			if err != nil {
151				if errors.Is(err, task.ErrNotFound) {
152					_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", pair.id)
153					continue
154				}
155				return err
156			}
157
158			// Skip if status unchanged
159			if before.Status == pair.status {
160				continue
161			}
162
163			updated, err := taskTxn.Update(sid, pair.id, func(t *task.Task) error {
164				t.Status = pair.status
165				return nil
166			})
167			if err != nil {
168				return err
169			}
170
171			_, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
172			if err != nil {
173				return err
174			}
175
176			lastUpdatedAt = updated.UpdatedAt
177			updatedCount++
178		}
179
180		if updatedCount > 0 {
181			_, err := sessionTxn.TouchAt(sid, lastUpdatedAt)
182			return err
183		}
184
185		return nil
186	})
187	if err != nil {
188		return err
189	}
190
191	if updatedCount == 0 {
192		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No tasks were updated (all tasks either not found or already at target status).")
193		return nil
194	}
195
196	tasks, err := env.LoadTasks(cmd.Context(), sid)
197	if err != nil {
198		return err
199	}
200	_, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks))
201
202	out := cmd.OutOrStdout()
203	_, _ = fmt.Fprintln(out, "")
204	_, _ = fmt.Fprintf(out, "Updated %d task(s).\n", updatedCount)
205
206	// Check if all work is complete
207	allComplete := true
208	pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
209	if err != nil {
210		return err
211	}
212	inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
213	if err != nil {
214		return err
215	}
216	if len(pending) > 0 || len(inProgress) > 0 {
217		allComplete = false
218	}
219
220	if allComplete {
221		_, _ = fmt.Fprintln(out, "No pending tasks remaining. If you've completed the goal, escalate to the operator and ask whether to archive the session (`np a`).")
222	}
223
224	return nil
225}
226
227func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskID string, statuses []string, titleInput, descInput, reasonInput string, titleChanged, descChanged bool) error {
228	current, err := env.TaskStore.Get(cmd.Context(), sid, taskID)
229	if err != nil {
230		if errors.Is(err, task.ErrNotFound) {
231			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID)
232			return nil
233		}
234		return err
235	}
236
237	newTitle := current.Title
238	if titleChanged {
239		newTitle = strings.TrimSpace(titleInput)
240		if newTitle == "" {
241			_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.")
242			return nil
243		}
244	}
245
246	newDescription := current.Description
247	if descChanged {
248		newDescription = strings.TrimSpace(descInput)
249	}
250
251	var newStatus task.Status
252	statusChanged := len(statuses) > 0
253	if statusChanged {
254		statusStr := strings.TrimSpace(statuses[0])
255		newStatus, err = task.ParseStatus(statusStr)
256		if err != nil {
257			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr)
258			return nil
259		}
260	} else {
261		newStatus = current.Status
262	}
263
264	if !titleChanged && !descChanged && !statusChanged {
265		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title, --description, or --status to update the task.")
266		return nil
267	}
268
269	reason := strings.TrimSpace(reasonInput)
270	reasonRequired := titleChanged || descChanged ||
271		(statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed))
272
273	if reasonRequired && reason == "" {
274		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed.")
275		return nil
276	}
277
278	contentChanged := titleChanged || descChanged
279	statusOnlyChanged := statusChanged && !contentChanged
280
281	var updated task.Task
282	err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
283		taskTxn := env.TaskStore.WithTxn(txn)
284		eventTxn := env.EventStore.WithTxn(txn)
285		sessionTxn := env.SessionStore.WithTxn(txn)
286
287		before, err := taskTxn.Get(sid, taskID)
288		if err != nil {
289			return err
290		}
291
292		updated, err = taskTxn.Update(sid, taskID, func(t *task.Task) error {
293			if titleChanged {
294				t.Title = newTitle
295			}
296			if descChanged {
297				t.Description = newDescription
298			}
299			if statusChanged {
300				t.Status = newStatus
301			}
302			return nil
303		})
304		if err != nil {
305			return err
306		}
307
308		if contentChanged {
309			_, err = eventTxn.Append(sid, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
310			if err != nil {
311				return err
312			}
313		}
314
315		if statusChanged {
316			_, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
317			if err != nil {
318				return err
319			}
320		}
321
322		_, err = sessionTxn.TouchAt(sid, updated.UpdatedAt)
323		return err
324	})
325	if err != nil {
326		return err
327	}
328
329	tasks, err := env.LoadTasks(cmd.Context(), sid)
330	if err != nil {
331		return err
332	}
333	_, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks))
334
335	out := cmd.OutOrStdout()
336	_, _ = fmt.Fprintln(out, "")
337	if statusOnlyChanged && newStatus == task.StatusCompleted {
338		pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
339		if err != nil {
340			return err
341		}
342		inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
343		if err != nil {
344			return err
345		}
346
347		if len(pending) == 0 && len(inProgress) == 0 {
348			_, _ = fmt.Fprintln(out, "No pending tasks remaining. If you've completed the goal, escalate to the operator and ask whether to archive the session (`np a`).")
349		}
350	} else {
351		_, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")
352	}
353
354	return nil
355}