mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
feat: 任务/模板备注展示与 Worker 运行数量配置(Codex gpt-6-astra 重写)
This commit is contained in:
1 parent
ba3025d04f
commit
a1dc362bae
21 files changed
+332
-32
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user