package server import ( "encoding/json" "net/http" "strconv" "strings" "github.com/Autumn-27/artex/db" ) // Human CRUD endpoints for the overview "Goal Management" card. They write the same goal nodes // as the agent-side set_goals tool, but the entry point here is a human editing in the UI; // add/edit reuse the "revive task" logic (admitTask resume: terminal→running, lift the pause, // queue when needed), delete does not revive (product decision). Each handler uses beginTaskOperation/decInflight to avoid racing task deletion (matching intent CRUD). // listGoals returns all goals of this task (text/vulnclass/state split out) for the goal-management card to render. func (s *Server) listGoals(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) if !ok { writeErr(w, 404, "task not found") return } goals, err := t.Store.ListByKind(db.KindGoal, 10000) if err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{"goals": goalDTOs(goals)}) } // addGoal manually adds one goal: persist it (under the task root's spawns edge) → record // a "goal added" trigger to wake the planner → revive the task so it re-judges completion against the new goal. func (s *Server) addGoal(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) if !ok { writeErr(w, 404, "task not found") return } if !s.engine.beginTaskOperation(t.ID) { writeErr(w, 409, "Task is being deleted; cannot add goal") return } defer s.engine.decInflight(t.ID) var body struct { Text string `json:"text"` VulnClass string `json:"vulnclass"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, 400, "invalid JSON") return } text := strings.TrimSpace(body.Text) if text == "" { writeErr(w, 400, "Goal text cannot be empty") return } payload := map[string]any{"text": text} if vc := strings.TrimSpace(body.VulnClass); vc != "" { payload["vulnclass"] = vc } id, err := t.Store.AddGoal(payload, "human") if err != nil { writeErr(w, 500, err.Error()) return } if of, _ := t.Store.OriginFactID(); of > 0 && id > 0 { _ = t.Store.Link(of, db.RelSpawns, id) // goal descends from the task root (origin fact) } t.NotifyGoal([]string{text}) // record a "human added a goal: …" trigger and wake the planner s.reviveTask(t) // pull a completed/paused task back to running node, _ := t.Store.GetNode(id) if node == nil { writeErr(w, 500, "failed to read the goal back after writing") return } writeJSON(w, 200, goalDTO(node)) } // editGoal manually edits a goal's text (and vulnclass): update the store → record a "user // changed the goal from old to new" trigger to wake the planner → revive the task so it adjusts direction. func (s *Server) editGoal(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) if !ok { writeErr(w, 404, "task not found") return } if !s.engine.beginTaskOperation(t.ID) { writeErr(w, 409, "Task is being deleted; cannot edit goal") return } defer s.engine.decInflight(t.ID) gid, err := strconv.ParseInt(r.PathValue("gid"), 10, 64) if err != nil || gid <= 0 { writeErr(w, 400, "bad goal id") return } var body struct { Text string `json:"text"` VulnClass string `json:"vulnclass"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, 400, "invalid JSON") return } text := strings.TrimSpace(body.Text) if text == "" { writeErr(w, 400, "Goal text cannot be empty") return } node, err := t.Store.GetNode(gid) if err != nil { writeErr(w, 500, err.Error()) return } if node == nil || node.Kind != db.KindGoal { writeErr(w, 404, "goal not found") return } oldText := goalDTO(node).Text if err := t.Store.UpdateGoalPayload(gid, text, strings.TrimSpace(body.VulnClass)); err != nil { writeErr(w, 500, err.Error()) return } t.NotifyGoalEdited(oldText, text) // record a "human changed the goal from old to new" trigger and wake the planner s.reviveTask(t) // same as add: revive the task to re-judge the new goal updated, _ := t.Store.GetNode(gid) if updated == nil { writeErr(w, 500, "failed to read the goal back after update") return } writeJSON(w, 200, goalDTO(updated)) } // deleteGoal manually deletes one goal (hard delete, cascades edges/anchors): delete from // the store → record a "user deleted goal X" trigger to wake the planner to re-judge the remaining goals. Per product decision, deleting does [not] revive the task. func (s *Server) deleteGoal(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) if !ok { writeErr(w, 404, "task not found") return } if !s.engine.beginTaskOperation(t.ID) { writeErr(w, 409, "Task is being deleted; cannot delete goal") return } defer s.engine.decInflight(t.ID) gid, err := strconv.ParseInt(r.PathValue("gid"), 10, 64) if err != nil || gid <= 0 { writeErr(w, 400, "bad goal id") return } node, err := t.Store.GetNode(gid) if err != nil { writeErr(w, 500, err.Error()) return } if node == nil || node.Kind != db.KindGoal { writeErr(w, 404, "goal not found") return } text := goalDTO(node).Text if err := t.Store.DeleteGoal(gid); err != nil { writeErr(w, 500, err.Error()) return } t.NotifyGoalDeleted(text) // record a "human deleted the goal: …" trigger and wake the planner (task not revived) writeJSON(w, 200, map[string]bool{"ok": true}) }