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}