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 obtain confirmation before archiving the session with `np a`.")
220 } else {
221 _, _ = fmt.Fprintln(out, "Continue working through remaining tasks. Use `np t u -i <id1> -s <status1> -i <id2> -s <status2>` to update multiple tasks at once.")
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 if _, err := shared.PrintPlan(cmd, env, sid); err != nil {
330 return err
331 }
332
333 out := cmd.OutOrStdout()
334 _, _ = fmt.Fprintln(out, "")
335 if statusOnlyChanged && newStatus == task.StatusCompleted {
336 pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
337 if err != nil {
338 return err
339 }
340 inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
341 if err != nil {
342 return err
343 }
344
345 if len(pending) == 0 && len(inProgress) == 0 {
346 _, _ = fmt.Fprintln(out, "No pending tasks remaining. If you've completed the goal, escalate to the operator and obtain confirmation before archiving the session with `np a`.")
347 } else {
348 _, _ = fmt.Fprintln(out, "Task marked completed. Continue working through remaining tasks. Use `np t u -i <id1> -s <status1> -i <id2> -s <status2>` to update multiple at once.")
349 }
350 } else {
351 _, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")
352 }
353
354 return nil
355}