Merge tag 'v0.3.4'
This commit is contained in:
commit
48e85b33d6
33 files changed
+1456
-120
No files matched your search
@@ -230,6 +230,13 @@ func (s *Server) addCompanyScope(w http.ResponseWriter, r *http.Request) {
|
||||
if len(errs) > 0 {
|
||||
out["errors"] = errs
|
||||
}
|
||||
// Reported separately from errors: this is pre-existing bad data, not a fault
|
||||
// in the submitted rules, but the operator still has to see it or those assets
|
||||
// look like the IP/CIDR rules simply never match. A failure to build the
|
||||
// warning must not fail the scope write that already committed.
|
||||
if warning, err := cs.MalformedIPAssetWarning(); err == nil && warning != "" {
|
||||
out["warnings"] = []string{warning}
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// TestChatUnavailableReasonDistinguishesStates pins the operator-facing message
|
||||
// to the actual configuration state: nothing configured vs. configured-but-not-
|
||||
// activated must read differently so the user knows what to do.
|
||||
func TestChatUnavailableReasonDistinguishesStates(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
|
||||
// Start from a clean profile table; other tests in the shared DB may have left rows.
|
||||
existing, _ := m.pg.ListProfiles()
|
||||
for _, p := range existing {
|
||||
if p.IsDefault {
|
||||
// The active profile cannot be deleted directly; clear the flag first.
|
||||
_, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, p.ID)
|
||||
}
|
||||
_ = m.pg.DeleteProfile(p.ID)
|
||||
}
|
||||
|
||||
if reason := s.chatUnavailableReason(); !strings.Contains(reason, "尚未配置") {
|
||||
t.Fatalf("no-profile reason=%q, want 尚未配置", reason)
|
||||
}
|
||||
|
||||
id, err := m.pg.SaveProfile(&db.LLMProfile{Name: "p1", Format: "anthropic", Model: "claude-x", APIKey: "sk-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, id)
|
||||
_ = m.pg.DeleteProfile(id)
|
||||
})
|
||||
|
||||
// Profile exists but is not activated.
|
||||
if reason := s.chatUnavailableReason(); !strings.Contains(reason, "没有已激活") {
|
||||
t.Fatalf("inactive reason=%q, want 没有已激活", reason)
|
||||
}
|
||||
|
||||
// Once activated, the reason no longer claims a missing/inactive config.
|
||||
if err := m.pg.SetActiveProfile(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reason := s.chatUnavailableReason(); strings.Contains(reason, "尚未配置") || strings.Contains(reason, "没有已激活") {
|
||||
t.Fatalf("active reason=%q should not report missing/inactive", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveChatAgentHonoursConversationProfile is the regression guard for the
|
||||
// reported bug: a conversation that picked a valid profile must resolve a chat
|
||||
// agent even when NO global profile is active, so the send precheck stops
|
||||
// rejecting it with "LLM 未配置".
|
||||
func TestResolveChatAgentHonoursConversationProfile(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
|
||||
// Force the global fallback to nil so a non-nil result can ONLY come from the
|
||||
// conversation's own profile — this is exactly the situation the user hit
|
||||
// (no usable global config) and isolates the fix from the test host's env.
|
||||
s.cfgMu.Lock()
|
||||
s.chatAgent = nil
|
||||
s.cfgMu.Unlock()
|
||||
|
||||
id, err := m.pg.SaveProfile(&db.LLMProfile{Name: "conv-pick", Format: "anthropic", Model: "claude-x", APIKey: "sk-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.pg.DeleteProfile(id) })
|
||||
|
||||
// A conversation that pinned this profile resolves a chat agent despite the
|
||||
// nil global fallback; without the fix the send precheck rejected it outright.
|
||||
conv := &db.Conversation{AgentKey: "mainagent", LLMProfileID: &id}
|
||||
if s.resolveChatAgent(conv) == nil {
|
||||
t.Fatalf("resolveChatAgent(conversation with valid profile) = nil; global fallback wrongly gates the pick")
|
||||
}
|
||||
|
||||
// With neither a pick nor a global fallback there is genuinely nothing to run.
|
||||
if s.resolveChatAgent(&db.Conversation{AgentKey: "mainagent"}) != nil {
|
||||
t.Fatalf("resolveChatAgent with neither pick nor global config should be nil")
|
||||
}
|
||||
}
|
||||
@@ -315,9 +315,12 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques
|
||||
writeErr(w, 400, "消息不能为空")
|
||||
return
|
||||
}
|
||||
ca := s.chatAgentRef()
|
||||
if ca == nil {
|
||||
writeErr(w, 503, "LLM 未配置,无法对话")
|
||||
// Same resolution as the background runner: honour the agent binding / this
|
||||
// conversation's chosen profile before falling back to the global config, so a
|
||||
// conversation that picked a valid LLM is not rejected just because no global
|
||||
// config is active.
|
||||
if s.resolveChatAgent(c) == nil {
|
||||
writeErr(w, 503, s.chatUnavailableReason())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,12 +393,7 @@ func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
|
||||
s.chatMu.Unlock()
|
||||
}()
|
||||
// precedence: the conversation agent's own binding → this conversation's pin → global.
|
||||
ca := s.chatAgentRef()
|
||||
if eff := s.effectiveProfileForAgent(c.AgentKey, c.LLMProfileID); eff != nil {
|
||||
if pa := s.chatAgentForProfile(*eff); pa != nil {
|
||||
ca = pa
|
||||
}
|
||||
}
|
||||
ca := s.resolveChatAgent(c)
|
||||
if ca == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -792,6 +792,24 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
if e.isSettling(t.ID) {
|
||||
return
|
||||
}
|
||||
// goalless(人工直投)分支:任务已无 open 目标时 planner 不跑——跑了会重判
|
||||
// met→cancelExec 杀掉用户经主 agent 直投的意图。是否结束改由 frontier 决定:
|
||||
// 还有 open/running 意图 → 保持 running、静默等待;意图已全部跑干 → 落 done。
|
||||
// 整段纯 Go、不触发任何 LLM 调用,也不打规划轮 marker。
|
||||
if open, err := t.Store.HasOpenGoal(); err == nil && !open {
|
||||
t.drainTriggers() // 丢弃累积的 done/finding 触发,避免 goalless 长会话里无界增长
|
||||
if active, err := t.Store.HasActiveIntent(); err == nil && !active {
|
||||
// frontier 抽干且无在跑意图 → 收尾。用 Guarded 版做 CAS,避免踩到并发的
|
||||
// pause/delete/超时收尾的状态转换。
|
||||
if won, err := e.m.SetTaskStatusGuarded(t.ID, "done"); err != nil {
|
||||
log.Printf("[goalless] task %s 收尾落 done 失败: %v", t.ID, err)
|
||||
} else if won {
|
||||
e.emitActivity(t, db.Activity{Worker: "system", Kind: "text",
|
||||
Summary: "目标已全部达成,直投意图已执行完毕,任务结束"})
|
||||
}
|
||||
}
|
||||
return // goalless 分支永不进入 planner.Plan
|
||||
}
|
||||
if !e.beginTaskOperation(t.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
+51
-1
@@ -812,6 +812,49 @@ func (m *Manager) SetTaskCategory(taskID string, categoryID *int64) (*pgdb.TaskC
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// SetTasksCategory applies one category change to several tasks at once. The
|
||||
// database write and the in-memory refresh share taskStateMu, so a concurrent
|
||||
// single-task update cannot interleave and leave a live DTO stale. The returned
|
||||
// set holds the ids that were actually moved; callers report the rest as gone.
|
||||
func (m *Manager) SetTasksCategory(taskIDs []string, categoryID *int64) (map[string]bool, *pgdb.TaskCategory, error) {
|
||||
m.taskStateMu.Lock()
|
||||
defer m.taskStateMu.Unlock()
|
||||
numeric := make([]int64, 0, len(taskIDs))
|
||||
for _, taskID := range taskIDs {
|
||||
id, err := strconv.ParseInt(taskID, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, nil, pgdb.ErrTaskCategoryTaskNotFound
|
||||
}
|
||||
numeric = append(numeric, id)
|
||||
}
|
||||
updatedIDs, category, err := m.pg.SetTasksCategory(numeric, categoryID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
categoryName := ""
|
||||
if category != nil {
|
||||
categoryName = category.Name
|
||||
}
|
||||
updated := make(map[string]bool, len(updatedIDs))
|
||||
m.mu.RLock()
|
||||
tasks := make([]*Task, 0, len(updatedIDs))
|
||||
for _, id := range updatedIDs {
|
||||
taskID := strconv.FormatInt(id, 10)
|
||||
updated[taskID] = true
|
||||
if task := m.tasks[taskID]; task != nil {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, task := range tasks {
|
||||
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||
state.CategoryID = cloneInt64Ptr(categoryID)
|
||||
state.CategoryName = categoryName
|
||||
})
|
||||
}
|
||||
return updated, category, nil
|
||||
}
|
||||
|
||||
// DeleteCompanyWithAssets keeps the database cascade and live task handles in
|
||||
// one manager-level critical section. This closes the gap where a task could
|
||||
// commit its company scope immediately before registration and miss the
|
||||
@@ -1515,7 +1558,14 @@ func (m *Manager) List() []*Task {
|
||||
for _, t := range m.tasks {
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||||
// 按 id 降序(最新插入在最前)。m.tasks 是 map,遍历顺序随机,每次轮询都要重排;
|
||||
// 若按 CreatedAt 排,同一秒创建的任务时间戳相同,排序不稳定,会在列表里频繁换
|
||||
// 位置。id 由 BIGSERIAL 单调递增分配,唯一且稳定,顺序恒定。
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ai, _ := strconv.ParseInt(out[i].ID, 10, 64)
|
||||
aj, _ := strconv.ParseInt(out[j].ID, 10, 64)
|
||||
return ai > aj
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
+33
-2
@@ -345,11 +345,11 @@ func (s *Server) toolAddHint() actool.CoreTool {
|
||||
|
||||
func (s *Server) toolGetWorkerTrace() actool.CoreTool {
|
||||
return roTool("get_task_worker_trace",
|
||||
"看指定任务里某个 work(意图)的执行过程:get_task_worker_trace(task_id, intent_id) 看步骤摘要;再带 step_ids=[...] 取那几步完整内容(一次≤5)。",
|
||||
"看指定任务里某个 work(意图)的执行过程:get_task_worker_trace(task_id, intent_id) 看步骤摘要;再带 step_ids=[...] 取那几步完整内容(一次最多 5 个,多传只返回前 5 个)。",
|
||||
objSchema(map[string]any{
|
||||
"task_id": strParam("任务 id"),
|
||||
"intent_id": map[string]any{"type": "integer", "description": "意图 id(该任务里的 work)"},
|
||||
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "可选:要取完整内容的步骤 id(≤5)"},
|
||||
"step_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "可选:要取完整内容的步骤 id(一次最多 5 个,多传只返回前 5 个,其余在 omitted_step_ids 里列出)"},
|
||||
}, "task_id", "intent_id"),
|
||||
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
return s.delegateToTask(ctx, in, (*agent.ToolSet).GetWorkerTraceTool)
|
||||
@@ -451,6 +451,7 @@ func (s *Server) seedOrchestrationTools() {
|
||||
s.seedAutoReportFindingBinding()
|
||||
s.unbindGoalMetDefault()
|
||||
s.reseedGoalsPrompt() // goals 提示词加入「抽操作约束」步 → 旧库追加一版新默认(一次性)
|
||||
s.reseedMainAgentPrompt() // mainagent 提示词加入「目标达成后 add_intent 反问是否建目标」(一次性)
|
||||
s.seedReporterAgent() // 预置「报告撰写」agent + 工具绑定 + finding 触发器(一次性)
|
||||
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
|
||||
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
|
||||
@@ -540,6 +541,36 @@ func (s *Server) reseedGoalsPrompt() {
|
||||
log.Printf("[prompts] goals 提示词已追加新默认版本(加入抽操作约束步,一次性)")
|
||||
}
|
||||
|
||||
// reseedMainAgentPrompt 把 mainagent 提示词刷成【当前代码默认】——默认正文新增了「目标全部
|
||||
// 达成后 add_intent 直投意图时,反问人是否登记为正式目标」这段引导,而 SeedPromptIfEmpty 首插入
|
||||
// only,旧库已有版本收不到。用版本管理【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍
|
||||
// 保留在历史里,用户若自定义过可从版本记录找回。settings flag 守卫 → 只做一次。全新库无需处理
|
||||
// (SeedPromptIfEmpty 已 seed 最新默认)。与 reseedGoalsPrompt 完全同构。
|
||||
func (s *Server) reseedMainAgentPrompt() {
|
||||
const flag = "mainagent_prompt_goalless_intent_v1"
|
||||
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||
return
|
||||
}
|
||||
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
|
||||
a, err := s.m.pg.GetAgentByKey("mainagent")
|
||||
if err != nil || a == nil {
|
||||
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
|
||||
}
|
||||
tmpl := agent.BuiltinPromptSeeds()["mainagent"]
|
||||
if tmpl == "" {
|
||||
return
|
||||
}
|
||||
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
|
||||
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
|
||||
return
|
||||
}
|
||||
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
|
||||
log.Printf("[prompts] mainagent 提示词重刷为新默认失败: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)")
|
||||
}
|
||||
|
||||
// seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除):
|
||||
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
|
||||
// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。
|
||||
|
||||
@@ -492,6 +492,37 @@ func (s *Server) effectiveProfileForAgent(agentKey string, pinID *int64) *int64
|
||||
return pinID
|
||||
}
|
||||
|
||||
// resolveChatAgent picks the ChatAgent for one conversation: the agent's own
|
||||
// binding or this conversation's chosen profile first, the global active config
|
||||
// only as a fallback. Both the send precheck and the background runner MUST use
|
||||
// this — resolving differently in the two paths is how a conversation that had
|
||||
// picked a valid profile still got rejected with "LLM 未配置" when no global
|
||||
// config was active.
|
||||
func (s *Server) resolveChatAgent(c *db.Conversation) *agent.ChatAgent {
|
||||
ca := s.chatAgentRef()
|
||||
if eff := s.effectiveProfileForAgent(c.AgentKey, c.LLMProfileID); eff != nil {
|
||||
if pa := s.chatAgentForProfile(*eff); pa != nil {
|
||||
ca = pa
|
||||
}
|
||||
}
|
||||
return ca
|
||||
}
|
||||
|
||||
// chatUnavailableReason explains why no ChatAgent could be resolved, so the user
|
||||
// knows whether to add a config, activate one, or pick one for this conversation
|
||||
// — rather than a flat "not configured" that hides which of those it is.
|
||||
func (s *Server) chatUnavailableReason() string {
|
||||
if s.m.pg != nil {
|
||||
if profiles, err := s.m.pg.ListProfiles(); err == nil && len(profiles) == 0 {
|
||||
return "尚未配置 LLM:请到 系统 → LLM 配置 添加一个配置"
|
||||
}
|
||||
if active, err := s.m.pg.ActiveProfile(); err == nil && active == nil {
|
||||
return "没有已激活的 LLM 配置:请到 系统 → LLM 配置 激活一个,或在本对话为该会话指定一个配置"
|
||||
}
|
||||
}
|
||||
return "LLM 未就绪,无法对话:请检查 系统 → LLM 配置是否有可用且已激活的配置"
|
||||
}
|
||||
|
||||
// providerForProfile returns a cached provider+cfg for a profile id, so every agent
|
||||
// bound/pinned to the same profile shares one provider instance (one rate limiter).
|
||||
// ok=false when the profile is missing/invalid → caller falls back to the global pair.
|
||||
@@ -650,6 +681,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/task-categories", s.pgCreateTaskCategory)
|
||||
mux.HandleFunc("PATCH /api/task-categories/{id}", s.pgRenameTaskCategory)
|
||||
mux.HandleFunc("DELETE /api/task-categories/{id}", s.pgDeleteTaskCategory)
|
||||
mux.HandleFunc("POST /api/tasks/category/batch", s.updateTasksCategoryBatch)
|
||||
mux.HandleFunc("GET /api/task-templates", s.pgListTaskTemplates)
|
||||
mux.HandleFunc("POST /api/task-templates", s.pgCreateTaskTemplate)
|
||||
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
|
||||
|
||||
+97
-15
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -119,6 +120,26 @@ func (s *Server) pgDeleteTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
// parseCategoryIDField reads the category_id field shared by the single-task and
|
||||
// batch endpoints. The field is required but may be null — that clears the
|
||||
// category — so it arrives as RawMessage instead of *int64, which cannot tell
|
||||
// an explicit null from an omitted key.
|
||||
func parseCategoryIDField(w http.ResponseWriter, raw json.RawMessage) (*int64, bool) {
|
||||
if len(raw) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "category_id is required")
|
||||
return nil, false
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return nil, true
|
||||
}
|
||||
var id int64
|
||||
if err := json.Unmarshal(raw, &id); err != nil || id <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return nil, false
|
||||
}
|
||||
return &id, true
|
||||
}
|
||||
|
||||
func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if _, ok := s.m.Task(taskID); !ok {
|
||||
@@ -133,21 +154,8 @@ func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(request.CategoryID) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "category_id is required")
|
||||
return
|
||||
}
|
||||
var categoryID *int64
|
||||
if !bytes.Equal(bytes.TrimSpace(request.CategoryID), []byte("null")) {
|
||||
var id int64
|
||||
if err := json.Unmarshal(request.CategoryID, &id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return
|
||||
}
|
||||
categoryID = &id
|
||||
}
|
||||
if categoryID != nil && *categoryID <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
categoryID, ok := parseCategoryIDField(w, request.CategoryID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil {
|
||||
@@ -157,3 +165,77 @@ func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
task, _ := s.m.Task(taskID)
|
||||
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
|
||||
}
|
||||
|
||||
// batchCategoryItem reports one requested task. Unlike batch pause/resume there
|
||||
// is no per-task runtime outcome, so only the failure reason is carried.
|
||||
type batchCategoryItem struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// updateTasksCategoryBatch moves every selected task into one category, or
|
||||
// clears it when category_id is null. The database write is atomic; per-task
|
||||
// entries only distinguish ids that never resolved to a live task, so a partial
|
||||
// success here means those tasks were deleted, not that the move half-applied.
|
||||
func (s *Server) updateTasksCategoryBatch(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
|
||||
var request struct {
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
CategoryID json.RawMessage `json:"category_id"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
categoryID, ok := parseCategoryIDField(w, request.CategoryID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
taskIDs := normalizeBatchTaskIDs(request.TaskIDs)
|
||||
if len(taskIDs) == 0 || len(taskIDs) > db.MaxTaskCategoryBatchSize {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Sprintf("task_ids 数量必须为 1-%d", db.MaxTaskCategoryBatchSize))
|
||||
return
|
||||
}
|
||||
items := make([]batchCategoryItem, 0, len(taskIDs))
|
||||
pending := make([]string, 0, len(taskIDs))
|
||||
for _, parsed := range taskIDs {
|
||||
item := batchCategoryItem{ID: parsed.id}
|
||||
switch {
|
||||
case !parsed.valid:
|
||||
item.Error = "bad task id"
|
||||
default:
|
||||
if _, live := s.m.Task(parsed.id); live {
|
||||
pending = append(pending, parsed.id)
|
||||
} else {
|
||||
item.Error = "task not found"
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
var category *db.TaskCategory
|
||||
if len(pending) > 0 {
|
||||
updated, moved, err := s.m.SetTasksCategory(pending, categoryID)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
category = moved
|
||||
for i := range items {
|
||||
if items[i].Error != "" {
|
||||
continue
|
||||
}
|
||||
if updated[items[i].ID] {
|
||||
items[i].OK = true
|
||||
} else {
|
||||
items[i].Error = "task not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "category": category})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTaskCategoryBatchRoute pins the contract of the batch move endpoint: it is
|
||||
// registered ahead of the /api/tasks/{id}/... patterns, it validates the request
|
||||
// before touching the database, and it reports unknown ids per task instead of
|
||||
// failing the whole batch.
|
||||
func TestTaskCategoryBatchRoute(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
td := t.TempDir()
|
||||
s := New(context.Background(), m, td, td, td)
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatalf("sign jwt: %v", err)
|
||||
}
|
||||
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tasks/category/batch", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// category_id is required, and omitting it must not be read as "uncategorize".
|
||||
if rec := post(`{"task_ids":["1"]}`); rec.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(rec.Body.String(), "category_id is required") {
|
||||
t.Fatalf("missing category_id: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// An empty selection is rejected before any database work.
|
||||
if rec := post(`{"task_ids":[],"category_id":null}`); rec.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(rec.Body.String(), "task_ids 数量必须为") {
|
||||
t.Fatalf("empty task_ids: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Unknown and malformed ids come back as per-task failures on a 200, so the
|
||||
// caller can tell exactly which selections went stale.
|
||||
rec := post(`{"task_ids":["999999999","abc","999999999"],"category_id":null}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("batch status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
} `json:"items"`
|
||||
Category *struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"category"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
// The duplicate id is collapsed, so two entries remain.
|
||||
if len(response.Items) != 2 {
|
||||
t.Fatalf("items=%+v, want 2 after de-duplication", response.Items)
|
||||
}
|
||||
if response.Items[0].ID != "999999999" || response.Items[0].OK || response.Items[0].Error != "task not found" {
|
||||
t.Fatalf("unknown id entry=%+v", response.Items[0])
|
||||
}
|
||||
if response.Items[1].ID != "abc" || response.Items[1].OK || response.Items[1].Error != "bad task id" {
|
||||
t.Fatalf("malformed id entry=%+v", response.Items[1])
|
||||
}
|
||||
if response.Category != nil {
|
||||
t.Fatalf("category=%+v, want null when clearing", response.Category)
|
||||
}
|
||||
}
|
||||
+35
-24
@@ -27,6 +27,40 @@ type intentControlResult struct {
|
||||
Deleted *db.IntentCleanup `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// parsedTaskID carries one requested batch id together with whether it parsed.
|
||||
// Invalid ids are kept rather than dropped so the response can name them.
|
||||
type parsedTaskID struct {
|
||||
id string
|
||||
valid bool
|
||||
}
|
||||
|
||||
// normalizeBatchTaskIDs trims, canonicalizes and de-duplicates the ids of one
|
||||
// batch request while preserving the caller's order. Shared by every batch
|
||||
// endpoint so they agree on what counts as a duplicate.
|
||||
func normalizeBatchTaskIDs(raw []string) []parsedTaskID {
|
||||
seen := map[string]bool{}
|
||||
seenInvalid := map[string]bool{}
|
||||
taskIDs := make([]parsedTaskID, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
id, valid := canonicalTaskID(trimmed)
|
||||
if !valid {
|
||||
if seenInvalid[trimmed] {
|
||||
continue
|
||||
}
|
||||
seenInvalid[trimmed] = true
|
||||
taskIDs = append(taskIDs, parsedTaskID{id: trimmed})
|
||||
continue
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
taskIDs = append(taskIDs, parsedTaskID{id: id, valid: true})
|
||||
}
|
||||
return taskIDs
|
||||
}
|
||||
|
||||
type batchControlItem struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
@@ -192,30 +226,7 @@ func (s *Server) controlTasksBatch(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "action must be pause|resume")
|
||||
return
|
||||
}
|
||||
type parsedTaskID struct {
|
||||
id string
|
||||
valid bool
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
seenInvalid := map[string]bool{}
|
||||
taskIDs := make([]parsedTaskID, 0, len(req.TaskIDs))
|
||||
for _, raw := range req.TaskIDs {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
id, valid := canonicalTaskID(trimmed)
|
||||
if !valid {
|
||||
if seenInvalid[trimmed] {
|
||||
continue
|
||||
}
|
||||
seenInvalid[trimmed] = true
|
||||
taskIDs = append(taskIDs, parsedTaskID{id: trimmed})
|
||||
continue
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
taskIDs = append(taskIDs, parsedTaskID{id: id, valid: true})
|
||||
}
|
||||
taskIDs := normalizeBatchTaskIDs(req.TaskIDs)
|
||||
if len(taskIDs) == 0 || len(taskIDs) > maxBatchControlIDs {
|
||||
writeErr(w, 400, fmt.Sprintf("task_ids 数量必须为 1-%d", maxBatchControlIDs))
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user