Files
ARTEX/db/schema.sql
T
pphua 9793b522d9 feat(tasks): add inherited context and resilient LLM execution
Add read-only task context inheritance, ordered task-level LLM failover, live Worker controls, cascading task cleanup, FIFO task concurrency, cancellation diagnostics, UI updates, tests, build script, and changelog.
2026-08-20 18:34:24 +08:00

678 lines
35 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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;
-- =====================================================================
-- 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,
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);
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')),
domain TEXT,
net CIDR,
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)
);
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 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()
);
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,
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')),
CONSTRAINT ck_node_state CHECK (
(kind='begin' AND state IN ('open')) OR
(kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) 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'))
)
);
ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS blocked_reason TEXT;
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%'
) 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')) 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'))
);
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 CHECK (rel IN ('spawns','derived_from','yields','proves')),
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)
);
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);
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);
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 '{}';
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);
-- 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);
-- =====================================================================
-- 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')),
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,
reasoning_effort 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,
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;
-- 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. 任务层
-- =====================================================================
CREATE TABLE IF NOT EXISTS tasks (
id BIGSERIAL PRIMARY KEY,
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,
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,
first_run_at TIMESTAMPTZ,
deadline_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;
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;
-- 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);
-- 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')),
company_id BIGINT REFERENCES companies(id) ON DELETE CASCADE, -- kind='company'
domain TEXT, -- root_domain / subdomain
net CIDR, -- ip / cidr
source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')),
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 去重:同一 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));
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 600,
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;
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')),
command TEXT,
args JSONB NOT NULL DEFAULT '[]',
env JSONB NOT NULL DEFAULT '{}',
url TEXT,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
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);
-- =====================================================================
-- 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,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conversations_llm_profile ON conversations(llm_profile_id) WHERE llm_profile_id 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);
-- =====================================================================
-- 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')),
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);
-- =====================================================================
-- 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 已处理 /
-- 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);
-- =====================================================================
-- 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);