diff --git a/db/task_archives_test.go b/db/task_archives_test.go index 93a1364..bc4274a 100644 --- a/db/task_archives_test.go +++ b/db/task_archives_test.go @@ -132,7 +132,9 @@ func TestTaskArchiveDatabaseRoundTrip(t *testing.T) { if err := d.EnsureLLMUsageTable(); err != nil { t.Fatal(err) } - task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{Name: "cold task"}) + task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{ + Name: "cold task", WorkerCount: 7, + }) if err != nil { t.Fatal(err) } @@ -297,7 +299,7 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if err != nil || live == nil { t.Fatalf("restored task = %+v, %v", live, err) } - if live.Name != "cold task" || live.Remark != remark || !live.Paused { + if live.Name != "cold task" || live.Remark != remark || live.WorkerCount != 7 || !live.Paused { t.Fatalf("restored task metadata mismatch: %+v", live) } if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil { diff --git a/db/task_options_test.go b/db/task_options_test.go index f10323a..100bda7 100644 --- a/db/task_options_test.go +++ b/db/task_options_test.go @@ -1,8 +1,10 @@ package db import ( + "fmt" "strings" "testing" + "time" ) func TestTaskAndTemplateRemarkLimitIsShared(t *testing.T) { @@ -26,3 +28,75 @@ func TestWorkerCountNormalizationAndLimit(t *testing.T) { t.Fatal("oversized worker count accepted") } } + +func TestTaskOptionsPersistAndValidateLimits(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + if _, err := d.CreateTaskWithOptions("oversized remark", "goal", TaskCreateOptions{ + Remark: strings.Repeat("界", MaxTaskRemarkRunes+1), + }); err == nil { + t.Fatal("2001-rune task remark accepted") + } + if _, err := d.CreateTaskWithOptions("oversized workers", "goal", TaskCreateOptions{ + WorkerCount: MaxTaskWorkerCount + 1, + }); err == nil { + t.Fatal("51-worker task accepted") + } + + remark := strings.Repeat("界", MaxTaskRemarkRunes) + task, err := d.CreateTaskWithOptions("task options boundary", "goal", TaskCreateOptions{ + Remark: remark, WorkerCount: MaxTaskWorkerCount, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + gotTask, err := d.GetTask(task.ID) + if err != nil { + t.Fatal(err) + } + if gotTask == nil || gotTask.Remark != remark || gotTask.WorkerCount != MaxTaskWorkerCount { + t.Fatalf("task options round trip = %+v", gotTask) + } + + name := fmt.Sprintf("options template %d", time.Now().UnixNano()) + templateRemark := "模板备注 📝" + template, err := d.CreateTaskTemplate(TaskTemplateInput{ + Name: name, Description: "template description", Goal: "template goal", + Remark: templateRemark, WorkerCount: MaxTaskWorkerCount, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(template.ID) }) + gotTemplate, err := d.GetTaskTemplate(template.ID) + if err != nil { + t.Fatal(err) + } + if gotTemplate == nil || gotTemplate.Remark != templateRemark || gotTemplate.WorkerCount != MaxTaskWorkerCount { + t.Fatalf("created template options round trip = %+v", gotTemplate) + } + + updatedRemark := strings.Repeat("改", MaxTaskRemarkRunes) + updatedWorkers := MaxTaskWorkerCount - 1 + updated, err := d.PatchTaskTemplate(template.ID, TaskTemplatePatch{ + Remark: &updatedRemark, WorkerCount: &updatedWorkers, + }) + if err != nil { + t.Fatal(err) + } + if updated.Remark != updatedRemark || updated.WorkerCount != updatedWorkers { + t.Fatalf("updated template options = %+v", updated) + } + gotTemplate, err = d.GetTaskTemplate(template.ID) + if err != nil { + t.Fatal(err) + } + if gotTemplate == nil || gotTemplate.Remark != updatedRemark || gotTemplate.WorkerCount != updatedWorkers { + t.Fatalf("updated template options round trip = %+v", gotTemplate) + } +} diff --git a/db/task_templates.go b/db/task_templates.go index 4c55de0..05b2904 100644 --- a/db/task_templates.go +++ b/db/task_templates.go @@ -119,11 +119,10 @@ func normalizeTaskTemplateInput(in TaskTemplateInput) (TaskTemplateInput, string return in, "", fmt.Errorf("%w: goal exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes) } in.Remark = strings.TrimSpace(in.Remark) - // Align with task remarks (server/remarks.go maxRemarkRunes=2000) so applying a - // template cannot produce a create-task payload that the task API rejects. - const maxTemplateRemarkRunes = MaxTaskRemarkRunes - if utf8.RuneCountInString(in.Remark) > maxTemplateRemarkRunes { - return in, "", fmt.Errorf("%w: remark exceeds %d characters", ErrTaskTemplateInvalid, maxTemplateRemarkRunes) + // Keep template remarks within the same shared limit as task remarks so + // applying a template cannot produce a rejected create-task payload. + if utf8.RuneCountInString(in.Remark) > MaxTaskRemarkRunes { + return in, "", fmt.Errorf("%w: remark exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskRemarkRunes) } in.WorkerCount = normalizeWorkerCount(in.WorkerCount) if err := validateWorkerCount(in.WorkerCount); err != nil { @@ -171,9 +170,8 @@ func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *st } if patch.Remark != nil { remark := strings.TrimSpace(*patch.Remark) - const maxTemplateRemarkRunes = MaxTaskRemarkRunes - if utf8.RuneCountInString(remark) > maxTemplateRemarkRunes { - return patch, nil, fmt.Errorf("%w: remark exceeds %d characters", ErrTaskTemplateInvalid, maxTemplateRemarkRunes) + if utf8.RuneCountInString(remark) > MaxTaskRemarkRunes { + return patch, nil, fmt.Errorf("%w: remark exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskRemarkRunes) } patch.Remark = &remark } diff --git a/db/tasks.go b/db/tasks.go index 03d948f..c17f1b6 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -143,8 +143,9 @@ const MaxTaskWorkerCount = 50 // MaxTaskRemarkRunes is the shared limit for task and template remarks. const MaxTaskRemarkRunes = 2000 -// normalizeWorkerCount clamps a per-task worker override into [0, MaxTaskWorkerCount]. -// 0 means "use the global settings.workers default". +// normalizeWorkerCount converts negative per-task worker overrides to 0. +// The upper bound is enforced separately by validateWorkerCount; 0 means +// "use the global settings.workers default". func normalizeWorkerCount(n int) int { if n < 0 { return 0 diff --git a/server/engine.go b/server/engine.go index 10a648e..5d07a03 100644 --- a/server/engine.go +++ b/server/engine.go @@ -778,6 +778,11 @@ func (e *Engine) Run(ctx context.Context, t *Task) { func workerCountForTask(t *Task, global int) int { if t != nil && t.WorkerCount > 0 { + // Direct database edits can bypass validation; cap those values so the + // engine never starts more than the supported per-task worker count. + if t.WorkerCount > db.MaxTaskWorkerCount { + return db.MaxTaskWorkerCount + } return t.WorkerCount } return global diff --git a/server/engine_worker_test.go b/server/engine_worker_test.go index f227628..132ee86 100644 --- a/server/engine_worker_test.go +++ b/server/engine_worker_test.go @@ -1,6 +1,10 @@ package server -import "testing" +import ( + "testing" + + "github.com/Autumn-27/artex/db" +) func TestWorkerCountForTaskUsesOverrideOrGlobalDefault(t *testing.T) { if got := workerCountForTask(&Task{WorkerCount: 5}, 3); got != 5 { @@ -12,4 +16,7 @@ func TestWorkerCountForTaskUsesOverrideOrGlobalDefault(t *testing.T) { if got := workerCountForTask(&Task{WorkerCount: -1}, 3); got != 3 { t.Fatalf("negative override = %d, want global 3", got) } + if got := workerCountForTask(&Task{WorkerCount: db.MaxTaskWorkerCount + 1}, 3); got != db.MaxTaskWorkerCount { + t.Fatalf("oversized override = %d, want capped %d", got, db.MaxTaskWorkerCount) + } } diff --git a/web/src/components/task-template-controls.tsx b/web/src/components/task-template-controls.tsx index 61d13ce..0d50a1b 100644 --- a/web/src/components/task-template-controls.tsx +++ b/web/src/components/task-template-controls.tsx @@ -217,7 +217,7 @@ function TaskTemplateManager({ 任务模板管理 - 模板保存描述、目标、分类与任务级拦截/允许规则;修改不会影响已经创建的任务。 + 模板保存描述、目标、备注、Worker 数量、分类与任务级拦截/允许规则;修改不会影响已经创建的任务。
diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index e269d57..3fe33d5 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -33,7 +33,7 @@ import type { TrafficEvidenceRole, TrafficEvidenceSnapshot, } from "../types"; -import { MAX_TASK_WORKER_COUNT } from "../types"; +import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT } from "../types"; import * as D from "./data"; const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms)); @@ -999,6 +999,21 @@ function parseBody(body?: BodyInit | null): Record { } } +function mockRemark(value: unknown): string { + const remark = typeof value === "string" ? value.trim() : ""; + if (Array.from(remark).length > MAX_TASK_REMARK_RUNES) { + throw new Error(`备注最多 ${MAX_TASK_REMARK_RUNES} 个字符`); + } + return remark; +} + +function mockWorkerCount(value: unknown): number { + if (typeof value !== "number") return 0; + if (value < 0) throw new Error("Worker 运行数量不能为负数"); + if (value > MAX_TASK_WORKER_COUNT) throw new Error(`Worker 运行数量最多 ${MAX_TASK_WORKER_COUNT}`); + return Math.floor(value); +} + export async function mockHandle(method: string, rawPath: string, body?: BodyInit | null): Promise { advanceMockRetests(); await delay(); @@ -1183,12 +1198,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const created: Task = { id, name: String(b.name ?? ""), - remark: typeof b.remark === "string" ? b.remark.trim() : "", + remark: mockRemark(b.remark), category_id: category?.id, category_name: category?.name, description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), - worker_count: typeof b.worker_count === "number" ? Math.min(MAX_TASK_WORKER_COUNT, Math.max(0, Math.floor(b.worker_count))) : 0, + worker_count: mockWorkerCount(b.worker_count), status: "created", created_at: now.toISOString(), created_unix: Math.floor(now.getTime() / 1000), @@ -1318,10 +1333,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re name, description, goal, - remark: typeof b.remark === "string" ? b.remark.trim() : "", + remark: mockRemark(b.remark), category_id: typeof b.category_id === "number" ? b.category_id : null, intercept_rules: Array.isArray(b.intercept_rules) ? b.intercept_rules : [], - worker_count: typeof b.worker_count === "number" ? Math.min(MAX_TASK_WORKER_COUNT, Math.max(0, Math.floor(b.worker_count))) : 0, + worker_count: mockWorkerCount(b.worker_count), created_at: now, updated_at: now, }; @@ -1342,13 +1357,15 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re ) { throw new Error("模板名称已存在"); } + const remark = typeof b.remark === "string" ? mockRemark(b.remark) : template.remark; + const workerCount = typeof b.worker_count === "number" ? mockWorkerCount(b.worker_count) : template.worker_count; template.name = name; template.description = description; template.goal = goal; - if (typeof b.remark === "string") template.remark = b.remark.trim(); + template.remark = remark; if ("category_id" in b) template.category_id = typeof b.category_id === "number" ? b.category_id : null; if (Array.isArray(b.intercept_rules)) template.intercept_rules = b.intercept_rules; - if (typeof b.worker_count === "number") template.worker_count = Math.min(MAX_TASK_WORKER_COUNT, Math.max(0, Math.floor(b.worker_count))); + template.worker_count = workerCount; template.updated_at = new Date().toISOString(); mockTaskTemplates.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.id - a.id); return template;