feat(goals): 总览新增目标管理(增/删/改),同步通知 planner 并复活任务

- 总览页新增「目标管理」卡片,支持人工查看/新增/修改/删除目标
- 新增/修改:通知 planner(新增了目标/由 old 变为 new)并复活任务
- 删除:硬删除 goal 节点(级联删边/锚点),通知 planner,不复活任务
- 无 schema 变更:目标仍存 exploration_nodes(kind='goal'),仅加查询方法
This commit is contained in:
Autumn-27 committed 2026-08-23 11:41:53 -04:00
1 parent 025e1ce445
commit d28ecd67fe
9 files changed
+506 -2

No files matched your search

+35
View File
@@ -173,6 +173,41 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO {
return d
}
// GoalDTO is a goal node with its payload unpacked into text/vulnclass — the shape
// the 总览「目标管理」UI works with (vs TaskNodeDTO which carries raw payload JSON).
type GoalDTO struct {
ID string `json:"id"`
Text string `json:"text"`
VulnClass string `json:"vulnclass,omitempty"`
State string `json:"state"`
Origin string `json:"origin,omitempty"`
TS string `json:"ts"`
}
func goalDTO(n *db.Node) GoalDTO {
var p struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
_ = json.Unmarshal(n.Payload, &p)
return GoalDTO{
ID: i64s(n.ID),
Text: p.Text,
VulnClass: p.VulnClass,
State: n.State,
Origin: n.Origin,
TS: rfc3339(n.CreatedAt),
}
}
func goalDTOs(in []*db.Node) []GoalDTO {
out := make([]GoalDTO, 0, len(in))
for _, n := range in {
out = append(out, goalDTO(n))
}
return out
}
func taskNodeDTOs(in []*db.Node) []TaskNodeDTO {
out := make([]TaskNodeDTO, 0, len(in))
for _, n := range in {
+172
View File
@@ -0,0 +1,172 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/Autumn-27/artex/db"
)
// 总览「目标管理」的人工 CRUD 接口。与 agent 侧的 set_goals 工具写同一批 goal 节点,
// 但入口是人类在 UI 上直接增删改;新增/修改后复用「复活任务」逻辑(admitTask resume:
// 终态→running、解除暂停、必要时排队),删除不复活(按产品决策)。每个变更 handler 都走
// beginTaskOperation/decInflight,避免与任务删除竞态(与意图 CRUD 一致)。
// listGoals 返回本任务的全部目标(text/vulnclass/state 拆好),供目标管理卡片渲染。
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 人工新增一个目标:落库(挂到任务根 spawns 下)→ 记一条「新增了目标」触发唤醒
// planner → 复活任务,让规划者据新目标重判是否达成。
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, "任务正在删除,无法新增目标")
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, "目标内容不能为空")
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}) // 记「人新增了目标:…」触发并唤醒 planner
s.reviveTask(t) // 把已完成/暂停的任务拉回运行态继续跑
node, _ := t.Store.GetNode(id)
if node == nil {
writeErr(w, 500, "目标写入后读取失败")
return
}
writeJSON(w, 200, goalDTO(node))
}
// editGoal 人工修改一个目标文本(及 vulnclass):改库 → 记「用户修改了目标由 old 变为 new」
// 触发唤醒 planner → 复活任务,让规划者据新目标调整方向。
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, "任务正在删除,无法修改目标")
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, "目标内容不能为空")
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, "目标不存在")
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) // 记「人修改了目标由 old 变为 new」触发并唤醒 planner
s.reviveTask(t) // 与新增一致:复活任务据新目标重判
updated, _ := t.Store.GetNode(gid)
if updated == nil {
writeErr(w, 500, "目标更新后读取失败")
return
}
writeJSON(w, 200, goalDTO(updated))
}
// deleteGoal 人工删除一个目标(硬删除,级联删边/锚点):删库 → 记「用户删除了该目标 X」
// 触发唤醒 planner 据此重判剩余目标。按产品决策,删除【不】复活任务。
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, "任务正在删除,无法删除目标")
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, "目标不存在")
return
}
text := goalDTO(node).Text
if err := t.Store.DeleteGoal(gid); err != nil {
writeErr(w, 500, err.Error())
return
}
t.NotifyGoalDeleted(text) // 记「人删除了该目标:…」触发并唤醒 planner(不复活任务)
writeJSON(w, 200, map[string]bool{"ok": true})
}
+28
View File
@@ -1488,6 +1488,34 @@ func (t *Task) NotifyGoal(texts []string) {
t.Notify()
}
// NotifyGoalDeleted records that the human deleted a goal (via 总览的目标管理), then
// wakes the planner so the next round spells out which goal was removed. The event
// survives an early-returning terminal round (drain happens after the gate).
func (t *Task) NotifyGoalDeleted(text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
t.trigMu.Lock()
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "goal_deleted", Detail: text})
t.trigMu.Unlock()
t.Notify()
}
// NotifyGoalEdited records that the human edited a goal (via 总览的目标管理), then wakes
// the planner so the next round spells out the old→new change. The event survives an
// early-returning terminal round (drain happens after the gate).
func (t *Task) NotifyGoalEdited(oldText, newText string) {
oldText, newText = strings.TrimSpace(oldText), strings.TrimSpace(newText)
if newText == "" {
return
}
t.trigMu.Lock()
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "goal_edited", OldGoal: oldText, NewGoal: newText})
t.trigMu.Unlock()
t.Notify()
}
// NotifyCancelled records that the human deleted intentID (reason = 删除原因), then
// wakes the planner so the next round spells out which intent was removed and why.
// The intent is stopped (not deleted); the reason is attached to it as a fact.
+4
View File
@@ -665,6 +665,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete)
mux.HandleFunc("GET /api/tasks/{id}/goals", s.listGoals) // 目标管理:列出本任务全部目标
mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务)
mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务)
mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活)
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles)
mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler)