feat: 任务/模板备注展示与 Worker 运行数量配置(Codex gpt-6-astra 重写)

This commit is contained in:
RuoJi6 committed 2026-10-24 21:46:03 +08:00
1 parent ba3025d04f
commit a1dc362bae
21 files changed
+332 -32

No files matched your search

+8
View File
@@ -524,6 +524,7 @@ CREATE TABLE IF NOT EXISTS tasks (
timeout_seconds INTEGER NOT NULL DEFAULT 0, timeout_seconds INTEGER NOT NULL DEFAULT 0,
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300, plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
coverage_enabled BOOLEAN NOT NULL DEFAULT true, coverage_enabled BOOLEAN NOT NULL DEFAULT true,
worker_count INTEGER NOT NULL DEFAULT 0, -- 0=用全局默认 workers
pinned_at TIMESTAMPTZ, pinned_at TIMESTAMPTZ,
first_run_at TIMESTAMPTZ, first_run_at TIMESTAMPTZ,
deadline_at TIMESTAMPTZ, deadline_at TIMESTAMPTZ,
@@ -546,6 +547,8 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false
-- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。 -- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。
-- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。 -- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true;
-- 任务级 Worker 运行数量;0=用全局默认(settings.workers)。补旧库。
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0;
-- queued_at makes admission FIFO reflect the actual enqueue order rather than the -- queued_at makes admission FIFO reflect the actual enqueue order rather than the
-- task creation order. queue_mode distinguishes first bootstrap from resuming an -- task creation order. queue_mode distinguishes first bootstrap from resuming an
-- exploration that already owns goals/history. -- exploration that already owns goals/history.
@@ -617,16 +620,21 @@ CREATE TABLE IF NOT EXISTS task_templates (
nkey TEXT NOT NULL UNIQUE, nkey TEXT NOT NULL UNIQUE,
description TEXT NOT NULL, description TEXT NOT NULL,
goal TEXT NOT NULL, goal TEXT NOT NULL,
remark TEXT NOT NULL DEFAULT '',
-- 预设的任务分类;分类删除时置空(与 tasks.category_id 一致,不阻断)。 -- 预设的任务分类;分类删除时置空(与 tasks.category_id 一致,不阻断)。
category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL, category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL,
-- 预设的任务级拦截/允许规则快照(AssetInterceptRuleInput 数组);应用模板时灌进新任务。 -- 预设的任务级拦截/允许规则快照(AssetInterceptRuleInput 数组);应用模板时灌进新任务。
intercept_rules JSONB NOT NULL DEFAULT '[]', intercept_rules JSONB NOT NULL DEFAULT '[]',
-- 预设的任务级 Worker 运行数量;0=用全局默认。
worker_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- 补旧库(已发版,加列带 IF NOT EXISTS)。 -- 补旧库(已发版,加列带 IF NOT EXISTS)。
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL; ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS intercept_rules JSONB NOT NULL DEFAULT '[]'; ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS intercept_rules JSONB NOT NULL DEFAULT '[]';
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS remark TEXT NOT NULL DEFAULT '';
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_task_templates_updated ON task_templates(updated_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_task_templates_updated ON task_templates(updated_at DESC, id DESC);
DROP TRIGGER IF EXISTS trg_task_templates_upd ON task_templates; DROP TRIGGER IF EXISTS trg_task_templates_upd ON task_templates;
CREATE TRIGGER trg_task_templates_upd BEFORE UPDATE ON task_templates CREATE TRIGGER trg_task_templates_upd BEFORE UPDATE ON task_templates
+2 -2
View File
@@ -122,7 +122,7 @@ AND NOT EXISTS (
if _, err := tx.Exec(`UPDATE tasks SET if _, err := tx.Exec(`UPDATE tasks SET
name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='', name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1, llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1,
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,pinned_at=NULL, company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,worker_count=0,pinned_at=NULL,
first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now() first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now()
WHERE id=$1`, taskID); err != nil { WHERE id=$1`, taskID); err != nil {
return err return err
@@ -322,7 +322,7 @@ func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) er
llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id, llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id,
llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref, llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref,
timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds, timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds,
coverage_enabled=archived.coverage_enabled,pinned_at=archived.pinned_at,first_run_at=archived.first_run_at, coverage_enabled=archived.coverage_enabled,worker_count=COALESCE(archived.worker_count,0),pinned_at=archived.pinned_at,first_run_at=archived.first_run_at,
deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision) deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision)
ELSE archived.deadline_at END, ELSE archived.deadline_at END,
deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at, deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at,
+28
View File
@@ -0,0 +1,28 @@
package db
import (
"strings"
"testing"
)
func TestTaskAndTemplateRemarkLimitIsShared(t *testing.T) {
boundary := strings.Repeat("界", MaxTaskRemarkRunes)
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", Remark: boundary}); err != nil {
t.Fatalf("boundary remark rejected: %v", err)
}
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", Remark: boundary + "界"}); err == nil {
t.Fatal("oversized template remark accepted")
}
}
func TestWorkerCountNormalizationAndLimit(t *testing.T) {
if got := normalizeWorkerCount(-1); got != 0 {
t.Fatalf("negative worker count = %d, want 0", got)
}
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", WorkerCount: MaxTaskWorkerCount}); err != nil {
t.Fatalf("maximum worker count rejected: %v", err)
}
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", WorkerCount: MaxTaskWorkerCount + 1}); err == nil {
t.Fatal("oversized worker count accepted")
}
}
+46 -9
View File
@@ -31,8 +31,10 @@ type TaskTemplate struct {
NKey string `json:"-"` NKey string `json:"-"`
Description string `json:"description"` Description string `json:"description"`
Goal string `json:"goal"` Goal string `json:"goal"`
Remark string `json:"remark"`
CategoryID *int64 `json:"category_id"` CategoryID *int64 `json:"category_id"`
InterceptRules []TaskInterceptRuleInput `json:"intercept_rules"` InterceptRules []TaskInterceptRuleInput `json:"intercept_rules"`
WorkerCount int `json:"worker_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -42,8 +44,10 @@ type TaskTemplateInput struct {
Name string Name string
Description string Description string
Goal string Goal string
Remark string
CategoryID *int64 CategoryID *int64
InterceptRules []TaskInterceptRuleInput InterceptRules []TaskInterceptRuleInput
WorkerCount int
} }
// TaskTemplatePatch changes only fields flagged as set. Name/Description/Goal use // TaskTemplatePatch changes only fields flagged as set. Name/Description/Goal use
@@ -53,18 +57,20 @@ type TaskTemplatePatch struct {
Name *string Name *string
Description *string Description *string
Goal *string Goal *string
Remark *string
CategoryID *int64 CategoryID *int64
SetCategoryID bool SetCategoryID bool
InterceptRules []TaskInterceptRuleInput InterceptRules []TaskInterceptRuleInput
SetInterceptRules bool SetInterceptRules bool
WorkerCount *int
} }
const taskTemplateCols = `id, name, nkey, description, goal, category_id, intercept_rules, created_at, updated_at` const taskTemplateCols = `id, name, nkey, description, goal, COALESCE(remark,''), category_id, intercept_rules, COALESCE(worker_count,0), created_at, updated_at`
func scanTaskTemplate(row interface{ Scan(...any) error }) (TaskTemplate, error) { func scanTaskTemplate(row interface{ Scan(...any) error }) (TaskTemplate, error) {
var t TaskTemplate var t TaskTemplate
var rulesRaw []byte var rulesRaw []byte
if err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CategoryID, &rulesRaw, &t.CreatedAt, &t.UpdatedAt); err != nil { if err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.Remark, &t.CategoryID, &rulesRaw, &t.WorkerCount, &t.CreatedAt, &t.UpdatedAt); err != nil {
return t, err return t, err
} }
t.InterceptRules = []TaskInterceptRuleInput{} t.InterceptRules = []TaskInterceptRuleInput{}
@@ -112,11 +118,22 @@ func normalizeTaskTemplateInput(in TaskTemplateInput) (TaskTemplateInput, string
case utf8.RuneCountInString(in.Goal) > MaxTaskTemplateTextRunes: case utf8.RuneCountInString(in.Goal) > MaxTaskTemplateTextRunes:
return in, "", fmt.Errorf("%w: goal exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes) 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)
}
in.WorkerCount = normalizeWorkerCount(in.WorkerCount)
if err := validateWorkerCount(in.WorkerCount); err != nil {
return in, "", fmt.Errorf("%w: %v", ErrTaskTemplateInvalid, err)
}
return in, taskTemplateNKey(in.Name), nil return in, taskTemplateNKey(in.Name), nil
} }
func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *string, error) { func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *string, error) {
if patch.Name == nil && patch.Description == nil && patch.Goal == nil && !patch.SetCategoryID && !patch.SetInterceptRules { if patch.Name == nil && patch.Description == nil && patch.Goal == nil && patch.Remark == nil && !patch.SetCategoryID && !patch.SetInterceptRules && patch.WorkerCount == nil {
return patch, nil, fmt.Errorf("%w: no fields supplied", ErrTaskTemplateInvalid) return patch, nil, fmt.Errorf("%w: no fields supplied", ErrTaskTemplateInvalid)
} }
var nkey *string var nkey *string
@@ -152,6 +169,21 @@ func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *st
} }
patch.Goal = &goal patch.Goal = &goal
} }
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)
}
patch.Remark = &remark
}
if patch.WorkerCount != nil {
n := normalizeWorkerCount(*patch.WorkerCount)
if err := validateWorkerCount(n); err != nil {
return patch, nil, fmt.Errorf("%w: %v", ErrTaskTemplateInvalid, err)
}
patch.WorkerCount = &n
}
return patch, nkey, nil return patch, nkey, nil
} }
@@ -171,10 +203,10 @@ func (d *DB) CreateTaskTemplate(in TaskTemplateInput) (*TaskTemplate, error) {
return nil, err return nil, err
} }
t, err := scanTaskTemplate(d.QueryRow(` t, err := scanTaskTemplate(d.QueryRow(`
INSERT INTO task_templates(name, nkey, description, goal, category_id, intercept_rules) INSERT INTO task_templates(name, nkey, description, goal, remark, category_id, intercept_rules, worker_count)
VALUES ($1,$2,$3,$4,$5,$6) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (nkey) DO NOTHING ON CONFLICT (nkey) DO NOTHING
RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal, in.CategoryID, rulesJSON)) RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal, in.Remark, in.CategoryID, rulesJSON, in.WorkerCount))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrTaskTemplateNameConflict return nil, ErrTaskTemplateNameConflict
} }
@@ -221,7 +253,8 @@ func (d *DB) UpdateTaskTemplate(id int64, in TaskTemplateInput) (*TaskTemplate,
return nil, err return nil, err
} }
return d.PatchTaskTemplate(id, TaskTemplatePatch{ return d.PatchTaskTemplate(id, TaskTemplatePatch{
Name: &in.Name, Description: &in.Description, Goal: &in.Goal, Name: &in.Name, Description: &in.Description, Goal: &in.Goal, Remark: &in.Remark,
WorkerCount: &in.WorkerCount,
}) })
} }
@@ -242,16 +275,20 @@ SET name=CASE WHEN $2 THEN $3::text ELSE name END,
nkey=CASE WHEN $2 THEN $4::text ELSE nkey END, nkey=CASE WHEN $2 THEN $4::text ELSE nkey END,
description=CASE WHEN $5 THEN $6::text ELSE description END, description=CASE WHEN $5 THEN $6::text ELSE description END,
goal=CASE WHEN $7 THEN $8::text ELSE goal END, goal=CASE WHEN $7 THEN $8::text ELSE goal END,
category_id=CASE WHEN $9 THEN $10::bigint ELSE category_id END, remark=CASE WHEN $9 THEN $10::text ELSE remark END,
intercept_rules=CASE WHEN $11 THEN $12::jsonb ELSE intercept_rules END category_id=CASE WHEN $11 THEN $12::bigint ELSE category_id END,
intercept_rules=CASE WHEN $13 THEN $14::jsonb ELSE intercept_rules END,
worker_count=CASE WHEN $15 THEN $16::integer ELSE worker_count END
WHERE id=$1 WHERE id=$1
RETURNING `+taskTemplateCols, RETURNING `+taskTemplateCols,
id, id,
patch.Name != nil, patch.Name, nkey, patch.Name != nil, patch.Name, nkey,
patch.Description != nil, patch.Description, patch.Description != nil, patch.Description,
patch.Goal != nil, patch.Goal, patch.Goal != nil, patch.Goal,
patch.Remark != nil, patch.Remark,
patch.SetCategoryID, patch.CategoryID, patch.SetCategoryID, patch.CategoryID,
patch.SetInterceptRules, rulesJSON, patch.SetInterceptRules, rulesJSON,
patch.WorkerCount != nil, patch.WorkerCount,
)) ))
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrTaskTemplateNotFound return nil, ErrTaskTemplateNotFound
+45 -6
View File
@@ -6,7 +6,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"unicode/utf8"
) )
// Task is a row in the task registry (1:1 with an exploration). // Task is a row in the task registry (1:1 with an exploration).
@@ -51,6 +53,9 @@ type Task struct {
// list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联 // list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联
// (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。 // (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。
CoverageEnabled bool `json:"coverage_enabled"` CoverageEnabled bool `json:"coverage_enabled"`
// WorkerCount is the per-task concurrent work-agent count. 0 means use the
// global settings.workers default when the engine starts this task.
WorkerCount int `json:"worker_count"`
} }
// TaskDeleteResult reports optional related-data cleanup performed in the same // TaskDeleteResult reports optional related-data cleanup performed in the same
@@ -132,6 +137,28 @@ func normalizeHeartbeat(sec int) int {
return sec return sec
} }
// MaxTaskWorkerCount is the hard upper bound for a per-task worker override.
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".
func normalizeWorkerCount(n int) int {
if n < 0 {
return 0
}
return n
}
func validateWorkerCount(n int) error {
if n > MaxTaskWorkerCount {
return fmt.Errorf("Worker 运行数量最多 %d", MaxTaskWorkerCount)
}
return nil
}
func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) { func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
var ids []int64 var ids []int64
if llmProfileID != nil { if llmProfileID != nil {
@@ -156,6 +183,10 @@ type TaskCreateOptions struct {
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建 // CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。 // 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
CoverageEnabled *bool CoverageEnabled *bool
// WorkerCount 是本任务并发运行的工作 Agent 数;0/省略=用全局默认。上限 50。
WorkerCount int
// Remark 是创建时可带的任务备注(最多 2000 字);空=无备注。
Remark string
// InterceptRules 是任务级资产拦截规则,创建时随任务在同一事务内写入 task_intercept_rules。 // InterceptRules 是任务级资产拦截规则,创建时随任务在同一事务内写入 task_intercept_rules。
InterceptRules []TaskInterceptRuleInput InterceptRules []TaskInterceptRuleInput
} }
@@ -200,6 +231,14 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
} }
opts.PlanHeartbeatSeconds = normalizeHeartbeat(opts.PlanHeartbeatSeconds) opts.PlanHeartbeatSeconds = normalizeHeartbeat(opts.PlanHeartbeatSeconds)
coverageEnabled := opts.CoverageEnabled == nil || *opts.CoverageEnabled coverageEnabled := opts.CoverageEnabled == nil || *opts.CoverageEnabled
opts.WorkerCount = normalizeWorkerCount(opts.WorkerCount)
if err := validateWorkerCount(opts.WorkerCount); err != nil {
return nil, err
}
opts.Remark = strings.TrimSpace(opts.Remark)
if utf8.RuneCountInString(opts.Remark) > MaxTaskRemarkRunes {
return nil, fmt.Errorf("备注最多 %d 个字符", MaxTaskRemarkRunes)
}
var categoryName string var categoryName string
if opts.CategoryID != nil { if opts.CategoryID != nil {
if *opts.CategoryID <= 0 { if *opts.CategoryID <= 0 {
@@ -225,12 +264,12 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...), SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
CompanyIDs: append([]int64(nil), opts.CompanyIDs...), CompanyIDs: append([]int64(nil), opts.CompanyIDs...),
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds, TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
CoverageEnabled: coverageEnabled, CoverageEnabled: coverageEnabled, WorkerCount: opts.WorkerCount, Remark: opts.Remark,
} }
if err := tx.QueryRow(` if err := tx.QueryRow(`
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled) INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count)
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9) VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11)
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
return nil, err return nil, err
} }
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil { if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
@@ -363,11 +402,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
const taskCols = `id, COALESCE(name,''), remark, category_id, const taskCols = `id, COALESCE(name,''), remark, category_id,
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''), COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at` description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), COALESCE(worker_count,0), first_run_at, deadline_at`
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
var t Task var t Task
if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil { if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt); err != nil {
return nil, err return nil, err
} }
t.Pinned = t.PinnedAt != nil t.Pinned = t.PinnedAt != nil
+1 -1
View File
@@ -6,9 +6,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
) )
// hasSkillTool reports whether the packed tool set contains the Skill meta-tool. // hasSkillTool reports whether the packed tool set contains the Skill meta-tool.
+2
View File
@@ -64,6 +64,7 @@ type TaskDTO struct {
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"` ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
CompanyIDs []int64 `json:"company_ids"` CompanyIDs []int64 `json:"company_ids"`
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定) CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
} }
// FindingSeverityDTO 是任务列表里按严重度分档的漏洞计数(严重/高/中/低)。 // FindingSeverityDTO 是任务列表里按严重度分档的漏洞计数(严重/高/中/低)。
@@ -138,6 +139,7 @@ func taskDTO(t *Task, status string) TaskDTO {
SourceTaskIDs: sourceIDs, SourceTaskIDs: sourceIDs,
CompanyIDs: lifecycle.CompanyIDs, CompanyIDs: lifecycle.CompanyIDs,
CoverageEnabled: t.CoverageEnabled, CoverageEnabled: t.CoverageEnabled,
WorkerCount: t.WorkerCount,
} }
} }
+8 -1
View File
@@ -746,7 +746,7 @@ func (e *Engine) ReadyFor(t *Task) bool {
// but no-op until an LLM is configured (so a task created while idle picks up // but no-op until an LLM is configured (so a task created while idle picks up
// automatically once LLM is set from the UI). // automatically once LLM is set from the UI).
func (e *Engine) Run(ctx context.Context, t *Task) { func (e *Engine) Run(ctx context.Context, t *Task) {
workers := e.m.Workers() workers := workerCountForTask(t, e.m.Workers())
e.deleteMu.RLock() e.deleteMu.RLock()
if e.IsDeleting(t.ID) { if e.IsDeleting(t.ID) {
e.deleteMu.RUnlock() e.deleteMu.RUnlock()
@@ -776,6 +776,13 @@ func (e *Engine) Run(ctx context.Context, t *Task) {
} }
} }
func workerCountForTask(t *Task, global int) int {
if t != nil && t.WorkerCount > 0 {
return t.WorkerCount
}
return global
}
// plannerHeartbeatInterval 解析任务的 planner 心跳间隔。db.CreateTask 已归一 // plannerHeartbeatInterval 解析任务的 planner 心跳间隔。db.CreateTask 已归一
// (低于 600 一律抬到 600);这里再兜一次底,防内存态异常值。 // (低于 600 一律抬到 600);这里再兜一次底,防内存态异常值。
func plannerHeartbeatInterval(t *Task) time.Duration { func plannerHeartbeatInterval(t *Task) time.Duration {
+15
View File
@@ -0,0 +1,15 @@
package server
import "testing"
func TestWorkerCountForTaskUsesOverrideOrGlobalDefault(t *testing.T) {
if got := workerCountForTask(&Task{WorkerCount: 5}, 3); got != 5 {
t.Fatalf("override = %d, want 5", got)
}
if got := workerCountForTask(&Task{WorkerCount: 0}, 3); got != 3 {
t.Fatalf("zero override = %d, want global 3", got)
}
if got := workerCountForTask(&Task{WorkerCount: -1}, 3); got != 3 {
t.Fatalf("negative override = %d, want global 3", got)
}
}
+3 -2
View File
@@ -59,6 +59,7 @@ type Task struct {
TimeoutSeconds int `json:"timeout_seconds"` TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒) PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开) CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
FirstRunAt int64 `json:"first_run_at,omitempty"` FirstRunAt int64 `json:"first_run_at,omitempty"`
DeadlineAt int64 `json:"deadline_at,omitempty"` DeadlineAt int64 `json:"deadline_at,omitempty"`
Store *pgdb.ExplorationStore `json:"-"` Store *pgdb.ExplorationStore `json:"-"`
@@ -849,8 +850,8 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...), SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
CompanyIDs: append([]int64(nil), pt.CompanyIDs...), CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds, TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
CoverageEnabled: pt.CoverageEnabled, CoverageEnabled: pt.CoverageEnabled, WorkerCount: pt.WorkerCount,
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
} }
} }
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/db"
) )
const maxRemarkRunes = 2000 const maxRemarkRunes = db.MaxTaskRemarkRunes
func normalizeRemark(value string) (string, error) { func normalizeRemark(value string) (string, error) {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
+17
View File
@@ -1540,6 +1540,8 @@ type createTaskReq struct {
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600 PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无
WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。 // InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"` InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
} }
@@ -1603,12 +1605,27 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
} }
schedule = &input schedule = &input
} }
remark, err := normalizeRemark(req.Remark)
if err != nil {
writeErr(w, 400, err.Error())
return
}
if req.WorkerCount < 0 {
writeErr(w, 400, "Worker 运行数量不能为负数")
return
}
if req.WorkerCount > db.MaxTaskWorkerCount {
writeErr(w, 400, fmt.Sprintf("Worker 运行数量最多 %d", db.MaxTaskWorkerCount))
return
}
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{ t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
Schedule: schedule, Schedule: schedule,
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID, Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
CoverageEnabled: req.CoverageEnabled, CoverageEnabled: req.CoverageEnabled,
Remark: remark,
WorkerCount: req.WorkerCount,
InterceptRules: interceptRules, InterceptRules: interceptRules,
}) })
if err != nil { if err != nil {
+36 -2
View File
@@ -22,8 +22,10 @@ type taskTemplateRequest struct {
Name *string `json:"name"` Name *string `json:"name"`
Description *string `json:"description"` Description *string `json:"description"`
Goal *string `json:"goal"` Goal *string `json:"goal"`
Remark *string `json:"remark"`
CategoryID *int64 `json:"category_id"` CategoryID *int64 `json:"category_id"`
InterceptRules []taskInterceptRuleReq `json:"intercept_rules"` InterceptRules []taskInterceptRuleReq `json:"intercept_rules"`
WorkerCount *int `json:"worker_count"`
} }
// decodeTaskTemplateRequest decodes the body into req and returns the set of // decodeTaskTemplateRequest decodes the body into req and returns the set of
@@ -65,6 +67,7 @@ func validateTaskTemplateRequest(req taskTemplateRequest) error {
{name: "name", value: req.Name, limit: db.MaxTaskTemplateNameRunes}, {name: "name", value: req.Name, limit: db.MaxTaskTemplateNameRunes},
{name: "description", value: req.Description, limit: db.MaxTaskTemplateTextRunes}, {name: "description", value: req.Description, limit: db.MaxTaskTemplateTextRunes},
{name: "goal", value: req.Goal, limit: db.MaxTaskTemplateTextRunes}, {name: "goal", value: req.Goal, limit: db.MaxTaskTemplateTextRunes},
{name: "remark", value: req.Remark, limit: db.MaxTaskRemarkRunes}, // 与任务备注 normalizeRemark 上限一致
} }
for _, check := range checks { for _, check := range checks {
if check.value == nil { if check.value == nil {
@@ -78,6 +81,14 @@ func validateTaskTemplateRequest(req taskTemplateRequest) error {
return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit) return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit)
} }
} }
if req.WorkerCount != nil {
if *req.WorkerCount < 0 {
return fmt.Errorf("Worker 运行数量不能为负数")
}
if *req.WorkerCount > db.MaxTaskWorkerCount {
return fmt.Errorf("Worker 运行数量最多 %d", db.MaxTaskWorkerCount)
}
}
return nil return nil
} }
@@ -125,12 +136,18 @@ func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error()) writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error())
return return
} }
workerCount := 0
if req.WorkerCount != nil {
workerCount = *req.WorkerCount
}
template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{ template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{
Name: stringValue(req.Name), Name: stringValue(req.Name),
Description: stringValue(req.Description), Description: stringValue(req.Description),
Goal: stringValue(req.Goal), Goal: stringValue(req.Goal),
Remark: stringValue(req.Remark),
CategoryID: req.CategoryID, CategoryID: req.CategoryID,
InterceptRules: rules, InterceptRules: rules,
WorkerCount: workerCount,
}) })
if err != nil { if err != nil {
writeTaskTemplateErr(w, err) writeTaskTemplateErr(w, err)
@@ -160,11 +177,21 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
} }
_, catPresent := present["category_id"] _, catPresent := present["category_id"]
_, rulesPresent := present["intercept_rules"] _, rulesPresent := present["intercept_rules"]
if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent { _, remarkPresent := present["remark"]
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、category_id 或 intercept_rules") _, workerPresent := present["worker_count"]
if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent && !remarkPresent && !workerPresent {
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、remark、category_id、intercept_rules 或 worker_count")
return return
} }
patch := db.TaskTemplatePatch{Name: req.Name, Description: req.Description, Goal: req.Goal} patch := db.TaskTemplatePatch{Name: req.Name, Description: req.Description, Goal: req.Goal}
if remarkPresent {
if req.Remark == nil {
empty := ""
patch.Remark = &empty
} else {
patch.Remark = req.Remark
}
}
if catPresent { if catPresent {
patch.SetCategoryID = true patch.SetCategoryID = true
patch.CategoryID = req.CategoryID patch.CategoryID = req.CategoryID
@@ -178,6 +205,13 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
patch.SetInterceptRules = true patch.SetInterceptRules = true
patch.InterceptRules = rules patch.InterceptRules = rules
} }
if workerPresent {
n := 0
if req.WorkerCount != nil {
n = *req.WorkerCount
}
patch.WorkerCount = &n
}
template, err := pg.PatchTaskTemplate(id, patch) template, err := pg.PatchTaskTemplate(id, patch)
if err != nil { if err != nil {
writeTaskTemplateErr(w, err) writeTaskTemplateErr(w, err)
+1 -1
View File
@@ -5,9 +5,9 @@ import (
"encoding/json" "encoding/json"
"testing" "testing"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
) )
func names(tools []actool.CoreTool) map[string]actool.CoreTool { func names(tools []actool.CoreTool) map[string]actool.CoreTool {
@@ -404,6 +404,11 @@ function TaskDetailInner() {
</Button> </Button>
</div> </div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p> <p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{task.remark ? (
<p className="truncate text-xs text-muted-foreground" title={task.remark}>
备注:{task.remark}
</p>
) : null}
{/* Tabs */} {/* Tabs */}
<div className="no-scrollbar min-w-0 overflow-x-auto"> <div className="no-scrollbar min-w-0 overflow-x-auto">
<TabsList variant="default" className="min-w-max"> <TabsList variant="default" className="min-w-max">
+41 -1
View File
@@ -137,6 +137,7 @@ import type {
TaskCategory, TaskCategory,
TaskStatus, TaskStatus,
} from "@/lib/types"; } from "@/lib/types";
import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// fmtBytes renders a human file size for the upload manifest. // fmtBytes renders a human file size for the upload manifest.
@@ -3104,6 +3105,7 @@ function CreateTaskSheet({
}) { }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState(""); const [name, setName] = React.useState("");
const [remark, setRemark] = React.useState("");
const [categoryID, setCategoryID] = React.useState<number | undefined>(undefined); const [categoryID, setCategoryID] = React.useState<number | undefined>(undefined);
const [description, setDescription] = React.useState(""); const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState(""); const [goal, setGoal] = React.useState("");
@@ -3118,6 +3120,7 @@ function CreateTaskSheet({
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响) const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认
const [interceptRules, setInterceptRules] = React.useState<AssetInterceptRuleInput[]>([]); // 任务级资产拦截规则(仅本任务生效,不进全局表) const [interceptRules, setInterceptRules] = React.useState<AssetInterceptRuleInput[]>([]); // 任务级资产拦截规则(仅本任务生效,不进全局表)
const [scheduleEnabled, setScheduleEnabled] = React.useState(false); const [scheduleEnabled, setScheduleEnabled] = React.useState(false);
const [schedule, setSchedule] = React.useState<ScheduleInput>({ const [schedule, setSchedule] = React.useState<ScheduleInput>({
@@ -3185,8 +3188,10 @@ function CreateTaskSheet({
try { try {
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60; const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致 const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
const workerCountNum = Math.min(MAX_TASK_WORKER_COUNT, Math.max(0, Math.floor(Number(workerCount) || 0)));
await api.createTask({ await api.createTask({
name: name.trim(), name: name.trim(),
remark: remark.trim(),
categoryId: categoryID, categoryId: categoryID,
description: description.trim(), description: description.trim(),
goal: goal.trim(), goal: goal.trim(),
@@ -3197,6 +3202,7 @@ function CreateTaskSheet({
seedFirstIntent, seedFirstIntent,
planHeartbeatSeconds: heartbeatSec, planHeartbeatSeconds: heartbeatSec,
coverageEnabled, coverageEnabled,
workerCount: workerCountNum,
schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined, schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined,
interceptRules: interceptRules interceptRules: interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() })) .map((r) => ({ ...r, pattern: r.pattern.trim() }))
@@ -3204,6 +3210,7 @@ function CreateTaskSheet({
}); });
toast.success("任务已创建"); toast.success("任务已创建");
setName(""); setName("");
setRemark("");
setCategoryID(undefined); setCategoryID(undefined);
setDescription(""); setDescription("");
setGoal(""); setGoal("");
@@ -3215,6 +3222,7 @@ function CreateTaskSheet({
setHeartbeatMin("10"); setHeartbeatMin("10");
setSeedFirstIntent(false); setSeedFirstIntent(false);
setCoverageEnabled(true); setCoverageEnabled(true);
setWorkerCount("");
setInterceptRules([]); setInterceptRules([]);
setScheduleEnabled(false); setScheduleEnabled(false);
setSchedule({ setSchedule({
@@ -3263,15 +3271,19 @@ function CreateTaskSheet({
<TaskTemplateControls <TaskTemplateControls
description={description} description={description}
goal={goal} goal={goal}
remark={remark}
categoryID={categoryID} categoryID={categoryID}
interceptRules={interceptRules} interceptRules={interceptRules}
workerCount={workerCount}
selectedTemplateID={selectedTemplateID} selectedTemplateID={selectedTemplateID}
onSelectedTemplateIDChange={setSelectedTemplateID} onSelectedTemplateIDChange={setSelectedTemplateID}
onApply={(template) => { onApply={(template) => {
setDescription(template.description); setDescription(template.description);
setGoal(template.goal); setGoal(template.goal);
setRemark(template.remark ?? "");
setCategoryID(template.category_id ?? undefined); setCategoryID(template.category_id ?? undefined);
setInterceptRules(template.intercept_rules ?? []); setInterceptRules(template.intercept_rules ?? []);
setWorkerCount(template.worker_count ? String(template.worker_count) : "");
setUploadCount(0); setUploadCount(0);
}} }}
portalContainer={sheetContentRef} portalContainer={sheetContentRef}
@@ -3285,6 +3297,17 @@ function CreateTaskSheet({
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
<div className="grid gap-2">
<Label htmlFor="remark">备注(可选)</Label>
<Textarea
id="remark"
className="min-h-20"
maxLength={MAX_TASK_REMARK_RUNES}
placeholder="补充说明、环境信息等,便于在任务列表识别"
value={remark}
onChange={(e) => setRemark(e.target.value)}
/>
</div>
<Field> <Field>
<FieldLabel htmlFor="task-category">任务分类</FieldLabel> <FieldLabel htmlFor="task-category">任务分类</FieldLabel>
<CategoryPicker <CategoryPicker
@@ -3507,7 +3530,7 @@ function CreateTaskSheet({
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium"> <CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" /> <ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
高级设置 高级设置
<span className="text-muted-foreground ml-auto text-xs font-normal">超时 · 心跳 · 首个意图</span> <span className="text-muted-foreground ml-auto text-xs font-normal">超时 · 心跳 · Worker · 首个意图</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="grid gap-5 pt-5"> <CollapsibleContent className="grid gap-5 pt-5">
<div className="grid gap-2"> <div className="grid gap-2">
@@ -3541,6 +3564,23 @@ function CreateTaskSheet({
worker)。下限 10 分钟。 worker)。下限 10 分钟。
</p> </p>
</div> </div>
<div className="grid gap-2">
<Label htmlFor="worker-count">Worker 运行数量(可选)</Label>
<Input
id="worker-count"
type="number"
min={0}
max={MAX_TASK_WORKER_COUNT}
className="w-40"
placeholder="留空 = 全局默认"
value={workerCount}
onChange={(e) => setWorkerCount(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
单独设置本任务并发运行的工作 Agent 数量;留空或 0 = 用全局默认(系统设置里的工作并发,默认
3)。上限 50。
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor="seed-first-intent" className="flex items-center gap-2 text-sm"> <label htmlFor="seed-first-intent" className="flex items-center gap-2 text-sm">
<Checkbox <Checkbox
+2 -1
View File
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MAX_TASK_REMARK_RUNES } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type EditorProps = { type EditorProps = {
@@ -17,7 +18,7 @@ type EditorProps = {
}; };
export function RemarkEditor(props: EditorProps) { export function RemarkEditor(props: EditorProps) {
return <InlineTextEditor {...props} multiline fieldName="备注" maxCharacters={2000} emptyLabel="添加备注" />; return <InlineTextEditor {...props} multiline fieldName="备注" maxCharacters={MAX_TASK_REMARK_RUNES} emptyLabel="添加备注" />;
} }
export function ScheduleNameEditor(props: EditorProps) { export function ScheduleNameEditor(props: EditorProps) {
+47 -3
View File
@@ -33,19 +33,21 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { AssetInterceptRuleInput, TaskCategory, TaskTemplate } from "@/lib/types"; import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT, type AssetInterceptRuleInput, type TaskCategory, type TaskTemplate } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface TemplateDraft { interface TemplateDraft {
name: string; name: string;
description: string; description: string;
goal: string; goal: string;
remark: string;
categoryID: number | null; categoryID: number | null;
interceptRules: AssetInterceptRuleInput[]; interceptRules: AssetInterceptRuleInput[];
workerCount: string; // 输入框用字符串;空/0=用全局默认
} }
// TemplateSeed 是「另存为模板」时从创建表单带入的初值。 // TemplateSeed 是「另存为模板」时从创建表单带入的初值。
type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "categoryID" | "interceptRules">; type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "remark" | "categoryID" | "interceptRules" | "workerCount">;
interface TaskTemplateManagerProps { interface TaskTemplateManagerProps {
open: boolean; open: boolean;
@@ -61,8 +63,10 @@ const emptyDraft = (): TemplateDraft => ({
name: "", name: "",
description: "", description: "",
goal: "", goal: "",
remark: "",
categoryID: null, categoryID: null,
interceptRules: [], interceptRules: [],
workerCount: "",
}); });
function templateDraft(template: TaskTemplate): TemplateDraft { function templateDraft(template: TaskTemplate): TemplateDraft {
@@ -70,8 +74,10 @@ function templateDraft(template: TaskTemplate): TemplateDraft {
name: template.name, name: template.name,
description: template.description, description: template.description,
goal: template.goal, goal: template.goal,
remark: template.remark ?? "",
categoryID: template.category_id ?? null, categoryID: template.category_id ?? null,
interceptRules: template.intercept_rules ?? [], interceptRules: template.intercept_rules ?? [],
workerCount: template.worker_count ? String(template.worker_count) : "",
}; };
} }
@@ -108,8 +114,10 @@ function TaskTemplateManager({
name: "", name: "",
description: seed.description, description: seed.description,
goal: seed.goal, goal: seed.goal,
remark: seed.remark,
categoryID: seed.categoryID, categoryID: seed.categoryID,
interceptRules: seed.interceptRules, interceptRules: seed.interceptRules,
workerCount: seed.workerCount,
}); });
} else if (templates[0]) { } else if (templates[0]) {
setSelectedID(templates[0].id); setSelectedID(templates[0].id);
@@ -132,7 +140,7 @@ function TaskTemplateManager({
setDraft(emptyDraft()); setDraft(emptyDraft());
}; };
const updateDraft = (field: "name" | "description" | "goal", value: string) => { const updateDraft = (field: "name" | "description" | "goal" | "remark" | "workerCount", value: string) => {
setDraft((current) => ({ ...current, [field]: value })); setDraft((current) => ({ ...current, [field]: value }));
}; };
@@ -145,7 +153,9 @@ function TaskTemplateManager({
name: draft.name.trim(), name: draft.name.trim(),
description: draft.description.trim(), description: draft.description.trim(),
goal: draft.goal.trim(), goal: draft.goal.trim(),
remark: draft.remark.trim(),
category_id: draft.categoryID, category_id: draft.categoryID,
worker_count: Math.min(MAX_TASK_WORKER_COUNT, Math.max(0, Math.floor(Number(draft.workerCount) || 0))),
intercept_rules: draft.interceptRules intercept_rules: draft.interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() })) .map((r) => ({ ...r, pattern: r.pattern.trim() }))
.filter((r) => r.pattern !== ""), .filter((r) => r.pattern !== ""),
@@ -232,6 +242,9 @@ function TaskTemplateManager({
> >
<span className="block truncate font-medium text-sm">{template.name}</span> <span className="block truncate font-medium text-sm">{template.name}</span>
<span className="block truncate text-muted-foreground text-xs">{template.description}</span> <span className="block truncate text-muted-foreground text-xs">{template.description}</span>
{template.remark ? (
<span className="block truncate text-muted-foreground/80 text-xs">备注:{template.remark}</span>
) : null}
</button> </button>
))} ))}
</div> </div>
@@ -269,6 +282,31 @@ function TaskTemplateManager({
onChange={(event) => updateDraft("goal", event.target.value)} onChange={(event) => updateDraft("goal", event.target.value)}
/> />
</Field> </Field>
<Field>
<FieldLabel htmlFor="task-template-remark">备注(可选)</FieldLabel>
<Textarea
id="task-template-remark"
className="min-h-20"
maxLength={MAX_TASK_REMARK_RUNES}
value={draft.remark}
placeholder="预设备注,应用模板时写入新任务"
onChange={(event) => updateDraft("remark", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-worker-count">Worker 运行数量(可选)</FieldLabel>
<Input
id="task-template-worker-count"
type="number"
min={0}
max={MAX_TASK_WORKER_COUNT}
className="w-40"
placeholder="留空 = 全局默认"
value={draft.workerCount}
onChange={(event) => updateDraft("workerCount", event.target.value)}
/>
<FieldDescription>应用模板时预填本任务 Worker 数;留空或 0 = 用全局默认。</FieldDescription>
</Field>
<Field> <Field>
<FieldLabel htmlFor="task-template-category">任务分类</FieldLabel> <FieldLabel htmlFor="task-template-category">任务分类</FieldLabel>
<NativeSelect <NativeSelect
@@ -347,8 +385,10 @@ function TaskTemplateManager({
interface TaskTemplateControlsProps { interface TaskTemplateControlsProps {
description: string; description: string;
goal: string; goal: string;
remark?: string;
categoryID?: number; categoryID?: number;
interceptRules?: AssetInterceptRuleInput[]; interceptRules?: AssetInterceptRuleInput[];
workerCount?: string;
selectedTemplateID: number | null; selectedTemplateID: number | null;
onSelectedTemplateIDChange: (id: number | null) => void; onSelectedTemplateIDChange: (id: number | null) => void;
onApply: (template: TaskTemplate) => void; onApply: (template: TaskTemplate) => void;
@@ -358,8 +398,10 @@ interface TaskTemplateControlsProps {
export function TaskTemplateControls({ export function TaskTemplateControls({
description, description,
goal, goal,
remark = "",
categoryID, categoryID,
interceptRules, interceptRules,
workerCount = "",
selectedTemplateID, selectedTemplateID,
onSelectedTemplateIDChange, onSelectedTemplateIDChange,
onApply, onApply,
@@ -455,8 +497,10 @@ export function TaskTemplateControls({
openManager({ openManager({
description, description,
goal, goal,
remark,
categoryID: categoryID ?? null, categoryID: categoryID ?? null,
interceptRules: interceptRules ?? [], interceptRules: interceptRules ?? [],
workerCount,
}) })
} }
> >
+6 -2
View File
@@ -300,6 +300,7 @@ export const api = {
task: (id: string) => get<Task>(`/tasks/${encodeURIComponent(id)}`), task: (id: string) => get<Task>(`/tasks/${encodeURIComponent(id)}`),
createTask: (input: { createTask: (input: {
name?: string; name?: string;
remark?: string;
categoryId?: number; categoryId?: number;
description: string; description: string;
goal: string; goal: string;
@@ -310,11 +311,13 @@ export const api = {
seedFirstIntent?: boolean; seedFirstIntent?: boolean;
planHeartbeatSeconds?: number; planHeartbeatSeconds?: number;
coverageEnabled?: boolean; coverageEnabled?: boolean;
workerCount?: number;
interceptRules?: AssetInterceptRuleInput[]; interceptRules?: AssetInterceptRuleInput[];
schedule?: ScheduleInput; schedule?: ScheduleInput;
}) => }) =>
post<Task>("/tasks", { post<Task>("/tasks", {
name: input.name ?? "", name: input.name ?? "",
remark: input.remark ?? "",
category_id: input.categoryId ?? null, category_id: input.categoryId ?? null,
description: input.description, description: input.description,
goal: input.goal, goal: input.goal,
@@ -325,6 +328,7 @@ export const api = {
seed_first_intent: input.seedFirstIntent ?? false, seed_first_intent: input.seedFirstIntent ?? false,
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能 coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则 intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
schedule: input.schedule ? schedulePayload(input.schedule) : undefined, schedule: input.schedule ? schedulePayload(input.schedule) : undefined,
}), }),
@@ -357,11 +361,11 @@ export const api = {
}), }),
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)), taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
createTaskTemplate: ( createTaskTemplate: (
input: Pick<TaskTemplate, "name" | "description" | "goal" | "category_id" | "intercept_rules">, input: Pick<TaskTemplate, "name" | "description" | "goal" | "remark" | "category_id" | "intercept_rules" | "worker_count">,
) => post<TaskTemplate>("/task-templates", input), ) => post<TaskTemplate>("/task-templates", input),
updateTaskTemplate: ( updateTaskTemplate: (
id: number, id: number,
input: Partial<Pick<TaskTemplate, "name" | "description" | "goal" | "category_id" | "intercept_rules">>, input: Partial<Pick<TaskTemplate, "name" | "description" | "goal" | "remark" | "category_id" | "intercept_rules" | "worker_count">>,
) => patch<TaskTemplate>(`/task-templates/${id}`, input), ) => patch<TaskTemplate>(`/task-templates/${id}`, input),
deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`), deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`),
updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) => updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
+11
View File
@@ -33,6 +33,7 @@ import type {
TrafficEvidenceRole, TrafficEvidenceRole,
TrafficEvidenceSnapshot, TrafficEvidenceSnapshot,
} from "../types"; } from "../types";
import { MAX_TASK_WORKER_COUNT } from "../types";
import * as D from "./data"; import * as D from "./data";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms)); const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
@@ -1182,10 +1183,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const created: Task = { const created: Task = {
id, id,
name: String(b.name ?? ""), name: String(b.name ?? ""),
remark: typeof b.remark === "string" ? b.remark.trim() : "",
category_id: category?.id, category_id: category?.id,
category_name: category?.name, category_name: category?.name,
description: String(b.description ?? "新任务"), description: String(b.description ?? "新任务"),
goal: String(b.goal ?? ""), 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,
status: "created", status: "created",
created_at: now.toISOString(), created_at: now.toISOString(),
created_unix: Math.floor(now.getTime() / 1000), created_unix: Math.floor(now.getTime() / 1000),
@@ -1315,6 +1318,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
name, name,
description, description,
goal, goal,
remark: typeof b.remark === "string" ? b.remark.trim() : "",
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,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1338,6 +1345,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
template.name = name; template.name = name;
template.description = description; template.description = description;
template.goal = goal; template.goal = goal;
if (typeof b.remark === "string") template.remark = b.remark.trim();
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.updated_at = new Date().toISOString(); template.updated_at = new Date().toISOString();
mockTaskTemplates.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.id - a.id); mockTaskTemplates.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.id - a.id);
return template; return template;
+7
View File
@@ -4,10 +4,15 @@
export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" | "failed" | "timeout"; export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" | "failed" | "timeout";
export type EngineMode = "exploring" | "paused" | "stalled" | "idle"; export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
// Keep these limits aligned with db.MaxTaskRemarkRunes and db.MaxTaskWorkerCount.
export const MAX_TASK_REMARK_RUNES = 2000;
export const MAX_TASK_WORKER_COUNT = 50;
export interface Task { export interface Task {
distinct_findings?: FindingDistinctStats; distinct_findings?: FindingDistinctStats;
id: string; id: string;
remark?: string; remark?: string;
worker_count?: number; // 本任务 Worker 数;0/缺省=用全局默认
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述 name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
category_id?: number; category_id?: number;
category_name?: string; category_name?: string;
@@ -56,8 +61,10 @@ export interface TaskTemplate {
name: string; name: string;
description: string; description: string;
goal: string; goal: string;
remark?: string; // 预设备注
category_id?: number | null; // 预设分类;null/缺省=无 category_id?: number | null; // 预设分类;null/缺省=无
intercept_rules?: AssetInterceptRuleInput[]; // 预设的任务级拦截/允许规则 intercept_rules?: AssetInterceptRuleInput[]; // 预设的任务级拦截/允许规则
worker_count?: number; // 预设 Worker 数;0/缺省=用全局默认
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }