@@ -8,8 +8,10 @@ import (
"errors"
"fmt"
"strings"
+ "time"
"git.secluded.site/np/cmd/shared"
+ "git.secluded.site/np/internal/cli"
"git.secluded.site/np/internal/db"
"git.secluded.site/np/internal/event"
"git.secluded.site/np/internal/task"
@@ -18,18 +20,18 @@ import (
var uCmd = &cobra.Command{
Use: "u",
- Short: "Update task",
- Long: `Update a task's status, title, or description`,
+ Short: "Update tasks",
+ Long: `Update one or more tasks' status, title, or description`,
RunE: runUpdateTask,
}
func init() {
TCmd.AddCommand(uCmd)
- uCmd.Flags().StringP("id", "i", "", "Task ID (required)")
- uCmd.Flags().StringP("status", "s", "", "New task status")
- uCmd.Flags().StringP("title", "t", "", "New task title")
- uCmd.Flags().StringP("description", "d", "", "New task description")
+ uCmd.Flags().StringArrayP("id", "i", []string{}, "Task ID (required, repeatable for batch updates)")
+ uCmd.Flags().StringArrayP("status", "s", []string{}, "New task status (repeatable, must match number of IDs)")
+ uCmd.Flags().StringP("title", "t", "", "New task title (only for single task updates)")
+ uCmd.Flags().StringP("description", "d", "", "New task description (only for single task updates)")
uCmd.Flags().StringP("reason", "r", "", "Reason for update (required for title/description changes and status=cancelled/failed)")
_ = uCmd.MarkFlagRequired("id")
}
@@ -48,40 +50,191 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
return nil
}
- taskID, err := cmd.Flags().GetString("id")
+ taskIDs, err := cmd.Flags().GetStringArray("id")
if err != nil {
return err
}
- taskID = strings.TrimSpace(taskID)
-
- current, err := env.TaskStore.Get(cmd.Context(), sessionDoc.SID, taskID)
+ statuses, err := cmd.Flags().GetStringArray("status")
if err != nil {
- if errors.Is(err, task.ErrNotFound) {
- _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID)
+ return err
+ }
+
+ // Trim and validate IDs
+ var cleanIDs []string
+ for _, id := range taskIDs {
+ cleaned := strings.TrimSpace(id)
+ if cleaned != "" {
+ cleanIDs = append(cleanIDs, cleaned)
+ }
+ }
+ taskIDs = cleanIDs
+
+ if len(taskIDs) == 0 {
+ _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one task ID with -i/--id.")
+ return nil
+ }
+
+ // Check if this is a batch status update
+ isBatchStatusUpdate := len(statuses) > 0 && len(statuses) == len(taskIDs)
+ isSingleTask := len(taskIDs) == 1
+
+ // Title/description only allowed for single task
+ titleInput, _ := cmd.Flags().GetString("title")
+ descInput, _ := cmd.Flags().GetString("description")
+ reasonInput, _ := cmd.Flags().GetString("reason")
+ titleChanged := cmd.Flags().Changed("title")
+ descChanged := cmd.Flags().Changed("description")
+
+ if (titleChanged || descChanged) && !isSingleTask {
+ _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Title and description updates are only supported for single task updates.")
+ return nil
+ }
+
+ // Handle batch status updates
+ if isBatchStatusUpdate && !titleChanged && !descChanged {
+ return runBatchStatusUpdate(cmd, env, sessionDoc.SID, taskIDs, statuses, reasonInput)
+ }
+
+ // Handle single task update (possibly with title/description)
+ if !isSingleTask {
+ _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Multiple task IDs provided but status count doesn't match. For batch updates, provide equal -i and -s flags.")
+ return nil
+ }
+
+ return runSingleTaskUpdate(cmd, env, sessionDoc.SID, taskIDs[0], statuses, titleInput, descInput, reasonInput, titleChanged, descChanged)
+}
+
+func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskIDs []string, statuses []string, reasonInput string) error {
+ type updatePair struct {
+ id string
+ status task.Status
+ }
+
+ // Parse and validate all statuses first
+ var pairs []updatePair
+ for i, taskID := range taskIDs {
+ statusStr := strings.TrimSpace(statuses[i])
+ newStatus, err := task.ParseStatus(statusStr)
+ if err != nil {
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q for task %s. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr, taskID)
return nil
}
- return err
+ pairs = append(pairs, updatePair{id: taskID, status: newStatus})
+ }
+
+ reason := strings.TrimSpace(reasonInput)
+ // Check if any status requires a reason
+ reasonRequired := false
+ for _, pair := range pairs {
+ if pair.status == task.StatusCancelled || pair.status == task.StatusFailed {
+ reasonRequired = true
+ break
+ }
}
- titleInput, err := cmd.Flags().GetString("title")
+ if reasonRequired && reason == "" {
+ _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required when changing status to cancelled or failed.")
+ return nil
+ }
+
+ var lastUpdatedAt time.Time
+ var updatedCount int
+
+ err := env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
+ taskTxn := env.TaskStore.WithTxn(txn)
+ eventTxn := env.EventStore.WithTxn(txn)
+ sessionTxn := env.SessionStore.WithTxn(txn)
+
+ for _, pair := range pairs {
+ before, err := taskTxn.Get(sid, pair.id)
+ if err != nil {
+ if errors.Is(err, task.ErrNotFound) {
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", pair.id)
+ continue
+ }
+ return err
+ }
+
+ // Skip if status unchanged
+ if before.Status == pair.status {
+ continue
+ }
+
+ updated, err := taskTxn.Update(sid, pair.id, func(t *task.Task) error {
+ t.Status = pair.status
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ _, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
+ if err != nil {
+ return err
+ }
+
+ lastUpdatedAt = updated.UpdatedAt
+ updatedCount++
+ }
+
+ if updatedCount > 0 {
+ _, err := sessionTxn.TouchAt(sid, lastUpdatedAt)
+ return err
+ }
+
+ return nil
+ })
if err != nil {
return err
}
- descInput, err := cmd.Flags().GetString("description")
+
+ if updatedCount == 0 {
+ _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No tasks were updated (all tasks either not found or already at target status).")
+ return nil
+ }
+
+ if _, err := shared.PrintPlan(cmd, env, sid); err != nil {
+ return err
+ }
+
+ out := cmd.OutOrStdout()
+ _, _ = fmt.Fprintln(out, "")
+ _, _ = fmt.Fprintf(out, "Updated %d task(s).\n", updatedCount)
+
+ // Check if all work is complete
+ allComplete := true
+ pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
if err != nil {
return err
}
- statusInput, err := cmd.Flags().GetString("status")
+ inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
if err != nil {
return err
}
- reasonInput, err := cmd.Flags().GetString("reason")
+ if len(pending) > 0 || len(inProgress) > 0 {
+ allComplete = false
+ }
+
+ if allComplete {
+ _, _ = 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`.")
+ } else {
+ _, _ = 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.")
+ }
+
+ return nil
+}
+
+func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskID string, statuses []string, titleInput, descInput, reasonInput string, titleChanged, descChanged bool) error {
+ current, err := env.TaskStore.Get(cmd.Context(), sid, taskID)
if err != nil {
+ if errors.Is(err, task.ErrNotFound) {
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID)
+ return nil
+ }
return err
}
newTitle := current.Title
- titleChanged := cmd.Flags().Changed("title")
if titleChanged {
newTitle = strings.TrimSpace(titleInput)
if newTitle == "" {
@@ -91,30 +244,30 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
}
newDescription := current.Description
- descriptionChanged := cmd.Flags().Changed("description")
- if descriptionChanged {
+ if descChanged {
newDescription = strings.TrimSpace(descInput)
}
var newStatus task.Status
- statusChanged := cmd.Flags().Changed("status")
+ statusChanged := len(statuses) > 0
if statusChanged {
- newStatus, err = task.ParseStatus(statusInput)
+ statusStr := strings.TrimSpace(statuses[0])
+ newStatus, err = task.ParseStatus(statusStr)
if err != nil {
- _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusInput)
+ _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr)
return nil
}
} else {
newStatus = current.Status
}
- if !titleChanged && !descriptionChanged && !statusChanged {
+ if !titleChanged && !descChanged && !statusChanged {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title, --description, or --status to update the task.")
return nil
}
reason := strings.TrimSpace(reasonInput)
- reasonRequired := titleChanged || descriptionChanged ||
+ reasonRequired := titleChanged || descChanged ||
(statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed))
if reasonRequired && reason == "" {
@@ -122,7 +275,7 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
return nil
}
- contentChanged := titleChanged || descriptionChanged
+ contentChanged := titleChanged || descChanged
statusOnlyChanged := statusChanged && !contentChanged
var updated task.Task
@@ -131,16 +284,16 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
eventTxn := env.EventStore.WithTxn(txn)
sessionTxn := env.SessionStore.WithTxn(txn)
- before, err := taskTxn.Get(sessionDoc.SID, taskID)
+ before, err := taskTxn.Get(sid, taskID)
if err != nil {
return err
}
- updated, err = taskTxn.Update(sessionDoc.SID, taskID, func(t *task.Task) error {
+ updated, err = taskTxn.Update(sid, taskID, func(t *task.Task) error {
if titleChanged {
t.Title = newTitle
}
- if descriptionChanged {
+ if descChanged {
t.Description = newDescription
}
if statusChanged {
@@ -153,38 +306,38 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
}
if contentChanged {
- _, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
+ _, err = eventTxn.Append(sid, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
if err != nil {
return err
}
}
if statusChanged {
- _, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
+ _, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
if err != nil {
return err
}
}
- _, err = sessionTxn.TouchAt(sessionDoc.SID, updated.UpdatedAt)
+ _, err = sessionTxn.TouchAt(sid, updated.UpdatedAt)
return err
})
if err != nil {
return err
}
- if _, err := shared.PrintPlan(cmd, env, sessionDoc.SID); err != nil {
+ if _, err := shared.PrintPlan(cmd, env, sid); err != nil {
return err
}
out := cmd.OutOrStdout()
_, _ = fmt.Fprintln(out, "")
if statusOnlyChanged && newStatus == task.StatusCompleted {
- pending, err := env.TaskStore.ListByStatus(cmd.Context(), sessionDoc.SID, task.StatusPending)
+ pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
if err != nil {
return err
}
- inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sessionDoc.SID, task.StatusInProgress)
+ inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
if err != nil {
return err
}
@@ -192,7 +345,7 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error {
if len(pending) == 0 && len(inProgress) == 0 {
_, _ = 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`.")
} else {
- _, _ = fmt.Fprintln(out, "Task marked completed. Continue working through remaining tasks with `np t u`.")
+ _, _ = 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.")
}
} else {
_, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.")