Skip to content

Commit 90fdc9e

Browse files
committed
add PostgreSQL
1 parent 10e985b commit 90fdc9e

13 files changed

Lines changed: 946 additions & 103 deletions

File tree

examples/skills_code_review_agent/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ CODE_REVIEW_STORAGE_BACKEND=sqlite
1818
CODE_REVIEW_SQLITE_PATH=storage/reviews.sqlite3
1919
# Optional compatible SQLite schema override
2020
CODE_REVIEW_SQLITE_SCHEMA_PATH=storage/schema.sql
21+
# PostgreSQL alternative (uncomment all fields and set backend=postgresql).
22+
# CODE_REVIEW_STORAGE_BACKEND=postgresql
23+
# CODE_REVIEW_POSTGRES_DSN=postgresql://review_agent:replace-me@127.0.0.1:5432/code_reviews
24+
# CODE_REVIEW_POSTGRES_SCHEMA_PATH=storage/postgres_schema.sql
25+
# CODE_REVIEW_POSTGRES_CONNECT_TIMEOUT_SECONDS=5
26+
# CODE_REVIEW_POSTGRES_STATEMENT_TIMEOUT_SECONDS=15
2127

2228
# Sandbox policy ceilings; values may be tightened but not raised above defaults
2329
CODE_REVIEW_MAX_TIMEOUT_SECONDS=120

examples/skills_code_review_agent/README.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@ skills_code_review_agent/
3535
│ ├── base.py # BaseReviewStore 抽象基类
3636
│ ├── factory.py # 环境变量驱动的实现选择
3737
│ ├── schema.sql # 显式 SQLite schema
38-
│ └── sqlite.py # SQLite 实现
38+
│ ├── sqlite.py # SQLite 实现
39+
│ ├── postgres_schema.sql # PostgreSQL 初始化/迁移 schema
40+
│ ├── postgresql.py # PostgreSQL 实现
41+
│ └── schema_loader.py # 受限 schema 文件加载
3942
├── reports/
4043
│ ├── models.py # 结构化审查模型
4144
│ └── writers.py # JSON/Markdown 输出
4245
├── tests/fixtures/ # 8 条要求样本及超时补充样本
4346
├── tests/run_tests.py # 非 Docker 验收测试入口
4447
├── tests/run_docker_tests.py # tRPC Container runtime 集成测试
48+
├── tests/run_postgres_tests.py # PostgreSQL 存储契约集成测试
4549
├── tests/evaluate_fixtures.py # 公开 fixture 指标评测
4650
└── examples/review_report.* # 示例报告
4751
```
@@ -116,10 +120,22 @@ CODE_REVIEW_SQLITE_PATH=storage/reviews.sqlite3
116120
CODE_REVIEW_SQLITE_SCHEMA_PATH=storage/schema.sql
117121
```
118122

119-
当前只实现 `sqlite`;新增后端应继承 `BaseReviewStore` 并在 `storage/factory.py` 注册。`--database` 的优先级高于 `CODE_REVIEW_SQLITE_PATH`
123+
PostgreSQL 使用可选驱动,启用前安装本示例的 `postgresql` extra,并仅通过环境变量传递 DSN,避免凭据出现在命令行进程列表中:
124+
125+
```bash
126+
uv sync --project examples/skills_code_review_agent --extra postgresql
127+
128+
CODE_REVIEW_STORAGE_BACKEND=postgresql
129+
CODE_REVIEW_POSTGRES_DSN=postgresql://review_agent:<password>@127.0.0.1:5432/code_reviews
130+
CODE_REVIEW_POSTGRES_SCHEMA_PATH=storage/postgres_schema.sql
131+
CODE_REVIEW_POSTGRES_CONNECT_TIMEOUT_SECONDS=5
132+
CODE_REVIEW_POSTGRES_STATEMENT_TIMEOUT_SECONDS=15
133+
```
134+
135+
远程 PostgreSQL DSN 必须设置 `sslmode=require``verify-ca``verify-full`,生产环境推荐 `verify-full`;本地 loopback 联调可不启用 TLS。数据库账号只需目标 schema 的建表/迁移和表读写权限,不应授予超级用户权限。`--database` 仅用于 SQLite,且优先级高于 `CODE_REVIEW_SQLITE_PATH`
120136
`CODE_REVIEW_SQLITE_SCHEMA_PATH` 可选择兼容的 SQLite 初始化 schema;替换文件必须保留存储实现使用的表和字段契约,并且只能使用 `storage/` 下的普通文件。schema 有大小限制,初始化时禁止 attach、trigger、view、虚拟表、删除对象和业务数据写入。
121137

122-
SQLite 分表保存 `review_tasks``review_inputs``sandbox_runs``filter_decisions``findings``monitoring_summaries``review_reports``get_task_details(task_id)` 可查询完整审计记录。任务在 Agent 启动前以 `running` 状态落库,异常终止会更新为 `failed`。SQLite 启用 WAL、等待锁和 digest/profile 索引。对于内容不可变的 diff/fixture,缓存必须同时匹配输入摘要、规则、Skill、模式、模型和审查范围;是否复用仍由 Agent 决定。
138+
SQLite 与 PostgreSQL 均通过 `BaseReviewStore` 分表保存 `review_tasks``review_inputs``sandbox_runs``filter_decisions``findings``monitoring_summaries``review_reports``get_task_details(task_id)` 可查询完整审计记录。任务在 Agent 启动前以 `running` 状态落库,异常终止会更新为 `failed`。SQLite 启用 WAL 和等待锁;PostgreSQL 使用短事务、连接/语句/锁超时、参数化 SQL 和 JSONB。两者均提供 digest/profile 索引。对于内容不可变的 diff/fixture,缓存必须同时匹配输入摘要、规则、Skill、模式、模型和审查范围;是否复用仍由 Agent 决定。
123139

124140
沙箱实现由 `CODE_REVIEW_SANDBOX_BACKEND=docker` 选择,当前仅提供 Docker;新增实现需满足 `SandboxProvider` 并在 `sandbox/factory.py` 注册。可通过 `--output-dir``--docker-image` 覆盖输出目录和镜像。Docker runtime 按 Agent 的 workspace 工具调用惰性创建;代码只读挂载,diff/fixture 仅挂载任务级副本。容器禁网、非 root、删除 capabilities、启用 `no-new-privileges` 和只读根文件系统,并限制 CPU、内存、PID 与 tmpfs。模型服务仍由宿主进程调用,因此应使用符合代码数据策略的模型服务。
125141

@@ -133,7 +149,7 @@ SQLite 分表保存 `review_tasks`、`review_inputs`、`sandbox_runs`、`filter_
133149
- 单次 Skill run 默认 30 秒;整次 review 默认 110 秒、30 次工具调用和 12 次 sandbox run。所有限制均可通过 `.env` 中的 `CODE_REVIEW_*` 字段收紧。
134150
- Workflow 可信地记录每个脚本的 cursor;缺少必需的 staged、unstaged、文件枚举或受控读取证据,或者任一 `next_cursor` 未读完时,报告会强制加入人工复核项。
135151
- 代码、注释和工具输出均按不可信数据处理;Filter 阻止外部 diff helper、敏感路径和跨输入模式读取。
136-
- 输入预览、finding、Filter、sandbox 输出、数据库和报告写入前执行敏感信息脱敏。
152+
- 输入预览、finding、Filter、sandbox 输出、数据库和报告写入前执行敏感信息脱敏;PostgreSQL DSN 不进入报告、数据库字段或 CLI 参数
137153
- 容器进程由容器内 `timeout` 终止;stdout/stderr 在返回模型前按 `CODE_REVIEW_MAX_OUTPUT_BYTES` 硬限制并脱敏。受 Skill 工具 16 KiB inline 契约约束,Docker 传输的两路输出合计还会取配置值与 15 KiB 的较小者,避免 Docker Desktop 在 64 KiB socket 边界产生长时间等待。
138154
- findings 按 `(file, line, category)` 去重,置信度低于 `0.70` 自动进入 warnings。
139155

@@ -165,6 +181,16 @@ uv run --project examples/skills_code_review_agent --with-editable . \
165181

166182
测试覆盖:无问题、安全问题、异步任务泄漏、资源生命周期、数据库连接生命周期、测试缺失、重复 finding、sandbox 失败/超时、敏感信息脱敏、六类独立规则、配置工厂、分页、挂载最小化和报告注入。Docker 集成脚本验证 Skill 加载、规则执行、Filter、只读输入、禁网、真实超时、分页以及容器资源安全配置;模型效果仍取决于所配置模型,隐藏样本指标不能由公开 fixture 证明。
167183

184+
对已授权的独立 PostgreSQL 测试库执行完整存储契约(会创建本示例的表并写入带随机 ID 的测试行):
185+
186+
```bash
187+
CODE_REVIEW_POSTGRES_DSN='postgresql://review_agent:<password>@127.0.0.1:5432/code_reviews' \
188+
uv run --project examples/skills_code_review_agent --extra postgresql --with-editable . \
189+
python examples/skills_code_review_agent/tests/run_postgres_tests.py
190+
```
191+
192+
该脚本验证 schema 初始化、task/report 往返、幂等保存、规范化明细查询、缓存查询、失败审计和落库前脱敏。只应对专用测试数据库执行。
193+
168194
使用 `.env` 中的真实模型做完整联调:
169195

170196
```bash
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# 方案设计
22

3-
本原型采用 workflow-shaped、agent-driven 架构。Workflow 负责输入、校验、落库和报告;Agent 决定复用历史证据,还是加载 `code-review` Skill 进入沙箱。Skill 将安全、异步、资源、数据库、测试和敏感信息检查拆成六个脚本,diff 与文件读取均分页返回证据。输入支持 diff、文件列表、Git 工作区和 fixture,并保留 hunk、上下文和行号。检查运行在禁网 Docker workspace;代码只读,外部 diff 仅挂载私有副本,容器采用非 root、只读根文件系统、无 capability 及资源限制。超时进程在容器内终止,输出进入模型前限量脱敏。Filter 按输入类型限制命令、Skill 参数、路径、网络、环境变量和预算,拒绝项不执行。SQLite 通过可替换基类保存任务、输入、执行、拦截、finding、监控和报告。结果按文件、行号、类别去重,低置信项进入 warnings。监控记录总耗时、沙箱耗时、工具调用、拦截、严重级别和异常;失败转人工复核并保留审计记录。
3+
本原型采用 workflow-shaped、agent-driven 架构。Workflow 负责输入、校验、落库和报告;Agent 决定复用历史证据,还是加载 `code-review` Skill 进入沙箱。Skill 将安全、异步、资源、数据库、测试和敏感信息检查拆成六个脚本,diff 与文件读取均分页返回证据。输入支持 diff、文件列表、Git 工作区和 fixture,并保留 hunk、上下文和行号。检查运行在禁网 Docker workspace;代码只读,外部 diff 仅挂载私有副本,容器采用非 root、只读根文件系统、无 capability 及资源限制。超时进程在容器内终止,输出进入模型前限量脱敏。Filter 按输入类型限制命令、Skill 参数、路径、网络、环境变量和预算,拒绝项不执行。可替换存储接口默认使用 SQLite,也可由环境变量切换 PostgreSQL;两种实现均分表保存任务、输入、执行、拦截、finding、监控和报告,PostgreSQL 另有短事务、参数化 SQL、TLS 与连接/语句超时边界,并限制凭据暴露。结果按文件、行号、类别去重,低置信项进入 warnings。监控记录总耗时、沙箱耗时、工具调用、拦截、严重级别和异常;失败转人工复核并保留审计记录。
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
[project]
22
name = "skills-code-review-agent"
33
version = "0.1.0"
4-
description = "A skill-based code review agent using Docker and SQLite"
4+
description = "A skill-based code review agent using Docker and SQL persistence"
55
readme = "README.md"
66
requires-python = ">=3.10"
77
dependencies = []
8+
9+
[project.optional-dependencies]
10+
postgresql = ["psycopg[binary]>=3.2,<4"]

examples/skills_code_review_agent/run_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def build_parser() -> argparse.ArgumentParser:
8484
"--database",
8585
type=Path,
8686
default=None,
87-
help="SQLite path overriding CODE_REVIEW_SQLITE_PATH",
87+
help="SQLite path overriding CODE_REVIEW_SQLITE_PATH (SQLite only)",
8888
)
8989
parser.add_argument(
9090
"--output-dir",

examples/skills_code_review_agent/storage/factory.py

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,70 @@
44
from pathlib import Path
55

66
from .base import BaseReviewStore
7+
from .postgresql import SCHEMA_PATH as POSTGRES_SCHEMA_PATH
8+
from .postgresql import PostgreSQLReviewStore
79
from .sqlite import SCHEMA_PATH
810
from .sqlite import SQLiteReviewStore
911

1012
EXAMPLE_ROOT = Path(__file__).resolve().parent.parent
1113
DEFAULT_SQLITE_PATH = EXAMPLE_ROOT / "storage" / "reviews.sqlite3"
1214

1315

16+
def _configured_path(name: str, default: Path) -> Path:
17+
value = os.getenv(name, "").strip()
18+
path = Path(value) if value else default
19+
if value and not path.is_absolute():
20+
path = EXAMPLE_ROOT / path
21+
return path
22+
23+
24+
def _bounded_integer(name: str, default: int, maximum: int) -> int:
25+
value = os.getenv(name, "").strip()
26+
if not value:
27+
return default
28+
try:
29+
parsed = int(value)
30+
except ValueError as error:
31+
raise ValueError(f"{name} must be an integer") from error
32+
if parsed < 1 or parsed > maximum:
33+
raise ValueError(f"{name} must be between 1 and {maximum}")
34+
return parsed
35+
36+
1437
def create_review_store(database_path: Path | None = None) -> BaseReviewStore:
1538
"""Create the configured persistence implementation.
1639
17-
An explicit CLI path overrides ``CODE_REVIEW_SQLITE_PATH``. The backend is
18-
selected with ``CODE_REVIEW_STORAGE_BACKEND`` and currently supports only
19-
``sqlite``.
40+
An explicit CLI path overrides ``CODE_REVIEW_SQLITE_PATH``. PostgreSQL is
41+
selected entirely through environment configuration so a DSN never appears
42+
in command-line process listings.
2043
"""
2144
backend = os.getenv("CODE_REVIEW_STORAGE_BACKEND", "sqlite").strip().lower()
45+
if backend in {"postgres", "postgresql"}:
46+
if database_path is not None:
47+
raise ValueError("--database can only be used with SQLite storage")
48+
return PostgreSQLReviewStore(
49+
os.getenv("CODE_REVIEW_POSTGRES_DSN", ""),
50+
schema_path=_configured_path(
51+
"CODE_REVIEW_POSTGRES_SCHEMA_PATH",
52+
POSTGRES_SCHEMA_PATH,
53+
),
54+
connect_timeout_seconds=_bounded_integer(
55+
"CODE_REVIEW_POSTGRES_CONNECT_TIMEOUT_SECONDS",
56+
5,
57+
30,
58+
),
59+
statement_timeout_seconds=_bounded_integer(
60+
"CODE_REVIEW_POSTGRES_STATEMENT_TIMEOUT_SECONDS",
61+
15,
62+
60,
63+
),
64+
)
2265
if backend != "sqlite":
2366
raise ValueError(f"Unsupported storage backend: {backend}")
2467

2568
if database_path is not None:
2669
sqlite_path = database_path
2770
else:
28-
configured_path = os.getenv("CODE_REVIEW_SQLITE_PATH", "").strip()
29-
sqlite_path = Path(configured_path) if configured_path else DEFAULT_SQLITE_PATH
30-
if configured_path and not sqlite_path.is_absolute():
31-
sqlite_path = EXAMPLE_ROOT / sqlite_path
32-
configured_schema = os.getenv("CODE_REVIEW_SQLITE_SCHEMA_PATH", "").strip()
33-
schema_path = Path(configured_schema) if configured_schema else SCHEMA_PATH
34-
if configured_schema and not schema_path.is_absolute():
35-
schema_path = EXAMPLE_ROOT / schema_path
71+
sqlite_path = _configured_path("CODE_REVIEW_SQLITE_PATH", DEFAULT_SQLITE_PATH)
72+
schema_path = _configured_path("CODE_REVIEW_SQLITE_SCHEMA_PATH", SCHEMA_PATH)
3673
return SQLiteReviewStore(sqlite_path, schema_path=schema_path)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
CREATE TABLE IF NOT EXISTS public.review_tasks (
2+
task_id TEXT PRIMARY KEY,
3+
created_at TEXT NOT NULL,
4+
completed_at TEXT NOT NULL,
5+
status TEXT NOT NULL,
6+
repository TEXT NOT NULL,
7+
scope TEXT NOT NULL,
8+
conclusion TEXT NOT NULL
9+
);
10+
11+
CREATE TABLE IF NOT EXISTS public.review_inputs (
12+
task_id TEXT PRIMARY KEY REFERENCES public.review_tasks(task_id) ON DELETE CASCADE,
13+
kind TEXT NOT NULL,
14+
source TEXT NOT NULL,
15+
digest TEXT NOT NULL,
16+
review_profile TEXT NOT NULL DEFAULT 'legacy',
17+
file_count INTEGER NOT NULL,
18+
hunk_count INTEGER NOT NULL,
19+
added_lines INTEGER NOT NULL,
20+
removed_lines INTEGER NOT NULL,
21+
files_json JSONB NOT NULL,
22+
redacted_preview TEXT NOT NULL
23+
);
24+
25+
ALTER TABLE public.review_inputs
26+
ADD COLUMN IF NOT EXISTS review_profile TEXT NOT NULL DEFAULT 'legacy';
27+
28+
CREATE TABLE IF NOT EXISTS public.sandbox_runs (
29+
run_id TEXT PRIMARY KEY,
30+
task_id TEXT NOT NULL REFERENCES public.review_tasks(task_id) ON DELETE CASCADE,
31+
command TEXT NOT NULL,
32+
status TEXT NOT NULL,
33+
duration_ms DOUBLE PRECISION NOT NULL,
34+
exit_code INTEGER,
35+
timed_out BOOLEAN NOT NULL,
36+
output_truncated BOOLEAN NOT NULL,
37+
stdout_summary TEXT NOT NULL,
38+
stderr_summary TEXT NOT NULL,
39+
error_type TEXT
40+
);
41+
42+
CREATE TABLE IF NOT EXISTS public.filter_decisions (
43+
decision_id TEXT PRIMARY KEY,
44+
task_id TEXT NOT NULL REFERENCES public.review_tasks(task_id) ON DELETE CASCADE,
45+
command TEXT NOT NULL,
46+
decision TEXT NOT NULL,
47+
reason TEXT NOT NULL,
48+
created_at TEXT NOT NULL
49+
);
50+
51+
CREATE TABLE IF NOT EXISTS public.findings (
52+
finding_id TEXT PRIMARY KEY,
53+
task_id TEXT NOT NULL REFERENCES public.review_tasks(task_id) ON DELETE CASCADE,
54+
bucket TEXT NOT NULL,
55+
severity TEXT NOT NULL,
56+
category TEXT NOT NULL,
57+
file TEXT NOT NULL,
58+
line INTEGER,
59+
title TEXT NOT NULL,
60+
evidence TEXT NOT NULL,
61+
recommendation TEXT NOT NULL,
62+
confidence DOUBLE PRECISION NOT NULL,
63+
source TEXT NOT NULL,
64+
UNIQUE(task_id, bucket, file, line, category)
65+
);
66+
67+
CREATE TABLE IF NOT EXISTS public.monitoring_summaries (
68+
task_id TEXT PRIMARY KEY REFERENCES public.review_tasks(task_id) ON DELETE CASCADE,
69+
total_duration_ms DOUBLE PRECISION NOT NULL,
70+
sandbox_duration_ms DOUBLE PRECISION NOT NULL,
71+
tool_call_count INTEGER NOT NULL,
72+
blocked_count INTEGER NOT NULL,
73+
finding_count INTEGER NOT NULL,
74+
severity_distribution_json JSONB NOT NULL,
75+
exception_distribution_json JSONB NOT NULL
76+
);
77+
78+
CREATE TABLE IF NOT EXISTS public.review_reports (
79+
task_id TEXT PRIMARY KEY REFERENCES public.review_tasks(task_id) ON DELETE CASCADE,
80+
report_json JSONB NOT NULL
81+
);
82+
83+
CREATE INDEX IF NOT EXISTS idx_review_inputs_digest_profile
84+
ON public.review_inputs(digest, review_profile);
85+
CREATE UNIQUE INDEX IF NOT EXISTS idx_findings_unique_issue
86+
ON public.findings(task_id, file, COALESCE(line, -1), category);
87+
CREATE INDEX IF NOT EXISTS idx_sandbox_runs_task_id
88+
ON public.sandbox_runs(task_id);
89+
CREATE INDEX IF NOT EXISTS idx_filter_decisions_task_id
90+
ON public.filter_decisions(task_id);

0 commit comments

Comments
 (0)