feat: make task global asset sharing opt-in

This commit is contained in:
ji ruo committed 2026-11-05 16:27:44 +08:00
1 parent d36e1c3295
commit 080a46eb2d
25 files changed
+838 -328

No files matched your search

+3 -1
View File
@@ -69,7 +69,8 @@ type TaskDTO struct {
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
CompanyIDs []int64 `json:"company_ids"`
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
ShareGlobalAssets bool `json:"share_global_assets"`
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
}
type ProxyReconnectDTO struct {
@@ -163,6 +164,7 @@ func taskDTO(t *Task, status string) TaskDTO {
SourceTaskIDs: sourceIDs,
CompanyIDs: lifecycle.CompanyIDs,
CoverageEnabled: t.CoverageEnabled,
ShareGlobalAssets: lifecycle.ShareGlobalAssets,
WorkerCount: t.WorkerCount,
}
}
+15 -10
View File
@@ -68,7 +68,8 @@ type Task struct {
TimeoutSeconds int `json:"timeout_seconds"`
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
ShareGlobalAssets bool `json:"share_global_assets"`
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
FirstRunAt int64 `json:"first_run_at,omitempty"`
DeadlineAt int64 `json:"deadline_at,omitempty"`
ActiveRunSeconds int64 `json:"active_run_seconds"`
@@ -91,6 +92,7 @@ type Task struct {
// updateLifecycle instead of reading or writing the corresponding Task fields
// directly after the task has been published by Manager.
type taskLifecycleState struct {
ShareGlobalAssets bool
Name string
Remark string
PinnedAt int64
@@ -128,13 +130,14 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState {
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
return taskLifecycleState{
Name: t.Name,
Remark: t.Remark,
PinnedAt: t.PinnedAt,
Status: t.Status,
Paused: t.Paused,
PauseReason: t.PauseReason,
ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration,
ShareGlobalAssets: t.ShareGlobalAssets,
Name: t.Name,
Remark: t.Remark,
PinnedAt: t.PinnedAt,
Status: t.Status,
Paused: t.Paused,
PauseReason: t.PauseReason,
ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration,
Queued: t.Queued,
QueuedAt: t.QueuedAt,
QueueMode: t.QueueMode,
@@ -157,6 +160,7 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
t.lifecycleMu.Lock()
state := t.lifecycleSnapshotLocked()
update(&state)
t.ShareGlobalAssets = state.ShareGlobalAssets
t.Name = state.Name
t.Remark = state.Remark
t.PinnedAt = state.PinnedAt
@@ -1153,14 +1157,14 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
CoverageEnabled: pt.CoverageEnabled, WorkerCount: pt.WorkerCount,
CoverageEnabled: pt.CoverageEnabled, ShareGlobalAssets: pt.ShareGlobalAssets, WorkerCount: pt.WorkerCount,
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
ActiveRunSeconds: pt.ActiveRunSeconds, RunSegmentStartedAt: unixOrZero(pt.RunSegmentStartedAt),
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
}
}
// UpdateTaskMetadata changes list-only task metadata without interrupting any
// UpdateTaskMetadata changes task metadata and asset visibility without interrupting any
// planner, main-agent, or worker call.
func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) {
m.taskStateMu.Lock()
@@ -1180,6 +1184,7 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task
return nil, nil
}
task.updateLifecycle(func(state *taskLifecycleState) {
state.ShareGlobalAssets = updated.ShareGlobalAssets
state.Name = updated.Name
state.Remark = updated.Remark
state.PinnedAt = unixOrZero(updated.PinnedAt)
+9 -7
View File
@@ -1710,9 +1710,10 @@ type createTaskReq struct {
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
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 轮)。
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
ShareGlobalAssets bool `json:"share_global_assets"`
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 []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
}
@@ -1794,10 +1795,11 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
CoverageEnabled: req.CoverageEnabled,
Remark: remark,
WorkerCount: req.WorkerCount,
InterceptRules: interceptRules,
CoverageEnabled: req.CoverageEnabled,
ShareGlobalAssets: req.ShareGlobalAssets,
Remark: remark,
WorkerCount: req.WorkerCount,
InterceptRules: interceptRules,
})
if err != nil {
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
+8 -8
View File
@@ -15,8 +15,7 @@ const (
maxTaskMetadataRequestSize = 16 << 10
)
// updateTaskMetadata changes list-only task metadata. It intentionally does not
// touch lifecycle state, scheduling, or the task's immutable description/goal.
// updateTaskMetadata updates editable metadata and the live asset-sharing policy.
func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
if _, ok := s.m.Task(taskID); !ok {
@@ -25,9 +24,10 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
}
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
var request struct {
Name *string `json:"name"`
Remark *string `json:"remark"`
Pinned *bool `json:"pinned"`
Name *string `json:"name"`
Remark *string `json:"remark"`
Pinned *bool `json:"pinned"`
ShareGlobalAssets *bool `json:"share_global_assets"`
}
if err := decode(r, &request); err != nil {
var tooLarge *http.MaxBytesError
@@ -38,8 +38,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
}
return
}
if request.Name == nil && request.Pinned == nil && request.Remark == nil {
writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned 或 remark")
if request.Name == nil && request.Pinned == nil && request.Remark == nil && request.ShareGlobalAssets == nil {
writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned、remark 或 share_global_assets")
return
}
if request.Name != nil {
@@ -63,7 +63,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
}
request.Remark = &remark
}
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, Remark: request.Remark})
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, Remark: request.Remark, ShareGlobalAssets: request.ShareGlobalAssets})
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
+48
View File
@@ -60,6 +60,54 @@ func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) {
}
}
func TestTaskMetadataPatchGlobalAssetSharing(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer m.Close()
task, err := m.CreateTask("sharing patch", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
id, _ := strconv.ParseInt(task.ID, 10, 64)
defer m.pg.DeleteTask(id)
ctx, cancel := context.WithCancel(context.Background())
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
defer func() { cancel(); s.Close() }()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
for _, enabled := range []bool{true, false} {
body, _ := json.Marshal(map[string]any{"share_global_assets": enabled})
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch %d: %s", rec.Code, rec.Body.String())
}
var result struct {
Shared *bool `json:"share_global_assets"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
saved, err := m.pg.GetTask(id)
if err != nil || saved.ShareGlobalAssets != enabled || result.Shared == nil || *result.Shared != enabled || task.lifecycleSnapshot().ShareGlobalAssets != enabled {
t.Fatalf("sharing not propagated: %s %+v %v", rec.Body.String(), saved, err)
}
}
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, strings.NewReader(`{"share_global_assets":"true"}`))
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
if rec.Code != 400 {
t.Fatalf("invalid boolean status=%d", rec.Code)
}
}
func TestConversationBatchDeleteReportsMissing(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {