mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
Merge pull request #53 from neouks/main
feat: 同步 0.3.3 并增强任务、资产与 LLM 运行时 冲突解决说明: main 此前已合入同源分支(#42/#31)的大部分内容,与本 PR 形成大量重复历史, git 报出 18 个文件 104 处冲突。逐块核对后确认 PR 树已完整包含 main 的全部 内容(net diff 无任何纯删除,adc8e35/476a445/9fad543/9d8e0ba 等 main 侧修复 均已在内),故统一取 PR 侧。 另修正 db/assets.go 的静默错误自动合并: git 未报冲突但把 CountByTask / CountsByTypeForTask / DeleteByTaskID 等 166 行重复插入两遍,会导致方法重复 定义编译失败。 相对 main 的净新增功能: - 任务分类: task_categories 表 + tasks.category_id,分类增删改查与筛选, 改名时同步刷新运行中任务的 DTO - 任务测试资产: task_asset_links 表记录关联来源(agent/manual/api/task/ anchor/system/legacy),支持实时挂载与移出;移出仅解绑,保留全局资产 - 测试范围新增 icp / keyword 两种 kind,ICP 按去空格小写匹配 icp/app_icp - Worker 会话标题旁展示当前测试资产及来源摘要,继承任务仅暴露终态意图 - 前端抽出公共 ScopeTextEditor,补齐上述接口的 mock 契约 验证: go build / go vet / go test / tsc --noEmit 均通过。
This commit is contained in:
commit
6b44a94ec7
29 files changed
+5889
-667
No files matched your search
+7
-1
@@ -33,12 +33,14 @@
|
||||
- 运行中和暂停任务支持编辑完整配置链、调整顺序和手动切换当前配置,从下一次 LLM 调用生效。
|
||||
- 自动切换、手动切换和全链耗尽写入结构化系统活动,并通过任务活动流弹出可去重提醒。
|
||||
- 增加任务角色模型解析接口,统一解析 Main Agent、Planner 和 Worker 下一次调用使用的配置与模型。
|
||||
- 增加可选的全局 LLM Pool,支持配置调用顺序、指定模型失败兜底、健康状态、冷却恢复和手动重置。
|
||||
- 增加始终开启的 LLM 用量账本,按任务、会话、模型和配置聚合输入、输出及缓存 Token。
|
||||
|
||||
#### 修改的功能
|
||||
|
||||
- 目标拆解、Planner、Worker 和主 Agent 共享任务级 LLM runtime(解析优先级见下)。
|
||||
- 故障转移只响应明确的额度、余额或账单错误;普通限流、鉴权、网络、服务端和上下文错误不会错误切换。
|
||||
- LLM 配置页改为配置卡片列表和右侧抽屉编辑,保留任务级配置链,不引入 Pool 轮询、优先级和熔断界面。
|
||||
- LLM 配置页改为配置卡片列表和右侧抽屉编辑,并提供 Pool 轮询顺序、优先级、排除项、健康状态和恢复操作。
|
||||
- 配置链耗尽信息支持窄屏换行;当前模型由会话列表图标改为当前会话标题旁的文字标签和完整 Tooltip。
|
||||
- 任务内各角色解析 LLM 的顺序调整为「Agent 绑定 → 任务配置链 → 全局」:显式绑定模型的角色始终跑在该模型上,未绑定角色才落到任务链,任务链为空时再落到全局。
|
||||
- 出站代理留空即直连,不再回退 `HTTP_PROXY`/`HTTPS_PROXY` 环境变量(显式 `ARTEX_LLM_PROXY` 不受影响);代理输入支持带账号密码的 `socks5://user:pass@host:port`。
|
||||
@@ -55,13 +57,16 @@
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
- 任务支持全局分类的创建、重命名、删除和筛选;新建任务可直接选择分类。
|
||||
- 增加全局任务模板 CRUD 和右侧管理抽屉;新建任务可加载预设描述与目标,也可将当前内容另存为模板。
|
||||
- 新建任务支持关联多个来源任务和多个企业资产范围;企业范围通过单个多行文本框自动识别域名、URL、IP、CIDR、ICP 和企业关键词。
|
||||
- 任务测试资产支持实时新增和删除,记录人工、企业、继承或 Agent 发现等来源;Worker 会话标题旁显示当前测试资产及来源摘要。
|
||||
- 发现页按任务分组并提供组内独立分页,支持为漏洞填写描述并创建高优先级深入意图。
|
||||
- 对话支持重命名、置顶、取消置顶和删除;任务列表支持当前页批量暂停与继续。
|
||||
- 流量详情和工具执行详情改为右侧抽屉;HTTP 报文补齐 `Host` 并高亮请求行、状态码、Header、JSON 和标记语言正文。
|
||||
- 任务列表操作列新增「详情」与「暂停/继续」按钮;会话发送键位可在系统设置中配置(`Enter` / `Cmd+Enter` 等),Web 搜索区新增代理输入框。
|
||||
- 会话标题右侧徽标改为显示 LLM 配置名称,模型 ID 移至 hover Tooltip;任务可设置可选名称,任务列表新增名称列与搜索(空则回退到描述)。
|
||||
- 技能页增加使用次数、最近使用时间和缺失依赖统计,便于定位未生效或未安装的技能。
|
||||
|
||||
#### 修改的功能
|
||||
|
||||
@@ -136,6 +141,7 @@
|
||||
|
||||
- 创建任务新增「资产覆盖度功能」开关(默认开):关闭后不计算与展示覆盖度、态势图只显示资产、不自动累积 `task_scope`,相关工具从 Planner / 主 Agent 剔除;企业关联不受此开关影响。
|
||||
- `insert_assets` 每条资产新增 `related` 标记(默认 `true`):仅在开启覆盖度时生效,`false` 只入共享资产库、不计入本任务覆盖度(如顺带发现的旁站或无关资产)。
|
||||
- 企业范围和任务测试资产统一支持域名、URL、IP、CIDR、ICP 与关键词文本识别;域名/IP 可创建或复用全局资产,CIDR/ICP/关键词保留为任务范围上下文。
|
||||
|
||||
### 构建
|
||||
|
||||
|
||||
@@ -486,6 +486,8 @@ func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
e["value"] = r.Domain
|
||||
case r.Net != "":
|
||||
e["value"] = r.Net
|
||||
case r.Value != "":
|
||||
e["value"] = r.Value
|
||||
case r.CompanyID != nil:
|
||||
e["company_id"] = *r.CompanyID
|
||||
if t.cs != nil {
|
||||
@@ -804,6 +806,8 @@ func (t *ToolSet) relatedTaskOverviews() []map[string]any {
|
||||
entry["value"] = budget.take(row.Domain, 400)
|
||||
case row.Net != "":
|
||||
entry["value"] = budget.take(row.Net, 400)
|
||||
case row.Value != "":
|
||||
entry["value"] = budget.take(row.Value, 400)
|
||||
case row.CompanyID != nil:
|
||||
entry["company_id"] = *row.CompanyID
|
||||
}
|
||||
|
||||
+19
-6
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -284,6 +285,18 @@ func (t *ToolSet) insertAssets() actool.CoreTool {
|
||||
results = append(results, result{Index: i, ID: id, Type: typ})
|
||||
t.writes.Assets++
|
||||
t.anchorOwner(id)
|
||||
if taskID > 0 {
|
||||
var sourceNodeID *int64
|
||||
if t.ownerNode > 0 {
|
||||
nodeID := t.ownerNode
|
||||
sourceNodeID = &nodeID
|
||||
}
|
||||
summary := "Agent 通过 insert_assets 登记"
|
||||
if t.ownerNode > 0 {
|
||||
summary = fmt.Sprintf("Worker 意图 #%d 通过 insert_assets 登记", t.ownerNode)
|
||||
}
|
||||
_ = t.as.SetTaskAssetSource(taskID, id, "agent", summary, sourceNodeID)
|
||||
}
|
||||
// 自动入测试范围(source='auto'):只对 worker 顶层显式插入的这一项,按其
|
||||
// 类型加保守范围;side-effect 派生的资产不经此处,故范围不盲目扩大。taskID=0 时无操作。
|
||||
// 资产覆盖度功能关闭时不再累积测试范围(分母);related=false(与当前任务无关)
|
||||
@@ -365,14 +378,14 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
|
||||
return writeTool(
|
||||
"add_task_scope",
|
||||
"把测试范围加入【本任务】——这是资产测试覆盖度的分母,也是本任务的授权边界。\n"+
|
||||
"kind 支持:company(整个公司名下资产) / root_domain(整个根域,含所有子域) / subdomain(单个精确子域) / ip / cidr。\n"+
|
||||
"kind 支持:company(整个公司名下资产) / root_domain(整个根域,含所有子域) / subdomain(单个精确子域) / ip / cidr / icp / keyword。\n"+
|
||||
"说明:worker 逐个碰到的主机会被系统【自动】加进范围(精确子域);本工具用于【主动扩大】——把整个根域/整个公司纳入,或补充指定某子域/IP。\n"+
|
||||
"value:company 传公司名或 id(公司须已存在);root_domain/subdomain 传域名;ip/cidr 传 IP 或网段。\n"+
|
||||
"value:company 传公司名或 id(公司须已存在);root_domain/subdomain 传域名;ip/cidr 传 IP 或网段;icp/keyword 传备案号或企业关键词。\n"+
|
||||
"务必给 reason 说明依据(可审计)。多条用 entries 数组。",
|
||||
obj(map[string]any{
|
||||
"entries": map[string]any{"type": "array", "description": "批量:[{kind, value}]。kind∈company/root_domain/subdomain/ip/cidr;value=公司名或id / 域名 / IP / CIDR。", "items": map[string]any{"type": "object"}},
|
||||
"kind": str("[单条] company / root_domain / subdomain / ip / cidr"),
|
||||
"value": str("[单条] 公司名或id / 域名 / IP / CIDR"),
|
||||
"entries": map[string]any{"type": "array", "description": "批量:[{kind, value}]。kind∈company/root_domain/subdomain/ip/cidr/icp/keyword。", "items": map[string]any{"type": "object"}},
|
||||
"kind": str("[单条] company / root_domain / subdomain / ip / cidr / icp / keyword"),
|
||||
"value": str("[单条] 公司名或id / 域名 / IP / CIDR / ICP / 关键词"),
|
||||
"reason": str("加入依据(用于审计),务必填写"),
|
||||
}),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
@@ -404,7 +417,7 @@ func (t *ToolSet) addTaskScope() actool.CoreTool {
|
||||
errs[strconv.Itoa(i)] = err.Error()
|
||||
continue
|
||||
}
|
||||
added = append(added, map[string]any{"kind": ts.Kind, "domain": ts.Domain, "net": ts.Net, "company_id": ts.CompanyID})
|
||||
added = append(added, map[string]any{"kind": ts.Kind, "domain": ts.Domain, "net": ts.Net, "value": ts.Value, "company_id": ts.CompanyID})
|
||||
}
|
||||
out := map[string]any{"added": added}
|
||||
if len(errs) > 0 {
|
||||
|
||||
+13
-3
@@ -52,8 +52,11 @@ type Asset struct {
|
||||
Method string `json:"method,omitempty"`
|
||||
Params []map[string]any `json:"params,omitempty"`
|
||||
// meta
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
TaskSource string `json:"task_source,omitempty"`
|
||||
TaskSourceSummary string `json:"task_source_summary,omitempty"`
|
||||
TaskSourceNodeID *int64 `json:"task_source_node_id,omitempty"`
|
||||
}
|
||||
|
||||
// AuthItem is one entry in the auth array.
|
||||
@@ -1140,7 +1143,14 @@ FROM assets WHERE $1 = ANY(task_ids)`
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssets(rows)
|
||||
assets, err := scanAssets(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydrateTaskAssetSources(taskID, assets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (s *AssetStore) CountByTask(taskID int64, typ string) (int, error) {
|
||||
|
||||
+77
-3
@@ -337,9 +337,24 @@ CREATE TABLE IF NOT EXISTS llm_profile_health (
|
||||
-- =====================================================================
|
||||
-- D. 任务层
|
||||
-- =====================================================================
|
||||
-- Global task categories are intentionally independent from task templates.
|
||||
-- Deleting a category only moves its tasks back to the uncategorized bucket.
|
||||
CREATE TABLE IF NOT EXISTS task_categories (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
nkey TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_categories_name ON task_categories(name, id);
|
||||
DROP TRIGGER IF EXISTS trg_task_categories_upd ON task_categories;
|
||||
CREATE TRIGGER trg_task_categories_upd BEFORE UPDATE ON task_categories
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL,
|
||||
description TEXT NOT NULL,
|
||||
goal TEXT NOT NULL,
|
||||
exploration_id BIGINT NOT NULL UNIQUE
|
||||
@@ -385,8 +400,11 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT '';
|
||||
-- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL AND category_id IS NOT NULL;
|
||||
|
||||
-- Reusable task description/goal presets. nkey is the normalized, case-insensitive
|
||||
-- identity used to reject visually equivalent duplicate names.
|
||||
@@ -415,6 +433,55 @@ CREATE TABLE IF NOT EXISTS task_relations (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_relations_source ON task_relations(source_task_id);
|
||||
|
||||
-- Task/asset provenance supplements the legacy assets.task_ids association. The
|
||||
-- array remains the compatibility source for existing query and cleanup paths;
|
||||
-- this relation records how each association was obtained for operator review.
|
||||
CREATE TABLE IF NOT EXISTS task_asset_links (
|
||||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL DEFAULT 'system',
|
||||
source_summary TEXT NOT NULL DEFAULT '',
|
||||
source_node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (task_id, asset_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_asset_links_asset ON task_asset_links(asset_id, task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_asset_links_node ON task_asset_links(source_node_id)
|
||||
WHERE source_node_id IS NOT NULL;
|
||||
DROP TRIGGER IF EXISTS trg_task_asset_links_upd ON task_asset_links;
|
||||
CREATE TRIGGER trg_task_asset_links_upd BEFORE UPDATE ON task_asset_links
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- Keep provenance rows synchronized when existing asset upsert paths append or
|
||||
-- remove task ids. Detailed callers overwrite the generic source after upsert.
|
||||
CREATE OR REPLACE FUNCTION sync_task_asset_links() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary)
|
||||
SELECT task.id, NEW.id, 'system', '任务执行期间自动关联'
|
||||
FROM unnest(NEW.task_ids) AS requested(task_id)
|
||||
JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL
|
||||
ON CONFLICT (task_id, asset_id) DO NOTHING;
|
||||
|
||||
DELETE FROM task_asset_links link
|
||||
WHERE link.asset_id=NEW.id
|
||||
AND NOT (link.task_id=ANY(NEW.task_ids));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
DROP TRIGGER IF EXISTS trg_assets_task_links ON assets;
|
||||
CREATE TRIGGER trg_assets_task_links AFTER INSERT OR UPDATE OF task_ids ON assets
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_task_asset_links();
|
||||
|
||||
-- Existing installations receive an auditable legacy source without rewriting
|
||||
-- task_ids. Ignore stale array ids that no longer resolve to a live task.
|
||||
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary)
|
||||
SELECT task.id, asset.id, 'legacy', '由历史任务资产关联迁移'
|
||||
FROM assets asset
|
||||
CROSS JOIN LATERAL unnest(asset.task_ids) AS requested(task_id)
|
||||
JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL
|
||||
ON CONFLICT (task_id, asset_id) DO NOTHING;
|
||||
|
||||
-- Ordered task-level LLM failover chain. A quota-exhausted entry is skipped
|
||||
-- until the user saves/resets the chain, which clears all failure state.
|
||||
CREATE TABLE IF NOT EXISTS task_llm_profiles (
|
||||
@@ -462,17 +529,24 @@ WHERE t.active_llm_profile_id IS NULL
|
||||
CREATE TABLE IF NOT EXISTS task_scope (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr')),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword')),
|
||||
company_id BIGINT REFERENCES companies(id) ON DELETE CASCADE, -- kind='company'
|
||||
domain TEXT, -- root_domain / subdomain
|
||||
net CIDR, -- ip / cidr
|
||||
value TEXT, -- icp / keyword
|
||||
source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')),
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- 旧库升级:扩展任务范围,使其与企业范围的单文本框识别能力一致。
|
||||
ALTER TABLE task_scope ADD COLUMN IF NOT EXISTS value TEXT;
|
||||
ALTER TABLE task_scope DROP CONSTRAINT IF EXISTS task_scope_kind_check;
|
||||
ALTER TABLE task_scope ADD CONSTRAINT task_scope_kind_check
|
||||
CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword'));
|
||||
-- 去重:同一 task 的同一条范围只存一次(自动填批量插入靠它幂等)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope ON task_scope(
|
||||
task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0));
|
||||
DROP INDEX IF EXISTS uq_task_scope;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope_v2 ON task_scope(
|
||||
task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0), COALESCE(value,''));
|
||||
CREATE INDEX IF NOT EXISTS idx_ts_domain ON task_scope(domain) WHERE kind IN ('root_domain','subdomain');
|
||||
CREATE INDEX IF NOT EXISTS idx_ts_net ON task_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr');
|
||||
CREATE INDEX IF NOT EXISTS idx_ts_company ON task_scope(company_id) WHERE kind = 'company';
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxTaskAssetMutationCount = 100
|
||||
MaxTaskAssetSummaryRunes = 500
|
||||
defaultTaskAssetSource = "system"
|
||||
manualTaskScopeSummary = "用户在测试资产页手工新增"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTaskAssetInvalid = errors.New("invalid task asset association")
|
||||
ErrTaskAssetTaskNotFound = errors.New("task not found")
|
||||
ErrTaskAssetAssetNotFound = errors.New("asset not found")
|
||||
)
|
||||
|
||||
// TaskAssetMutation summarizes one attach request. Attached counts newly added
|
||||
// associations; Existing counts requested assets that were already on the task.
|
||||
type TaskAssetMutation struct {
|
||||
Requested int `json:"requested"`
|
||||
Attached int `json:"attached"`
|
||||
Existing int `json:"existing"`
|
||||
}
|
||||
|
||||
// TaskAssetScopeMutation summarizes one free-form scope registration. Domain
|
||||
// and IP entries create or reuse global assets; every entry also becomes an
|
||||
// idempotent task_scope row.
|
||||
type TaskAssetScopeMutation struct {
|
||||
Requested int `json:"requested"`
|
||||
AssetsLinked int `json:"assets_linked"`
|
||||
AssetsExisting int `json:"assets_existing"`
|
||||
ScopesAdded int `json:"scopes_added"`
|
||||
ScopesExisting int `json:"scopes_existing"`
|
||||
}
|
||||
|
||||
// IntentAsset describes an asset explicitly anchored to a worker intent.
|
||||
type IntentAsset struct {
|
||||
IntentID int64 `json:"intent_id"`
|
||||
AssetID int64 `json:"asset_id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Source string `json:"source"`
|
||||
SourceSummary string `json:"source_summary"`
|
||||
SourceNodeID *int64 `json:"source_node_id,omitempty"`
|
||||
SourceTaskID int64 `json:"source_task_id"`
|
||||
Inherited bool `json:"inherited"`
|
||||
}
|
||||
|
||||
func normalizeTaskAssetIDs(ids []int64) ([]int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("%w: asset_ids is required", ErrTaskAssetInvalid)
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
normalized := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("%w: asset id must be positive", ErrTaskAssetInvalid)
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
normalized = append(normalized, id)
|
||||
if len(normalized) > MaxTaskAssetMutationCount {
|
||||
return nil, fmt.Errorf("%w: at most %d assets per request", ErrTaskAssetInvalid, MaxTaskAssetMutationCount)
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeTaskAssetSource(source, summary string) (string, string, error) {
|
||||
source = strings.TrimSpace(strings.ToLower(source))
|
||||
if source == "" {
|
||||
source = defaultTaskAssetSource
|
||||
}
|
||||
summary = strings.TrimSpace(summary)
|
||||
if utf8.RuneCountInString(summary) > MaxTaskAssetSummaryRunes {
|
||||
return "", "", fmt.Errorf("%w: source summary exceeds %d characters", ErrTaskAssetInvalid, MaxTaskAssetSummaryRunes)
|
||||
}
|
||||
return source, summary, nil
|
||||
}
|
||||
|
||||
// SetTaskAssetSource improves the generic trigger-created provenance for one
|
||||
// existing task association. It never creates or deletes an asset.
|
||||
func (s *AssetStore) SetTaskAssetSource(taskID, assetID int64, source, summary string, sourceNodeID *int64) error {
|
||||
if taskID <= 0 || assetID <= 0 {
|
||||
return fmt.Errorf("%w: task and asset ids must be positive", ErrTaskAssetInvalid)
|
||||
}
|
||||
source, summary, err := normalizeTaskAssetSource(source, summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := `
|
||||
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary, source_node_id)
|
||||
SELECT task.id, asset.id, $3, $4, $5
|
||||
FROM tasks task
|
||||
JOIN assets asset ON asset.id=$2 AND task.id=ANY(asset.task_ids)
|
||||
WHERE task.id=$1 AND task.deleted_at IS NULL
|
||||
ON CONFLICT (task_id, asset_id) DO UPDATE
|
||||
SET source=EXCLUDED.source,
|
||||
source_summary=EXCLUDED.source_summary,
|
||||
source_node_id=COALESCE(EXCLUDED.source_node_id, task_asset_links.source_node_id)`
|
||||
var result sql.Result
|
||||
if s.tx != nil {
|
||||
result, err = s.tx.Exec(query, taskID, assetID, source, summary, sourceNodeID)
|
||||
} else {
|
||||
result, err = s.db.Exec(query, taskID, assetID, source, summary, sourceNodeID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("%w: task or asset association does not exist", ErrTaskAssetInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterTaskAssetScopes accepts the same structured scope rules as enterprise
|
||||
// assets. The entire request is atomic: invalid input or any storage failure
|
||||
// leaves both global assets and task scope unchanged.
|
||||
func (s *AssetStore) RegisterTaskAssetScopes(taskID int64, inputs []ScopeInput) (TaskAssetScopeMutation, error) {
|
||||
mutation := TaskAssetScopeMutation{Requested: len(inputs)}
|
||||
if taskID <= 0 {
|
||||
return mutation, fmt.Errorf("%w: task id must be positive", ErrTaskAssetInvalid)
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return mutation, fmt.Errorf("%w: scope is required", ErrTaskAssetInvalid)
|
||||
}
|
||||
if err := ValidateCompanyScopeInputBounds(inputs); err != nil {
|
||||
return mutation, fmt.Errorf("%w: %v", ErrTaskAssetInvalid, err)
|
||||
}
|
||||
parsed := make([]ParsedScope, 0, len(inputs))
|
||||
for index, input := range inputs {
|
||||
rule, err := ParseScopeInput(input)
|
||||
if err != nil {
|
||||
return mutation, fmt.Errorf("%w: 第 %d 条范围无效: %v", ErrTaskAssetInvalid, index+1, err)
|
||||
}
|
||||
parsed = append(parsed, rule)
|
||||
}
|
||||
if err := validateParsedScopeBounds(parsed); err != nil {
|
||||
return mutation, fmt.Errorf("%w: %v", ErrTaskAssetInvalid, err)
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
if err := lockCompanyScopeMutation(tx); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
var taskExists bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, taskID).Scan(&taskExists); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if !taskExists {
|
||||
return mutation, ErrTaskAssetTaskNotFound
|
||||
}
|
||||
|
||||
scoped := &AssetStore{db: s.db, company: s.company, tx: tx}
|
||||
for _, rule := range parsed {
|
||||
taskScope := TaskScope{
|
||||
TaskID: taskID,
|
||||
Source: "manual",
|
||||
Reason: manualTaskScopeSummary,
|
||||
}
|
||||
var assetID int64
|
||||
switch rule.Kind {
|
||||
case "domain":
|
||||
taskScope.Kind = "root_domain"
|
||||
taskScope.Domain = rule.Domain
|
||||
var alreadyLinked bool
|
||||
err := tx.QueryRow(`SELECT id, $2=ANY(task_ids) FROM assets WHERE type='root_domain' AND domain=$1`, rule.Domain, taskID).
|
||||
Scan(&assetID, &alreadyLinked)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return mutation, err
|
||||
}
|
||||
assetID, err = scoped.UpsertRootDomain(UpsertRootDomainReq{Domain: rule.Domain, TaskID: taskID})
|
||||
if err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if alreadyLinked {
|
||||
mutation.AssetsExisting++
|
||||
} else {
|
||||
mutation.AssetsLinked++
|
||||
}
|
||||
case "ip":
|
||||
taskScope.Kind = "ip"
|
||||
taskScope.Net = rule.Net
|
||||
ip, _, parseErr := net.ParseCIDR(rule.Net)
|
||||
if parseErr != nil {
|
||||
return mutation, fmt.Errorf("%w: 无效 IP: %s", ErrTaskAssetInvalid, rule.Raw)
|
||||
}
|
||||
ipValue := ip.String()
|
||||
var alreadyLinked bool
|
||||
err := tx.QueryRow(`SELECT id, $2=ANY(task_ids) FROM assets WHERE type='ip' AND ip=$1`, ipValue, taskID).
|
||||
Scan(&assetID, &alreadyLinked)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return mutation, err
|
||||
}
|
||||
assetID, err = scoped.UpsertIP(UpsertIPReq{IP: ipValue, TaskID: taskID})
|
||||
if err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if alreadyLinked {
|
||||
mutation.AssetsExisting++
|
||||
} else {
|
||||
mutation.AssetsLinked++
|
||||
}
|
||||
case "cidr":
|
||||
taskScope.Kind = "cidr"
|
||||
taskScope.Net = rule.Net
|
||||
case "icp", "keyword":
|
||||
taskScope.Kind = rule.Kind
|
||||
taskScope.Value = rule.Value
|
||||
default:
|
||||
return mutation, fmt.Errorf("%w: unsupported scope kind %q", ErrTaskAssetInvalid, rule.Kind)
|
||||
}
|
||||
|
||||
if assetID > 0 {
|
||||
if err := scoped.SetTaskAssetSource(taskID, assetID, "manual", manualTaskScopeSummary, nil); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
}
|
||||
inserted, err := scoped.upsertTaskScopeResult(taskScope)
|
||||
if err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if inserted {
|
||||
mutation.ScopesAdded++
|
||||
} else {
|
||||
mutation.ScopesExisting++
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
return mutation, nil
|
||||
}
|
||||
|
||||
// AttachAssetsToTask associates existing global assets with one live task and
|
||||
// records an operator-authored source summary. Global asset rows are retained.
|
||||
func (s *AssetStore) AttachAssetsToTask(taskID int64, assetIDs []int64, sourceSummary string) (TaskAssetMutation, error) {
|
||||
var mutation TaskAssetMutation
|
||||
assetIDs, err := normalizeTaskAssetIDs(assetIDs)
|
||||
if err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
_, sourceSummary, err = normalizeTaskAssetSource("manual", sourceSummary)
|
||||
if err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if sourceSummary == "" {
|
||||
return mutation, fmt.Errorf("%w: source_summary is required", ErrTaskAssetInvalid)
|
||||
}
|
||||
mutation.Requested = len(assetIDs)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var taskExists bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, taskID).Scan(&taskExists); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if !taskExists {
|
||||
return mutation, ErrTaskAssetTaskNotFound
|
||||
}
|
||||
var found, existing int
|
||||
if err := tx.QueryRow(`
|
||||
SELECT count(*), count(*) FILTER (WHERE $1=ANY(task_ids))
|
||||
FROM assets WHERE id=ANY($2::bigint[])`, taskID, assetIDs).Scan(&found, &existing); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if found != len(assetIDs) {
|
||||
return mutation, ErrTaskAssetAssetNotFound
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE assets
|
||||
SET task_ids=CASE WHEN $1=ANY(task_ids) THEN task_ids ELSE array_append(task_ids,$1) END
|
||||
WHERE id=ANY($2::bigint[])`, taskID, assetIDs); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary)
|
||||
SELECT $1, id, 'manual', $3 FROM assets WHERE id=ANY($2::bigint[])
|
||||
ON CONFLICT (task_id, asset_id) DO UPDATE
|
||||
SET source='manual', source_summary=EXCLUDED.source_summary, source_node_id=NULL`,
|
||||
taskID, assetIDs, sourceSummary); err != nil {
|
||||
return mutation, err
|
||||
}
|
||||
mutation.Existing = existing
|
||||
mutation.Attached = len(assetIDs) - existing
|
||||
return mutation, tx.Commit()
|
||||
}
|
||||
|
||||
// DetachAssetFromTask removes only the task association. The global asset and
|
||||
// exploration anchors remain available for historical blackboard auditing.
|
||||
func (s *AssetStore) DetachAssetFromTask(taskID, assetID int64) (bool, error) {
|
||||
if taskID <= 0 || assetID <= 0 {
|
||||
return false, fmt.Errorf("%w: task and asset ids must be positive", ErrTaskAssetInvalid)
|
||||
}
|
||||
var detachedID int64
|
||||
err := s.db.QueryRow(`
|
||||
UPDATE assets SET task_ids=array_remove(task_ids,$1)
|
||||
WHERE id=$2 AND $1=ANY(task_ids)
|
||||
RETURNING id`, taskID, assetID).Scan(&detachedID)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return detachedID == assetID, nil
|
||||
}
|
||||
|
||||
func (s *AssetStore) hydrateTaskAssetSources(taskID int64, assets []*Asset) error {
|
||||
if len(assets) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int64, 0, len(assets))
|
||||
byID := make(map[int64]*Asset, len(assets))
|
||||
for _, asset := range assets {
|
||||
ids = append(ids, asset.ID)
|
||||
byID[asset.ID] = asset
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT asset_id, source, source_summary, source_node_id
|
||||
FROM task_asset_links
|
||||
WHERE task_id=$1 AND asset_id=ANY($2::bigint[])`, taskID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var assetID int64
|
||||
var source, summary string
|
||||
var sourceNodeID sql.NullInt64
|
||||
if err := rows.Scan(&assetID, &source, &summary, &sourceNodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
if asset := byID[assetID]; asset != nil {
|
||||
asset.TaskSource = source
|
||||
asset.TaskSourceSummary = summary
|
||||
if sourceNodeID.Valid {
|
||||
id := sourceNodeID.Int64
|
||||
asset.TaskSourceNodeID = &id
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// IntentAssets returns all local worker targets plus immutable targets from the
|
||||
// task's direct sources. Inherited non-terminal intents remain hidden, matching
|
||||
// the existing source-aware session contract.
|
||||
func (s *AssetStore) IntentAssets(taskID int64) ([]IntentAsset, error) {
|
||||
rows, err := s.db.Query(`
|
||||
WITH context AS (
|
||||
SELECT task.id AS task_id, task.exploration_id, false AS inherited
|
||||
FROM tasks task
|
||||
WHERE task.id=$1 AND task.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT source.id, source.exploration_id, true
|
||||
FROM task_relations relation
|
||||
JOIN tasks source ON source.id=relation.source_task_id AND source.deleted_at IS NULL
|
||||
WHERE relation.task_id=$1
|
||||
)
|
||||
SELECT intent.id, asset.id, asset.type,
|
||||
CASE asset.type
|
||||
WHEN 'root_domain' THEN COALESCE(asset.domain,'')
|
||||
WHEN 'subdomain' THEN COALESCE(asset.domain,'')
|
||||
WHEN 'ip' THEN COALESCE(asset.ip,'')
|
||||
WHEN 'app' THEN COALESCE(asset.app_name,'')
|
||||
WHEN 'service' THEN COALESCE(NULLIF(asset.url,''), NULLIF(concat_ws(':', COALESCE(NULLIF(asset.domain,''), NULLIF(asset.ip,'')), asset.port::text),''), NULLIF(asset.service_name,''), '#' || asset.id::text)
|
||||
WHEN 'endpoint' THEN COALESCE(NULLIF(asset.url,''), '#' || asset.id::text)
|
||||
ELSE '#' || asset.id::text
|
||||
END,
|
||||
COALESCE(link.source,'anchor'),
|
||||
COALESCE(NULLIF(link.source_summary,''), '意图在黑板中锚定该资产'),
|
||||
link.source_node_id, context.task_id, context.inherited
|
||||
FROM context
|
||||
JOIN exploration_nodes intent ON intent.exploration_id=context.exploration_id AND intent.kind='intent'
|
||||
JOIN exploration_anchors anchor ON anchor.node_id=intent.id
|
||||
JOIN assets asset ON asset.id=anchor.asset_id
|
||||
LEFT JOIN task_asset_links link ON link.task_id=context.task_id AND link.asset_id=asset.id
|
||||
WHERE NOT context.inherited OR intent.state IN ('done','blocked','exhausted','stopped')
|
||||
ORDER BY context.inherited, intent.id DESC, asset.id`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []IntentAsset{}
|
||||
for rows.Next() {
|
||||
var asset IntentAsset
|
||||
var sourceNodeID sql.NullInt64
|
||||
if err := rows.Scan(&asset.IntentID, &asset.AssetID, &asset.Type, &asset.Label,
|
||||
&asset.Source, &asset.SourceSummary, &sourceNodeID, &asset.SourceTaskID, &asset.Inherited); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceNodeID.Valid {
|
||||
id := sourceNodeID.Int64
|
||||
asset.SourceNodeID = &id
|
||||
}
|
||||
out = append(out, asset)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -29,8 +29,12 @@ target AS (
|
||||
JOIN task_scope ts ON (
|
||||
(ts.kind='company' AND a.company_id = ts.company_id)
|
||||
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
|
||||
OR (ts.kind='subdomain' AND a.domain = ts.domain)
|
||||
OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
|
||||
OR (ts.kind='subdomain' AND a.domain = ts.domain)
|
||||
OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
|
||||
OR (ts.kind='icp' AND (
|
||||
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
||||
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
||||
))
|
||||
)
|
||||
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
||||
UNION
|
||||
@@ -53,7 +57,7 @@ tested AS (
|
||||
func (s *AssetStore) ListTaskScopeWithSources(taskID int64) ([]TaskScope, error) {
|
||||
rows, err := s.db.Query(`WITH `+directTaskContextCTE+`
|
||||
SELECT ts.id, ts.task_id, ts.kind, COALESCE(ts.company_id,0), COALESCE(c.name,''), COALESCE(ts.domain,''),
|
||||
COALESCE(ts.net::text,''), ts.source, COALESCE(ts.reason,'')
|
||||
COALESCE(ts.net::text,''), COALESCE(ts.value,''), ts.source, COALESCE(ts.reason,'')
|
||||
FROM task_scope ts
|
||||
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
||||
LEFT JOIN companies c ON c.id=ts.company_id
|
||||
@@ -66,7 +70,7 @@ ORDER BY CASE WHEN ts.task_id=$1 THEN 0 ELSE 1 END, ts.id`, taskID)
|
||||
for rows.Next() {
|
||||
var scope TaskScope
|
||||
var companyID int64
|
||||
if err := rows.Scan(&scope.ID, &scope.TaskID, &scope.Kind, &companyID, &scope.CompanyName, &scope.Domain, &scope.Net, &scope.Source, &scope.Reason); err != nil {
|
||||
if err := rows.Scan(&scope.ID, &scope.TaskID, &scope.Kind, &companyID, &scope.CompanyName, &scope.Domain, &scope.Net, &scope.Value, &scope.Source, &scope.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if companyID > 0 {
|
||||
@@ -175,8 +179,12 @@ context_assets AS (
|
||||
JOIN task_scope ts ON (
|
||||
(ts.kind='company' AND a.company_id=ts.company_id)
|
||||
OR (ts.kind='root_domain' AND a.root_domain=ts.domain)
|
||||
OR (ts.kind='subdomain' AND a.domain=ts.domain)
|
||||
OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
|
||||
OR (ts.kind='subdomain' AND a.domain=ts.domain)
|
||||
OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
|
||||
OR (ts.kind='icp' AND (
|
||||
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
||||
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
||||
))
|
||||
)
|
||||
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
||||
)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRegisterTaskAssetScopesCreatesAssetsAndPersistsTextScope(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
task, err := d.CreateTask("manual scope registration", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
suffix := time.Now().UnixNano()
|
||||
domain := fmt.Sprintf("manual-scope-%d.example.test", suffix)
|
||||
ip := fmt.Sprintf("203.0.%d.%d", (suffix/250)%250, suffix%250+1)
|
||||
inputs := []ScopeInput{
|
||||
{Kind: "domain", Value: domain},
|
||||
{Kind: "ip", Value: ip},
|
||||
{Kind: "cidr", Value: "198.51.100.0/24"},
|
||||
{Kind: "icp", Value: " 京 ICP 备 12345678 号-1 "},
|
||||
{Kind: "keyword", Value: " Acme Security "},
|
||||
}
|
||||
|
||||
first, err := d.Assets().RegisterTaskAssetScopes(task.ID, inputs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Requested != 5 || first.AssetsLinked != 2 || first.AssetsExisting != 0 || first.ScopesAdded != 5 || first.ScopesExisting != 0 {
|
||||
t.Fatalf("unexpected first mutation: %+v", first)
|
||||
}
|
||||
assets, err := d.Assets().QueryByTask(task.ID, "", 10, 0)
|
||||
if err != nil || len(assets) != 2 {
|
||||
t.Fatalf("task assets=%+v err=%v", assets, err)
|
||||
}
|
||||
assetIDs := make([]int64, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
assetIDs = append(assetIDs, asset.ID)
|
||||
if asset.TaskSource != "manual" || asset.TaskSourceSummary != manualTaskScopeSummary {
|
||||
t.Fatalf("unexpected task asset provenance: %+v", asset)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs(assetIDs) })
|
||||
scopes, err := d.Assets().ListTaskScope(task.ID)
|
||||
if err != nil || len(scopes) != 5 {
|
||||
t.Fatalf("task scope=%+v err=%v", scopes, err)
|
||||
}
|
||||
values := map[string]string{}
|
||||
for _, scope := range scopes {
|
||||
values[scope.Kind] = scope.Value
|
||||
}
|
||||
if values["icp"] != NormalizeICP(inputs[3].Value) || values["keyword"] != normalizeKeyword(inputs[4].Value) {
|
||||
t.Fatalf("text scope not normalized: %+v", values)
|
||||
}
|
||||
|
||||
second, err := d.Assets().RegisterTaskAssetScopes(task.ID, inputs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.AssetsExisting != 2 || second.AssetsLinked != 0 || second.ScopesExisting != 5 || second.ScopesAdded != 0 {
|
||||
t.Fatalf("unexpected idempotent mutation: %+v", second)
|
||||
}
|
||||
|
||||
rollbackDomain := fmt.Sprintf("rollback-%d.example.test", suffix)
|
||||
_, err = d.Assets().RegisterTaskAssetScopes(task.ID, []ScopeInput{
|
||||
{Kind: "domain", Value: rollbackDomain},
|
||||
{Kind: "cidr", Value: "10.0.0.0/8"},
|
||||
})
|
||||
if !errors.Is(err, ErrTaskAssetInvalid) {
|
||||
t.Fatalf("invalid CIDR error=%v", err)
|
||||
}
|
||||
var rollbackAssets int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM assets WHERE type='root_domain' AND domain=$1`, rollbackDomain).Scan(&rollbackAssets); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rollbackAssets != 0 {
|
||||
t.Fatalf("invalid request created %d assets", rollbackAssets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskAssetAttachDetachPreservesGlobalAssetAndAnchors(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
task, err := d.CreateTask("asset editing", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
assetID, err := d.Assets().UpsertRootDomain(UpsertRootDomainReq{Domain: fmt.Sprintf("manual-%d.example.test", time.Now().UnixNano())})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{assetID}) })
|
||||
|
||||
mutation, err := d.Assets().AttachAssetsToTask(task.ID, []int64{assetID, assetID}, "授权资产清单第 3 项")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mutation.Requested != 1 || mutation.Attached != 1 || mutation.Existing != 0 {
|
||||
t.Fatalf("unexpected first mutation: %+v", mutation)
|
||||
}
|
||||
assets, err := d.Assets().QueryByTask(task.ID, "", 10, 0)
|
||||
if err != nil || len(assets) != 1 {
|
||||
t.Fatalf("task assets=%+v err=%v", assets, err)
|
||||
}
|
||||
if assets[0].TaskSource != "manual" || assets[0].TaskSourceSummary != "授权资产清单第 3 项" {
|
||||
t.Fatalf("unexpected provenance: %+v", assets[0])
|
||||
}
|
||||
|
||||
intentID, err := d.Exploration(task.ExplorationID).AddIntent(map[string]any{"summary": "test asset"}, 5, []int64{assetID}, "human")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detached, err := d.Assets().DetachAssetFromTask(task.ID, assetID)
|
||||
if err != nil || !detached {
|
||||
t.Fatalf("detach=%v err=%v", detached, err)
|
||||
}
|
||||
if assets, err := d.Assets().QueryByTask(task.ID, "", 10, 0); err != nil || len(assets) != 0 {
|
||||
t.Fatalf("detached task assets=%+v err=%v", assets, err)
|
||||
}
|
||||
var global, anchors, links int
|
||||
_ = d.QueryRow(`SELECT count(*) FROM assets WHERE id=$1`, assetID).Scan(&global)
|
||||
_ = d.QueryRow(`SELECT count(*) FROM exploration_anchors WHERE node_id=$1 AND asset_id=$2`, intentID, assetID).Scan(&anchors)
|
||||
_ = d.QueryRow(`SELECT count(*) FROM task_asset_links WHERE task_id=$1 AND asset_id=$2`, task.ID, assetID).Scan(&links)
|
||||
if global != 1 || anchors != 1 || links != 0 {
|
||||
t.Fatalf("global=%d anchors=%d links=%d", global, anchors, links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntentAssetsIncludesDirectSourceProvenance(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
source, err := d.CreateTask("source assets", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := d.CreateTaskWithOptions("current assets", "goal", TaskCreateOptions{SourceTaskIDs: []int64{source.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(current.ID); _ = d.DeleteTask(source.ID) })
|
||||
|
||||
assetID, err := d.Assets().UpsertHTTPService(UpsertHTTPServiceReq{
|
||||
URL: fmt.Sprintf("https://intent-%d.example.test", time.Now().UnixNano()), TaskID: source.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Assets().DeleteByIDs([]int64{assetID}) })
|
||||
intentID, err := d.Exploration(source.ExplorationID).AddIntent(map[string]any{"summary": "source worker"}, 5, []int64{assetID}, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Exploration(source.ExplorationID).SetNodeState(intentID, "done"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodeID := intentID
|
||||
if err := d.Assets().SetTaskAssetSource(source.ID, assetID, "agent", "Worker 通过 insert_assets 登记", &nodeID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assets, err := d.Assets().IntentAssets(current.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(assets) != 1 || assets[0].IntentID != intentID || assets[0].SourceTaskID != source.ID || !assets[0].Inherited {
|
||||
t.Fatalf("unexpected intent assets: %+v", assets)
|
||||
}
|
||||
if assets[0].Source != "agent" || assets[0].SourceSummary == "" || assets[0].SourceNodeID == nil || *assets[0].SourceNodeID != intentID {
|
||||
t.Fatalf("unexpected intent provenance: %+v", assets[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const MaxTaskCategoryNameRunes = 80
|
||||
|
||||
var (
|
||||
ErrTaskCategoryInvalid = errors.New("invalid task category")
|
||||
ErrTaskCategoryNameConflict = errors.New("task category name already exists")
|
||||
ErrTaskCategoryNotFound = errors.New("task category not found")
|
||||
ErrTaskCategoryTaskNotFound = errors.New("task not found")
|
||||
)
|
||||
|
||||
// TaskCategory is a globally reusable task grouping label.
|
||||
type TaskCategory struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NKey string `json:"-"`
|
||||
TaskCount int `json:"task_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const taskCategoryCols = `category.id, category.name, category.nkey,
|
||||
count(task.id) FILTER (WHERE task.deleted_at IS NULL),
|
||||
category.created_at, category.updated_at`
|
||||
|
||||
func scanTaskCategory(row interface{ Scan(...any) error }) (TaskCategory, error) {
|
||||
var category TaskCategory
|
||||
err := row.Scan(&category.ID, &category.Name, &category.NKey, &category.TaskCount, &category.CreatedAt, &category.UpdatedAt)
|
||||
return category, err
|
||||
}
|
||||
|
||||
func normalizeTaskCategoryName(name string) (string, string, error) {
|
||||
name = strings.Join(strings.Fields(name), " ")
|
||||
if name == "" {
|
||||
return "", "", fmt.Errorf("%w: name is required", ErrTaskCategoryInvalid)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > MaxTaskCategoryNameRunes {
|
||||
return "", "", fmt.Errorf("%w: name exceeds %d characters", ErrTaskCategoryInvalid, MaxTaskCategoryNameRunes)
|
||||
}
|
||||
return name, strings.ToLower(name), nil
|
||||
}
|
||||
|
||||
func taskCategoryUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
func (d *DB) CreateTaskCategory(name string) (*TaskCategory, error) {
|
||||
name, nkey, err := normalizeTaskCategoryName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
WITH inserted AS (
|
||||
INSERT INTO task_categories(name, nkey)
|
||||
VALUES ($1,$2)
|
||||
ON CONFLICT (nkey) DO NOTHING
|
||||
RETURNING *
|
||||
)
|
||||
SELECT inserted.id, inserted.name, inserted.nkey, 0, inserted.created_at, inserted.updated_at
|
||||
FROM inserted`, name, nkey))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskCategoryNameConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListTaskCategories() ([]*TaskCategory, error) {
|
||||
rows, err := d.Query(`
|
||||
SELECT ` + taskCategoryCols + `
|
||||
FROM task_categories category
|
||||
LEFT JOIN tasks task ON task.category_id=category.id
|
||||
GROUP BY category.id
|
||||
ORDER BY category.name, category.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
categories := []*TaskCategory{}
|
||||
for rows.Next() {
|
||||
category, err := scanTaskCategory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categories = append(categories, &category)
|
||||
}
|
||||
return categories, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetTaskCategory(id int64) (*TaskCategory, error) {
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
SELECT `+taskCategoryCols+`
|
||||
FROM task_categories category
|
||||
LEFT JOIN tasks task ON task.category_id=category.id
|
||||
WHERE category.id=$1
|
||||
GROUP BY category.id`, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (d *DB) RenameTaskCategory(id int64, name string) (*TaskCategory, error) {
|
||||
name, nkey, err := normalizeTaskCategoryName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
WITH updated AS (
|
||||
UPDATE task_categories SET name=$2, nkey=$3 WHERE id=$1 RETURNING *
|
||||
)
|
||||
SELECT updated.id, updated.name, updated.nkey,
|
||||
(SELECT count(*) FROM tasks WHERE category_id=updated.id AND deleted_at IS NULL),
|
||||
updated.created_at, updated.updated_at
|
||||
FROM updated`, id, name, nkey))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskCategoryNotFound
|
||||
}
|
||||
if taskCategoryUniqueViolation(err) {
|
||||
return nil, ErrTaskCategoryNameConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
// DeleteTaskCategory moves affected tasks to the uncategorized bucket through
|
||||
// the tasks.category_id ON DELETE SET NULL foreign key.
|
||||
func (d *DB) DeleteTaskCategory(id int64) (bool, error) {
|
||||
result, err := d.Exec(`DELETE FROM task_categories WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
return rows > 0, err
|
||||
}
|
||||
|
||||
// SetTaskCategory updates one live task. A nil category means uncategorized.
|
||||
func (d *DB) SetTaskCategory(taskID int64, categoryID *int64) (*TaskCategory, error) {
|
||||
if categoryID == nil {
|
||||
result, err := d.Exec(`UPDATE tasks SET category_id=NULL WHERE id=$1 AND deleted_at IS NULL`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows == 0 {
|
||||
return nil, ErrTaskCategoryTaskNotFound
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if *categoryID <= 0 {
|
||||
return nil, fmt.Errorf("%w: category id must be positive", ErrTaskCategoryInvalid)
|
||||
}
|
||||
category, err := scanTaskCategory(d.QueryRow(`
|
||||
WITH selected AS (
|
||||
SELECT * FROM task_categories WHERE id=$2
|
||||
), updated AS (
|
||||
UPDATE tasks SET category_id=$2
|
||||
WHERE id=$1 AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM selected)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT selected.id, selected.name, selected.nkey,
|
||||
(SELECT count(*) FROM tasks WHERE category_id=selected.id AND deleted_at IS NULL),
|
||||
selected.created_at, selected.updated_at
|
||||
FROM selected, updated`, taskID, *categoryID))
|
||||
if err == sql.ErrNoRows {
|
||||
var taskExists bool
|
||||
if checkErr := d.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, taskID).Scan(&taskExists); checkErr != nil {
|
||||
return nil, checkErr
|
||||
}
|
||||
if !taskExists {
|
||||
return nil, ErrTaskCategoryTaskNotFound
|
||||
}
|
||||
return nil, ErrTaskCategoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskCategoryCRUDAndTaskAssignment(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
name := fmt.Sprintf("Category %d", time.Now().UnixNano())
|
||||
category, err := d.CreateTaskCategory(" " + strings.ReplaceAll(name, " ", " ") + " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if category.Name != name {
|
||||
t.Fatalf("category name=%q, want %q", category.Name, name)
|
||||
}
|
||||
if _, err := d.CreateTaskCategory(strings.ToUpper(name)); !errors.Is(err, ErrTaskCategoryNameConflict) {
|
||||
t.Fatalf("duplicate error=%v, want %v", err, ErrTaskCategoryNameConflict)
|
||||
}
|
||||
|
||||
task, err := d.CreateTaskWithOptions("categorized task", "goal", TaskCreateOptions{CategoryID: &category.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
loaded, err := d.GetTask(task.ID)
|
||||
if err != nil || loaded == nil || loaded.CategoryID == nil || *loaded.CategoryID != category.ID || loaded.CategoryName != name {
|
||||
t.Fatalf("categorized task=%+v err=%v", loaded, err)
|
||||
}
|
||||
|
||||
renamed, err := d.RenameTaskCategory(category.ID, name+" renamed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if renamed.TaskCount != 1 {
|
||||
t.Fatalf("renamed task count=%d, want 1", renamed.TaskCount)
|
||||
}
|
||||
loaded, _ = d.GetTask(task.ID)
|
||||
if loaded.CategoryName != renamed.Name {
|
||||
t.Fatalf("task category name=%q, want %q", loaded.CategoryName, renamed.Name)
|
||||
}
|
||||
|
||||
if _, err := d.SetTaskCategory(task.ID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, _ = d.GetTask(task.ID)
|
||||
if loaded.CategoryID != nil || loaded.CategoryName != "" {
|
||||
t.Fatalf("uncategorized task retained category: %+v", loaded)
|
||||
}
|
||||
if _, err := d.SetTaskCategory(task.ID, &category.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := d.DeleteTaskCategory(category.ID)
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("delete category=%v err=%v", deleted, err)
|
||||
}
|
||||
loaded, _ = d.GetTask(task.ID)
|
||||
if loaded.CategoryID != nil || loaded.CategoryName != "" {
|
||||
t.Fatalf("category deletion did not clear task: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsMissingCategoryAtomically(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
missing := int64(1 << 62)
|
||||
description := fmt.Sprintf("missing-category-%d", time.Now().UnixNano())
|
||||
if _, err := d.CreateTaskWithOptions(description, "goal", TaskCreateOptions{CategoryID: &missing}); !errors.Is(err, ErrTaskCategoryNotFound) {
|
||||
t.Fatalf("create error=%v, want %v", err, ErrTaskCategoryNotFound)
|
||||
}
|
||||
var count int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM explorations WHERE description=$1`, description).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("failed create leaked %d exploration rows", count)
|
||||
}
|
||||
}
|
||||
+48
-15
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
@@ -10,17 +11,18 @@ import (
|
||||
// TaskScope is one row of a task's test scope — the coverage denominator and the
|
||||
// per-task authorization edge. Rows come either from insertAssets (source='auto',
|
||||
// conservative, one per explicitly-inserted asset) or from the add_task_scope tool
|
||||
// (source='agent', for company / whole-root-domain / subdomain / ip).
|
||||
// (source='agent', for company / domain / network / ICP / keyword scope).
|
||||
type TaskScope struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
Kind string `json:"kind"` // company|root_domain|subdomain|ip|cidr
|
||||
Kind string `json:"kind"` // company|root_domain|subdomain|ip|cidr|icp|keyword
|
||||
CompanyID *int64 `json:"company_id,omitempty"`
|
||||
// CompanyName is resolved for kind=company so callers can label a scope row
|
||||
// without a second lookup. Empty when the row is not a company reference.
|
||||
CompanyName string `json:"company_name,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Net string `json:"net,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
@@ -53,11 +55,11 @@ func ipToHostCIDR(ip string) string {
|
||||
|
||||
// upsertTaskScope inserts one scope row idempotently (uq_task_scope). A duplicate is
|
||||
// silently ignored. taskID<=0 or empty kind → no-op.
|
||||
func (s *AssetStore) upsertTaskScope(ts TaskScope) error {
|
||||
func (s *AssetStore) upsertTaskScopeResult(ts TaskScope) (bool, error) {
|
||||
if ts.TaskID <= 0 || ts.Kind == "" {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
var domainVal, netVal, companyVal any
|
||||
var domainVal, netVal, companyVal, valueVal any
|
||||
if ts.Domain != "" {
|
||||
domainVal = ts.Domain
|
||||
}
|
||||
@@ -67,14 +69,35 @@ func (s *AssetStore) upsertTaskScope(ts TaskScope) error {
|
||||
if ts.CompanyID != nil && *ts.CompanyID > 0 {
|
||||
companyVal = *ts.CompanyID
|
||||
}
|
||||
if ts.Value != "" {
|
||||
valueVal = ts.Value
|
||||
}
|
||||
src := ts.Source
|
||||
if src == "" {
|
||||
src = "auto"
|
||||
}
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO task_scope(task_id, kind, company_id, domain, net, source, reason)
|
||||
VALUES ($1,$2,$3,$4,$5::cidr,$6,NULLIF($7,''))
|
||||
ON CONFLICT DO NOTHING`, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, src, ts.Reason)
|
||||
query := `
|
||||
INSERT INTO task_scope(task_id, kind, company_id, domain, net, value, source, reason)
|
||||
VALUES ($1,$2,$3,$4,$5::cidr,$6,$7,NULLIF($8,''))
|
||||
ON CONFLICT DO NOTHING`
|
||||
var (
|
||||
result sql.Result
|
||||
err error
|
||||
)
|
||||
if s.tx != nil {
|
||||
result, err = s.tx.Exec(query, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, valueVal, src, ts.Reason)
|
||||
} else {
|
||||
result, err = s.db.Exec(query, ts.TaskID, ts.Kind, companyVal, domainVal, netVal, valueVal, src, ts.Reason)
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
return rows > 0, err
|
||||
}
|
||||
|
||||
func (s *AssetStore) upsertTaskScope(ts TaskScope) error {
|
||||
_, err := s.upsertTaskScopeResult(ts)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -182,8 +205,14 @@ func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source str
|
||||
return ts, fmt.Errorf("无效 ip/cidr: %s", value)
|
||||
}
|
||||
ts.Net = v
|
||||
case "icp", "keyword":
|
||||
parsed, err := ParseScopeInput(ScopeInput{Kind: kind, Value: value})
|
||||
if err != nil {
|
||||
return ts, err
|
||||
}
|
||||
ts.Value = parsed.Value
|
||||
default:
|
||||
return ts, fmt.Errorf("不支持的 kind: %s(company/root_domain/subdomain/ip/cidr)", kind)
|
||||
return ts, fmt.Errorf("不支持的 kind: %s(company/root_domain/subdomain/ip/cidr/icp/keyword)", kind)
|
||||
}
|
||||
if err := s.upsertTaskScope(ts); err != nil {
|
||||
return ts, err
|
||||
@@ -206,7 +235,7 @@ func (s *AssetStore) DeleteTaskScope(taskID, scopeID int64) (bool, error) {
|
||||
func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT ts.id, ts.kind, COALESCE(ts.company_id,0), COALESCE(c.name,''), COALESCE(ts.domain,''),
|
||||
COALESCE(ts.net::text,''), ts.source, COALESCE(ts.reason,'')
|
||||
COALESCE(ts.net::text,''), COALESCE(ts.value,''), ts.source, COALESCE(ts.reason,'')
|
||||
FROM task_scope ts
|
||||
LEFT JOIN companies c ON c.id=ts.company_id
|
||||
WHERE ts.task_id=$1 ORDER BY ts.id`, taskID)
|
||||
@@ -218,7 +247,7 @@ WHERE ts.task_id=$1 ORDER BY ts.id`, taskID)
|
||||
for rows.Next() {
|
||||
var t TaskScope
|
||||
var cid int64
|
||||
if err := rows.Scan(&t.ID, &t.Kind, &cid, &t.CompanyName, &t.Domain, &t.Net, &t.Source, &t.Reason); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Kind, &cid, &t.CompanyName, &t.Domain, &t.Net, &t.Value, &t.Source, &t.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.TaskID = taskID
|
||||
@@ -279,9 +308,13 @@ target AS (
|
||||
JOIN task_scope ts ON ts.task_id = $1 AND (
|
||||
(ts.kind='company' AND a.company_id = ts.company_id)
|
||||
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
|
||||
OR (ts.kind='subdomain' AND a.domain = ts.domain)
|
||||
OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
|
||||
)
|
||||
OR (ts.kind='subdomain' AND a.domain = ts.domain)
|
||||
OR (ts.kind IN ('ip','cidr') AND a.ip IS NOT NULL AND a.ip !~ '[^0-9a-fA-F:.]' AND ts.net >>= a.ip::inet)
|
||||
OR (ts.kind='icp' AND (
|
||||
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
||||
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
||||
))
|
||||
)
|
||||
),
|
||||
tested AS (
|
||||
SELECT DISTINCT ea.asset_id
|
||||
|
||||
+23
-6
@@ -13,6 +13,8 @@ import (
|
||||
type Task struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
ExplorationID int64 `json:"exploration_id"`
|
||||
@@ -139,6 +141,7 @@ func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSe
|
||||
// with the task/exploration row.
|
||||
type TaskCreateOptions struct {
|
||||
Name string // 可选任务名称;空=未命名
|
||||
CategoryID *int64
|
||||
SourceTaskIDs []int64
|
||||
CompanyIDs []int64
|
||||
LLMProfileIDs []int64
|
||||
@@ -189,13 +192,25 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
}
|
||||
opts.PlanHeartbeatSeconds = normalizeHeartbeat(opts.PlanHeartbeatSeconds)
|
||||
coverageEnabled := opts.CoverageEnabled == nil || *opts.CoverageEnabled
|
||||
var categoryName string
|
||||
if opts.CategoryID != nil {
|
||||
if *opts.CategoryID <= 0 {
|
||||
return nil, fmt.Errorf("%w: category id must be positive", ErrTaskCategoryInvalid)
|
||||
}
|
||||
if err := tx.QueryRow(`SELECT name FROM task_categories WHERE id=$1`, *opts.CategoryID).Scan(&categoryName); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskCategoryNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var active *int64
|
||||
if len(opts.LLMProfileIDs) > 0 {
|
||||
id := opts.LLMProfileIDs[0]
|
||||
active = &id
|
||||
}
|
||||
t := &Task{
|
||||
Name: opts.Name,
|
||||
Name: opts.Name, CategoryID: opts.CategoryID, CategoryName: categoryName,
|
||||
Description: description, Goal: goal, ExplorationID: expID,
|
||||
LLMProfileID: active, ActiveLLMProfileID: active,
|
||||
LLMProfileIDs: append([]int64(nil), opts.LLMProfileIDs...),
|
||||
@@ -205,9 +220,9 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
CoverageEnabled: coverageEnabled,
|
||||
}
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO tasks(name, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$5,$6,$7,$8)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
|
||||
@@ -287,11 +302,13 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const taskCols = `id, COALESCE(name,''), description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
const taskCols = `id, COALESCE(name,''), 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,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
|
||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||
var t Task
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &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.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.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
|
||||
@@ -531,6 +531,9 @@ func (s *Server) insertAssets(w http.ResponseWriter, r *http.Request) {
|
||||
errs = append(errs, errEntry{Index: i, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
if req.TaskID > 0 {
|
||||
_ = as.SetTaskAssetSource(req.TaskID, id, "api", "通过资产 API 登记", nil)
|
||||
}
|
||||
results = append(results, result{Index: i, ID: id, Type: a.Type})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"results": results, "errors": errs})
|
||||
|
||||
@@ -33,6 +33,8 @@ type TaskDTO struct {
|
||||
ID string `json:"id"`
|
||||
ExplorationID int64 `json:"exploration_id"`
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
Status string `json:"status"` // created | running | paused | done | failed
|
||||
@@ -85,6 +87,8 @@ func taskDTO(t *Task, status string) TaskDTO {
|
||||
ID: t.ID,
|
||||
ExplorationID: t.ExpID,
|
||||
Name: t.Name,
|
||||
CategoryID: lifecycle.CategoryID,
|
||||
CategoryName: lifecycle.CategoryName,
|
||||
Description: t.Description,
|
||||
Goal: t.Goal,
|
||||
Status: status,
|
||||
|
||||
+88
-10
@@ -27,15 +27,17 @@ import (
|
||||
// sharing the process-wide asset store. ID is the PG task id as a string; ExpID
|
||||
// is the exploration the task owns.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
ExpID int64 `json:"exploration_id"`
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt int64 `json:"completed_at,omitempty"` // 进入终态的 unix 秒;0=未完成
|
||||
Paused bool `json:"paused"`
|
||||
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑
|
||||
ID string `json:"id"`
|
||||
ExpID int64 `json:"exploration_id"`
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
CategoryName string `json:"category_name,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt int64 `json:"completed_at,omitempty"` // 进入终态的 unix 秒;0=未完成
|
||||
Paused bool `json:"paused"`
|
||||
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑
|
||||
// QueuedAt is an internal Unix-nanosecond ordering key. It is deliberately
|
||||
// finer than CreatedAt so several tasks enqueued in the same second retain
|
||||
// their real FIFO order.
|
||||
@@ -85,6 +87,8 @@ type taskLifecycleState struct {
|
||||
DeadlineAt int64
|
||||
SourceTaskIDs []int64
|
||||
CompanyIDs []int64
|
||||
CategoryID *int64
|
||||
CategoryName string
|
||||
}
|
||||
|
||||
func (t *Task) lifecycleSnapshot() taskLifecycleState {
|
||||
@@ -108,6 +112,8 @@ func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
|
||||
DeadlineAt: t.DeadlineAt,
|
||||
SourceTaskIDs: append([]int64(nil), t.SourceTaskIDs...),
|
||||
CompanyIDs: append([]int64(nil), t.CompanyIDs...),
|
||||
CategoryID: cloneInt64Ptr(t.CategoryID),
|
||||
CategoryName: t.CategoryName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +134,8 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
|
||||
t.DeadlineAt = state.DeadlineAt
|
||||
t.SourceTaskIDs = append(t.SourceTaskIDs[:0], state.SourceTaskIDs...)
|
||||
t.CompanyIDs = append(t.CompanyIDs[:0], state.CompanyIDs...)
|
||||
t.CategoryID = cloneInt64Ptr(state.CategoryID)
|
||||
t.CategoryName = state.CategoryName
|
||||
t.lifecycleMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -688,7 +696,8 @@ func unixNanoOrZero(t *time.Time) int64 {
|
||||
func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Interceptor) *Task {
|
||||
return &Task{
|
||||
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
|
||||
Name: pt.Name,
|
||||
Name: pt.Name,
|
||||
CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName,
|
||||
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
|
||||
QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode,
|
||||
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
|
||||
@@ -734,6 +743,75 @@ func (m *Manager) CreateTaskWithOptions(description, goal string, opts pgdb.Task
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// RenameTaskCategory persists a category name and refreshes every live task DTO
|
||||
// that references it. taskStateMu keeps this ordered with task reassignment.
|
||||
func (m *Manager) RenameTaskCategory(id int64, name string) (*pgdb.TaskCategory, error) {
|
||||
m.taskStateMu.Lock()
|
||||
defer m.taskStateMu.Unlock()
|
||||
category, err := m.pg.RenameTaskCategory(id, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, task := range m.tasks {
|
||||
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||
if state.CategoryID != nil && *state.CategoryID == id {
|
||||
state.CategoryName = category.Name
|
||||
}
|
||||
})
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// DeleteTaskCategory moves all affected live tasks to the uncategorized bucket.
|
||||
func (m *Manager) DeleteTaskCategory(id int64) (bool, error) {
|
||||
m.taskStateMu.Lock()
|
||||
defer m.taskStateMu.Unlock()
|
||||
deleted, err := m.pg.DeleteTaskCategory(id)
|
||||
if err != nil || !deleted {
|
||||
return deleted, err
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, task := range m.tasks {
|
||||
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||
if state.CategoryID != nil && *state.CategoryID == id {
|
||||
state.CategoryID = nil
|
||||
state.CategoryName = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetTaskCategory updates one live task without interrupting its runtime.
|
||||
func (m *Manager) SetTaskCategory(taskID string, categoryID *int64) (*pgdb.TaskCategory, error) {
|
||||
m.taskStateMu.Lock()
|
||||
defer m.taskStateMu.Unlock()
|
||||
id, err := strconv.ParseInt(taskID, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, pgdb.ErrTaskCategoryTaskNotFound
|
||||
}
|
||||
category, err := m.pg.SetTaskCategory(id, categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.RLock()
|
||||
task := m.tasks[taskID]
|
||||
m.mu.RUnlock()
|
||||
if task != nil {
|
||||
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||
state.CategoryID = cloneInt64Ptr(categoryID)
|
||||
state.CategoryName = ""
|
||||
if category != nil {
|
||||
state.CategoryName = category.Name
|
||||
}
|
||||
})
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
// DeleteCompanyWithAssets keeps the database cascade and live task handles in
|
||||
// one manager-level critical section. This closes the gap where a task could
|
||||
// commit its company scope immediately before registration and miss the
|
||||
|
||||
+24
-8
@@ -390,9 +390,9 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
|
||||
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读)
|
||||
pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg)
|
||||
pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner"))
|
||||
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
|
||||
pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction
|
||||
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
|
||||
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
|
||||
pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction
|
||||
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
|
||||
pl.SetWebSearch(s.webSearchFor("planner"))
|
||||
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读)
|
||||
return pl, wk
|
||||
@@ -646,15 +646,23 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /api/tasks", s.listTasks)
|
||||
mux.HandleFunc("POST /api/tasks", s.createTask)
|
||||
mux.HandleFunc("GET /api/task-categories", s.pgListTaskCategories)
|
||||
mux.HandleFunc("POST /api/task-categories", s.pgCreateTaskCategory)
|
||||
mux.HandleFunc("PATCH /api/task-categories/{id}", s.pgRenameTaskCategory)
|
||||
mux.HandleFunc("DELETE /api/task-categories/{id}", s.pgDeleteTaskCategory)
|
||||
mux.HandleFunc("GET /api/task-templates", s.pgListTaskTemplates)
|
||||
mux.HandleFunc("POST /api/task-templates", s.pgCreateTaskTemplate)
|
||||
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
|
||||
mux.HandleFunc("DELETE /api/task-templates/{id}", s.pgDeleteTaskTemplate)
|
||||
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory)
|
||||
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/assets", s.attachTaskAssets)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/assets/{assetID}", s.detachTaskAsset)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/intent-assets", s.taskIntentAssets)
|
||||
|
||||
// 工作空间文件管理器(针对 workDir)
|
||||
mux.HandleFunc("GET /api/workspace/list", s.wsList)
|
||||
@@ -667,10 +675,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/goals", s.listGoals) // 目标管理:列出本任务全部目标
|
||||
mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/goals", s.listGoals) // 目标管理:列出本任务全部目标
|
||||
mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务)
|
||||
mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/constraints", s.listConstraints) // 约束管理:列出本任务操作约束
|
||||
mux.HandleFunc("POST /api/tasks/{id}/constraints", s.addConstraint) // 约束管理:新增约束(不通知 planner)
|
||||
mux.HandleFunc("PATCH /api/tasks/{id}/constraints/{cid}", s.editConstraint) // 约束管理:修改约束
|
||||
@@ -1315,6 +1323,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type createTaskReq struct {
|
||||
Name string `json:"name,omitempty"` // 可选任务名称;省略/空=未命名
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
|
||||
@@ -1372,12 +1381,16 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
req.CompanyIDs = companyIDs
|
||||
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
|
||||
writeErr(w, 400, "任务分类不存在或无效")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, db.ErrTaskCompanyIDsInvalid) || errors.Is(err, db.ErrTaskCompanyNotFound) {
|
||||
writeErr(w, 400, "关联企业不存在或无效")
|
||||
return
|
||||
@@ -1554,6 +1567,9 @@ func (s *Server) seed(t *Task, text string) {
|
||||
} else {
|
||||
rootID, _ = as.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host, TaskID: taskID})
|
||||
}
|
||||
if rootID > 0 {
|
||||
_ = as.SetTaskAssetSource(taskID, rootID, "task", "由任务描述或目标初始化", nil)
|
||||
}
|
||||
}
|
||||
// anchor the seeded assets to this task's begin root as lineage/provenance
|
||||
// (the asset graph is global and shared; anchoring no longer gates reads).
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxTaskAssetRequestBytes = 512 << 10
|
||||
|
||||
func writeTaskAssetError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrTaskAssetInvalid):
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, db.ErrTaskAssetTaskNotFound), errors.Is(err, db.ErrTaskAssetAssetNotFound):
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) attachTaskAssets(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskAssetRequestBytes)
|
||||
var request struct {
|
||||
AssetIDs []int64 `json:"asset_ids"`
|
||||
SourceSummary string `json:"source_summary"`
|
||||
Scope companyScopeInputs `json:"scope"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
request.SourceSummary = strings.TrimSpace(request.SourceSummary)
|
||||
taskID, _ := parseTaskID(task.ID)
|
||||
if request.Scope != nil && len(request.AssetIDs) > 0 {
|
||||
writeErr(w, http.StatusBadRequest, "scope 与 asset_ids 不能同时提交")
|
||||
return
|
||||
}
|
||||
if request.Scope != nil {
|
||||
mutation, err := s.m.Assets().RegisterTaskAssetScopes(taskID, request.Scope)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
task.Notify()
|
||||
writeJSON(w, http.StatusOK, mutation)
|
||||
return
|
||||
}
|
||||
mutation, err := s.m.Assets().AttachAssetsToTask(taskID, request.AssetIDs, request.SourceSummary)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
task.Notify()
|
||||
writeJSON(w, http.StatusOK, mutation)
|
||||
}
|
||||
|
||||
func (s *Server) detachTaskAsset(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
assetID, ok := pathInt(r, "assetID")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad asset id")
|
||||
return
|
||||
}
|
||||
taskID, _ := parseTaskID(task.ID)
|
||||
detached, err := s.m.Assets().DetachAssetFromTask(taskID, assetID)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
if !detached {
|
||||
writeErr(w, http.StatusNotFound, "asset is not associated with this task")
|
||||
return
|
||||
}
|
||||
task.Notify()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"detached": assetID})
|
||||
}
|
||||
|
||||
func (s *Server) taskIntentAssets(w http.ResponseWriter, r *http.Request) {
|
||||
task, ok := s.m.Task(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
taskID, _ := parseTaskID(task.ID)
|
||||
assets, err := s.m.Assets().IntentAssets(taskID)
|
||||
if err != nil {
|
||||
writeTaskAssetError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"assets": assets})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxTaskCategoryRequestBytes = 16 << 10
|
||||
|
||||
type taskCategoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
|
||||
func decodeTaskCategoryRequest(w http.ResponseWriter, r *http.Request) (*taskCategoryRequest, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
|
||||
var request taskCategoryRequest
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if request.Name == nil || strings.TrimSpace(*request.Name) == "" {
|
||||
writeErr(w, http.StatusBadRequest, "分类名称不能为空")
|
||||
return nil, false
|
||||
}
|
||||
if utf8.RuneCountInString(strings.TrimSpace(*request.Name)) > db.MaxTaskCategoryNameRunes {
|
||||
writeErr(w, http.StatusBadRequest, "分类名称最多 80 个字符")
|
||||
return nil, false
|
||||
}
|
||||
return &request, true
|
||||
}
|
||||
|
||||
func writeTaskCategoryError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrTaskCategoryInvalid):
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, db.ErrTaskCategoryNameConflict):
|
||||
writeErr(w, http.StatusConflict, "分类名称已存在")
|
||||
case errors.Is(err, db.ErrTaskCategoryNotFound), errors.Is(err, db.ErrTaskCategoryTaskNotFound):
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pgListTaskCategories(w http.ResponseWriter, _ *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
categories, err := pg.ListTaskCategories()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"categories": categories})
|
||||
}
|
||||
|
||||
func (s *Server) pgCreateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
request, ok := decodeTaskCategoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, err := pg.CreateTaskCategory(*request.Name)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, category)
|
||||
}
|
||||
|
||||
func (s *Server) pgRenameTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task category id")
|
||||
return
|
||||
}
|
||||
request, ok := decodeTaskCategoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, err := s.m.RenameTaskCategory(id, *request.Name)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, category)
|
||||
}
|
||||
|
||||
func (s *Server) pgDeleteTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task category id")
|
||||
return
|
||||
}
|
||||
deleted, err := s.m.DeleteTaskCategory(id)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeErr(w, http.StatusNotFound, "task category not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if _, ok := s.m.Task(taskID); !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
|
||||
var request struct {
|
||||
CategoryID json.RawMessage `json:"category_id"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(request.CategoryID) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "category_id is required")
|
||||
return
|
||||
}
|
||||
var categoryID *int64
|
||||
if !bytes.Equal(bytes.TrimSpace(request.CategoryID), []byte("null")) {
|
||||
var id int64
|
||||
if err := json.Unmarshal(request.CategoryID, &id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return
|
||||
}
|
||||
categoryID = &id
|
||||
}
|
||||
if categoryID != nil && *categoryID <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return
|
||||
}
|
||||
if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
task, _ := s.m.Task(taskID)
|
||||
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ScopeTextEditor } from "@/components/scope-text-editor";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
@@ -57,10 +58,9 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import { MAX_COMPANY_SCOPE_RULES, type ParsedCompanyScopeText, parseCompanyScopeText } from "@/lib/company-scope";
|
||||
import type { Asset, Company, CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
|
||||
import { MAX_COMPANY_SCOPE_RULES, parseCompanyScopeText } from "@/lib/company-scope";
|
||||
import type { Asset, Company, CompanyScopeRule } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── DSL autocomplete ──────────────────────────────────────────────────────────
|
||||
@@ -1321,70 +1321,6 @@ function CompanyAvatar({ name, logo }: { name: string; logo?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const COMPANY_SCOPE_KIND_LABELS: Record<CompanyScopeKind, string> = {
|
||||
domain: "域名",
|
||||
ip: "IP",
|
||||
cidr: "CIDR",
|
||||
icp: "ICP",
|
||||
keyword: "关键词",
|
||||
};
|
||||
|
||||
function ScopeTextEditor({
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
parsed,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
parsed: ParsedCompanyScopeText;
|
||||
}) {
|
||||
const counts = React.useMemo(() => {
|
||||
const result = new Map<CompanyScopeKind, number>();
|
||||
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
|
||||
return result;
|
||||
}, [parsed.rules]);
|
||||
return (
|
||||
<Field data-invalid={parsed.errors.length > 0}>
|
||||
<FieldLabel htmlFor={id}>资产范围</FieldLabel>
|
||||
<FieldDescription>每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。</FieldDescription>
|
||||
<Textarea
|
||||
id={id}
|
||||
rows={8}
|
||||
value={value}
|
||||
aria-invalid={parsed.errors.length > 0}
|
||||
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24\n京ICP备12345678号-1\n企业名称关键词"}
|
||||
className="min-h-36 resize-y font-mono text-sm"
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
/>
|
||||
{parsed.rules.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>已识别 {parsed.rules.length} 条</span>
|
||||
{Object.entries(COMPANY_SCOPE_KIND_LABELS).map(([kind, label]) => {
|
||||
const count = counts.get(kind as CompanyScopeKind) ?? 0;
|
||||
return count > 0 ? (
|
||||
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
|
||||
{label} {count}
|
||||
</Badge>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{parsed.errors.length > 0 && (
|
||||
<FieldError>
|
||||
{parsed.errors.slice(0, 5).map((item) => (
|
||||
<span key={`${item.line}-${item.error}`} className="block">
|
||||
第 {item.line} 行:{item.error}
|
||||
</span>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <span className="block">另有 {parsed.errors.length - 5} 行错误</span>}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function savedScopeRules(company: Company): CompanyScopeRule[] {
|
||||
return (company.scope ?? [])
|
||||
.map((scope) => ({ kind: scope.kind, value: scope.raw.trim() }))
|
||||
|
||||
@@ -1,56 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
LayoutTemplateIcon,
|
||||
LinkIcon,
|
||||
KeyRoundIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutTemplateIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SmartphoneIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ScopeTextEditor } from "@/components/scope-text-editor";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Asset } from "@/lib/types";
|
||||
import { parseCompanyScopeText } from "@/lib/company-scope";
|
||||
import { taskAssetSourceLabel } from "@/lib/task-assets";
|
||||
import type { Asset, NewAssetType } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100, 200];
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
GET: "bg-emerald-100 text-emerald-700",
|
||||
POST: "bg-blue-100 text-blue-700",
|
||||
PUT: "bg-amber-100 text-amber-700",
|
||||
PATCH: "bg-orange-100 text-orange-700",
|
||||
DELETE: "bg-red-100 text-red-700",
|
||||
GET: "bg-emerald-100 text-emerald-700",
|
||||
HEAD: "bg-purple-100 text-purple-700",
|
||||
OPTIONS: "bg-slate-100 text-slate-600",
|
||||
PATCH: "bg-orange-100 text-orange-700",
|
||||
POST: "bg-blue-100 text-blue-700",
|
||||
PUT: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
|
||||
const TABS: { key: NewAssetType; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "root_domain", label: "根域名", icon: GlobeIcon },
|
||||
{ key: "ip", label: "IP", icon: NetworkIcon },
|
||||
{ key: "subdomain", label: "子域名", icon: GlobeIcon },
|
||||
{ key: "app", label: "应用", icon: SmartphoneIcon },
|
||||
{ key: "service", label: "服务", icon: LayoutTemplateIcon },
|
||||
{ key: "endpoint", label: "接口", icon: LinkIcon },
|
||||
];
|
||||
|
||||
function firstText(values: Array<string | undefined>, fallback: string): string {
|
||||
return values.find((value) => value?.trim()) ?? fallback;
|
||||
}
|
||||
|
||||
function assetLabel(asset: Asset): string {
|
||||
switch (asset.type) {
|
||||
case "root_domain":
|
||||
case "subdomain":
|
||||
return firstText([asset.domain], `#${asset.id}`);
|
||||
case "ip":
|
||||
return firstText([asset.ip], `#${asset.id}`);
|
||||
case "app":
|
||||
return firstText([asset.app_name, asset.url], `#${asset.id}`);
|
||||
case "service": {
|
||||
const host = firstText([asset.ip, asset.domain], "");
|
||||
const address = [host, asset.port].filter((value) => value !== undefined && value !== "").join(":");
|
||||
return firstText([asset.url, address, asset.service_name], `#${asset.id}`);
|
||||
}
|
||||
case "endpoint":
|
||||
return firstText([[asset.method, asset.url].filter(Boolean).join(" ")], `#${asset.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
const m = method.toUpperCase();
|
||||
const normalized = method.toUpperCase();
|
||||
return (
|
||||
<span className={cn("inline-block rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold leading-none", METHOD_COLOR[m] ?? "bg-muted text-muted-foreground")}>
|
||||
{m || "—"}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded px-1.5 py-0.5 font-mono font-semibold text-[10px] leading-none",
|
||||
METHOD_COLOR[normalized] ?? "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{normalized || "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -63,253 +109,470 @@ function statusTone(code: number) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function fmtBytes(n?: number | null) {
|
||||
if (!n || !Number.isFinite(n) || n <= 0) return "—";
|
||||
const u = ["B", "KB", "MB"];
|
||||
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
|
||||
return `${i === 0 ? n : (n / 1024 ** i).toFixed(1)} ${u[i]}`;
|
||||
function fmtBytes(value?: number | null) {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return "—";
|
||||
const units = ["B", "KB", "MB"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${index === 0 ? value : (value / 1024 ** index).toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function Chips({ items, mono }: { items: string[]; mono?: boolean }) {
|
||||
const clean = items.filter(Boolean);
|
||||
if (clean.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
if (clean.length === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clean.map((s, i) => (
|
||||
<Badge key={i} variant="outline" className={cn("text-[10px]", mono && "font-mono")}>{s}</Badge>
|
||||
{clean.map((item) => (
|
||||
<Badge key={item} variant="outline" className={cn(mono && "font-mono")}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCell({ asset }: { asset: Asset }) {
|
||||
const source = firstText([asset.task_source], "legacy");
|
||||
const summary = firstText([asset.task_source_summary], "由历史任务资产关联迁移,暂无更详细来源说明");
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="max-w-28 shrink-0 font-normal">
|
||||
<span className="truncate">{taskAssetSourceLabel(source)}</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" align="start" className="max-w-sm">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="font-medium">{taskAssetSourceLabel(source)}</span>
|
||||
<span className="[overflow-wrap:anywhere]">{summary}</span>
|
||||
{asset.task_source_node_id ? (
|
||||
<span className="font-mono opacity-80">来源节点 #{asset.task_source_node_id}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({
|
||||
cols, total, page, size, onSize, onPage, children,
|
||||
children,
|
||||
cols,
|
||||
loaded,
|
||||
onPage,
|
||||
onSize,
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols: string[];
|
||||
total: number;
|
||||
loaded: boolean;
|
||||
onPage: (page: number) => void;
|
||||
onSize: (size: number) => void;
|
||||
page: number;
|
||||
size: number;
|
||||
onSize: (v: number) => void;
|
||||
onPage: (fn: (p: number) => number) => void;
|
||||
children: React.ReactNode;
|
||||
total: number;
|
||||
}) {
|
||||
const rows = React.Children.toArray(children);
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const start = total === 0 ? 0 : page * size + 1;
|
||||
const end = page * size + rows.length;
|
||||
const end = Math.min(total, page * size + rows.length);
|
||||
let tableRows: React.ReactNode;
|
||||
if (!loaded) {
|
||||
tableRows = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={cols.length} className="py-14 text-center">
|
||||
<Spinner className="mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
} else if (rows.length > 0) {
|
||||
tableRows = rows;
|
||||
} else {
|
||||
tableRows = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={cols.length} className="py-10 text-center text-muted-foreground text-sm">
|
||||
当前分类暂无测试资产
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent">
|
||||
<Card className="flex min-h-0 flex-1 flex-col py-0">
|
||||
<CardContent className="scrollbar-thin scrollbar-track-transparent min-h-0 flex-1 overflow-auto p-0">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
{cols.map((c) => <TableHead key={c}>{c}</TableHead>)}
|
||||
{cols.map((column) => (
|
||||
<TableHead key={column}>{column}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length > 0 ? rows : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={cols.length} className="py-12 text-center text-sm text-muted-foreground">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<TableBody>{tableRows}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<Select value={String(size)} onValueChange={(v) => onSize(Number(v))}>
|
||||
<SelectTrigger size="sm" className="h-7 w-24"><SelectValue /></SelectTrigger>
|
||||
</CardContent>
|
||||
{total > 0 ? (
|
||||
<CardFooter className="gap-2 px-3 py-1.5 text-muted-foreground text-xs">
|
||||
<Select
|
||||
value={String(size)}
|
||||
onValueChange={(value) => {
|
||||
onSize(Number(value));
|
||||
onPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((n) => <SelectItem key={n} value={String(n)}>{n} / 页</SelectItem>)}
|
||||
<SelectGroup>
|
||||
{PAGE_SIZES.map((value) => (
|
||||
<SelectItem key={value} value={String(value)}>
|
||||
{value} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">{start}–{end} / {total}</span>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={page <= 0} onClick={() => onPage((p) => Math.max(0, p - 1))}>
|
||||
<span className="tabular-nums">
|
||||
{start}–{end} / {total}
|
||||
</span>
|
||||
{pageCount > 1 ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page <= 0}
|
||||
onClick={() => onPage(Math.max(0, page - 1))}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="tabular-nums">{page + 1} / {pageCount}</span>
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={page + 1 >= pageCount} onClick={() => onPage((p) => Math.min(pageCount - 1, p + 1))}>
|
||||
<span className="tabular-nums">
|
||||
{page + 1} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page + 1 >= pageCount}
|
||||
onClick={() => onPage(Math.min(pageCount - 1, page + 1))}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "root_domain", label: "根域名", icon: GlobeIcon },
|
||||
{ key: "ip", label: "IP", icon: NetworkIcon },
|
||||
{ key: "subdomain", label: "子域名", icon: GlobeIcon },
|
||||
{ key: "service", label: "服务", icon: LayoutTemplateIcon },
|
||||
{ key: "endpoint", label: "接口", icon: LinkIcon },
|
||||
];
|
||||
function AddTaskAssetsSheet({
|
||||
onAttached,
|
||||
onOpenChange,
|
||||
open,
|
||||
taskId,
|
||||
}: {
|
||||
onAttached: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
taskId: string;
|
||||
}) {
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScopeText("");
|
||||
}, [open]);
|
||||
|
||||
const attach = async () => {
|
||||
if (parsedScope.rules.length === 0 || parsedScope.errors.length > 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.registerTaskAssetScopes(taskId, parsedScope.rules);
|
||||
const assetSummary = result.assets_linked + result.assets_existing;
|
||||
toast.success(`已登记 ${result.requested} 条范围,关联 ${assetSummary} 项域名/IP 资产`);
|
||||
onAttached();
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
toast.error(`新增失败:${String((reason as Error)?.message ?? reason)}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>新增测试资产</SheetTitle>
|
||||
<SheetDescription>
|
||||
直接填写测试范围。域名和 IP 会创建或复用全局资产;CIDR、ICP 和关键词作为 Agent 范围上下文。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<ScopeTextEditor
|
||||
id="task-asset-scope"
|
||||
value={scopeText}
|
||||
onValueChange={setScopeText}
|
||||
parsed={parsedScope}
|
||||
label="测试资产与范围"
|
||||
description="每行一条,自动识别域名、IP、CIDR、ICP 备案和关键词。"
|
||||
/>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void attach()}
|
||||
disabled={saving || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}
|
||||
>
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <PlusIcon data-icon="inline-start" />}
|
||||
登记 {parsedScope.rules.length > 0 ? parsedScope.rules.length : ""} 条
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetsTab({ taskId }: { taskId: string }) {
|
||||
const [rows, setRows] = React.useState<Asset[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [counts, setCounts] = React.useState<Record<string, number>>({});
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("root_domain");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
const [rows, setRows] = React.useState<Asset[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [counts, setCounts] = React.useState<Record<string, number>>({});
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState<NewAssetType>("root_domain");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [addOpen, setAddOpen] = React.useState(false);
|
||||
const [removeTarget, setRemoveTarget] = React.useState<Asset | null>(null);
|
||||
const [removing, setRemoving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(0);
|
||||
setRows([]);
|
||||
}, [tab, size]);
|
||||
setLoaded(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const [cur, nextCounts] = await Promise.all([
|
||||
const [current, nextCounts] = await Promise.all([
|
||||
api.taskAssets(taskId, tab, size, page * size),
|
||||
api.assetCounts(taskId),
|
||||
]);
|
||||
if (!alive) return;
|
||||
setRows(cur.assets);
|
||||
setTotal(cur.total);
|
||||
if (!active) return;
|
||||
setRows(current.assets);
|
||||
setTotal(current.total);
|
||||
setCounts(nextCounts ?? {});
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
if (alive) setLoaded(true);
|
||||
} catch (reason) {
|
||||
if (active) toast.error(`加载任务资产失败:${String((reason as Error)?.message ?? reason)}`);
|
||||
} finally {
|
||||
if (active) setLoaded(true);
|
||||
}
|
||||
};
|
||||
load();
|
||||
void load();
|
||||
const timer = setInterval(load, 10_000);
|
||||
return () => { alive = false; clearInterval(timer); };
|
||||
}, [taskId, tab, page, size]);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [page, refreshKey, size, tab, taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
|
||||
if (page > maxPage) setPage(maxPage);
|
||||
}, [total, size, page]);
|
||||
}, [page, size, total]);
|
||||
|
||||
const totalAll = TABS.reduce((n, t) => n + (counts[t.key] ?? 0), 0);
|
||||
const refresh = React.useCallback(() => {
|
||||
setLoaded(false);
|
||||
setRefreshKey((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="py-20 text-center text-sm text-muted-foreground">加载中…</div>;
|
||||
}
|
||||
if (totalAll === 0) {
|
||||
return (
|
||||
<div className="py-20 text-center text-sm text-muted-foreground">
|
||||
该任务暂无涉及资产
|
||||
<p className="mt-1 text-xs">资产在任务执行过程中由 agent 发现并记录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!removeTarget) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await api.detachTaskAsset(taskId, removeTarget.id);
|
||||
toast.success(`已将 ${assetLabel(removeTarget)} 移出当前任务`);
|
||||
setRemoveTarget(null);
|
||||
refresh();
|
||||
} catch (reason) {
|
||||
toast.error(`移出失败:${String((reason as Error)?.message ?? reason)}`);
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeButton = (asset: Asset) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setRemoveTarget(asset)}
|
||||
aria-label={`将资产 ${assetLabel(asset)} 移出任务`}
|
||||
title="移出任务"
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const commonCardProps = { loaded, onPage: setPage, onSize: setSize, page, size, total };
|
||||
const totalAll = TABS.reduce((sum, item) => sum + (counts[item.key] ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Tabs orientation="vertical" value={tab} onValueChange={setTab} className="items-start gap-4">
|
||||
{/* left nav */}
|
||||
<div className="w-36 shrink-0 rounded-lg border bg-card p-1 shadow-sm">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
|
||||
tab === t.key
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<t.icon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left">{t.label}</span>
|
||||
<span className="tabular-nums text-xs opacity-70">{counts[t.key] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-medium text-sm">测试资产</h2>
|
||||
<p className="text-muted-foreground text-xs">当前任务共关联 {totalAll} 项资产</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
新增测试资产
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* right content */}
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-4">
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as NewAssetType)} className="min-h-0 flex-1">
|
||||
<div className="overflow-x-auto overflow-y-hidden">
|
||||
<TabsList className="w-max">
|
||||
{TABS.map((item) => (
|
||||
<TabsTrigger key={item.key} value={item.key}>
|
||||
<item.icon data-icon="inline-start" />
|
||||
{item.label}
|
||||
<span className="text-muted-foreground tabular-nums">{counts[item.key] ?? 0}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["域名", "ICP 备案"]} total={total} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{rows.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
|
||||
<TableCell className="text-xs">{a.icp || "—"}</TableCell>
|
||||
<TabsContent value="root_domain" className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["域名", "ICP 备案", "来源", "操作"]} {...commonCardProps}>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
|
||||
<TableCell className="text-xs">{asset.icp || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口"]} total={total} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{rows.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono text-xs font-medium">{a.ip}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.c_segment || "—"}</TableCell>
|
||||
<TableCell><Chips items={a.bound_domains ?? []} mono /></TableCell>
|
||||
<TabsContent value="ip" className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口", "来源", "操作"]} {...commonCardProps}>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="font-medium font-mono text-xs">{asset.ip}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{asset.c_segment || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chips items={asset.bound_domains ?? []} mono />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chips
|
||||
items={(a.open_ports ?? []).map((p) =>
|
||||
p.service ? `${p.port}/${p.service}` : String(p.port),
|
||||
items={(asset.open_ports ?? []).map((item) =>
|
||||
item.service ? `${item.port}/${item.service}` : String(item.port),
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["域名", "根域名", "解析类型", "解析值"]} total={total} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{rows.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.root_domain || "—"}</TableCell>
|
||||
<TableCell className="text-xs">{a.record_type || "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs">{(Array.isArray(a.record_value) ? a.record_value.join(", ") : a.record_value) || "—"}</TableCell>
|
||||
<TabsContent value="subdomain" className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["域名", "根域名", "解析类型", "解析值", "来源", "操作"]} {...commonCardProps}>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{asset.root_domain || "—"}</TableCell>
|
||||
<TableCell className="text-xs">{asset.record_type || "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs">
|
||||
{(Array.isArray(asset.record_value) ? asset.record_value.join(", ") : asset.record_value) || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<TabsContent value="app" className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["应用", "地址", "分类", "标题", "指纹", "来源", "操作"]} {...commonCardProps}>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="max-w-48 truncate font-medium text-xs">{asset.app_name || "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs" title={asset.url}>
|
||||
{asset.url || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{asset.category || "—"}</TableCell>
|
||||
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chips items={asset.technologies ?? []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="service" className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard
|
||||
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证"]}
|
||||
total={total}
|
||||
page={page}
|
||||
size={size}
|
||||
onSize={setSize}
|
||||
onPage={setPage}
|
||||
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证", "来源", "操作"]}
|
||||
{...commonCardProps}
|
||||
>
|
||||
{rows.map((a) => {
|
||||
const isHttp = a.service_type === "http";
|
||||
const addr = isHttp
|
||||
? (a.url || "")
|
||||
: (a.service_name || [a.ip || a.domain, a.port].filter(Boolean).join(":"));
|
||||
{rows.map((asset) => {
|
||||
const isHttp = asset.service_type === "http";
|
||||
const address = isHttp
|
||||
? asset.url || ""
|
||||
: asset.service_name || [asset.ip || asset.domain, asset.port].filter(Boolean).join(":");
|
||||
return (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs" title={addr}>{addr || "—"}</TableCell>
|
||||
<TableCell>
|
||||
{a.status_code != null ? (
|
||||
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(a.status_code))}>
|
||||
{a.status_code}
|
||||
</span>
|
||||
) : "—"}
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs" title={address}>
|
||||
{address || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate text-xs">{a.page_title || "—"}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{fmtBytes(a.content_length)}</TableCell>
|
||||
<TableCell><Chips items={a.technologies ?? []} /></TableCell>
|
||||
<TableCell>
|
||||
{(a.auth ?? []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
{asset.status_code != null ? (
|
||||
<span
|
||||
className={cn("font-mono font-semibold text-xs tabular-nums", statusTone(asset.status_code))}
|
||||
>
|
||||
{asset.status_code}
|
||||
</span>
|
||||
) : (
|
||||
(a.auth ?? []).map((authItem, i) => {
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{fmtBytes(asset.content_length)}</TableCell>
|
||||
<TableCell>
|
||||
<Chips items={asset.technologies ?? []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(asset.auth ?? []).length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
) : (
|
||||
(asset.auth ?? []).map((authItem) => {
|
||||
const item = authItem as Record<string, string>;
|
||||
return (
|
||||
<span key={i} className="inline-flex items-center gap-1 text-[11px]">
|
||||
<span
|
||||
key={`${asset.id}-${item.type ?? ""}-${item.username ?? ""}-${JSON.stringify(item)}`}
|
||||
className="inline-flex items-center gap-1 text-[11px]"
|
||||
>
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono">{item.type || item.username || "认证"}</span>
|
||||
</span>
|
||||
@@ -317,33 +580,72 @@ export function AssetsTab({ taskId }: { taskId: string }) {
|
||||
})
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["方法", "完整地址", "参数"]} total={total} page={page} size={size} onSize={setSize} onPage={setPage}>
|
||||
{rows.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="w-16"><MethodBadge method={a.method || ""} /></TableCell>
|
||||
<TableCell className="max-w-sm truncate font-mono text-xs" title={a.url}>{a.url || "—"}</TableCell>
|
||||
<TabsContent value="endpoint" className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={["方法", "完整地址", "参数", "来源", "操作"]} {...commonCardProps}>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="w-16">
|
||||
<MethodBadge method={asset.method || ""} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm truncate font-mono text-xs" title={asset.url}>
|
||||
{asset.url || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chips
|
||||
items={(a.params ?? []).map((p) => {
|
||||
const item = p as Record<string, string>;
|
||||
return item.name ? `${item.name}(${item.location || "?"})` : "?";
|
||||
items={(asset.params ?? []).map((param) => {
|
||||
const item = param as Record<string, string>;
|
||||
return item.name ? `${item.name}(${item.location || item.in || "?"})` : "?";
|
||||
})}
|
||||
mono
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
</Tabs>
|
||||
<AddTaskAssetsSheet onAttached={refresh} onOpenChange={setAddOpen} open={addOpen} taskId={taskId} />
|
||||
|
||||
<AlertDialog open={Boolean(removeTarget)} onOpenChange={(open) => !open && !removing && setRemoveTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>移出当前任务?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="[overflow-wrap:anywhere]">
|
||||
{removeTarget ? `将“${assetLabel(removeTarget)}”从当前任务的测试资产中移出。` : ""}
|
||||
全局资产、关联流量和历史黑板锚点会继续保留。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={removing}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={removing}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void remove();
|
||||
}}
|
||||
>
|
||||
{removing ? <Spinner data-icon="inline-start" /> : <Trash2Icon data-icon="inline-start" />}
|
||||
{removing ? "移出中" : "确认移出"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ function cacheHitRate(cacheRead: number, input: number): string {
|
||||
|
||||
// 测试范围一条的显示值:域名 / 网段 / 公司。
|
||||
function scopeValue(row: TaskScopeRow): string {
|
||||
if (row.value) return row.value;
|
||||
if (row.domain) return row.domain;
|
||||
if (row.net) return row.net;
|
||||
if (row.company_id) return row.company_name || `企业 #${row.company_id}`;
|
||||
@@ -65,6 +66,8 @@ const SCOPE_KIND_LABELS: Record<TaskScopeRow["kind"], string> = {
|
||||
subdomain: "子域名",
|
||||
ip: "IP",
|
||||
cidr: "网段",
|
||||
icp: "ICP",
|
||||
keyword: "关键词",
|
||||
};
|
||||
|
||||
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
|
||||
@@ -364,7 +367,9 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
.then((c) => {
|
||||
if (!cancelled) setCoverage(c);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// A transient coverage failure is retried by the next poll.
|
||||
});
|
||||
} catch {
|
||||
// transient errors are ignored; the next poll will retry
|
||||
}
|
||||
@@ -580,7 +585,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
}}
|
||||
disabled={conBusy}
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={conBusy || !conText.trim()} onClick={() => void addConstraint()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={conBusy || !conText.trim()}
|
||||
onClick={() => void addConstraint()}
|
||||
>
|
||||
<PlusIcon className="size-3.5" /> 添加
|
||||
</Button>
|
||||
{conErr && <span className="text-xs text-red-500">{conErr}</span>}
|
||||
@@ -796,6 +806,8 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
<NativeSelectOption value="subdomain">子域名</NativeSelectOption>
|
||||
<NativeSelectOption value="ip">IP</NativeSelectOption>
|
||||
<NativeSelectOption value="cidr">网段</NativeSelectOption>
|
||||
<NativeSelectOption value="icp">ICP</NativeSelectOption>
|
||||
<NativeSelectOption value="keyword">关键词</NativeSelectOption>
|
||||
<NativeSelectOption value="company">公司</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Input
|
||||
@@ -805,7 +817,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
|
||||
? "公司名或 id"
|
||||
: scopeKind === "ip" || scopeKind === "cidr"
|
||||
? "如 10.0.0.1 或 10.0.0.0/24"
|
||||
: "如 example.com"
|
||||
: scopeKind === "icp"
|
||||
? "如 京ICP备12345678号-1"
|
||||
: scopeKind === "keyword"
|
||||
? "如 企业名称关键词"
|
||||
: "如 example.com"
|
||||
}
|
||||
value={scopeValueInput}
|
||||
onChange={(e) => setScopeValueInput(e.target.value)}
|
||||
|
||||
@@ -41,15 +41,17 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { taskAssetSourceLabel, taskAssetTypeLabel } from "@/lib/task-assets";
|
||||
import type {
|
||||
Activity,
|
||||
ChatAttachment,
|
||||
IntentAsset,
|
||||
InterceptApprovalRow,
|
||||
Session,
|
||||
SessionStatus,
|
||||
@@ -360,7 +362,9 @@ function SessionItem({
|
||||
来源 #{s.source_task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className={cn("min-w-0 flex-1 truncate text-sm font-medium", deleted && "text-muted-foreground line-through")}>
|
||||
<span
|
||||
className={cn("min-w-0 flex-1 truncate text-sm font-medium", deleted && "text-muted-foreground line-through")}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
{deleted && (
|
||||
@@ -426,12 +430,44 @@ function SessionItem({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) {
|
||||
const first = assets[0];
|
||||
const firstLabel = first.label.trim() ? first.label : `#${first.asset_id}`;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="max-w-28 shrink-0 font-normal">
|
||||
<span className="truncate">当前资产:{firstLabel}</span>
|
||||
{assets.length > 1 && <span className="shrink-0 tabular-nums">+{assets.length - 1}</span>}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start" className="max-w-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
{assets.map((asset) => (
|
||||
<div key={`${asset.intent_id}-${asset.asset_id}`} className="min-w-0">
|
||||
<div className="break-all font-mono text-xs">
|
||||
{asset.label.trim() ? asset.label : `#${asset.asset_id}`}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{taskAssetTypeLabel(asset.type)} · {taskAssetSourceLabel(asset.source)}
|
||||
{asset.inherited ? ` · 来源任务 #${asset.source_task_id}` : ""}
|
||||
</div>
|
||||
<div className="mt-0.5 [overflow-wrap:anywhere] text-xs">{asset.source_summary}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [activeId, setActiveId] = React.useState(MAIN_ID);
|
||||
// Per-session lazily-loaded caches, keyed by session_key (main | plan | intent:<id>).
|
||||
const [store, setStore] = React.useState<SessionStore>({});
|
||||
// Worker sessions derived from exploration intents (paged past the old 300 cap).
|
||||
const [intents, setIntents] = React.useState<TaskNode[]>([]);
|
||||
const [intentAssets, setIntentAssets] = React.useState<IntentAsset[]>([]);
|
||||
const [olderIntents, setOlderIntents] = React.useState<TaskNode[]>([]);
|
||||
const [firstIntentsHasMore, setFirstIntentsHasMore] = React.useState(false);
|
||||
const [olderIntentsHasMore, setOlderIntentsHasMore] = React.useState(false);
|
||||
@@ -750,6 +786,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
React.useEffect(() => {
|
||||
setStore({});
|
||||
setIntents([]);
|
||||
setIntentAssets([]);
|
||||
setOlderIntents([]);
|
||||
setFirstIntentsHasMore(false);
|
||||
setOlderIntentsHasMore(false);
|
||||
@@ -900,6 +937,25 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
}, [taskId, patchStore]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
const load = () =>
|
||||
api
|
||||
.taskIntentAssets(taskId)
|
||||
.then((assets) => {
|
||||
if (active) setIntentAssets(assets);
|
||||
})
|
||||
.catch(() => {
|
||||
// The next poll retries; Worker controls and transcripts remain available.
|
||||
});
|
||||
void load();
|
||||
const timer = setInterval(load, 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
// ── worker (intent) session list — paged, poll first page lightly ───────────────
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
@@ -969,6 +1025,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
for (const n of intents) byId.set(n.id, n); // fresh poll wins over older snapshot
|
||||
return [...byId.values()].sort((a, b) => Number(b.id) - Number(a.id));
|
||||
}, [intents, olderIntents]);
|
||||
const intentAssetsByID = React.useMemo(() => {
|
||||
const grouped = new Map<string, IntentAsset[]>();
|
||||
for (const asset of intentAssets) {
|
||||
const key = String(asset.intent_id);
|
||||
const current = grouped.get(key);
|
||||
if (current) current.push(asset);
|
||||
else grouped.set(key, [asset]);
|
||||
}
|
||||
return grouped;
|
||||
}, [intentAssets]);
|
||||
|
||||
const workerSessions = React.useMemo(() => allIntents.map(intentToSession), [allIntents]);
|
||||
const intentsHasMore = hasLoadedOlderIntentsPage ? olderIntentsHasMore : firstIntentsHasMore;
|
||||
@@ -1269,6 +1335,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
return llmResolutions.worker;
|
||||
};
|
||||
const activeResolution = resolutionForSession(active);
|
||||
const activeAssets =
|
||||
active.role === "worker" && active.intent_id ? intentAssetsByID.get(active.intent_id) : undefined;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -1422,6 +1490,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{activeAssets && activeAssets.length > 0 && <WorkerAssetBadge assets={activeAssets} />}
|
||||
{active.inherited && active.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge>
|
||||
)}
|
||||
@@ -1622,7 +1691,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="break-words whitespace-normal">
|
||||
删除会<strong>停止该意图</strong>(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
|
||||
删除会<strong>停止该意图</strong>
|
||||
(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞
|
||||
<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="grid gap-2 py-1">
|
||||
|
||||
@@ -5,8 +5,10 @@ import * as React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
EyeIcon,
|
||||
FolderKanbanIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
PauseIcon,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
SearchIcon,
|
||||
SlidersHorizontalIcon,
|
||||
StarIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
@@ -94,6 +97,7 @@ import type {
|
||||
DeleteTaskResult,
|
||||
LLMProfile,
|
||||
Task,
|
||||
TaskCategory,
|
||||
TaskStatus,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -212,8 +216,10 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
|
||||
|
||||
export default function TasksPage() {
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
|
||||
const [categoryFilter, setCategoryFilter] = React.useState("all");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
|
||||
@@ -223,21 +229,25 @@ export default function TasksPage() {
|
||||
const q = query.trim().toLowerCase();
|
||||
return tasks.filter((t) => {
|
||||
if (statusFilter !== "all" && t.status !== statusFilter) return false;
|
||||
if (categoryFilter === "uncategorized" && t.category_id != null) return false;
|
||||
if (categoryFilter !== "all" && categoryFilter !== "uncategorized" && String(t.category_id) !== categoryFilter)
|
||||
return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
(t.name ?? "").toLowerCase().includes(q) ||
|
||||
(t.category_name ?? "").toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
t.goal.toLowerCase().includes(q) ||
|
||||
t.id.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [tasks, query, statusFilter]);
|
||||
}, [tasks, query, statusFilter, categoryFilter]);
|
||||
|
||||
// reset to page 1 whenever filters change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination.
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query, statusFilter]);
|
||||
}, [query, statusFilter, categoryFilter]);
|
||||
|
||||
const paginated = React.useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
@@ -311,11 +321,32 @@ export default function TasksPage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadCategories = React.useCallback(() => {
|
||||
api
|
||||
.taskCategories()
|
||||
.then(setCategories)
|
||||
.catch(() => {
|
||||
// Category management remains retryable without blocking the task list.
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
loadCategories();
|
||||
const i = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(i);
|
||||
}, [load]);
|
||||
}, [load, loadCategories]);
|
||||
|
||||
const refreshCategoriesAndTasks = React.useCallback(() => {
|
||||
lastRef.current = "";
|
||||
loadCategories();
|
||||
load();
|
||||
}, [load, loadCategories]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (categoryFilter === "all" || categoryFilter === "uncategorized") return;
|
||||
if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all");
|
||||
}, [categories, categoryFilter]);
|
||||
|
||||
// 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白
|
||||
// 重渲染整张表。
|
||||
@@ -512,6 +543,22 @@ export default function TasksPage() {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="任务分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
<SelectItem value="uncategorized">未分类</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{filtered.length}/{tasks.length} 条
|
||||
</span>
|
||||
@@ -555,7 +602,8 @@ export default function TasksPage() {
|
||||
</>
|
||||
)}
|
||||
<ConcurrencySettingsDialog />
|
||||
<CreateTaskSheet tasks={tasks} onCreated={load} />
|
||||
<CategoryManagementSheet categories={categories} onChanged={refreshCategoriesAndTasks} />
|
||||
<CreateTaskSheet tasks={tasks} categories={categories} onCreated={refreshCategoriesAndTasks} />
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
@@ -745,9 +793,9 @@ const TaskRow = React.memo(function TaskRow({
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
|
||||
className="min-w-0 truncate rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={task.name || task.description}
|
||||
title={task.name?.trim() ? task.name : task.description}
|
||||
>
|
||||
{task.name || <span className="text-muted-foreground">未命名</span>}
|
||||
{task.name?.trim() ? task.name : <span className="text-muted-foreground">未命名</span>}
|
||||
</Link>
|
||||
{task.active && <StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />}
|
||||
</div>
|
||||
@@ -1293,9 +1341,193 @@ function CompanyPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => void }) {
|
||||
function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCategory[]; onChanged: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [newName, setNewName] = React.useState("");
|
||||
const [editingID, setEditingID] = React.useState<number | null>(null);
|
||||
const [editingName, setEditingName] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState<TaskCategory | null>(null);
|
||||
|
||||
async function createCategory() {
|
||||
const name = newName.trim();
|
||||
if (!name || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.createTaskCategory(name);
|
||||
setNewName("");
|
||||
toast.success("分类已创建");
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(`创建分类失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameCategory() {
|
||||
const name = editingName.trim();
|
||||
if (!editingID || !name || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.renameTaskCategory(editingID, name);
|
||||
setEditingID(null);
|
||||
setEditingName("");
|
||||
toast.success("分类已更新");
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(`更新分类失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory() {
|
||||
if (!deleting || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.deleteTaskCategory(deleting.id);
|
||||
toast.success("分类已删除,关联任务已移入未分类");
|
||||
setDeleting(null);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(`删除分类失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<TagsIcon data-icon="inline-start" />
|
||||
分类管理
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>任务分类</SheetTitle>
|
||||
<SheetDescription>创建、重命名或删除全局分类。删除分类不会删除任务。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-task-category">新分类</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="new-task-category"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
placeholder="例如:外网评估"
|
||||
maxLength={80}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void createCategory();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => void createCategory()}
|
||||
disabled={!newName.trim() || saving}
|
||||
aria-label="创建分类"
|
||||
>
|
||||
{saving && editingID == null && deleting == null ? <Spinner /> : <PlusIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col gap-2">
|
||||
{categories.length === 0 && <p className="py-8 text-center text-sm text-muted-foreground">暂无分类</p>}
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex min-w-0 items-center gap-2 rounded-md border p-2">
|
||||
<FolderKanbanIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
{editingID === category.id ? (
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
value={editingName}
|
||||
maxLength={80}
|
||||
onChange={(event) => setEditingName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void renameCategory();
|
||||
if (event.key === "Escape") setEditingID(null);
|
||||
}}
|
||||
aria-label={`重命名分类 ${category.name}`}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-medium"
|
||||
onClick={() => {
|
||||
setEditingID(category.id);
|
||||
setEditingName(category.name);
|
||||
}}
|
||||
title="点击重命名"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{category.task_count} 个任务
|
||||
</span>
|
||||
{editingID === category.id && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => void renameCategory()}
|
||||
disabled={!editingName.trim() || saving}
|
||||
aria-label="保存分类名称"
|
||||
>
|
||||
{saving ? <Spinner /> : <CheckIcon />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleting(category)}
|
||||
disabled={saving}
|
||||
aria-label={`删除分类 ${category.name}`}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={deleting != null} onOpenChange={(next) => !next && setDeleting(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除分类“{deleting?.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
分类删除后,其中 {deleting?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void deleteCategory()} disabled={saving}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTaskSheet({
|
||||
tasks,
|
||||
categories,
|
||||
onCreated,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
categories: TaskCategory[];
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [categoryID, setCategoryID] = React.useState("uncategorized");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [goal, setGoal] = React.useState("");
|
||||
const [selectedTemplateID, setSelectedTemplateID] = React.useState<number | null>(null);
|
||||
@@ -1365,6 +1597,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
|
||||
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
|
||||
await api.createTask({
|
||||
name: name.trim(),
|
||||
categoryId: categoryID === "uncategorized" ? undefined : Number(categoryID),
|
||||
description: description.trim(),
|
||||
goal: goal.trim(),
|
||||
llmProfileIds: llmProfileIDs.map(Number),
|
||||
@@ -1377,6 +1610,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
|
||||
});
|
||||
toast.success("任务已创建");
|
||||
setName("");
|
||||
setCategoryID("uncategorized");
|
||||
setDescription("");
|
||||
setGoal("");
|
||||
setSelectedTemplateID(null);
|
||||
@@ -1440,6 +1674,25 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-category">任务分类</FieldLabel>
|
||||
<Select value={categoryID} onValueChange={setCategoryID}>
|
||||
<SelectTrigger id="task-category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="uncategorized">未分类</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>用于任务列表筛选和归档,不影响 Agent 执行。</FieldDescription>
|
||||
</Field>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { ParsedCompanyScopeText } from "@/lib/company-scope";
|
||||
import type { CompanyScopeKind } from "@/lib/types";
|
||||
|
||||
const SCOPE_KIND_LABELS: Record<CompanyScopeKind, string> = {
|
||||
domain: "域名",
|
||||
ip: "IP",
|
||||
cidr: "CIDR",
|
||||
icp: "ICP",
|
||||
keyword: "关键词",
|
||||
};
|
||||
|
||||
export function ScopeTextEditor({
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
parsed,
|
||||
label = "资产范围",
|
||||
description = "每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。",
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
parsed: ParsedCompanyScopeText;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const counts = React.useMemo(() => {
|
||||
const result = new Map<CompanyScopeKind, number>();
|
||||
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
|
||||
return result;
|
||||
}, [parsed.rules]);
|
||||
|
||||
return (
|
||||
<Field data-invalid={parsed.errors.length > 0}>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
<Textarea
|
||||
id={id}
|
||||
rows={8}
|
||||
value={value}
|
||||
aria-invalid={parsed.errors.length > 0}
|
||||
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24\n京ICP备12345678号-1\n企业名称关键词"}
|
||||
className="min-h-36 resize-y font-mono text-sm"
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
/>
|
||||
{parsed.rules.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<span>已识别 {parsed.rules.length} 条</span>
|
||||
{Object.entries(SCOPE_KIND_LABELS).map(([kind, kindLabel]) => {
|
||||
const count = counts.get(kind as CompanyScopeKind) ?? 0;
|
||||
return count > 0 ? (
|
||||
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
|
||||
{kindLabel} {count}
|
||||
</Badge>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{parsed.errors.length > 0 && (
|
||||
<FieldError>
|
||||
{parsed.errors.slice(0, 5).map((item) => (
|
||||
<span key={`${item.line}-${item.error}`} className="block">
|
||||
第 {item.line} 行:{item.error}
|
||||
</span>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <span className="block">另有 {parsed.errors.length - 5} 行错误</span>}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
FindingsPage,
|
||||
IntentAsset,
|
||||
InterceptApprovalRow,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
@@ -56,6 +57,9 @@ import type {
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskAssetMutation,
|
||||
TaskAssetScopeMutation,
|
||||
TaskCategory,
|
||||
TaskConstraint,
|
||||
TaskGoal,
|
||||
TaskLLMResolutions,
|
||||
@@ -184,6 +188,7 @@ export const api = {
|
||||
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
createTask: (input: {
|
||||
name?: string;
|
||||
categoryId?: number;
|
||||
description: string;
|
||||
goal: string;
|
||||
llmProfileIds?: number[];
|
||||
@@ -196,6 +201,7 @@ export const api = {
|
||||
}) =>
|
||||
post<Task>("/tasks", {
|
||||
name: input.name ?? "",
|
||||
category_id: input.categoryId ?? null,
|
||||
description: input.description,
|
||||
goal: input.goal,
|
||||
llm_profile_ids: input.llmProfileIds ?? [],
|
||||
@@ -206,6 +212,12 @@ export const api = {
|
||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
||||
}),
|
||||
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
|
||||
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
|
||||
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
|
||||
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
||||
updateTaskCategory: (taskId: string, categoryId?: number) =>
|
||||
patch<Task>(`/tasks/${taskId}/category`, { category_id: categoryId ?? null }),
|
||||
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
||||
post<TaskTemplate>("/task-templates", input),
|
||||
@@ -354,6 +366,16 @@ export const api = {
|
||||
get<{ count: number; total: number; assets: Asset[] }>(
|
||||
`/assets?task_id=${encodeURIComponent(taskId)}&type=${encodeURIComponent(type)}&limit=${limit}&offset=${offset}`,
|
||||
).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
|
||||
attachTaskAssets: (taskId: string, assetIds: number[], sourceSummary: string) =>
|
||||
post<TaskAssetMutation>(`/tasks/${taskId}/assets`, {
|
||||
asset_ids: assetIds,
|
||||
source_summary: sourceSummary,
|
||||
}),
|
||||
registerTaskAssetScopes: (taskId: string, scope: CompanyScopeRule[]) =>
|
||||
post<TaskAssetScopeMutation>(`/tasks/${taskId}/assets`, { scope }),
|
||||
detachTaskAsset: (taskId: string, assetId: number) => del<{ detached: number }>(`/tasks/${taskId}/assets/${assetId}`),
|
||||
taskIntentAssets: (taskId: string) =>
|
||||
get<{ assets: IntentAsset[] }>(`/tasks/${taskId}/intent-assets`).then((r) => arr(r.assets)),
|
||||
|
||||
// ---- companies (企业 + 资产范围;归属唯一来源) ----
|
||||
companies: () => get<Company[]>("/companies").then(arr),
|
||||
|
||||
+3087
-325
File diff suppressed because it is too large.
Load diff
+289
-9
@@ -15,9 +15,14 @@ import type {
|
||||
Company,
|
||||
CompanyScopeRule,
|
||||
Conversation,
|
||||
IntentAsset,
|
||||
ScopeRow,
|
||||
Task,
|
||||
TaskAssetMutation,
|
||||
TaskAssetScopeMutation,
|
||||
TaskCategory,
|
||||
TaskLLMResolution,
|
||||
TaskScopeRow,
|
||||
TaskTemplate,
|
||||
} from "../types";
|
||||
import * as D from "./data";
|
||||
@@ -30,19 +35,50 @@ const mockTasks = structuredClone(D.tasks);
|
||||
const mockFindings = structuredClone(D.findings);
|
||||
const mockLLMRecords = structuredClone(D.llmRecords);
|
||||
const mockTaskTemplates = structuredClone(D.taskTemplates);
|
||||
const mockTaskCategories = structuredClone(D.taskCategories);
|
||||
const mockConversations = structuredClone(D.conversations);
|
||||
const mockIntents = structuredClone(D.intents);
|
||||
const mockCompanies = structuredClone(D.companies);
|
||||
const mockAssets = structuredClone(D.assets);
|
||||
const mockTaskAssetIDs = new Map(D.tasks.map((task, index) => [task.id, index + 1]));
|
||||
const mockTaskScopes = new Map<string, TaskScopeRow[]>();
|
||||
let nextMockTaskAssetID = D.tasks.length + 1;
|
||||
let mockActiveTask = D.ACTIVE_TASK;
|
||||
|
||||
function mockAssetCounts(): Record<string, number> {
|
||||
function mockTaskAssetID(taskID: string): number | undefined {
|
||||
const numeric = Number(taskID);
|
||||
if (Number.isInteger(numeric) && numeric > 0) return numeric;
|
||||
return mockTaskAssetIDs.get(taskID);
|
||||
}
|
||||
|
||||
function mockAssetCounts(taskID?: string | null): Record<string, number> {
|
||||
const numericTaskID = taskID ? mockTaskAssetID(taskID) : undefined;
|
||||
return mockAssets.reduce<Record<string, number>>((counts, asset) => {
|
||||
if (taskID && (numericTaskID === undefined || !asset.task_ids.includes(numericTaskID))) return counts;
|
||||
counts[asset.type] = (counts[asset.type] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function mockAssetMatchesDSL(asset: Asset, dsl: string): boolean {
|
||||
const query = dsl
|
||||
.replaceAll(/[()"]/g, " ")
|
||||
.replaceAll(/\b(?:AND|OR)\b/gi, " ")
|
||||
.replaceAll(/\b[a-z_][a-z0-9_]*(?:==|!=|>=|<=|=|>|<)/gi, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!query) return true;
|
||||
const haystack = JSON.stringify(asset).toLowerCase();
|
||||
return query.split(/\s+/).every((term) => haystack.includes(term));
|
||||
}
|
||||
|
||||
function mockTaskCategorySnapshot(): TaskCategory[] {
|
||||
return mockTaskCategories.map((category) => ({
|
||||
...category,
|
||||
task_count: mockTasks.filter((task) => task.category_id === category.id).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function mockScopeRows(
|
||||
companyID: number,
|
||||
input: unknown,
|
||||
@@ -241,9 +277,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
|
||||
const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])];
|
||||
const categoryID = typeof b.category_id === "number" ? b.category_id : undefined;
|
||||
const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID);
|
||||
if (categoryID !== undefined && !category) throw new Error("任务分类不存在");
|
||||
const created: Task = {
|
||||
id,
|
||||
name: String(b.name ?? ""),
|
||||
category_id: category?.id,
|
||||
category_name: category?.name,
|
||||
description: String(b.description ?? "新任务"),
|
||||
goal: String(b.goal ?? ""),
|
||||
status: "created",
|
||||
@@ -264,11 +305,67 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
source_task_ids: sourceTaskIDs,
|
||||
company_ids: companyIDs,
|
||||
};
|
||||
mockTaskAssetIDs.set(id, nextMockTaskAssetID++);
|
||||
for (const item of mockTasks) item.active = false;
|
||||
mockTasks.unshift(created);
|
||||
mockActiveTask = id;
|
||||
return created;
|
||||
}
|
||||
if (path === "/task-categories" && m === "GET") return { categories: mockTaskCategorySnapshot() };
|
||||
if (path === "/task-categories" && m === "POST") {
|
||||
const name = normalizedTemplateName(b.name);
|
||||
if (!name) throw new Error("分类名称不能为空");
|
||||
if (mockTaskCategories.some((category) => category.name.toLowerCase() === name.toLowerCase())) {
|
||||
throw new Error("分类名称已存在");
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const category: TaskCategory = {
|
||||
id: mockTaskCategories.reduce((maximum, item) => Math.max(maximum, item.id), 0) + 1,
|
||||
name,
|
||||
task_count: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockTaskCategories.push(category);
|
||||
return category;
|
||||
}
|
||||
if (seg[0] === "task-categories" && seg.length === 2 && m === "PATCH") {
|
||||
const category = mockTaskCategories.find((item) => item.id === Number(seg[1]));
|
||||
if (!category) throw new Error("任务分类不存在");
|
||||
const name = normalizedTemplateName(b.name);
|
||||
if (!name) throw new Error("分类名称不能为空");
|
||||
if (mockTaskCategories.some((item) => item.id !== category.id && item.name.toLowerCase() === name.toLowerCase())) {
|
||||
throw new Error("分类名称已存在");
|
||||
}
|
||||
category.name = name;
|
||||
category.updated_at = new Date().toISOString();
|
||||
for (const task of mockTasks) {
|
||||
if (task.category_id === category.id) task.category_name = name;
|
||||
}
|
||||
return { ...category, task_count: mockTasks.filter((task) => task.category_id === category.id).length };
|
||||
}
|
||||
if (seg[0] === "task-categories" && seg.length === 2 && m === "DELETE") {
|
||||
const categoryID = Number(seg[1]);
|
||||
const index = mockTaskCategories.findIndex((item) => item.id === categoryID);
|
||||
if (index < 0) throw new Error("任务分类不存在");
|
||||
mockTaskCategories.splice(index, 1);
|
||||
for (const task of mockTasks) {
|
||||
if (task.category_id !== categoryID) continue;
|
||||
task.category_id = undefined;
|
||||
task.category_name = undefined;
|
||||
}
|
||||
return { deleted: categoryID };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "category" && seg.length === 3 && m === "PATCH") {
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
const categoryID = typeof b.category_id === "number" ? b.category_id : undefined;
|
||||
const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID);
|
||||
if (categoryID !== undefined && !category) throw new Error("任务分类不存在");
|
||||
task.category_id = category?.id;
|
||||
task.category_name = category?.name;
|
||||
return task;
|
||||
}
|
||||
if (path === "/task-templates" && m === "GET") return { templates: mockTaskTemplates };
|
||||
if (path === "/task-templates" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
@@ -324,6 +421,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const id = seg[1];
|
||||
const index = mockTasks.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockTasks.splice(index, 1);
|
||||
mockTaskAssetIDs.delete(id);
|
||||
|
||||
let findingsDeleted = 0;
|
||||
if (b.delete_findings) {
|
||||
@@ -447,10 +545,33 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0));
|
||||
|
||||
// ── 任务测试范围(增删查)──
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") return { scope: [] };
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST")
|
||||
return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" };
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true };
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") {
|
||||
return { scope: mockTaskScopes.get(seg[1]) ?? [] };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST") {
|
||||
const current = mockTaskScopes.get(seg[1]) ?? [];
|
||||
const kind = String(b.kind ?? "") as TaskScopeRow["kind"];
|
||||
const value = String(b.value ?? "").trim();
|
||||
const row: TaskScopeRow = {
|
||||
id: Date.now(),
|
||||
task_id: mockTaskAssetID(seg[1]) ?? Number(seg[1]),
|
||||
kind,
|
||||
source: "manual",
|
||||
};
|
||||
if (kind === "root_domain" || kind === "subdomain") row.domain = value;
|
||||
else if (kind === "ip" || kind === "cidr") row.net = value;
|
||||
else row.value = value;
|
||||
mockTaskScopes.set(seg[1], [...current, row]);
|
||||
return row;
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") {
|
||||
const id = Number(seg[3]);
|
||||
mockTaskScopes.set(
|
||||
seg[1],
|
||||
(mockTaskScopes.get(seg[1]) ?? []).filter((row) => row.id !== id),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── 全局 llm_usage 聚合(仪表盘新版视图,demo)──
|
||||
if (path === "/tokens/usage")
|
||||
@@ -524,13 +645,21 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
|
||||
// ── assets ──
|
||||
if (path === "/assets/counts") return mockAssetCounts();
|
||||
if (path === "/assets/counts") return mockAssetCounts(q.get("task_id"));
|
||||
if (path === "/assets" && m === "GET") {
|
||||
const type = q.get("type") ?? "";
|
||||
const list = type ? mockAssets.filter((a) => a.type === type) : mockAssets;
|
||||
const taskID = q.get("task_id");
|
||||
const numericTaskID = taskID ? mockTaskAssetID(taskID) : undefined;
|
||||
const dsl = q.get("dsl") ?? "";
|
||||
const list = mockAssets.filter((asset) => {
|
||||
if (type && asset.type !== type) return false;
|
||||
if (taskID && (numericTaskID === undefined || !asset.task_ids.includes(numericTaskID))) return false;
|
||||
return !dsl || mockAssetMatchesDSL(asset, dsl);
|
||||
});
|
||||
const limit = Number(q.get("limit") ?? 50);
|
||||
const offset = Number(q.get("offset") ?? 0);
|
||||
return { count: list.length, total: list.length, assets: list.slice(offset, offset + limit) };
|
||||
const page = list.slice(offset, offset + limit);
|
||||
return { count: page.length, total: list.length, assets: page };
|
||||
}
|
||||
if (path === "/assets" && m === "DELETE") {
|
||||
const ids = new Set(Array.isArray(b.ids) ? b.ids.map(Number) : []);
|
||||
@@ -542,6 +671,153 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
return { deleted };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "assets" && seg.length === 3 && m === "POST") {
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
const numericTaskID = mockTaskAssetID(seg[1]);
|
||||
if (!task || numericTaskID === undefined) throw new Error("任务不存在");
|
||||
if (Array.isArray(b.scope)) {
|
||||
if (b.scope.length === 0 || b.scope.length > MAX_COMPANY_SCOPE_RULES) throw new Error("请填写有效测试范围");
|
||||
const rules: CompanyScopeRule[] = b.scope.map((candidate, index) => {
|
||||
if (typeof candidate === "string") {
|
||||
const issue = classifyCompanyScopeLine(candidate, index + 1);
|
||||
if (!issue.rule || issue.error) throw new Error(`第 ${index + 1} 条范围无效:${issue.error ?? "无法识别"}`);
|
||||
return issue.rule;
|
||||
}
|
||||
const item = candidate as { kind?: unknown; value?: unknown };
|
||||
const value = String(item?.value ?? "").trim();
|
||||
const rule =
|
||||
item?.kind && isCompanyScopeKind(item.kind)
|
||||
? { kind: item.kind, value }
|
||||
: classifyCompanyScopeLine(value, index + 1).rule;
|
||||
const error = rule ? companyScopeRuleError(rule) : "无法识别";
|
||||
if (!rule || error) throw new Error(`第 ${index + 1} 条范围无效:${error}`);
|
||||
return rule;
|
||||
});
|
||||
const mutation: TaskAssetScopeMutation = {
|
||||
requested: rules.length,
|
||||
assets_linked: 0,
|
||||
assets_existing: 0,
|
||||
scopes_added: 0,
|
||||
scopes_existing: 0,
|
||||
};
|
||||
const currentScopes = mockTaskScopes.get(seg[1]) ?? [];
|
||||
const scopeKeys = new Set(
|
||||
currentScopes.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.company_id ?? ""}`),
|
||||
);
|
||||
for (const rule of rules) {
|
||||
const normalized = normalizeCompanyScopeValue(rule);
|
||||
const scope: TaskScopeRow = {
|
||||
id: Date.now() + currentScopes.length,
|
||||
task_id: numericTaskID,
|
||||
kind: rule.kind === "domain" ? "root_domain" : rule.kind,
|
||||
source: "manual",
|
||||
reason: "用户在测试资产页手工新增",
|
||||
};
|
||||
if (rule.kind === "domain") scope.domain = normalized;
|
||||
else if (rule.kind === "ip") scope.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`;
|
||||
else if (rule.kind === "cidr") scope.net = normalized;
|
||||
else scope.value = normalized;
|
||||
const scopeKey = `${scope.kind}|${scope.domain ?? scope.net ?? scope.value ?? ""}`;
|
||||
if (scopeKeys.has(scopeKey)) mutation.scopes_existing++;
|
||||
else {
|
||||
scopeKeys.add(scopeKey);
|
||||
currentScopes.push(scope);
|
||||
mutation.scopes_added++;
|
||||
}
|
||||
|
||||
if (rule.kind !== "domain" && rule.kind !== "ip") continue;
|
||||
const type = rule.kind === "domain" ? "root_domain" : "ip";
|
||||
let asset = mockAssets.find((item) =>
|
||||
type === "root_domain"
|
||||
? item.type === type && item.domain === normalized
|
||||
: item.type === type && item.ip === normalized,
|
||||
);
|
||||
const alreadyLinked = asset?.task_ids.includes(numericTaskID) ?? false;
|
||||
if (!asset) {
|
||||
const nextID = mockAssets.reduce((max, item) => Math.max(max, item.id), 0) + 1;
|
||||
asset = {
|
||||
id: nextID,
|
||||
type,
|
||||
task_ids: [],
|
||||
...(type === "root_domain" ? { domain: normalized, root_domain: normalized } : { ip: normalized }),
|
||||
last_seen: new Date().toISOString(),
|
||||
};
|
||||
mockAssets.push(asset);
|
||||
}
|
||||
if (alreadyLinked) mutation.assets_existing++;
|
||||
else {
|
||||
asset.task_ids.push(numericTaskID);
|
||||
mutation.assets_linked++;
|
||||
}
|
||||
asset.task_source = "manual";
|
||||
asset.task_source_summary = "用户在测试资产页手工新增";
|
||||
asset.task_source_node_id = undefined;
|
||||
}
|
||||
mockTaskScopes.set(seg[1], currentScopes);
|
||||
return mutation;
|
||||
}
|
||||
const ids = [...new Set(Array.isArray(b.asset_ids) ? b.asset_ids.map(Number) : [])];
|
||||
const sourceSummary = String(b.source_summary ?? "").trim();
|
||||
if (ids.length === 0 || ids.length > 100 || !sourceSummary) throw new Error("请选择资产并填写来源说明");
|
||||
const requestedAssets = ids.map((id) => mockAssets.find((asset) => asset.id === id));
|
||||
if (requestedAssets.some((asset) => !asset)) throw new Error("资产不存在");
|
||||
const mutation: TaskAssetMutation = { requested: ids.length, attached: 0, existing: 0 };
|
||||
for (const asset of requestedAssets) {
|
||||
if (!asset) continue;
|
||||
if (asset.task_ids.includes(numericTaskID)) mutation.existing++;
|
||||
else {
|
||||
asset.task_ids.push(numericTaskID);
|
||||
mutation.attached++;
|
||||
}
|
||||
asset.task_source = "manual";
|
||||
asset.task_source_summary = sourceSummary;
|
||||
asset.task_source_node_id = undefined;
|
||||
}
|
||||
return mutation;
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "assets" && seg.length === 4 && m === "DELETE") {
|
||||
const numericTaskID = mockTaskAssetID(seg[1]);
|
||||
const asset = mockAssets.find((item) => item.id === Number(seg[3]));
|
||||
if (numericTaskID === undefined || !asset) throw new Error("任务或资产不存在");
|
||||
if (!asset.task_ids.includes(numericTaskID)) throw new Error("资产未关联当前任务");
|
||||
asset.task_ids = asset.task_ids.filter((id) => id !== numericTaskID);
|
||||
if (asset.task_ids.length === 0) {
|
||||
asset.task_source = undefined;
|
||||
asset.task_source_summary = undefined;
|
||||
asset.task_source_node_id = undefined;
|
||||
}
|
||||
return { detached: asset.id };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intent-assets" && seg.length === 3 && m === "GET") {
|
||||
let mappings: Array<{ intentID: string; assetID: number; summary: string }> = [];
|
||||
if (seg[1] === "t-acme-web") {
|
||||
mappings = [
|
||||
{ intentID: "i3", assetID: 6, summary: "后台功能枚举意图从前序子域发现中选定" },
|
||||
{ intentID: "i5", assetID: 3, summary: "订单接口测试意图从 API 任务目标中选定" },
|
||||
];
|
||||
} else if (seg[1] === "t-acme-api") {
|
||||
mappings = [{ intentID: "i5", assetID: 3, summary: "订单接口测试意图从 API 任务目标中选定" }];
|
||||
}
|
||||
const sourceTaskID = mockTaskAssetID(seg[1]) ?? 0;
|
||||
const assets: IntentAsset[] = mappings.flatMap((mapping) => {
|
||||
const asset = mockAssets.find((item) => item.id === mapping.assetID);
|
||||
if (!asset) return [];
|
||||
return [
|
||||
{
|
||||
intent_id: mapping.intentID,
|
||||
asset_id: asset.id,
|
||||
type: asset.type,
|
||||
label: asset.domain ?? asset.ip ?? asset.app_name ?? asset.url ?? asset.service_name ?? `#${asset.id}`,
|
||||
source: asset.task_source ?? "agent",
|
||||
source_summary: asset.task_source_summary ?? mapping.summary,
|
||||
source_node_id: asset.task_source_node_id,
|
||||
source_task_id: sourceTaskID,
|
||||
inherited: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
return { assets };
|
||||
}
|
||||
|
||||
// ── companies ──
|
||||
if (path === "/companies" && m === "GET") return structuredClone(mockCompanies);
|
||||
@@ -600,7 +876,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
for (const f of mockFindings) {
|
||||
if (!f.task_id) continue;
|
||||
const owner = mockTasks.find((candidate) => candidate.id === f.task_id);
|
||||
const cur = taskMap.get(f.task_id) ?? { name: owner?.name ?? "", description: f.task_description ?? "", count: 0 };
|
||||
const cur = taskMap.get(f.task_id) ?? {
|
||||
name: owner?.name ?? "",
|
||||
description: f.task_description ?? "",
|
||||
count: 0,
|
||||
};
|
||||
cur.count++;
|
||||
taskMap.set(f.task_id, cur);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { NewAssetType } from "@/lib/types";
|
||||
|
||||
const ASSET_TYPE_LABELS: Record<NewAssetType, string> = {
|
||||
app: "应用",
|
||||
endpoint: "接口",
|
||||
ip: "IP",
|
||||
root_domain: "根域名",
|
||||
service: "服务",
|
||||
subdomain: "子域名",
|
||||
};
|
||||
|
||||
const TASK_ASSET_SOURCE_LABELS: Record<string, string> = {
|
||||
agent: "Agent 发现",
|
||||
anchor: "黑板锚点",
|
||||
api: "资产 API",
|
||||
legacy: "历史关联",
|
||||
manual: "人工加入",
|
||||
system: "系统关联",
|
||||
task: "任务初始化",
|
||||
};
|
||||
|
||||
export function taskAssetTypeLabel(type: NewAssetType): string {
|
||||
return ASSET_TYPE_LABELS[type];
|
||||
}
|
||||
|
||||
export function taskAssetSourceLabel(source: string): string {
|
||||
return TASK_ASSET_SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
+41
-1
@@ -7,6 +7,8 @@ export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
|
||||
export interface Task {
|
||||
id: string;
|
||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||
category_id?: number;
|
||||
category_name?: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
status: TaskStatus;
|
||||
@@ -35,6 +37,14 @@ export interface Task {
|
||||
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
|
||||
}
|
||||
|
||||
export interface TaskCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
task_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -168,6 +178,35 @@ export interface Asset {
|
||||
params?: Record<string, unknown>[];
|
||||
extra?: Record<string, unknown>;
|
||||
last_seen: string;
|
||||
task_source?: string;
|
||||
task_source_summary?: string;
|
||||
task_source_node_id?: number;
|
||||
}
|
||||
|
||||
export interface IntentAsset {
|
||||
intent_id: number | string;
|
||||
asset_id: number;
|
||||
type: NewAssetType;
|
||||
label: string;
|
||||
source: string;
|
||||
source_summary: string;
|
||||
source_node_id?: number;
|
||||
source_task_id: number;
|
||||
inherited: boolean;
|
||||
}
|
||||
|
||||
export interface TaskAssetMutation {
|
||||
requested: number;
|
||||
attached: number;
|
||||
existing: number;
|
||||
}
|
||||
|
||||
export interface TaskAssetScopeMutation {
|
||||
requested: number;
|
||||
assets_linked: number;
|
||||
assets_existing: number;
|
||||
scopes_added: number;
|
||||
scopes_existing: number;
|
||||
}
|
||||
|
||||
// ---- Asset coverage graph (per task) ----
|
||||
@@ -241,11 +280,12 @@ export interface WorkspaceFile {
|
||||
export interface TaskScopeRow {
|
||||
id: number;
|
||||
task_id: number;
|
||||
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr";
|
||||
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr" | "icp" | "keyword";
|
||||
company_id?: number;
|
||||
company_name?: string; // 后端 JOIN companies 解析,仅 kind=company 有值
|
||||
domain?: string;
|
||||
net?: string;
|
||||
value?: string;
|
||||
source: "auto" | "agent" | "manual";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user