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	if _, err := shared.PrintPlan(cmd, env, sid); err != nil {
197		return err
198	}
199
200	out := cmd.OutOrStdout()
201	_, _ = fmt.Fprintln(out, "")
202	_, _ = fmt.Fprintf(out, "Updated %d task(s).\n", updatedCount)
203
204	// Check if all work is complete
205	allComplete := true
206	pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
207	if err != nil {
208		return err
209	}
210	inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
211	if err != nil {
212		return err
213	}
214	if len(pending) > 0 || len(inProgress) > 0 {
215		allComplete = false
216	}
217
218	if allComplete {
219		_, _ = 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`).")
220	}
221
222	return nil
223}
224
225func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskID string, statuses []string, titleInput, descInput, reasonInput string, titleChanged, descChanged bool) error {
226	current, err := env.TaskStore.Get(cmd.Context(), sid, taskID)
227	if err != nil {
228		if errors.Is(err, task.ErrNotFound) {
229			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID)
230			return nil
231		}
232		return err
233	}
234
235	newTitle := current.Title
236	if titleChanged {
237		newTitle = strings.TrimSpace(titleInput)
238		if newTitle == "" {
239			_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.")
240			return nil
241		}
242	}
243
244	newDescription := current.Description
245	if descChanged {
246		newDescription = strings.TrimSpace(descInput)
247	}
248
249	var newStatus task.Status
250	statusChanged := len(statuses) > 0
251	if statusChanged {
252		statusStr := strings.TrimSpace(statuses[0])
253		newStatus, err = task.ParseStatus(statusStr)
254		if err != nil {
255			_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr)
256			return nil
257		}
258	} else {
259		newStatus = current.Status
260	}
261
262	if !titleChanged && !descChanged && !statusChanged {
263		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title, --description, or --status to update the task.")
264		return nil
265	}
266
267	reason := strings.TrimSpace(reasonInput)
268	reasonRequired := titleChanged || descChanged ||
269		(statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed))
270
271	if reasonRequired && reason == "" {
272		_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed.")
273		return nil
274	}
275
276	contentChanged := titleChanged || descChanged
277	statusOnlyChanged := statusChanged && !contentChanged
278
279	var updated task.Task
280	err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
281		taskTxn := env.TaskStore.WithTxn(txn)
282		eventTxn := env.EventStore.WithTxn(txn)
283		sessionTxn := env.SessionStore.WithTxn(txn)
284
285		before, err := taskTxn.Get(sid, taskID)
286		if err != nil {
287			return err
288		}
289
290		updated, err = taskTxn.Update(sid, taskID, func(t *task.Task) error {
291			if titleChanged {
292				t.Title = newTitle
293			}
294			if descChanged {
295				t.Description = newDescription
296			}
297			if statusChanged {
298				t.Status = newStatus
299			}
300			return nil
301		})
302		if err != nil {
303			return err
304		}
305
306		if contentChanged {
307			_, err = eventTxn.Append(sid, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
308			if err != nil {
309				return err
310			}
311		}
312
313		if statusChanged {
314			_, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
315			if err != nil {
316				return err
317			}
318		}
319
320		_, err = sessionTxn.TouchAt(sid, updated.UpdatedAt)
321		return err
322	})
323	if err != nil {
324		return err
325	}
326
327	if _, err := shared.PrintPlan(cmd, env, sid); err != nil {
328		return err
329	}
330
331	out := cmd.OutOrStdout()
332	_, _ = fmt.Fprintln(out, "")
333	if statusOnlyChanged && newStatus == task.StatusCompleted {
334		pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
335		if err != nil {
336			return err
337		}
338		inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
339		if err != nil {
340			return err
341		}
342
343		if len(pending) == 0 && len(inProgress) == 0 {
344			_, _ = 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`).")
345		}
346	} else {
347		_, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")
348	}
349
350	return nil
351}