ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
1387 lines
77 KiB
PL/PgSQL
1387 lines
77 KiB
PL/PgSQL
-- ARTEX PostgreSQL schema (单一数据源)
|
||
-- 幂等:可重复执行(IF NOT EXISTS / OR REPLACE / DROP TRIGGER IF EXISTS)。
|
||
|
||
-- =====================================================================
|
||
-- 0. 通用:updated_at 触发器
|
||
-- =====================================================================
|
||
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
|
||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||
$$ LANGUAGE plpgsql;
|
||
|
||
-- 安全的 text→inet 转换:非法值返回 NULL 而不是抛 22P02。assets.ip 是自由文本
|
||
-- (Agent / 资产 API 可能写进主机名),裸转 a.ip::inet 会让单独一行脏数据把整条
|
||
-- 企业归属重算语句打挂。调用方用 try_inet(...) IS NULL 找出这些行并告警。
|
||
-- 不用 pg_input_is_valid 是因为那要 PG16+,这里要兼容更老的存量库。
|
||
CREATE OR REPLACE FUNCTION try_inet(value text) RETURNS inet AS $$
|
||
BEGIN
|
||
RETURN value::inet;
|
||
EXCEPTION WHEN others THEN
|
||
RETURN NULL;
|
||
END;
|
||
$$ LANGUAGE plpgsql IMMUTABLE STRICT;
|
||
|
||
-- =====================================================================
|
||
-- A. 资产层:companies / assets / company_scope
|
||
-- =====================================================================
|
||
|
||
CREATE TABLE IF NOT EXISTS companies (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
nkey TEXT NOT NULL UNIQUE,
|
||
logo TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_companies_nkey ON companies(nkey);
|
||
DROP TRIGGER IF EXISTS trg_companies_upd ON companies;
|
||
CREATE TRIGGER trg_companies_upd BEFORE UPDATE ON companies
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS assets (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
type TEXT NOT NULL CHECK (type IN (
|
||
'root_domain','ip','subdomain','app','service','endpoint'
|
||
)),
|
||
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
|
||
-- explicit: caller/user selected the company; scope: derived from company_scope.
|
||
-- Existing installations are conservatively migrated as explicit so a scope
|
||
-- rebuild can never erase a historical manual association.
|
||
company_source TEXT NOT NULL DEFAULT 'explicit'
|
||
CHECK (company_source IN ('explicit','scope')),
|
||
task_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||
domain TEXT,
|
||
root_domain TEXT,
|
||
ip TEXT,
|
||
c_segment CIDR,
|
||
port INTEGER CHECK (port BETWEEN 1 AND 65535),
|
||
icp TEXT,
|
||
bound_domains TEXT[] NOT NULL DEFAULT '{}',
|
||
open_ports JSONB[] NOT NULL DEFAULT '{}',
|
||
record_type TEXT,
|
||
record_value TEXT[],
|
||
bundle_id TEXT,
|
||
app_name TEXT,
|
||
category TEXT,
|
||
app_description TEXT,
|
||
app_icp TEXT,
|
||
url TEXT,
|
||
service_type TEXT CHECK (service_type IN ('http','other')),
|
||
service_name TEXT,
|
||
favicon_mmh3 TEXT,
|
||
status_code INTEGER,
|
||
content_length BIGINT,
|
||
page_title TEXT,
|
||
technologies TEXT[] NOT NULL DEFAULT '{}',
|
||
auth JSONB[] NOT NULL DEFAULT '{}',
|
||
method TEXT,
|
||
params JSONB[] NOT NULL DEFAULT '{}',
|
||
extra JSONB NOT NULL DEFAULT '{}',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_root_domain ON assets(domain) WHERE type = 'root_domain';
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_ip ON assets(ip) WHERE type = 'ip';
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_subdomain ON assets(domain, COALESCE(record_type,'')) WHERE type = 'subdomain';
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_app_bundle ON assets(bundle_id) WHERE type = 'app' AND bundle_id IS NOT NULL;
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_app_name ON assets(app_name) WHERE type = 'app' AND bundle_id IS NULL;
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_service_http ON assets(url) WHERE type = 'service' AND service_type = 'http';
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_service_other
|
||
ON assets(COALESCE(domain,''), COALESCE(ip,''), port, service_name) WHERE type = 'service' AND service_type = 'other';
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_endpoint ON assets(url, method) WHERE type = 'endpoint';
|
||
CREATE INDEX IF NOT EXISTS idx_av2_company ON assets(company_id) WHERE company_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_av2_company_type ON assets(company_id, type) WHERE company_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_av2_task_ids ON assets USING GIN(task_ids);
|
||
CREATE INDEX IF NOT EXISTS idx_av2_domain ON assets(domain) WHERE domain IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_av2_root_domain ON assets(root_domain) WHERE root_domain IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_av2_ip ON assets(ip) WHERE ip IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_av2_c_segment ON assets USING GIST(c_segment inet_ops) WHERE c_segment IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_av2_technologies ON assets USING GIN(technologies) WHERE type = 'service';
|
||
CREATE INDEX IF NOT EXISTS idx_av2_bound_domains ON assets USING GIN(bound_domains) WHERE type = 'ip';
|
||
CREATE INDEX IF NOT EXISTS idx_av2_open_ports ON assets USING GIN(open_ports) WHERE type = 'ip';
|
||
CREATE INDEX IF NOT EXISTS idx_av2_last_seen ON assets(last_seen DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_av2_type_seen ON assets(type, last_seen DESC);
|
||
ALTER TABLE assets ADD COLUMN IF NOT EXISTS company_source TEXT;
|
||
UPDATE assets SET company_source = 'explicit' WHERE company_source IS NULL;
|
||
ALTER TABLE assets ALTER COLUMN company_source SET DEFAULT 'explicit';
|
||
ALTER TABLE assets ALTER COLUMN company_source SET NOT NULL;
|
||
ALTER TABLE assets DROP CONSTRAINT IF EXISTS assets_company_source_check;
|
||
ALTER TABLE assets ADD CONSTRAINT assets_company_source_check
|
||
CHECK (company_source IN ('explicit','scope'));
|
||
DROP TRIGGER IF EXISTS trg_av2_upd ON assets;
|
||
CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS company_scope (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
company_id BIGINT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||
kind TEXT NOT NULL CHECK (kind IN ('domain','ip','cidr','icp','keyword')),
|
||
domain TEXT,
|
||
net CIDR,
|
||
value TEXT,
|
||
raw TEXT NOT NULL,
|
||
reason TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT uq_sv2_domain UNIQUE (company_id, domain),
|
||
CONSTRAINT uq_sv2_net UNIQUE (company_id, net),
|
||
CONSTRAINT ck_company_scope_payload CHECK (
|
||
(kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL)
|
||
OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL)
|
||
OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL)
|
||
)
|
||
);
|
||
-- Existing installations need the new text payload and expanded kind check.
|
||
ALTER TABLE company_scope ADD COLUMN IF NOT EXISTS value TEXT;
|
||
ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS company_scope_kind_check;
|
||
ALTER TABLE company_scope ADD CONSTRAINT company_scope_kind_check
|
||
CHECK (kind IN ('domain','ip','cidr','icp','keyword'));
|
||
ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS ck_company_scope_payload;
|
||
ALTER TABLE company_scope ADD CONSTRAINT ck_company_scope_payload CHECK (
|
||
(kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL)
|
||
OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL)
|
||
OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_sv2_domain ON company_scope(domain) WHERE kind = 'domain';
|
||
CREATE INDEX IF NOT EXISTS idx_sv2_net ON company_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr');
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sv2_value ON company_scope(company_id, kind, value) WHERE kind IN ('icp','keyword');
|
||
CREATE INDEX IF NOT EXISTS idx_sv2_icp ON company_scope(value) WHERE kind = 'icp';
|
||
CREATE INDEX IF NOT EXISTS idx_sv2_company ON company_scope(company_id);
|
||
|
||
-- =====================================================================
|
||
-- B. 推理探索层
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS explorations (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
description TEXT,
|
||
goal TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'open'
|
||
CHECK (status IN ('open','achieved','failed')),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
-- cold-digest (§2.3): per-task planner round counter — bumped once each time the
|
||
-- planner wakes and processes a round. Drives the ≥R cold-node debounce (measured in
|
||
-- this exploration's own rounds, not global node ids or wall-clock).
|
||
ALTER TABLE explorations ADD COLUMN IF NOT EXISTS round_no BIGINT NOT NULL DEFAULT 0;
|
||
DROP TRIGGER IF EXISTS trg_exp_upd ON explorations;
|
||
CREATE TRIGGER trg_exp_upd BEFORE UPDATE ON explorations
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS exploration_nodes (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE,
|
||
kind TEXT NOT NULL,
|
||
payload JSONB NOT NULL DEFAULT '{}',
|
||
priority INT NOT NULL DEFAULT 0,
|
||
state TEXT NOT NULL DEFAULT 'open',
|
||
origin TEXT,
|
||
owner TEXT,
|
||
blocked_reason TEXT,
|
||
delete_reason TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
completed_at TIMESTAMPTZ,
|
||
CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest')),
|
||
CONSTRAINT ck_node_state CHECK (
|
||
(kind='begin' AND state IN ('open')) OR
|
||
(kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped','deleted')) OR
|
||
(kind='goal' AND state IN ('open','met','abandoned')) OR
|
||
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
|
||
(kind='finding' AND state IN ('confirmed','dismissed')) OR
|
||
(kind='hint' AND state IN ('active','consumed')) OR
|
||
(kind='digest' AND state IN ('active','superseded'))
|
||
)
|
||
);
|
||
ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS blocked_reason TEXT;
|
||
-- 意图假删除(soft delete):state='deleted' 时,delete_reason 记用户填写的删除原因。
|
||
ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS delete_reason TEXT;
|
||
-- cold-digest (§2.3/§5.3): content_version bumps on any change that could alter a
|
||
-- digest body (summary/state/confidence); cold_since_round stamps the planner round
|
||
-- a node most recently went from "has a live downstream branch" to none (NULL = hot).
|
||
ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS content_version INT NOT NULL DEFAULT 0;
|
||
ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS cold_since_round BIGINT;
|
||
-- ck_node_kind: existing installs predate the 'digest' kind — recreate to allow it.
|
||
DO $$
|
||
BEGIN
|
||
IF EXISTS (
|
||
SELECT 1 FROM pg_constraint
|
||
WHERE conrelid='exploration_nodes'::regclass
|
||
AND conname='ck_node_kind'
|
||
AND pg_get_constraintdef(oid) NOT LIKE '%digest%'
|
||
) THEN
|
||
ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_kind;
|
||
ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_kind
|
||
CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest'));
|
||
END IF;
|
||
END $$;
|
||
-- ck_node_state: recreate when it lacks the 'paused' (older), 'superseded' (digest rev),
|
||
-- or 'deleted' (intent soft-delete rev) branches.
|
||
DO $$
|
||
BEGIN
|
||
IF EXISTS (
|
||
SELECT 1 FROM pg_constraint
|
||
WHERE conrelid='exploration_nodes'::regclass
|
||
AND conname='ck_node_state'
|
||
AND (pg_get_constraintdef(oid) NOT LIKE '%paused%'
|
||
OR pg_get_constraintdef(oid) NOT LIKE '%superseded%'
|
||
OR pg_get_constraintdef(oid) NOT LIKE '%deleted%')
|
||
) THEN
|
||
ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state;
|
||
ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK (
|
||
(kind='begin' AND state IN ('open')) OR
|
||
(kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped','deleted')) OR
|
||
(kind='goal' AND state IN ('open','met','abandoned')) OR
|
||
(kind='fact' AND state IN ('confirmed','dismissed','origin')) OR
|
||
(kind='finding' AND state IN ('confirmed','dismissed')) OR
|
||
(kind='hint' AND state IN ('active','consumed')) OR
|
||
(kind='digest' AND state IN ('active','superseded'))
|
||
);
|
||
END IF;
|
||
END $$;
|
||
CREATE INDEX IF NOT EXISTS idx_expnodes_part ON exploration_nodes(exploration_id, kind);
|
||
CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC)
|
||
WHERE kind='intent' AND state='open';
|
||
DROP TRIGGER IF EXISTS trg_expnodes_upd ON exploration_nodes;
|
||
CREATE TRIGGER trg_expnodes_upd BEFORE UPDATE ON exploration_nodes
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS exploration_edges (
|
||
exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE,
|
||
src_id BIGINT NOT NULL REFERENCES exploration_nodes(id) ON DELETE CASCADE,
|
||
dst_id BIGINT NOT NULL REFERENCES exploration_nodes(id) ON DELETE CASCADE,
|
||
rel TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (exploration_id, src_id, rel, dst_id),
|
||
CONSTRAINT ck_edge_noself CHECK (src_id <> dst_id),
|
||
CONSTRAINT ck_edge_rel CHECK (rel IN ('spawns','derived_from','yields','proves','covers'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_expedges_src ON exploration_edges(src_id, rel);
|
||
CREATE INDEX IF NOT EXISTS idx_expedges_dst ON exploration_edges(dst_id, rel);
|
||
-- cold-digest (§1): the 'covers' relation (digest→member) postdates shipped installs,
|
||
-- whose rel CHECK is an inline auto-named constraint. Find and recreate it as ck_edge_rel.
|
||
DO $$
|
||
DECLARE cname text;
|
||
BEGIN
|
||
SELECT conname INTO cname FROM pg_constraint
|
||
WHERE conrelid='exploration_edges'::regclass AND contype='c'
|
||
AND pg_get_constraintdef(oid) LIKE '%rel%'
|
||
AND pg_get_constraintdef(oid) NOT LIKE '%covers%';
|
||
IF cname IS NOT NULL THEN
|
||
EXECUTE 'ALTER TABLE exploration_edges DROP CONSTRAINT '||quote_ident(cname);
|
||
ALTER TABLE exploration_edges ADD CONSTRAINT ck_edge_rel
|
||
CHECK (rel IN ('spawns','derived_from','yields','proves','covers'));
|
||
END IF;
|
||
END $$;
|
||
|
||
CREATE TABLE IF NOT EXISTS exploration_anchors (
|
||
node_id BIGINT NOT NULL REFERENCES exploration_nodes(id) ON DELETE CASCADE,
|
||
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (node_id, asset_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_anchor_asset ON exploration_anchors(asset_id);
|
||
|
||
-- task_constraints: operator-authored operation constraints (allow/deny) for a task.
|
||
-- Extracted by the goals decomposer at round 0 (from goal/description), editable at
|
||
-- runtime by the main agent + 总览「约束管理」. Injected into the planner/worker system
|
||
-- prompt each round (config-gated) to keep exploration within the operator's boundary.
|
||
CREATE TABLE IF NOT EXISTS task_constraints (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE,
|
||
kind TEXT NOT NULL CHECK (kind IN ('allow','deny')),
|
||
text TEXT NOT NULL,
|
||
origin TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_task_constraints_exp ON task_constraints(exploration_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS activity (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE,
|
||
node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL,
|
||
worker TEXT,
|
||
kind TEXT,
|
||
tool TEXT,
|
||
tool_use_id TEXT,
|
||
is_error BOOLEAN NOT NULL DEFAULT false,
|
||
summary TEXT,
|
||
detail TEXT,
|
||
metadata JSONB NOT NULL DEFAULT '{}',
|
||
input_tokens INTEGER,
|
||
output_tokens INTEGER,
|
||
cache_read_tokens INTEGER,
|
||
cache_write_tokens INTEGER,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
ALTER TABLE activity ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
|
||
-- main_seg segments the main-agent session into resettable conversations: a new
|
||
-- main session bumps the segment so its transcript + activity start clean while the
|
||
-- task's graph/assets/goal are untouched. NULL == legacy rows == segment 0 (the
|
||
-- original session). Only worker='mainagent' rows carry it.
|
||
ALTER TABLE activity ADD COLUMN IF NOT EXISTS main_seg INTEGER;
|
||
CREATE INDEX IF NOT EXISTS idx_act_node ON activity(exploration_id, node_id, id);
|
||
CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id);
|
||
CREATE INDEX IF NOT EXISTS idx_act_tool_call ON activity(exploration_id, tool_use_id, id)
|
||
WHERE kind IN ('tool_use', 'tool_result');
|
||
-- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node
|
||
-- can't distinguish them); this covers reverse pagination of those sessions.
|
||
CREATE INDEX IF NOT EXISTS idx_act_worker ON activity(exploration_id, worker, id);
|
||
-- Main-session pages filter by segment on top of worker='mainagent'; this partial
|
||
-- index covers reverse pagination within one segment.
|
||
CREATE INDEX IF NOT EXISTS idx_act_main_seg ON activity(exploration_id, main_seg, id)
|
||
WHERE worker='mainagent';
|
||
-- Task-list polls aggregate result usage and find the latest event repeatedly.
|
||
-- Cover the token columns for index-only aggregation and the timestamp order for
|
||
-- per-exploration latest-activity lookups.
|
||
CREATE INDEX IF NOT EXISTS idx_act_result_usage ON activity(exploration_id)
|
||
INCLUDE (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
|
||
WHERE kind='result';
|
||
CREATE INDEX IF NOT EXISTS idx_act_latest ON activity(exploration_id, created_at DESC);
|
||
|
||
-- main_sessions records the resettable main-agent conversation segments of a task.
|
||
-- Segment 0 (the original session) is implicit and never stored; this table holds
|
||
-- only the extra segments created by "新建会话" (seq >= 1). The current segment is
|
||
-- MAX(seq) or 0. Each segment gets its own transcript file + activity slice; the
|
||
-- task's exploration graph/assets/goal are shared and never reset.
|
||
CREATE TABLE IF NOT EXISTS main_sessions (
|
||
exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE,
|
||
seq INTEGER NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (exploration_id, seq)
|
||
);
|
||
|
||
-- =====================================================================
|
||
-- C. LLM profiles
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS llm_profiles (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL UNIQUE,
|
||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')),
|
||
base_url TEXT,
|
||
proxy TEXT,
|
||
model TEXT NOT NULL,
|
||
api_key TEXT,
|
||
api_key_hint TEXT,
|
||
rate_per_second DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||
rate_per_minute DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||
context_window_k INTEGER NOT NULL DEFAULT 0,
|
||
-- 思考参数拆成两个独立字段:thinking_type=思考开关(''/disabled/enabled),
|
||
-- reasoning_effort=思考强度(''/low/medium/high/xhigh/max),互不牵连。
|
||
reasoning_effort TEXT NOT NULL DEFAULT '',
|
||
thinking_type TEXT NOT NULL DEFAULT '',
|
||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||
-- 轮询(故障转移)参数,见 docs/LLM轮询设计.md:
|
||
-- priority 顺位,越大越先被选中;激活配置(is_default)永远排链首,与本值无关。
|
||
-- pool_exclude true=不作为故障转移目标(仍可被 agent/任务显式绑定使用)。
|
||
priority INTEGER NOT NULL DEFAULT 0,
|
||
pool_exclude BOOLEAN NOT NULL DEFAULT false,
|
||
-- streaming=true(默认)走流式 SSE;false 走真·非流式(stream:false,一次性 JSON)。
|
||
streaming BOOLEAN NOT NULL DEFAULT true,
|
||
-- 单次回复的输出上限(token)。0=不发送该字段,由服务端默认值决定——保持既有行为。
|
||
-- 与 context_window_k(模型总容量,仅本地用于压缩阈值)是两回事:本值会随请求发出。
|
||
max_tokens INTEGER NOT NULL DEFAULT 0,
|
||
-- 输出上限用哪个请求字段名,仅对 format='openai' 生效:
|
||
-- '' = max_tokens(默认,兼容绝大多数网关)
|
||
-- 'max_completion_tokens' = 新字段;OpenAI 推理模型(o 系列/GPT-5)只认它,
|
||
-- 发 max_tokens 会被 unsupported_parameter 拒绝。
|
||
-- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。
|
||
max_tokens_field TEXT NOT NULL DEFAULT '',
|
||
-- 自定义会话头:非空时每次请求带一个该名字的 HTTP 头,头值=当前运行的 session id
|
||
-- (chat 会话/worker 意图)。用于某些按 session-id 头做提示缓存/粘性路由的网关。''=不发送。
|
||
session_header_key TEXT NOT NULL DEFAULT '',
|
||
-- 重试覆盖:次数 0=用全局默认/-1=关闭/>0=该值;间隔 0=用默认指数退避/>0=固定毫秒。
|
||
-- 三组分别对应建连重试、空响应重试、同 provider 安全窗口重试,详见下方 ALTER 处注释。
|
||
retry_connect_attempts INTEGER NOT NULL DEFAULT 0,
|
||
retry_connect_interval_ms INTEGER NOT NULL DEFAULT 0,
|
||
retry_empty_attempts INTEGER NOT NULL DEFAULT 0,
|
||
retry_empty_interval_ms INTEGER NOT NULL DEFAULT 0,
|
||
retry_stream_attempts INTEGER NOT NULL DEFAULT 0,
|
||
retry_stream_interval_ms INTEGER NOT NULL DEFAULT 0,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_llm_one_default ON llm_profiles(is_default) WHERE is_default;
|
||
DROP TRIGGER IF EXISTS trg_llm_upd ON llm_profiles;
|
||
CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
-- 轮询顺位/排除标记;补旧库。默认 0 / false = 全部配置都参与轮询。
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false;
|
||
-- 流式开关;补旧库。默认 true = 保持既有的流式行为,旧配置无感升级。
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true;
|
||
-- 放开 format 约束以容纳 openai-responses(OpenAI Responses API);补旧库。
|
||
-- 每次启动执行,幂等:先删旧 CHECK 再建含三值的新 CHECK。
|
||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check;
|
||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check
|
||
CHECK (format IN ('openai','anthropic','openai-responses'));
|
||
|
||
-- 输出上限及其字段名;补旧库。默认 0 / '' = 不发送上限、沿用 max_tokens 字段名,
|
||
-- 旧配置行为完全不变。
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens_field TEXT NOT NULL DEFAULT '';
|
||
-- 同 format:先删再建,保证每次启动幂等。
|
||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_field_check;
|
||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_field_check
|
||
CHECK (max_tokens_field IN ('','max_completion_tokens'));
|
||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_check;
|
||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
|
||
CHECK (max_tokens >= 0);
|
||
-- 自定义会话头名;补旧库。默认 '' = 不发送,旧配置行为不变。
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT '';
|
||
|
||
-- 单配置的重试覆盖(见 docs/LLM重试设计.md)。三组各自一对「次数 + 固定间隔」,
|
||
-- 语义统一:次数 0=沿用全局默认、-1=关闭该层重试、>0=用该值;间隔 0=沿用该层的
|
||
-- 默认指数退避、>0=改用这个固定毫秒数。全部默认 0,所以旧库/旧配置行为不变。
|
||
-- connect = 建连重试(SDK doStream:连接重置/超时/429/5xx,流开始前)
|
||
-- empty = 空响应重试(SDK:完成但没有任何 content block,仅 openai 格式)
|
||
-- stream = 同 provider 安全窗口重试(本项目 task_llm:未交付输出前的断流重放)
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_connect_attempts INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_connect_interval_ms INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_empty_attempts INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_empty_interval_ms INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_stream_attempts INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_stream_interval_ms INTEGER NOT NULL DEFAULT 0;
|
||
-- 同 format:先删再建,保证每次启动幂等。次数下限 -1(关闭),间隔不能为负。
|
||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_retry_check;
|
||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_retry_check CHECK (
|
||
retry_connect_attempts >= -1 AND retry_empty_attempts >= -1 AND retry_stream_attempts >= -1
|
||
AND retry_connect_interval_ms >= 0 AND retry_empty_interval_ms >= 0 AND retry_stream_interval_ms >= 0);
|
||
|
||
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
||
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
||
-- 否则每次启动都会把用户后来手动设的组合覆盖回去。旧 reasoning_effort 语义:
|
||
-- 'off' → 显式关闭 → thinking_type='disabled',强度清空
|
||
-- 'low/medium/high/max' → 开启+强度 → thinking_type='enabled',强度保留
|
||
-- '' → 不发送 → 两者皆空(默认)
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM information_schema.columns
|
||
WHERE table_name = 'llm_profiles' AND column_name = 'thinking_type'
|
||
) THEN
|
||
ALTER TABLE llm_profiles ADD COLUMN thinking_type TEXT NOT NULL DEFAULT '';
|
||
UPDATE llm_profiles SET thinking_type = 'enabled'
|
||
WHERE reasoning_effort IN ('low','medium','high','max');
|
||
UPDATE llm_profiles SET thinking_type = 'disabled', reasoning_effort = ''
|
||
WHERE reasoning_effort = 'off';
|
||
END IF;
|
||
END $$;
|
||
|
||
-- LLM 轮询熔断状态:某个配置连续失败(余额不足/key 失效/限流)后进入冷却,冷却期内
|
||
-- 轮询直接跳过它。内存态为准,这里落库只为重启后不丢冷却窗口——加载时只取尚未
|
||
-- 到期的行(open_until > now),已到期的自然回到"正常",等下一次调用半开试探。
|
||
CREATE TABLE IF NOT EXISTS llm_profile_health (
|
||
profile_id BIGINT PRIMARY KEY REFERENCES llm_profiles(id) ON DELETE CASCADE,
|
||
fails INTEGER NOT NULL DEFAULT 0, -- 当前连续失败次数(成功即清零)
|
||
trips INTEGER NOT NULL DEFAULT 0, -- 累计熔断次数,用于冷却时间指数退避
|
||
open_until TIMESTAMPTZ, -- 冷却截止;NULL/过期 = 未熔断
|
||
last_error TEXT NOT NULL DEFAULT '',
|
||
last_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- =====================================================================
|
||
-- 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
|
||
REFERENCES explorations(id) ON DELETE RESTRICT,
|
||
status TEXT NOT NULL DEFAULT 'created'
|
||
CHECK (status IN ('created','running','paused','done','failed','timeout')),
|
||
paused BOOLEAN NOT NULL DEFAULT false,
|
||
queued BOOLEAN NOT NULL DEFAULT false,
|
||
queued_at TIMESTAMPTZ,
|
||
queue_mode TEXT NOT NULL DEFAULT '',
|
||
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||
active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||
llm_chain_revision BIGINT NOT NULL DEFAULT 0,
|
||
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
|
||
parent_ref TEXT,
|
||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
||
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
|
||
coverage_enabled BOOLEAN NOT NULL DEFAULT true,
|
||
pinned_at TIMESTAMPTZ,
|
||
first_run_at TIMESTAMPTZ,
|
||
deadline_at TIMESTAMPTZ,
|
||
archived_at TIMESTAMPTZ,
|
||
deleted_at TIMESTAMPTZ,
|
||
completed_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_tasks_alive ON tasks(created_at DESC) WHERE deleted_at IS NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status) WHERE deleted_at IS NULL;
|
||
DROP TRIGGER IF EXISTS trg_tasks_upd ON tasks;
|
||
CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
-- planner 心跳触发间隔(秒);补旧库。默认 300s(5min)。见 docs/planner-trigger-impl-plan.md
|
||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
|
||
-- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。
|
||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
|
||
-- 资产覆盖度功能开关;补旧库。true(默认)=计算/展示测试覆盖度、自动累积测试范围、
|
||
-- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。
|
||
-- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。
|
||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true;
|
||
-- queued_at makes admission FIFO reflect the actual enqueue order rather than the
|
||
-- task creation order. queue_mode distinguishes first bootstrap from resuming an
|
||
-- exploration that already owns goals/history.
|
||
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 pinned_at TIMESTAMPTZ;
|
||
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;
|
||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||
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;
|
||
CREATE INDEX IF NOT EXISTS idx_tasks_pinned ON tasks(pinned_at DESC)
|
||
WHERE deleted_at IS NULL AND pinned_at IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived_at DESC)
|
||
WHERE archived_at IS NOT NULL;
|
||
|
||
-- Cold task archives retain only compact metadata in PostgreSQL. The complete
|
||
-- task payload lives in a versioned .tar.zst package under data/archives/tasks.
|
||
-- task_id stays unique so an operation can be retried safely after a restart.
|
||
CREATE TABLE IF NOT EXISTS task_archives (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
task_id BIGINT NOT NULL UNIQUE REFERENCES tasks(id) ON DELETE CASCADE,
|
||
state TEXT NOT NULL DEFAULT 'archive_queued' CHECK (state IN (
|
||
'archive_queued','archiving','archive_failed','ready',
|
||
'restore_queued','restoring','restore_failed',
|
||
'delete_queued','deleting','delete_failed'
|
||
)),
|
||
phase TEXT NOT NULL DEFAULT 'queued',
|
||
progress INTEGER NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
|
||
error TEXT NOT NULL DEFAULT '',
|
||
warnings JSONB NOT NULL DEFAULT '[]',
|
||
format_version INTEGER NOT NULL DEFAULT 2,
|
||
archive_path TEXT NOT NULL DEFAULT '',
|
||
sha256 TEXT NOT NULL DEFAULT '',
|
||
original_size BIGINT NOT NULL DEFAULT 0,
|
||
compressed_size BIGINT NOT NULL DEFAULT 0,
|
||
task_name TEXT NOT NULL DEFAULT '',
|
||
task_description TEXT NOT NULL DEFAULT '',
|
||
task_goal TEXT NOT NULL DEFAULT '',
|
||
original_status TEXT NOT NULL DEFAULT '',
|
||
category_id_snapshot BIGINT,
|
||
category_name_snapshot TEXT NOT NULL DEFAULT '',
|
||
source_task_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||
remaining_timeout_seconds BIGINT NOT NULL DEFAULT 0,
|
||
data_counts JSONB NOT NULL DEFAULT '{}',
|
||
aggregate_stats JSONB NOT NULL DEFAULT '{}',
|
||
archived_at TIMESTAMPTZ,
|
||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
ALTER TABLE task_archives ALTER COLUMN format_version SET DEFAULT 2;
|
||
CREATE INDEX IF NOT EXISTS idx_task_archives_state ON task_archives(state, requested_at, id);
|
||
CREATE INDEX IF NOT EXISTS idx_task_archives_archived ON task_archives(archived_at DESC, id DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_task_archives_sources ON task_archives USING GIN(source_task_ids);
|
||
DROP TRIGGER IF EXISTS trg_task_archives_upd ON task_archives;
|
||
CREATE TRIGGER trg_task_archives_upd BEFORE UPDATE ON task_archives
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- Reusable task description/goal presets. nkey is the normalized, case-insensitive
|
||
-- identity used to reject visually equivalent duplicate names.
|
||
CREATE TABLE IF NOT EXISTS task_templates (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
nkey TEXT NOT NULL UNIQUE,
|
||
description TEXT NOT NULL,
|
||
goal TEXT NOT NULL,
|
||
-- 预设的任务分类;分类删除时置空(与 tasks.category_id 一致,不阻断)。
|
||
category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL,
|
||
-- 预设的任务级拦截/允许规则快照(AssetInterceptRuleInput 数组);应用模板时灌进新任务。
|
||
intercept_rules JSONB NOT NULL DEFAULT '[]',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
-- 补旧库(已发版,加列带 IF NOT EXISTS)。
|
||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
|
||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS intercept_rules JSONB NOT NULL DEFAULT '[]';
|
||
CREATE INDEX IF NOT EXISTS idx_task_templates_updated ON task_templates(updated_at DESC, id DESC);
|
||
DROP TRIGGER IF EXISTS trg_task_templates_upd ON task_templates;
|
||
CREATE TRIGGER trg_task_templates_upd BEFORE UPDATE ON task_templates
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- Direct, read-only task context inheritance. Relations are intentionally not
|
||
-- recursive: a task sees only the source tasks explicitly chosen at creation.
|
||
CREATE TABLE IF NOT EXISTS task_relations (
|
||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||
source_task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (task_id, source_task_id),
|
||
CONSTRAINT ck_task_relation_not_self CHECK (task_id <> source_task_id)
|
||
);
|
||
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 (
|
||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||
profile_id BIGINT NOT NULL REFERENCES llm_profiles(id) ON DELETE CASCADE,
|
||
position INTEGER NOT NULL CHECK (position >= 0),
|
||
status TEXT NOT NULL DEFAULT 'ready'
|
||
CHECK (status IN ('ready','quota_exhausted')),
|
||
last_error TEXT,
|
||
exhausted_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (task_id, profile_id),
|
||
UNIQUE (task_id, position)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_task_llm_profiles_order ON task_llm_profiles(task_id, position);
|
||
CREATE INDEX IF NOT EXISTS idx_task_llm_profiles_profile ON task_llm_profiles(profile_id, task_id);
|
||
CREATE INDEX IF NOT EXISTS idx_tasks_llm_profile ON tasks(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_tasks_active_llm_profile ON tasks(active_llm_profile_id) WHERE active_llm_profile_id IS NOT NULL;
|
||
DROP TRIGGER IF EXISTS trg_task_llm_profiles_upd ON task_llm_profiles;
|
||
CREATE TRIGGER trg_task_llm_profiles_upd BEFORE UPDATE ON task_llm_profiles
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- One-time-compatible backfill: old pinned tasks become one-entry chains. A user
|
||
-- can still clear the chain later because the update path also clears the legacy
|
||
-- llm_profile_id column, preventing this block from re-adding it on restart.
|
||
INSERT INTO task_llm_profiles(task_id, profile_id, position)
|
||
SELECT t.id, t.llm_profile_id, 0
|
||
FROM tasks t
|
||
WHERE t.llm_profile_id IS NOT NULL
|
||
AND NOT EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id)
|
||
ON CONFLICT DO NOTHING;
|
||
UPDATE tasks t
|
||
SET active_llm_profile_id = t.llm_profile_id
|
||
WHERE t.active_llm_profile_id IS NULL
|
||
AND t.llm_profile_id IS NOT NULL
|
||
AND EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=t.llm_profile_id);
|
||
|
||
-- 任务测试范围(资产覆盖度的分母 + 授权边界)。
|
||
-- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围
|
||
-- (root_domain→root_domain,subdomain/service/endpoint→subdomain(host),ip→ip);
|
||
-- side-effect 派生的资产不入范围(钩子在 handler 顶层,派生在 db 层内部)。
|
||
-- agent 填(source='agent'):add_task_scope 加 company/root_domain/subdomain/ip。
|
||
-- 覆盖度 = 匹配 active 行的 assets(分母)中,被 fact 节点锚定过的占比(分子)。
|
||
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','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 的同一条范围只存一次(自动填批量插入靠它幂等)。
|
||
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';
|
||
|
||
-- =====================================================================
|
||
-- E. Agents / 提示词模板 / 变量目录
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS agents (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
key TEXT NOT NULL UNIQUE CHECK (key ~ '^[a-z][a-z0-9_]*$'),
|
||
name TEXT NOT NULL,
|
||
description TEXT,
|
||
role TEXT NOT NULL,
|
||
builtin BOOLEAN NOT NULL DEFAULT true,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||
current_prompt_id BIGINT,
|
||
max_turns INTEGER NOT NULL DEFAULT 0,
|
||
run_seconds INTEGER NOT NULL DEFAULT 1200,
|
||
web_search BOOLEAN NOT NULL DEFAULT false,
|
||
interactive_shell BOOLEAN NOT NULL DEFAULT false,
|
||
wrapup_prompt TEXT NOT NULL DEFAULT '',
|
||
wrapup_max_turns INTEGER NOT NULL DEFAULT 0,
|
||
task_timeout_wrapup_prompt TEXT NOT NULL DEFAULT '',
|
||
task_timeout_wrapup_max_turns INTEGER NOT NULL DEFAULT 0,
|
||
trigger_run_mode TEXT NOT NULL DEFAULT 'serial' CHECK (trigger_run_mode IN ('serial','parallel')),
|
||
trigger_merge_mode TEXT NOT NULL DEFAULT 'all' CHECK (trigger_merge_mode IN ('by_task','all','none')),
|
||
trigger_max_parallel INTEGER NOT NULL DEFAULT 5,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
CONSTRAINT agents_role_ck CHECK (role IN ('goals','main','planner','worker','assistant'))
|
||
);
|
||
-- 加列迁移(已发版,旧库升级补列;新库 CREATE 已含。迁移不带 CHECK:旧库存量安全 + 后端写入白名单兜底)。
|
||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_run_mode TEXT NOT NULL DEFAULT 'serial';
|
||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_merge_mode TEXT NOT NULL DEFAULT 'all';
|
||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5;
|
||
-- per-agent LLM 绑定(agent 级默认模型):列自初版即在上方 CREATE 中,此 ALTER 仅为极旧库兜底(幂等)。
|
||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
|
||
-- run_seconds 单次 run 墙钟默认 600→1200:只改列默认(影响将来新插入的行),不动旧库存量行。
|
||
ALTER TABLE agents ALTER COLUMN run_seconds SET DEFAULT 1200;
|
||
CREATE INDEX IF NOT EXISTS idx_agents_llm_profile ON agents(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
|
||
DROP TRIGGER IF EXISTS trg_agents_upd ON agents;
|
||
CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS agent_prompts (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
version INT NOT NULL,
|
||
template_text TEXT NOT NULL,
|
||
note TEXT,
|
||
updated_by TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
UNIQUE (agent_id, version)
|
||
);
|
||
-- 循环外键:agents.current_prompt_id → agent_prompts.id(需在两表创建后加)
|
||
DO $$ BEGIN
|
||
ALTER TABLE agents ADD CONSTRAINT fk_agents_curprompt
|
||
FOREIGN KEY (current_prompt_id) REFERENCES agent_prompts(id) ON DELETE SET NULL;
|
||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||
|
||
CREATE TABLE IF NOT EXISTS agent_prompt_vars (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
var_name TEXT NOT NULL,
|
||
description TEXT,
|
||
example TEXT,
|
||
source TEXT NOT NULL CHECK (source IN ('exploration','runtime','distilled')),
|
||
UNIQUE (agent_id, var_name)
|
||
);
|
||
|
||
-- =====================================================================
|
||
-- F. MCP 服务
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS mcp_servers (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL UNIQUE,
|
||
transport TEXT NOT NULL CHECK (transport IN ('stdio','http','sse')),
|
||
command TEXT,
|
||
args JSONB NOT NULL DEFAULT '[]',
|
||
env JSONB NOT NULL DEFAULT '{}',
|
||
url TEXT,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
insecure BOOLEAN NOT NULL DEFAULT false, -- http: 跳过 TLS 证书校验(自签证书场景, issue #108)
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
-- Allow legacy MCP SSE servers on databases created before SSE support.
|
||
ALTER TABLE mcp_servers DROP CONSTRAINT IF EXISTS mcp_servers_transport_check;
|
||
ALTER TABLE mcp_servers ADD CONSTRAINT mcp_servers_transport_check
|
||
CHECK (transport IN ('stdio','http','sse'));
|
||
-- 旧库补列(schema.sql 每次启动都会 Exec)。
|
||
ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS insecure BOOLEAN NOT NULL DEFAULT false;
|
||
DROP TRIGGER IF EXISTS trg_mcp_upd ON mcp_servers;
|
||
CREATE TRIGGER trg_mcp_upd BEFORE UPDATE ON mcp_servers
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- 默认数据源占位:ScopeSentry 资产同步 MCP(地址与认证均留空、未启用)。
|
||
-- 供「资产同步」页检测数据源是否已配置;用户在页面填入 url 与 X-API-Key 后再启用。
|
||
-- 仅在缺失时插入,绝不覆盖用户已配置/已启用的服务器(schema.sql 每次启动都会 Exec)。
|
||
INSERT INTO mcp_servers (name, transport, url, env, enabled)
|
||
VALUES ('ScopeSentry', 'http', NULL, '{"X-API-Key":""}', false)
|
||
ON CONFLICT (name) DO NOTHING;
|
||
|
||
CREATE TABLE IF NOT EXISTS mcp_tools_cache (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
server_id BIGINT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
|
||
tool_name TEXT NOT NULL,
|
||
description TEXT,
|
||
schema JSONB,
|
||
discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
UNIQUE (server_id, tool_name)
|
||
);
|
||
|
||
-- =====================================================================
|
||
-- G. 可见性:agent × mcp / skill
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS agent_visibility (
|
||
agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('mcp')),
|
||
resource_id BIGINT NOT NULL,
|
||
mcp_tool_name TEXT NOT NULL DEFAULT '',
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (agent_id, resource_kind, resource_id, mcp_tool_name)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_vis_resource ON agent_visibility(resource_kind, resource_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS agent_skill_visibility (
|
||
agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||
skill_name TEXT NOT NULL,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (agent_id, skill_name)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_askv_skill ON agent_skill_visibility(skill_name);
|
||
|
||
-- Skill 调用账本(见 db/skill_usage.go)。一次 Skill() 调用一行,只记维度不记正文。
|
||
-- 刻意不设外键:任务/会话删除后统计仍要保留(与 llm_usage 同理),skill 本身也只是
|
||
-- 文件系统上的目录名,没有对应的表。
|
||
CREATE TABLE IF NOT EXISTS skill_usage (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
skill TEXT NOT NULL,
|
||
agent_key TEXT,
|
||
task_id BIGINT,
|
||
exploration_id BIGINT,
|
||
intent_id BIGINT,
|
||
session_id TEXT,
|
||
args_len INTEGER NOT NULL DEFAULT 0,
|
||
-- false = 模型点名了一个不存在的 skill(未命中)。这类行同样保留:它反映"想用但没有"
|
||
-- 的缺口,是补 skill 的依据。
|
||
found BOOLEAN NOT NULL DEFAULT true
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_skill_usage_skill ON skill_usage(skill, ts DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id);
|
||
|
||
-- 工具调用账本(见 db/tool_usage.go)。一次实际 CoreTool.Call 一行,只记归属维度,
|
||
-- 不保存工具参数或返回内容。刻意不设外键,任务、会话或自定义工具删除后仍保留统计。
|
||
CREATE TABLE IF NOT EXISTS tool_usage (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
tool_key TEXT NOT NULL,
|
||
agent_key TEXT,
|
||
task_id BIGINT,
|
||
exploration_id BIGINT,
|
||
intent_id BIGINT,
|
||
session_id TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_tool_usage_tool ON tool_usage(tool_key, ts DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_tool_usage_task ON tool_usage(task_id);
|
||
|
||
-- =====================================================================
|
||
-- H. 内置工具目录
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS tools (
|
||
key TEXT PRIMARY KEY,
|
||
system BOOLEAN NOT NULL DEFAULT true,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
schema JSONB NOT NULL DEFAULT '{}',
|
||
agents JSONB NOT NULL DEFAULT '[]',
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
kind TEXT NOT NULL DEFAULT 'builtin',
|
||
exec JSONB NOT NULL DEFAULT '{}',
|
||
deferred BOOLEAN NOT NULL DEFAULT false,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
DROP TRIGGER IF EXISTS trg_tools_upd ON tools;
|
||
CREATE TRIGGER trg_tools_upd BEFORE UPDATE ON tools
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- =====================================================================
|
||
-- I. 会话(对话页)
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS conversations (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
agent_key TEXT NOT NULL,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL,
|
||
pinned_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
|
||
CREATE INDEX IF NOT EXISTS idx_conversations_llm_profile ON conversations(llm_profile_id) WHERE llm_profile_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned_at DESC) WHERE pinned_at IS NOT NULL;
|
||
DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations;
|
||
CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS conversation_activities (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||
worker TEXT,
|
||
kind TEXT,
|
||
tool TEXT,
|
||
tool_use_id TEXT,
|
||
is_error BOOLEAN NOT NULL DEFAULT false,
|
||
summary TEXT,
|
||
detail TEXT,
|
||
input_tokens INTEGER,
|
||
output_tokens INTEGER,
|
||
cache_read_tokens INTEGER,
|
||
cache_write_tokens INTEGER,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_conv_act ON conversation_activities(conversation_id, id);
|
||
CREATE INDEX IF NOT EXISTS idx_conv_act_tool_call ON conversation_activities(conversation_id, tool_use_id, id)
|
||
WHERE kind IN ('tool_use', 'tool_result');
|
||
|
||
-- =====================================================================
|
||
-- J. Agent 触发器
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS agent_triggers (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
agent_key TEXT NOT NULL,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||
on_finding BOOLEAN NOT NULL DEFAULT false,
|
||
on_goal_met BOOLEAN NOT NULL DEFAULT false,
|
||
on_task_timeout BOOLEAN NOT NULL DEFAULT false,
|
||
on_tool_call BOOLEAN NOT NULL DEFAULT false,
|
||
on_task_create BOOLEAN NOT NULL DEFAULT false,
|
||
interval_message TEXT NOT NULL DEFAULT '',
|
||
finding_message TEXT NOT NULL DEFAULT '',
|
||
goal_message TEXT NOT NULL DEFAULT '',
|
||
task_timeout_message TEXT NOT NULL DEFAULT '',
|
||
tool_call_message TEXT NOT NULL DEFAULT '',
|
||
task_create_message TEXT NOT NULL DEFAULT '',
|
||
tool_names TEXT NOT NULL DEFAULT '',
|
||
last_fire TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_agent_triggers_agent ON agent_triggers(agent_key);
|
||
-- 加列迁移(已发版,旧库升级补列;新库 CREATE 已含这些列,ALTER 为 no-op)。幂等,每次启动可重复执行。
|
||
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS on_tool_call BOOLEAN NOT NULL DEFAULT false;
|
||
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS tool_call_message TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS tool_names TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS on_task_create BOOLEAN NOT NULL DEFAULT false;
|
||
ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS task_create_message TEXT NOT NULL DEFAULT '';
|
||
DROP TRIGGER IF EXISTS trg_agent_triggers_upd ON agent_triggers;
|
||
CREATE TRIGGER trg_agent_triggers_upd BEFORE UPDATE ON agent_triggers
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS scheduler_state (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL DEFAULT ''
|
||
);
|
||
|
||
-- =====================================================================
|
||
-- K. 拦截规则
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS intercept_rules (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
priority INTEGER NOT NULL DEFAULT 0,
|
||
match_target TEXT NOT NULL CHECK (match_target IN ('tool_name', 'tool_input')),
|
||
match_type TEXT NOT NULL CHECK (match_type IN ('string', 'regex')),
|
||
pattern TEXT NOT NULL,
|
||
action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'ask')),
|
||
message TEXT NOT NULL DEFAULT '',
|
||
timeout_enabled BOOLEAN NOT NULL DEFAULT true,
|
||
timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
||
timeout_action TEXT NOT NULL DEFAULT 'deny',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
DROP TRIGGER IF EXISTS trg_intercept_rules_upd ON intercept_rules;
|
||
CREATE TRIGGER trg_intercept_rules_upd BEFORE UPDATE ON intercept_rules
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
CREATE TABLE IF NOT EXISTS intercept_pending (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
rule_id BIGINT REFERENCES intercept_rules(id) ON DELETE SET NULL,
|
||
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE,
|
||
task_id TEXT,
|
||
agent_name TEXT NOT NULL DEFAULT '',
|
||
tool_name TEXT NOT NULL,
|
||
tool_input JSONB NOT NULL DEFAULT '{}',
|
||
status TEXT NOT NULL DEFAULT 'pending'
|
||
CHECK (status IN ('pending', 'allowed', 'denied', 'timeout')),
|
||
-- 判定理由:规则命中时为规则 message;LLM 兜底判定时为模型给的简短理由(前缀 [模型])。
|
||
reason TEXT NOT NULL DEFAULT '',
|
||
decided_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_intercept_pending_status ON intercept_pending(status, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_intercept_pending_task ON intercept_pending(task_id, created_at DESC);
|
||
-- 补旧库:reason 列(已发版,加列要带 IF NOT EXISTS)。
|
||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT '';
|
||
-- Detail payloads are lazy-loaded; NULL preserves the meaning of legacy history.
|
||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB;
|
||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT '';
|
||
UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule'
|
||
WHEN reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END WHERE decision_source='';
|
||
|
||
-- =====================================================================
|
||
-- L. 漏洞发现持久化
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS findings (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
|
||
node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL,
|
||
vulnclass TEXT NOT NULL DEFAULT '',
|
||
-- 漏洞名称(可读标题);为空时前端回退展示 vulnclass。severity 取值:
|
||
-- critical 严重 / high 高 / medium 中 / low 低(不加 CHECK,与 status 一致由 server 白名单校验)。
|
||
name TEXT NOT NULL DEFAULT '',
|
||
severity TEXT NOT NULL DEFAULT '',
|
||
summary TEXT NOT NULL DEFAULT '',
|
||
evidence TEXT NOT NULL DEFAULT '',
|
||
worker TEXT NOT NULL DEFAULT '',
|
||
asset_ids JSONB NOT NULL DEFAULT '[]',
|
||
-- 处置状态:pending 待处理 / in_progress 处理中 / confirmed 已确认 / resolved 已处理 / fixed 已修复 /
|
||
-- false_positive 误报 / ignored 忽略 / duplicate 重复 / risk_accepted 风险接受。
|
||
-- 取值不加 CHECK:旧库靠下面的 ALTER 补列,CHECK 无法回填,统一由 server 侧白名单校验。
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
-- 漏洞详细报告(Markdown);默认空,仅详情页读取/展示,不进列表接口以免 payload 膨胀。
|
||
report TEXT NOT NULL DEFAULT '',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
|
||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT '';
|
||
CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC);
|
||
-- 「按资产」视图靠 asset_ids @> '[<id>]' 反查发现,没有这个 GIN 索引就是全表扫。
|
||
CREATE INDEX IF NOT EXISTS idx_findings_asset_ids ON findings USING GIN(asset_ids jsonb_path_ops);
|
||
|
||
-- 手动复测属于独立会话;结论与原漏洞处置状态分开保存。
|
||
CREATE TABLE IF NOT EXISTS finding_retests (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
finding_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||
conversation_id BIGINT UNIQUE REFERENCES conversations(id) ON DELETE SET NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','completed','failed','stopped')),
|
||
verdict TEXT NOT NULL DEFAULT '' CHECK (verdict IN ('','reproduced','fixed','inconclusive')),
|
||
notes TEXT NOT NULL DEFAULT '',
|
||
snapshot JSONB NOT NULL,
|
||
summary TEXT NOT NULL DEFAULT '',
|
||
evidence TEXT NOT NULL DEFAULT '',
|
||
error TEXT NOT NULL DEFAULT '',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
started_at TIMESTAMPTZ,
|
||
finished_at TIMESTAMPTZ
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_finding_retests_history ON finding_retests(finding_id, id DESC);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_finding_retests_active ON finding_retests(finding_id)
|
||
WHERE status IN ('pending','running');
|
||
|
||
-- 删除会话保留复测记录,同时解除尚未结束的复测占用。
|
||
CREATE OR REPLACE FUNCTION stop_deleted_conversation_retest() RETURNS trigger LANGUAGE plpgsql AS $$
|
||
BEGIN
|
||
UPDATE finding_retests SET status='stopped', error='复测会话已删除', finished_at=now()
|
||
WHERE conversation_id=OLD.id AND status IN ('pending','running');
|
||
RETURN OLD;
|
||
END;
|
||
$$;
|
||
DROP TRIGGER IF EXISTS trg_conversation_retest_delete ON conversations;
|
||
CREATE TRIGGER trg_conversation_retest_delete BEFORE DELETE ON conversations
|
||
FOR EACH ROW EXECUTE FUNCTION stop_deleted_conversation_retest();
|
||
|
||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS evidence_version BIGINT NOT NULL DEFAULT 0;
|
||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report_evidence_version BIGINT NOT NULL DEFAULT 0;
|
||
|
||
CREATE TABLE IF NOT EXISTS traffic_evidence_snapshots (
|
||
id TEXT PRIMARY KEY,
|
||
source_traffic_id TEXT NOT NULL,
|
||
captured_at BIGINT NOT NULL,
|
||
url TEXT NOT NULL,
|
||
method TEXT NOT NULL,
|
||
status INTEGER NOT NULL,
|
||
content_type TEXT NOT NULL DEFAULT '',
|
||
req_head TEXT NOT NULL,
|
||
resp_head TEXT NOT NULL,
|
||
req_hash TEXT NOT NULL,
|
||
resp_hash TEXT NOT NULL,
|
||
req_len BIGINT NOT NULL,
|
||
resp_len BIGINT NOT NULL,
|
||
unreferenced_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE TABLE IF NOT EXISTS finding_traffic_bindings (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
finding_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||
snapshot_id TEXT NOT NULL REFERENCES traffic_evidence_snapshots(id),
|
||
role TEXT NOT NULL DEFAULT 'supporting',
|
||
note TEXT NOT NULL DEFAULT '',
|
||
position INTEGER NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
UNIQUE(finding_id, snapshot_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_finding_traffic_order ON finding_traffic_bindings(finding_id, position, id);
|
||
CREATE INDEX IF NOT EXISTS idx_finding_traffic_snapshot ON finding_traffic_bindings(snapshot_id);
|
||
|
||
-- =====================================================================
|
||
-- M. 后端日志持久化
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS server_logs (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
level TEXT NOT NULL DEFAULT 'info',
|
||
tag TEXT NOT NULL DEFAULT '',
|
||
text TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_server_logs_id ON server_logs(id DESC);
|
||
|
||
-- Independent /btw history and the latest provider-ready main checkpoint.
|
||
CREATE TABLE IF NOT EXISTS side_question_sessions (
|
||
session_key TEXT PRIMARY KEY,
|
||
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE,
|
||
task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE,
|
||
exploration_id BIGINT REFERENCES explorations(id) ON DELETE CASCADE,
|
||
intent_id BIGINT REFERENCES exploration_nodes(id) ON DELETE CASCADE,
|
||
run_id BIGINT NOT NULL,
|
||
version BIGINT NOT NULL,
|
||
snapshot JSONB NOT NULL,
|
||
generation BIGINT NOT NULL DEFAULT 0,
|
||
CHECK ((conversation_id IS NOT NULL AND task_id IS NULL AND exploration_id IS NULL AND intent_id IS NULL)
|
||
OR (conversation_id IS NULL AND task_id IS NOT NULL AND exploration_id IS NOT NULL))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_side_sessions_conv ON side_question_sessions(conversation_id);
|
||
CREATE INDEX IF NOT EXISTS idx_side_sessions_task ON side_question_sessions(task_id);
|
||
CREATE INDEX IF NOT EXISTS idx_side_sessions_exp ON side_question_sessions(exploration_id);
|
||
CREATE INDEX IF NOT EXISTS idx_side_sessions_intent ON side_question_sessions(intent_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS side_question_requests (
|
||
id TEXT PRIMARY KEY,
|
||
ordinal BIGSERIAL UNIQUE,
|
||
session_key TEXT NOT NULL REFERENCES side_question_sessions(session_key) ON DELETE CASCADE,
|
||
generation BIGINT NOT NULL,
|
||
client_id TEXT NOT NULL,
|
||
question TEXT NOT NULL,
|
||
answer TEXT NOT NULL DEFAULT '',
|
||
status TEXT NOT NULL CHECK(status IN ('running','completed','failed','cancelled','interrupted')),
|
||
error TEXT NOT NULL DEFAULT '',
|
||
model JSONB NOT NULL,
|
||
snapshot_at TIMESTAMPTZ NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
sequence BIGINT NOT NULL DEFAULT 0,
|
||
usage JSONB NOT NULL DEFAULT '{}',
|
||
UNIQUE(session_key,generation,client_id)
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_side_request_running ON side_question_requests(session_key) WHERE status='running';
|
||
CREATE INDEX IF NOT EXISTS idx_side_requests_history ON side_question_requests(session_key,ordinal DESC);
|
||
|
||
-- Additive v3 archive fields; old archives restore these as empty objects.
|
||
ALTER TABLE side_question_sessions ADD COLUMN IF NOT EXISTS memory JSONB NOT NULL DEFAULT '{}';
|
||
ALTER TABLE side_question_requests ADD COLUMN IF NOT EXISTS context_info JSONB NOT NULL DEFAULT '{}';
|
||
|
||
-- =====================================================================
|
||
-- 资产拦截规则(全局黑名单)
|
||
-- 独立于 §K 命令拦截(intercept_rules):intercept_rules 匹配工具名/入参文本,
|
||
-- 这张表匹配「目标资产」——全等/模糊的域名·IP·URL 以及 CIDR 网段。
|
||
-- 仅存规则;具体的匹配/拦截逻辑在别处实现。
|
||
-- kind 七种:
|
||
-- exact_domain / exact_ip / exact_url —— 全等匹配
|
||
-- fuzzy_domain / fuzzy_ip / fuzzy_url —— 模糊匹配
|
||
-- cidr —— CIDR 网段
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS asset_intercept_rules (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
kind TEXT NOT NULL CHECK (kind IN (
|
||
'exact_domain', 'exact_ip', 'exact_url',
|
||
'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url',
|
||
'cidr')),
|
||
pattern TEXT NOT NULL,
|
||
note TEXT NOT NULL DEFAULT '',
|
||
builtin BOOLEAN NOT NULL DEFAULT false,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_asset_intercept_enabled ON asset_intercept_rules(enabled);
|
||
DROP TRIGGER IF EXISTS trg_asset_intercept_rules_upd ON asset_intercept_rules;
|
||
CREATE TRIGGER trg_asset_intercept_rules_upd BEFORE UPDATE ON asset_intercept_rules
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- =====================================================================
|
||
-- 任务级资产拦截/允许规则
|
||
-- 与全局 asset_intercept_rules 同构(kind/pattern/note/enabled),但按 task_id
|
||
-- 关联、随任务级联删除;创建任务时录入、任务详情里可编辑。
|
||
-- action: 'block'=拦截(禁止测试) 'allow'=允许(白名单)。
|
||
-- 执行判定:先按 拦截规则(全局 ∪ 任务block) 匹配,命中即禁止;未命中且该任务存在
|
||
-- 启用的 allow 规则时,须命中某条 allow 才放行,否则「不允许测试」。
|
||
-- =====================================================================
|
||
CREATE TABLE IF NOT EXISTS task_intercept_rules (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
action TEXT NOT NULL DEFAULT 'block' CHECK (action IN ('block','allow')),
|
||
kind TEXT NOT NULL CHECK (kind IN (
|
||
'exact_domain', 'exact_ip', 'exact_url',
|
||
'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url',
|
||
'cidr')),
|
||
pattern TEXT NOT NULL,
|
||
note TEXT NOT NULL DEFAULT '',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_task_intercept_task ON task_intercept_rules(task_id);
|
||
-- 补旧库(本会话早前建过该表、无 action 列):加列(带 IF NOT EXISTS)。
|
||
ALTER TABLE task_intercept_rules ADD COLUMN IF NOT EXISTS action TEXT NOT NULL DEFAULT 'block';
|
||
DROP TRIGGER IF EXISTS trg_task_intercept_rules_upd ON task_intercept_rules;
|
||
CREATE TRIGGER trg_task_intercept_rules_upd BEFORE UPDATE ON task_intercept_rules
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- =====================================================================
|
||
-- M. 漏洞 IM 推送
|
||
--
|
||
-- 三张表刻意分开,核心是**爆炸半径**:写漏洞的那个事务(RecordFindingTx,
|
||
-- 持任务行锁)只允许做一次盲 INSERT,不读渠道表、不跑用户的过滤规则。否则
|
||
-- 一条配错的 webhook 过滤条件就能污染/中止事务,导致漏洞存不进去。
|
||
--
|
||
-- notification_channels 渠道实例配置(可变、含凭据、UI 管理)
|
||
-- notification_events 事件事实(写漏洞事务内盲插,含渲染快照)
|
||
-- notification_deliveries 投递任务(事务外 fan-out 产生,承载状态/重试/批次)
|
||
-- =====================================================================
|
||
|
||
-- 渠道实例:同一 kind 可配任意多个(如「应急群」「日常群」各一个钉钉机器人)。
|
||
-- kind 取值由 server 侧白名单校验,不加 CHECK:与 findings.status 同理,
|
||
-- 后续加渠道不应要求改表结构。
|
||
CREATE TABLE IF NOT EXISTS notification_channels (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
-- dingtalk 钉钉 / feishu 飞书 / wecom 企业微信 / webhook 通用 / telegram / email
|
||
kind TEXT NOT NULL,
|
||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||
-- 凭据(明文存储,UI 掩码回显;见 server 侧 maskChannelSecrets)。六种渠道字段差异极大,
|
||
-- 统一 JSONB + Go 侧按 kind 严格校验,避免为每渠道加一堆 NULL 列:
|
||
-- dingtalk {webhook,secret}
|
||
-- feishu {webhook,secret}
|
||
-- wecom {webhook}
|
||
-- webhook {url,method,content_type,headers{},body_template}
|
||
-- telegram {bot_token,chat_id,base_url}
|
||
-- email {host,port,username,password,from,to[],tls}
|
||
config JSONB NOT NULL DEFAULT '{}',
|
||
-- 推送时机:realtime 命中即推 / digest 进批次按全局周期汇总成一条。
|
||
mode TEXT NOT NULL DEFAULT 'realtime',
|
||
-- 过滤条件,字段全部可选(缺省=不过滤):
|
||
-- min_severity ''|low|medium|high|critical
|
||
-- task_ids/asset_ids 空数组=不限;非空则须交集非空
|
||
-- vulnclass_include/exclude 关键词数组(大小写不敏感子串);include 空=全收
|
||
-- on_status_change bool,仅 realtime 模式有意义
|
||
filter JSONB NOT NULL DEFAULT '{}',
|
||
-- 每分钟投递上限;0=不限流。默认 20 对齐钉钉/企微官方硬限。
|
||
-- 超限不丢消息,只把投递推迟到下一个 tick。
|
||
rate_per_min INTEGER NOT NULL DEFAULT 20,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
DROP TRIGGER IF EXISTS trg_notification_channels_upd ON notification_channels;
|
||
CREATE TRIGGER trg_notification_channels_upd BEFORE UPDATE ON notification_channels
|
||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||
|
||
-- 事件事实。由 RecordFindingTx / 状态变更事务**同事务**写入,保证「漏洞落库」
|
||
-- 与「推送任务存在」原子一致——不存在提交成功但没入队、消息永久丢失的窗口。
|
||
-- snapshot 刻意冗余:漏洞事后会被改名/改级别/改状态,推送内容应反映「事发当时」,
|
||
-- 且 fan-out 与渲染不必回查 findings/tasks/assets 多张表。
|
||
-- finding 删除后事件不级联删除:与 findings 表「任务删除仍独立留存」的语义一致。
|
||
CREATE TABLE IF NOT EXISTS notification_events (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
-- finding_created | finding_status_changed
|
||
kind TEXT NOT NULL,
|
||
finding_id BIGINT NOT NULL,
|
||
snapshot JSONB NOT NULL,
|
||
-- fan-out 幂等标记:dispatcher 按此列取待分派事件,处理完置 true。
|
||
-- 用列而非删行,以便投递历史能回溯到事件。
|
||
fanned_out BOOLEAN NOT NULL DEFAULT false,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_notification_events_pending
|
||
ON notification_events(id) WHERE NOT fanned_out;
|
||
|
||
-- 投递任务:一条事件 × 一个启用渠道 = 一行。fan-out 在事务外做,所以渠道
|
||
-- 后开不会补历史(与 agent_triggers 的「迟开 trigger 不补历史」语义一致,
|
||
-- 避免启用渠道时一次性刷屏历史积压)。
|
||
-- channel_id 级联删除:渠道配置都没了,其投递历史无意义。
|
||
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
event_id BIGINT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE,
|
||
channel_id BIGINT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||
-- pending 待发 / sent 已发 / failed 重试耗尽(可手动重发) / skipped 渠道停用或批次取消
|
||
-- pending 待发 / sending 已被某 dispatcher 领取(租约未到期) / sent 已发 /
|
||
-- failed 重试耗尽或永久失败(可手动重发) / skipped 渠道停用。取值不加 CHECK,
|
||
-- 与 findings.status 同理,由 server 侧白名单校验。
|
||
state TEXT NOT NULL DEFAULT 'pending',
|
||
attempts INTEGER NOT NULL DEFAULT 0,
|
||
-- 兼作「下次可领取时间」与「租约到期时间」:领取时把它推到未来即构成租约,
|
||
-- 于是「租约未到期」与「未到重试时间」共用同一个条件表达,不需要额外的
|
||
-- lease_until 列。进程崩溃留下的 sending 行会因租约到期被下一轮重新领取。
|
||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
last_error TEXT NOT NULL DEFAULT '',
|
||
-- digest 模式同批次共享;realtime 恒为 NULL。整批渲染成一条消息后一起置 sent。
|
||
batch_id BIGINT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
sent_at TIMESTAMPTZ
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_due
|
||
ON notification_deliveries(next_attempt_at) WHERE state='pending';
|
||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_history
|
||
ON notification_deliveries(id DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch
|
||
ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL;
|
||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel
|
||
ON notification_deliveries(channel_id, id DESC);
|