package server import ( "encoding/json" "net/http" "strconv" "strings" "github.com/Autumn-27/artex/db" ) // Human CRUD endpoints for the overview "Constraint Management" card + injection-scope // switch parsing. Operational constraints (allow/deny) share the task_constraints table // with the agent-side set_constraints tool; here a human edits them in the UI. Constraints // are prompt context only — edits do [not] notify the planner; the next planning round reads the table anyway (product decision). Each handler uses beginTaskOperation/decInflight to avoid racing task deletion (matching goal/intent CRUD). // Settings keys for the injection-scope switches, both on by default (GetBool's second arg = true). const ( settingConstraintsInjectPlanner = "constraints_inject_planner" settingConstraintsInjectWorker = "constraints_inject_worker" ) // constraintInjectPlanner / constraintInjectWorker report whether operational constraints // are injected into the given agent's system prompt (default on). Passed to planner/worker as resolvers: read every round, so toggling takes effect immediately. func (s *Server) constraintInjectPlanner() bool { return s.m.pg.GetBool(settingConstraintsInjectPlanner, true) } func (s *Server) constraintInjectWorker() bool { return s.m.pg.GetBool(settingConstraintsInjectWorker, true) } // listConstraints returns all operational constraints of this task (allow first, then deny). func (s *Server) listConstraints(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) if !ok { writeErr(w, 404, "task not found") return } rows, err := t.Store.ListConstraints() if err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{"constraints": constraintDTOs(rows)}) } // addConstraint manually adds one operational constraint (kind=allow|deny). Does not notify the planner. func (s *Server) addConstraint(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 constraint") return } defer s.engine.decInflight(t.ID) var body struct { Text string `json:"text"` Kind string `json:"kind"` } 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, "Constraint text cannot be empty") return } kind := normalizeConstraintKind(body.Kind) if kind == "" { writeErr(w, 400, "kind must be allow or deny") return } id, err := t.Store.AddConstraint(kind, text, "human") if err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, ConstraintDTO{ID: strconv.FormatInt(id, 10), Kind: kind, Text: text, Origin: "human"}) } // editConstraint manually edits one constraint (kind + text). Does not notify the planner. func (s *Server) editConstraint(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 constraint") return } defer s.engine.decInflight(t.ID) cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64) if err != nil || cid <= 0 { writeErr(w, 400, "bad constraint id") return } var body struct { Text string `json:"text"` Kind string `json:"kind"` } 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, "Constraint text cannot be empty") return } kind := normalizeConstraintKind(body.Kind) if kind == "" { writeErr(w, 400, "kind must be allow or deny") return } if err := t.Store.UpdateConstraint(cid, kind, text); err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, ConstraintDTO{ID: strconv.FormatInt(cid, 10), Kind: kind, Text: text}) } // deleteConstraint manually deletes one constraint. Does not notify the planner. func (s *Server) deleteConstraint(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 constraint") return } defer s.engine.decInflight(t.ID) cid, err := strconv.ParseInt(r.PathValue("cid"), 10, 64) if err != nil || cid <= 0 { writeErr(w, 400, "bad constraint id") return } if err := t.Store.DeleteConstraint(cid); err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]bool{"ok": true}) } // normalizeConstraintKind lowercases + validates the kind; "" on invalid. func normalizeConstraintKind(k string) string { k = strings.TrimSpace(strings.ToLower(k)) if k == "allow" || k == "deny" { return k } return "" } // constraintDTOs converts db rows to the frontend shape. func constraintDTOs(in []db.Constraint) []ConstraintDTO { out := make([]ConstraintDTO, 0, len(in)) for _, c := range in { out = append(out, ConstraintDTO{ ID: strconv.FormatInt(c.ID, 10), Kind: c.Kind, Text: c.Text, Origin: c.Origin, TS: rfc3339(c.CreatedAt), }) } return out }