Compare commits

...

12 Commits

Author SHA1 Message Date
q792602257 6ade6e8fa9 bugfix 2026-06-22 08:55:57 +08:00
q792602257 dbb87823e8 bugfix 2026-06-20 19:16:44 +08:00
q792602257 db3d030169 TTY 2026-06-18 22:56:52 +08:00
q792602257 3d831650b7 dockerfile,nodejs version bump 2026-06-15 11:25:44 +08:00
q792602257 cfb75c30cf WEb fix 2026-06-15 11:13:25 +08:00
q792602257 c6007c710d Web配置ACP 2026-06-15 09:42:51 +08:00
q792602257 4c167b30e7 Web优化 4 2026-06-12 14:45:22 +08:00
q792602257 abcae3d963 Web优化 3 2026-06-12 14:44:39 +08:00
q792602257 61a1a4d368 Web优化 2 2026-06-12 14:36:41 +08:00
q792602257 7e9df04bf8 Web优化 1 2026-06-12 14:01:55 +08:00
q792602257 17ca339c66 ESLint fix 2026-06-12 13:41:23 +08:00
q792602257 522e88a6a2 前端优化 2026-06-12 13:36:39 +08:00
392 changed files with 43781 additions and 2059 deletions
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Format staged Go files.
STAGED_GO=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' || true)
if [ -n "$STAGED_GO" ]; then
echo "$STAGED_GO" | while IFS= read -r f; do
gofmt -w "$f"
git add "$f"
done
fi
# Lint and format staged web source files. Paths are repo-root relative; we
# strip the leading "web/" so tooling runs inside the web workspace.
cd web
STAGED_WEB_SRC=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^web/src/.*\.(ts|tsx|vue|js|jsx|mjs)$' || true)
if [ -n "$STAGED_WEB_SRC" ]; then
echo "$STAGED_WEB_SRC" | sed 's|^web/||' | while IFS= read -r f; do
pnpm exec eslint --fix "$f"
pnpm exec prettier --write "$f"
git add "../web/$f"
done
fi
STAGED_WEB_FMT=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^web/.*\.(css|scss|json|md|yaml|yml)$' || true)
if [ -n "$STAGED_WEB_FMT" ]; then
echo "$STAGED_WEB_FMT" | sed 's|^web/||' | while IFS= read -r f; do
pnpm exec prettier --write "$f"
git add "../web/$f"
done
fi
+10 -3
View File
@@ -22,14 +22,21 @@ COPY --from=web /web/dist/. ./cmd/server/web/dist/
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/server ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/server ./cmd/server
# ─── Stage 3: runtime ─── # ─── Stage 3: runtime ───
FROM registry.jerryyan.top/library/alpine:3.20 FROM registry.jerryyan.top/library/alpine:3.24
# nodejs/npm 供安装 ACP agent CLI(claude-code-acp / codex / gemini-cli 等, # nodejs/npm 供安装 ACP agent CLI(claude-code-acp / codex / gemini-cli 等,
# 均为 npm 包)。npm 全局 prefix 指到 /data/npm-global(持久卷),容器内 # 均为 npm 包)。npm 全局 prefix 指到 /data/npm-global(持久卷),容器内
# `npm i -g xxx` 一次安装,重建镜像后无需重装;binary_path 填 # `npm i -g xxx` 一次安装,重建镜像后无需重装;binary_path 填
# /data/npm-global/bin/<cli> 即可。 # /data/npm-global/bin/<cli> 即可。
# bubblewrap + util-linux(prlimit/setpriv) 供 acp.sandbox mode=uid/container 使用:
# - prlimit 给 agent 子进程套 RLIMIT_AS/NPROC/CPU/FSIZE(sandbox_uid_linux.go)
# - bubblewrap 作 rootless 隔离(USER app 无 CAP_SYS_ADMIN,bwrap 走 user namespace)
# mode=none(默认)时这些工具不被调用,安装它们对默认部署无副作用。
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata git openssh-client nodejs npm && \ && apk add --no-cache ca-certificates tzdata git openssh-client nodejs npm \
addgroup -S app && adduser -S app -G app bubblewrap util-linux && \
addgroup -S app && adduser -S app -G app && \
addgroup -S sandbox && \
for i in 0 1 2 3 4 5 6 7 8 9; do adduser -S -D -H -G sandbox "sbx$i" 2>/dev/null || true; done
WORKDIR /app WORKDIR /app
COPY --from=go /out/server /app/server COPY --from=go /out/server /app/server
COPY migrations /app/migrations COPY migrations /app/migrations
+179 -1
View File
@@ -20,6 +20,45 @@ master_key: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
http: http:
# HTTP 监听地址,格式 host:port,留空 host 表示监听所有网卡 # HTTP 监听地址,格式 host:port,留空 host 表示监听所有网卡
addr: ":8080" addr: ":8080"
# 可选 TLS:enabled=true 时以 HTTPS 提供服务,读取 cert/key 文件。
tls:
enabled: false
cert_file: ""
key_file: ""
# 跨域中间件:默认 off,避免误配破坏 SPA + WS 握手。开启时填 origin 白名单。
cors:
enabled: false
allowed_origins: [] # 如 ["https://app.example.com"]
allowed_methods: [] # 留空 = GET,POST,PUT,PATCH,DELETE,OPTIONS
allowed_headers: [] # 留空 = Authorization,Content-Type,X-Client-Request-ID
allow_credentials: true
max_age_seconds: 600
# 安全响应头:默认 on(透传安全)。HSTS 仅在 tls.enabled 时下发。
security:
enabled: true
hsts_max_age_seconds: 31536000
frame_options: "DENY"
referrer_policy: "no-referrer"
content_security_policy: "" # 默认不下发 CSP(可能破坏 SPA/WS);按需显式配置
# secret 加密 key provider:env(默认,APP_MASTER_KEY=version 1)| vault | kms。
# 轮换时把新版本 key 暂存到 APP_MASTER_KEY_V<n>,再调 POST /api/v1/admin/security/rotate-key。
crypto:
provider: env
vault:
address: ""
token: ""
key_name: ""
kms:
key_id: ""
region: ""
# user 模块:登录暴力破解节流(key = ip + 小写 email,固定窗口计数)。
user:
login_throttle:
enabled: true
max_failures: 10
window: 15m
db: db:
# PostgreSQL DSN(必填) # PostgreSQL DSN(必填)
@@ -45,6 +84,15 @@ workspace:
git: git:
binary: "git" binary: "git"
# VCS / PR 集成(v1 支持 Gitea)。change_requests 与 done 阶段网关会使用这里的配置。
vcs:
require_ci_pass: false
gitea:
base_url: "https://git.example.com"
token: "" # 平台级 PAT;也可用 APP_VCS_GITEA_TOKEN 注入
timeout: 15s
merge_method: "merge" # merge / squash / rebase,取决于 Gitea 实例支持
# Chat 模块(多模态对话 + LLM endpoints) # Chat 模块(多模态对话 + LLM endpoints)
chat: chat:
attachment: attachment:
@@ -93,7 +141,9 @@ jobs:
job_reaper: job_reaper:
enabled: true enabled: true
interval: 1m interval: 1m
lease_timeout: 5m # 须 > orchestrator.turn_timeout:编排器步骤的 handler 超时 = lease_timeout*9/10,
# 必须容纳一次完整 agent 回合(默认 turn_timeout 10m → lease 15m,handler 13.5m)。
lease_timeout: 15m
job_purge: job_purge:
enabled: true enabled: true
interval: 24h interval: 24h
@@ -106,6 +156,18 @@ jobs:
enabled: true enabled: true
interval: 24h interval: 24h
retention: 168h # 7 days retention: 168h # 7 days
# 审计日志保留期清理(observability roadmap §11):删除早于 retention 的 audit_logs。
audit_retention:
enabled: true
interval: 24h
retention: 2160h # 90 天
# Prometheus /metrics 端点(observability roadmap §11)。
# 暴露成本/会话数:auth_token 非空时要求 Authorization: Bearer <token> 或 ?token=<token>;
# 为空仅适用于绑定内网 / 由反向代理鉴权的部署。
metrics:
enabled: true
auth_token: ""
# 通知通道配置 # 通知通道配置
notify: notify:
@@ -121,6 +183,15 @@ notify:
# - worktree.prune_pending # - worktree.prune_pending
# - webhook.dead_letter # - webhook.dead_letter
# - acp.session_crashed # - acp.session_crashed
email:
enabled: false
smtp_host: ""
port: 587
from: ""
username: ""
password: ""
im:
enabled: false
# ACP module: agent subprocess pool + WebSocket fanout + event retention # ACP module: agent subprocess pool + WebSocket fanout + event retention
acp: acp:
@@ -138,6 +209,46 @@ acp:
event_truncate_field_bytes: 4096 event_truncate_field_bytes: 4096
event_retention_days: 7 event_retention_days: 7
shutdown_grace: 30s shutdown_grace: 30s
permission_timeout: 5m
brief_token_budget: 6000
default_project_budget_usd: 0
# per-session 执行沙箱(mode=none 默认;uid/container 仅 Linux)。
# none : 不隔离(Windows 开发 / CI / 任何非 Linux 机器)
# uid : 降权到低权 UID + setrlimit(+ egress 代理 env)
# container : rootless 容器(UID + 文件系统屏蔽 + 网络策略 + cgroup 限额)
sandbox:
mode: none
base_uid: 0 # 低权 UID 基址;per-session uid = base + offset
proxy_url: "" # 注入子进程的 HTTP(S)_PROXY(egress 白名单代理)
address_space_bytes: 0 # RLIMIT_AS / --memory(0 = 不限)
nproc: 0 # RLIMIT_NPROC(0 = 不限)
cpu_seconds: 0 # RLIMIT_CPU(0 = 不限)
pids: 0 # 容器 --pids-limit(0 = 取 nproc)
disk_bytes: 0 # RLIMIT_FSIZE(0 = 不限)
# Orchestrator / Planner module configuration(per-requirement run 状态机)
orchestrator:
enabled: true
max_attempts_per_phase: 3 # 单阶段 step 在 dead-letter 前的最大尝试次数
turn_timeout: 10m # 单回合(SendPromptAndWait)超时,须 < jobs.job_reaper.lease_timeout
max_depth: 3 # request_subtask 递归深度上限
fan_out_limit: 3 # 单 step 并发子任务上限
# 阶段→默认 agent-kind 名映射(run.config 未覆盖时用)。app 层按名解析为 agent_kind_id。
# 为空时 StartRun 必须在 phase_agent_kinds 入参里显式提供每阶段 agent-kind id。
phase_agent_kinds:
# planning: "codex"
# prototyping: "codex"
# auditing: "codex"
# implementing: "codex"
# reviewing: "codex"
# done: "codex"
# 任务分解并行调度器(autonomy roadmap §10):拓扑选取就绪子任务并扇出到并行 ACP session。
# 注册为 jobs 周期 RunnerFn(单实例、单 tick,跨重启存活)。默认关闭,须显式开启。
scheduler:
enabled: false
interval: 30s # 调度 tick 周期
max_fanout_per_tick: 8 # 每 project 每 tick 最多派发的 session 数
max_concurrent_per_project: 4 # project 内活跃 session 软上限(<=0 不预检,仅靠 acp 硬上限)
# MCP (Model Context Protocol) module configuration # MCP (Model Context Protocol) module configuration
mcp: mcp:
@@ -175,3 +286,70 @@ run:
- PATHEXT - PATHEXT
- ComSpec - ComSpec
- NUMBER_OF_PROCESSORS - NUMBER_OF_PROCESSORS
# 一次性 exec 原语(MCP: run_command / run_tests)。默认保持命令白名单非空;
# 生产环境不要设成 [],否则 system token 会得到任意命令执行能力。
exec:
default_timeout: 60s
max_timeout: 600s
max_output_bytes: 1048576
allowed_commands:
- go
- npm
- pnpm
- yarn
- node
- python
- python3
- pytest
- uv
- cargo
- rustc
- make
- cmake
- ctest
- git
# 管理员网页终端。仅 admin 可用;命令在 server 宿主进程/运行容器内执行。
terminal:
enabled: true
shell: "/bin/bash" # Windows 开发环境可用 APP_TERMINAL_SHELL 覆盖为 powershell.exe
max_sessions: 5
kill_grace: 5s
env_whitelist:
- PATH
- HOME
- LANG
- LC_ALL
- TZ
- USER
- SHELL
- TERM
- NPM_CONFIG_PREFIX
- NODE_PATH
- NODE_ENV
- SystemRoot
- TEMP
- TMP
- USERPROFILE
- APPDATA
- LOCALAPPDATA
- PATHEXT
- ComSpec
- NUMBER_OF_PROCESSORS
# 代码索引 / 项目记忆。embedding_endpoint_id 为空时自动降级:向量检索不可用,
# memory_search 走 keyword/tag 回退,grep/list 文件类工具仍可用。
code_index:
enabled: true
embedding_endpoint_id: ""
embedding_model: "text-embedding-3-small"
chunk_window_lines: 60
chunk_overlap_lines: 10
max_file_bytes: 524288
search_top_k_cap: 50
grep_max_matches: 200
grep_max_file_bytes: 1048576
# 自动文档 / PR 摘要。默认关闭,避免每次提交都调用 LLM 产生成本。
docs:
enabled: false
+32
View File
@@ -25,6 +25,14 @@ services:
APP_MASTER_KEY: "${APP_MASTER_KEY}" APP_MASTER_KEY: "${APP_MASTER_KEY}"
APP_BOOTSTRAP_ADMIN_EMAIL: "${APP_BOOTSTRAP_ADMIN_EMAIL:-admin@local}" APP_BOOTSTRAP_ADMIN_EMAIL: "${APP_BOOTSTRAP_ADMIN_EMAIL:-admin@local}"
APP_BOOTSTRAP_ADMIN_PASSWORD: "${APP_BOOTSTRAP_ADMIN_PASSWORD}" APP_BOOTSTRAP_ADMIN_PASSWORD: "${APP_BOOTSTRAP_ADMIN_PASSWORD}"
# secret key provider(env|vault|kms)。轮换时把新版本 key 暂存到
# APP_MASTER_KEY_V2 再调 POST /api/v1/admin/security/rotate-key。
APP_CRYPTO_PROVIDER: "${APP_CRYPTO_PROVIDER:-env}"
# per-session 沙箱:默认 none。Linux 生产可设 uid/container,并配 base_uid /
# egress proxy。需要 egress 白名单时取消下方 forward-proxy 注释并指向它。
APP_ACP_SANDBOX_MODE: "${APP_ACP_SANDBOX_MODE:-none}"
APP_ACP_SANDBOX_BASE_UID: "${APP_ACP_SANDBOX_BASE_UID:-0}"
APP_ACP_SANDBOX_PROXY_URL: "${APP_ACP_SANDBOX_PROXY_URL:-}"
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
@@ -39,6 +47,30 @@ services:
retries: 6 retries: 6
start_period: 30s start_period: 30s
# ── Egress allowlist forward proxy (opt-in for acp.sandbox egress control) ──
# Stand up a forward proxy whose only outbound is the allowlisted hosts, put
# the app (and, in container mode, the agent containers) on the egress-net,
# and set APP_ACP_SANDBOX_PROXY_URL=http://forward-proxy:8888. Provide your own
# tinyproxy.conf with an Allow/ConnectPort allowlist. Commented out so the
# default `docker compose up` works without extra config.
#
# forward-proxy:
# image: registry.jerryyan.top/library/tinyproxy:latest
# restart: unless-stopped
# volumes:
# - ./deploy/tinyproxy.conf:/etc/tinyproxy/tinyproxy.conf:ro
# networks:
# - egress-net
# - default
volumes: volumes:
acw-pg-data: acw-pg-data:
acw-data: acw-data:
# networks:
# egress-net:
# # internal:true makes this network have NO direct internet route; only the
# # forward-proxy (multi-homed) can reach allowlisted hosts. Attach agent
# # sandbox containers here in mode=container so a non-cooperating binary
# # cannot bypass the proxy.
# internal: true
+10 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/alexedwards/argon2id v1.0.0 github.com/alexedwards/argon2id v1.0.0
github.com/anthropics/anthropic-sdk-go v1.38.0 github.com/anthropics/anthropic-sdk-go v1.38.0
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/felixge/httpsnoop v1.0.4 github.com/felixge/httpsnoop v1.0.4
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
@@ -16,7 +17,9 @@ require (
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/openai/openai-go v1.12.0 github.com/openai/openai-go v1.12.0
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
github.com/pgvector/pgvector-go v0.4.0
github.com/pkoukk/tiktoken-go v0.1.8 github.com/pkoukk/tiktoken-go v0.1.8
github.com/prometheus/client_golang v1.23.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
@@ -32,6 +35,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect github.com/buger/jsonparser v1.1.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -75,10 +79,14 @@ require (
github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
@@ -104,6 +112,7 @@ require (
go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.49.0 // indirect
@@ -112,6 +121,6 @@ require (
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/grpc v1.74.2 // indirect google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.7 // indirect google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+22 -2
View File
@@ -18,6 +18,8 @@ github.com/anthropics/anthropic-sdk-go v1.38.0 h1:bA4DcK+91gorIX+5VTONnynyt9LRU4
github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI= github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -116,6 +118,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
@@ -148,6 +152,8 @@ github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
@@ -159,6 +165,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZSgW5v4=
github.com/pgvector/pgvector-go v0.4.0/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
@@ -168,6 +176,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
@@ -238,6 +254,10 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -303,8 +323,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+9 -6
View File
@@ -1,9 +1,11 @@
// agent_home.go 实现 agent CLI 受管 home 目录的物化与重定向 env 注入。 // agent_home.go 实现 agent CLI 受管 home 目录的物化与重定向 env 注入。
// //
// 每个 AgentKind 一个受管 home:<agent_homes_dir>/<agent_kind_id>/。spawn 前把 // 受管 home 按 user + agent-kind 维度隔离:<agent_homes_dir>/<user_id>/<agent_kind_id>/。
// DB 中的配置文件解密落盘(全量覆写受管文件,不清空目录 —— agent 运行期写入的 // 早期实现仅按 agent-kind 维度(多个用户共享同一 home 与缓存凭据),沙箱化要求
// 缓存 / 凭据得以保留),再按 client_type 注入配置目录重定向变量。受管 home 位于 // per-USER home,故 home 路径以 user_id 为一级目录、agent_kind_id 为二级目录。
// DataDir 下,容器场景随持久卷存活。 // spawn 前把 DB 中的配置文件解密落盘(全量覆写受管文件,不清空目录 —— agent 运行期
// 写入的缓存 / 凭据得以保留),再按 client_type 注入配置目录重定向变量。受管 home 位于
// DataDir 下,容器场景随持久卷存活。旧的 per-kind 目录无害遗留,下次 spawn 用新路径。
package acp package acp
import ( import (
@@ -16,9 +18,10 @@ import (
) )
// materializeAgentHome 创建受管 home 并物化该 AgentKind 的全部配置文件。 // materializeAgentHome 创建受管 home 并物化该 AgentKind 的全部配置文件。
// home 按 user + agent-kind 隔离:<AgentHomesDir>/<user_id>/<kind_id>。
// 返回 home 绝对路径。配置文件为空时仍创建目录(agent 需要可写 home)。 // 返回 home 绝对路径。配置文件为空时仍创建目录(agent 需要可写 home)。
func (s *Supervisor) materializeAgentHome(ctx context.Context, kind *AgentKind) (string, error) { func (s *Supervisor) materializeAgentHome(ctx context.Context, sess *Session, kind *AgentKind) (string, error) {
home, err := filepath.Abs(filepath.Join(s.cfg.AgentHomesDir, kind.ID.String())) home, err := filepath.Abs(filepath.Join(s.cfg.AgentHomesDir, sess.UserID.String(), kind.ID.String()))
if err != nil { if err != nil {
return "", fmt.Errorf("resolve agent home: %w", err) return "", fmt.Errorf("resolve agent home: %w", err)
} }
+39 -4
View File
@@ -41,16 +41,50 @@ func TestMaterializeAgentHome(t *testing.T) {
cfg: SupervisorConfig{AgentHomesDir: root}, cfg: SupervisorConfig{AgentHomesDir: root},
} }
home, err := s.materializeAgentHome(context.Background(), kind) sess := &Session{ID: uuid.New(), UserID: uuid.New()}
home, err := s.materializeAgentHome(context.Background(), sess, kind)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, filepath.Join(root, kind.ID.String()), home) // per-user home:<root>/<user_id>/<kind_id>
assert.Equal(t, filepath.Join(root, sess.UserID.String(), kind.ID.String()), home)
got, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml")) got, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml"))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, content, got) assert.Equal(t, content, got)
// 二次物化幂等(覆写) // 二次物化幂等(覆写)
_, err = s.materializeAgentHome(context.Background(), kind) _, err = s.materializeAgentHome(context.Background(), sess, kind)
require.NoError(t, err)
}
func TestMaterializeAgentHome_PerUserIsolation(t *testing.T) {
t.Parallel()
enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef"))
require.NoError(t, err)
kind := &AgentKind{ID: uuid.New(), ClientType: ClientCodex}
encrypted, err := enc.Encrypt([]byte("x = 1"))
require.NoError(t, err)
root := t.TempDir()
s := &Supervisor{
repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: ".codex/config.toml", EncryptedContent: encrypted}}},
crypto: enc,
cfg: SupervisorConfig{AgentHomesDir: root},
}
// 同一 agent-kind、两个不同用户 → 两个互不相同的 home 目录。
sessA := &Session{ID: uuid.New(), UserID: uuid.New()}
sessB := &Session{ID: uuid.New(), UserID: uuid.New()}
homeA, err := s.materializeAgentHome(context.Background(), sessA, kind)
require.NoError(t, err)
homeB, err := s.materializeAgentHome(context.Background(), sessB, kind)
require.NoError(t, err)
assert.NotEqual(t, homeA, homeB)
// 两个 home 各自物化了配置文件。
_, err = os.Stat(filepath.Join(homeA, ".codex", "config.toml"))
require.NoError(t, err)
_, err = os.Stat(filepath.Join(homeB, ".codex", "config.toml"))
require.NoError(t, err) require.NoError(t, err)
} }
@@ -67,7 +101,8 @@ func TestMaterializeAgentHome_RejectsEscape(t *testing.T) {
crypto: enc, crypto: enc,
cfg: SupervisorConfig{AgentHomesDir: t.TempDir()}, cfg: SupervisorConfig{AgentHomesDir: t.TempDir()},
} }
_, err = s.materializeAgentHome(context.Background(), kind) sess := &Session{ID: uuid.New(), UserID: uuid.New()}
_, err = s.materializeAgentHome(context.Background(), sess, kind)
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "escapes agent home") assert.Contains(t, err.Error(), "escapes agent home")
} }
+77
View File
@@ -15,6 +15,13 @@ type agentKindService struct {
repo Repository repo Repository
enc *crypto.Encryptor enc *crypto.Encryptor
audit audit.Recorder audit audit.Recorder
modelCheck ModelValidator // 校验 model_id 引用一个启用的 llm_model;可为 nil(跳过校验)
}
// ModelValidator 校验某 model_id 引用一个存在且启用的 llm_model。装配期由 app.go
// 注入一个委托给 chat 的 adapter,避免 acp 构造期直接依赖 chat。
type ModelValidator interface {
IsEnabledModel(ctx context.Context, modelID uuid.UUID) (bool, error)
} }
// NewAgentKindService 构造 AgentKindService。 // NewAgentKindService 构造 AgentKindService。
@@ -22,6 +29,9 @@ func NewAgentKindService(repo Repository, enc *crypto.Encryptor, rec audit.Recor
return &agentKindService{repo: repo, enc: enc, audit: rec} return &agentKindService{repo: repo, enc: enc, audit: rec}
} }
// SetModelValidator 注入 model 校验器(装配期回填)。
func (s *agentKindService) SetModelValidator(v ModelValidator) { s.modelCheck = v }
func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error) { func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error) {
if !c.IsAdmin { if !c.IsAdmin {
return nil, errs.New(errs.CodeForbidden, "admin only") return nil, errs.New(errs.CodeForbidden, "admin only")
@@ -52,6 +62,12 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
if allowlist == nil { if allowlist == nil {
allowlist = []string{} allowlist = []string{}
} }
if err := s.validateBudget(in.MaxCostUSD, in.MaxTokens, in.MaxWallClockSeconds); err != nil {
return nil, err
}
if err := s.validateModel(ctx, in.ModelID); err != nil {
return nil, err
}
k := &AgentKind{ k := &AgentKind{
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description, ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled, BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
@@ -59,6 +75,10 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
ClientType: clientType, ClientType: clientType,
EncryptedMCPServers: encryptedMCP, EncryptedMCPServers: encryptedMCP,
CreatedBy: c.UserID, CreatedBy: c.UserID,
ModelID: in.ModelID,
MaxCostUSD: in.MaxCostUSD,
MaxTokens: in.MaxTokens,
MaxWallClockSeconds: in.MaxWallClockSeconds,
} }
out, err := s.repo.CreateAgentKind(ctx, k) out, err := s.repo.CreateAgentKind(ctx, k)
if err != nil { if err != nil {
@@ -146,6 +166,34 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i
cur.Enabled = *in.Enabled cur.Enabled = *in.Enabled
changed["enabled"] = *in.Enabled changed["enabled"] = *in.Enabled
} }
if in.SetModelID {
if err := s.validateModel(ctx, in.ModelID); err != nil {
return nil, err
}
cur.ModelID = in.ModelID
changed["model_id"] = "<changed>"
}
if in.SetMaxCostUSD {
if err := s.validateBudget(in.MaxCostUSD, nil, nil); err != nil {
return nil, err
}
cur.MaxCostUSD = in.MaxCostUSD
changed["max_cost_usd"] = "<changed>"
}
if in.SetMaxTokens {
if err := s.validateBudget(nil, in.MaxTokens, nil); err != nil {
return nil, err
}
cur.MaxTokens = in.MaxTokens
changed["max_tokens"] = "<changed>"
}
if in.SetMaxWallClock {
if err := s.validateBudget(nil, nil, in.MaxWallClockSeconds); err != nil {
return nil, err
}
cur.MaxWallClockSeconds = in.MaxWallClockSeconds
changed["max_wall_clock_seconds"] = "<changed>"
}
if len(changed) == 0 { if len(changed) == 0 {
return cur, nil return cur, nil
} }
@@ -174,6 +222,35 @@ func (s *agentKindService) Delete(ctx context.Context, c Caller, id uuid.UUID) e
return nil return nil
} }
// validateBudget 校验预算上限:非空值必须为正。
func (s *agentKindService) validateBudget(maxCost *float64, maxTokens *int64, maxWall *int32) error {
if maxCost != nil && *maxCost <= 0 {
return errs.New(errs.CodeInvalidInput, "max_cost_usd must be positive")
}
if maxTokens != nil && *maxTokens <= 0 {
return errs.New(errs.CodeInvalidInput, "max_tokens must be positive")
}
if maxWall != nil && *maxWall <= 0 {
return errs.New(errs.CodeInvalidInput, "max_wall_clock_seconds must be positive")
}
return nil
}
// validateModel 校验 model_id 引用一个启用的 llm_model(modelCheck 为 nil 时跳过)。
func (s *agentKindService) validateModel(ctx context.Context, modelID *uuid.UUID) error {
if modelID == nil || s.modelCheck == nil {
return nil
}
ok, err := s.modelCheck.IsEnabledModel(ctx, *modelID)
if err != nil {
return err
}
if !ok {
return errs.New(errs.CodeInvalidInput, "model_id does not reference an enabled model")
}
return nil
}
func (s *agentKindService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { func (s *agentKindService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
if s.audit == nil { if s.audit == nil {
return return
+69
View File
@@ -116,6 +116,15 @@ func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.S
func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a") panic("n/a")
} }
func (f *fakeAgentKindRepo) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) ResetSessionForResume(context.Context, uuid.UUID) error {
panic("n/a")
}
func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) { func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) {
panic("n/a") panic("n/a")
} }
@@ -125,6 +134,9 @@ func (f *fakeAgentKindRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp
func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
panic("n/a") panic("n/a")
} }
func (f *fakeAgentKindRepo) UpdateSessionSandboxMode(context.Context, uuid.UUID, string) error {
panic("n/a")
}
func (f *fakeAgentKindRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error { func (f *fakeAgentKindRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error {
panic("n/a") panic("n/a")
} }
@@ -155,6 +167,63 @@ func (f *fakeAgentKindRepo) ListEventsSince(context.Context, uuid.UUID, int64, i
func (f *fakeAgentKindRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) { func (f *fakeAgentKindRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) {
panic("n/a") panic("n/a")
} }
func (f *fakeAgentKindRepo) InsertSessionUsage(context.Context, *acp.SessionUsageRecord) (bool, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) AddSessionUsageTotals(context.Context, uuid.UUID, int64, int64, int64, float64) (int64, int64, int64, float64, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) SumProjectCostUSD(context.Context, uuid.UUID, time.Time) (float64, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) MarkSessionTerminatedReason(context.Context, uuid.UUID, string) error {
panic("n/a")
}
func (f *fakeAgentKindRepo) ListSessionsForReaper(context.Context, time.Time, time.Time) ([]acp.ReaperSession, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) ListSessionUsage(context.Context, uuid.UUID, int32) ([]*acp.SessionUsageRecord, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) SummarizeAcpUsageByUser(context.Context, time.Time, time.Time) ([]acp.AcpUsageUserRow, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) SummarizeAcpUsageByModel(context.Context, time.Time, time.Time) ([]acp.AcpUsageModelRow, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) SummarizeAcpUsageByDay(context.Context, time.Time, time.Time) ([]acp.AcpUsageDayRow, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) SessionRollup(context.Context, acp.DashboardFilter) (acp.SessionRollup, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) SessionsByDay(context.Context, acp.DashboardFilter) ([]acp.SessionDayRow, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) SessionTimeline(context.Context, uuid.UUID) ([]acp.SessionTimelineBucket, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) InsertTurn(context.Context, *acp.Turn) (*acp.Turn, error) { panic("n/a") }
func (f *fakeAgentKindRepo) MarkTurnCompleted(context.Context, uuid.UUID, int, string, int) error {
panic("n/a")
}
func (f *fakeAgentKindRepo) MarkTurnAborted(context.Context, uuid.UUID, int) error { panic("n/a") }
func (f *fakeAgentKindRepo) IncrementTurnUpdateCount(context.Context, uuid.UUID, int) error {
panic("n/a")
}
func (f *fakeAgentKindRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) ListTurnsBySession(context.Context, uuid.UUID) ([]*acp.Turn, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) UpdateSessionLastStopReason(context.Context, uuid.UUID, string) error {
panic("n/a")
}
func (f *fakeAgentKindRepo) PurgeTurnsBefore(context.Context, time.Time) (int64, error) {
panic("n/a")
}
func (f *fakeAgentKindRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error { func (f *fakeAgentKindRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error {
panic("n/a") panic("n/a")
+137
View File
@@ -0,0 +1,137 @@
package acp_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
)
// mountDashboardHandler builds a Handler whose SessionService is backed by the
// in-memory fakeAcpRepo so run-history endpoints can be exercised without a DB.
func mountDashboardHandler(t *testing.T, callerUID uuid.UUID, isAdmin bool, repo *fakeAcpRepo) chi.Router {
t.Helper()
enc := testEncryptor(t)
ss := acp.NewSessionService(repo, nil, nil, nil, nil, nil, nil, nil, nil,
acp.SessionServiceConfig{}, nil)
r := chi.NewRouter()
h := acp.NewHandler(
nil, // akSvc — not exercised
ss,
nil, // sup — not exercised
repo,
nil, // permSvc — not exercised
fakeResolver{uid: callerUID},
fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}},
enc,
acp.WSConfig{},
)
h.Mount(r)
return r
}
func getJSONArray(t *testing.T, r chi.Router, path, token string) (*httptest.ResponseRecorder, []map[string]any) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
var out []map[string]any
if rr.Body.Len() > 0 {
_ = json.Unmarshal(rr.Body.Bytes(), &out)
}
return rr, out
}
func TestDashboardHandler_Unauthenticated_401(t *testing.T) {
t.Parallel()
repo := &fakeAcpRepo{}
r := mountDashboardHandler(t, uuid.New(), false, repo)
rr, _ := doJSON(t, r, "GET", "/api/v1/acp/dashboard", "", nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestDashboardHandler_OwnerScoped(t *testing.T) {
t.Parallel()
uid := uuid.New()
repo := &fakeAcpRepo{
rollupResult: acp.SessionRollup{Total: 2, Succeeded: 2, TotalCostUSD: 0.4},
byDayResult: []acp.SessionDayRow{{Day: time.Now().UTC(), Total: 2, Succeeded: 2, TotalCostUSD: 0.4}},
}
r := mountDashboardHandler(t, uid, false, repo)
rr, body := doJSON(t, r, "GET", "/api/v1/acp/dashboard", "valid", nil)
require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())
rollup := body["rollup"].(map[string]any)
assert.Equal(t, float64(2), rollup["total"])
assert.Equal(t, float64(1), rollup["success_rate"])
// 非 admin → repo filter scoped to caller.
require.NotNil(t, repo.rollupFilter.UserID)
assert.Equal(t, uid, *repo.rollupFilter.UserID)
}
func TestDashboardHandler_Admin_NotScoped_WithProjectFilter(t *testing.T) {
t.Parallel()
uid := uuid.New()
pid := uuid.New()
repo := &fakeAcpRepo{rollupResult: acp.SessionRollup{Total: 5, Succeeded: 4}}
r := mountDashboardHandler(t, uid, true, repo)
rr, _ := doJSON(t, r, "GET", "/api/v1/acp/dashboard?project_id="+pid.String(), "valid", nil)
require.Equal(t, http.StatusOK, rr.Code)
assert.Nil(t, repo.rollupFilter.UserID) // admin → 全量
require.NotNil(t, repo.rollupFilter.ProjectID)
assert.Equal(t, pid, *repo.rollupFilter.ProjectID)
}
func TestDashboardHandler_BadProjectID_400(t *testing.T) {
t.Parallel()
repo := &fakeAcpRepo{}
r := mountDashboardHandler(t, uuid.New(), true, repo)
rr, _ := doJSON(t, r, "GET", "/api/v1/acp/dashboard?project_id=not-a-uuid", "valid", nil)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestTimelineHandler_NonOwner_NotFound(t *testing.T) {
t.Parallel()
sid := uuid.New()
repo := &fakeAcpRepo{}
repo.sessions = []*acp.Session{{ID: sid, UserID: uuid.New()}}
r := mountDashboardHandler(t, uuid.New(), false, repo)
rr, _ := doJSON(t, r, "GET", "/api/v1/acp/sessions/"+sid.String()+"/timeline", "valid", nil)
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestTimelineHandler_Owner_OK(t *testing.T) {
t.Parallel()
uid := uuid.New()
sid := uuid.New()
repo := &fakeAcpRepo{
timelineResult: []acp.SessionTimelineBucket{
{Direction: "out", RPCKind: "request", Method: "session/prompt", EventCount: 3},
},
}
repo.sessions = []*acp.Session{{ID: sid, UserID: uid}}
r := mountDashboardHandler(t, uid, false, repo)
rr, arr := getJSONArray(t, r, "/api/v1/acp/sessions/"+sid.String()+"/timeline", "valid")
require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())
require.Len(t, arr, 1)
assert.Equal(t, "session/prompt", arr[0]["method"])
assert.Equal(t, "request", arr[0]["kind"])
assert.Equal(t, sid, repo.timelineSID)
}
+242
View File
@@ -118,6 +118,12 @@ type AgentKind struct {
CreatedBy uuid.UUID CreatedBy uuid.UUID
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
// 成本核算:关联的 llm_models 行(价格来源)+ 每 kind 默认预算上限。
// 全部 NULLABLE:未配置 model 时成本按 0/agent 自报;未配置上限时无限制。
ModelID *uuid.UUID
MaxCostUSD *float64
MaxTokens *int64
MaxWallClockSeconds *int32
} }
// ConfigFile 是 AgentKind 维度的 agent CLI 配置文件(如 .claude/settings.json、 // ConfigFile 是 AgentKind 维度的 agent CLI 配置文件(如 .claude/settings.json、
@@ -153,6 +159,191 @@ type Session struct {
LastError *string LastError *string
StartedAt time.Time StartedAt time.Time
EndedAt *time.Time EndedAt *time.Time
// LastStopReason 是 agent 最近一次回合完成时上报的 stopReason 原始字符串
// (nil = 尚无完成的回合)。归一化枚举见 NormalizeStopReason,但落库为原值。
LastStopReason *string
// OrchestratorStepID 非 nil 时表示该 session 由编排器某个 step 驱动;onExit /
// 启动 reaper 据此把崩溃 session 映射回 step 并触发再驱动。manual session 为 nil。
OrchestratorStepID *uuid.UUID
// 成本核算运行时累加器(落库快照)。
PromptTokens int64
CompletionTokens int64
ThinkingTokens int64
TotalCostUSD float64
LastActivityAt time.Time
// 创建时快照的有效预算上限(session 覆盖 > agent-kind 默认 > 全局默认)。
BudgetMaxCostUSD *float64
BudgetMaxTokens *int64
BudgetMaxWallClockSeconds *int32
// TerminatedReason 非 nil 时记录会话被强制终止的原因(budget_*/reaper_*/manual)。
TerminatedReason *string
}
// BudgetCaps 是一次会话的有效预算上限快照。nil 字段 = 该维度无限制。
type BudgetCaps struct {
MaxCostUSD *float64
MaxTokens *int64
MaxWallClockSeconds *int32
}
// BreachReason 标识预算被突破的维度,落 acp_sessions.terminated_reason。
type BreachReason string
const (
BreachCost BreachReason = "budget_cost"
BreachTokens BreachReason = "budget_tokens"
BreachWallClock BreachReason = "budget_wall_clock"
)
// SessionUsageRecord 是 acp_session_usage 表一行(per-turn 账目,镜像 llm_usage)。
type SessionUsageRecord struct {
ID int64
SessionID uuid.UUID
UserID uuid.UUID
ProjectID uuid.UUID
AgentKindID uuid.UUID
ModelID *uuid.UUID
PromptTokens int64
CompletionTokens int64
ThinkingTokens int64
CostUSD float64
SourceEventID *int64
CreatedAt time.Time
}
// AcpUsageUserRow 按用户聚合的 ACP 用量。
type AcpUsageUserRow struct {
UserID uuid.UUID
PromptTokens, CompletionTokens, ThinkingTokens int64
CostUSD float64
}
// AcpUsageModelRow 按模型聚合的 ACP 用量。
type AcpUsageModelRow struct {
ModelID uuid.UUID
PromptTokens, CompletionTokens, ThinkingTokens int64
CostUSD float64
}
// AcpUsageDayRow 按天聚合的 ACP 用量。
type AcpUsageDayRow struct {
Day time.Time
PromptTokens, CompletionTokens, ThinkingTokens int64
CostUSD float64
}
// ===== run-history dashboard 读模型 =====
// DashboardFilter 限定 run-history 汇总的范围。UserID 非 nil 时仅统计该用户的
// 会话(owner scope);nil 表示全量(admin scope)。ProjectID/RequirementID
// 为可选过滤;From/To 为半开时间窗 [From, To)。
type DashboardFilter struct {
UserID *uuid.UUID
ProjectID *uuid.UUID
RequirementID *uuid.UUID
From time.Time
To time.Time
}
// SessionRollup 是 run-history 汇总:会话计数 + 成功率 + 总成本 + 平均时长。
type SessionRollup struct {
Total int64
Succeeded int64
Crashed int64
Active int64
TotalCostUSD float64
TotalTokens int64
AvgDurationSeconds float64
}
// SuccessRate 返回 succeeded/total(total=0 时为 0),便于 handler/MCP 直接使用。
func (r SessionRollup) SuccessRate() float64 {
if r.Total == 0 {
return 0
}
return float64(r.Succeeded) / float64(r.Total)
}
// SessionDayRow 是 run-history 按天的时间序列一行。
type SessionDayRow struct {
Day time.Time
Total int64
Succeeded int64
Crashed int64
TotalCostUSD float64
}
// SessionTimelineBucket 是单会话事件按 (direction, rpc_kind, method) 分桶的一行。
type SessionTimelineBucket struct {
Direction string
RPCKind string
Method string
EventCount int64
FirstAt time.Time
LastAt time.Time
}
// ReaperSession 是 reaper 需要的最小 session 投影。
type ReaperSession struct {
ID uuid.UUID
UserID uuid.UUID
StartedAt time.Time
LastActivityAt time.Time
MaxWallClockSeconds *int32
}
// StopReason 是回合完成的归一化原因枚举。agent 可能上报枚举外的 vendor-specific
// 值;这些值归一化为 StopOther,但原始字符串仍会原样落库(stop_reason /
// last_stop_reason),避免信息丢失。
type StopReason string
const (
StopEndTurn StopReason = "end_turn"
StopMaxTokens StopReason = "max_tokens"
StopRefusal StopReason = "refusal"
StopCancelled StopReason = "cancelled"
StopOther StopReason = "other"
)
// NormalizeStopReason 把 agent 上报的原始 stopReason 映射到归一化枚举。
// 未知值(含空串)映射为 StopOther。
func NormalizeStopReason(raw string) StopReason {
switch StopReason(raw) {
case StopEndTurn:
return StopEndTurn
case StopMaxTokens:
return StopMaxTokens
case StopRefusal:
return StopRefusal
case StopCancelled:
return StopCancelled
default:
return StopOther
}
}
// TurnStatus 是 acp_turns.status 枚举。
type TurnStatus string
const (
TurnInProgress TurnStatus = "in_progress"
TurnCompleted TurnStatus = "completed"
TurnAborted TurnStatus = "aborted"
)
// Turn 是一次 agent 回合的审计记录。一个 session 内 turn_index 单调递增(0-based)。
// PromptRequestID 是发起该回合的 session/prompt 的 JSON-RPC id(用于响应相关联)。
// StopReason 在完成前为 nil;存储 agent 上报的原始字符串。
type Turn struct {
ID int64
SessionID uuid.UUID
TurnIndex int
PromptRequestID *string
Status TurnStatus
StopReason *string
UpdateCount int
StartedAt time.Time
CompletedAt *time.Time
} }
// Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON // Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON
@@ -204,6 +395,39 @@ type SessionService interface {
Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error)
List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error)
Terminate(ctx context.Context, c Caller, id uuid.UUID) error Terminate(ctx context.Context, c Caller, id uuid.UUID) error
// Dashboard 返回 run-history 汇总 + 按天序列。非 admin 调用强制按 caller
// scope(UserID 覆盖为 caller),admin 才能查看全量。
Dashboard(ctx context.Context, c Caller, in DashboardInput) (*DashboardResult, error)
// Timeline 返回单会话的事件时间线(先经 Get 做 owner/admin 访问校验)。
Timeline(ctx context.Context, c Caller, sessionID uuid.UUID) ([]SessionTimelineBucket, error)
}
// DashboardInput 是 run-history 汇总的入参(过滤维度由 service 在 scope 内归一化)。
type DashboardInput struct {
ProjectID *uuid.UUID
RequirementID *uuid.UUID
From time.Time
To time.Time
}
// DashboardResult 是 run-history 汇总响应:总览 + 按天序列。
type DashboardResult struct {
Rollup SessionRollup
ByDay []SessionDayRow
From time.Time
To time.Time
}
// TurnRunner 是编排器驱动一个阻塞回合所需的窄接口(不扩大 SessionService 的
// HTTP/MCP 暴露面)。sessionService 同时实现 SessionService 与 TurnRunner。
//
// - SendPromptAndWait 发送一条 session/prompt 并阻塞等待回合完成,返回原始
// stopReason(来自 relay.Call 的 server-initiated 请求响应)。
// - ResumeSession 在崩溃 session 的同一 cwd/worktree 上重新拉起 agent。
type TurnRunner interface {
SendPromptAndWait(ctx context.Context, sessionID uuid.UUID, prompt string) (stopReason string, err error)
ResumeSession(ctx context.Context, c Caller, sessionID uuid.UUID) (*Session, error)
} }
// SessionListFilter 控制 sessions 列表的过滤维度。 // SessionListFilter 控制 sessions 列表的过滤维度。
@@ -222,6 +446,9 @@ type CreateSessionInput struct {
IssueNumber *int IssueNumber *int
RequirementNumber *int RequirementNumber *int
InitialPrompt *string InitialPrompt *string
// OrchestratorStepID 非 nil 时把 session 与编排器 step 反向关联(仅由 orchestrator
// 内部调用设置;HTTP/MCP 入口不暴露)。
OrchestratorStepID *uuid.UUID
} }
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。 // CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
@@ -237,6 +464,11 @@ type CreateAgentKindInput struct {
ToolAllowlist []string ToolAllowlist []string
ClientType ClientType ClientType ClientType
MCPServers []McpServerSpec MCPServers []McpServerSpec
// 成本核算:model 价格来源 + 默认预算上限(均可选)。
ModelID *uuid.UUID
MaxCostUSD *float64
MaxTokens *int64
MaxWallClockSeconds *int32
} }
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。 // UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
@@ -251,6 +483,16 @@ type UpdateAgentKindInput struct {
ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换 ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换
ClientType *ClientType // nil = 不改 ClientType *ClientType // nil = 不改
MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换 MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换
// 成本核算字段:三态指针,nil = 不改。SetModelID 控制 ModelID 是否参与更新
// (ModelID 本身可被显式置空,故用独立 flag 区分"不改"与"清空")。
SetModelID bool
ModelID *uuid.UUID
SetMaxCostUSD bool
MaxCostUSD *float64
SetMaxTokens bool
MaxTokens *int64
SetMaxWallClock bool
MaxWallClockSeconds *int32
} }
// PermissionStatus 是 acp_permission_requests.status 枚举。 // PermissionStatus 是 acp_permission_requests.status 枚举。
+168
View File
@@ -21,6 +21,11 @@ type AgentKindAdminDTO struct {
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
// MCPServers 明文返回(admin only,编辑所需);条目可能含 header/env 密钥。 // MCPServers 明文返回(admin only,编辑所需);条目可能含 header/env 密钥。
MCPServers []McpServerSpec `json:"mcp_servers"` MCPServers []McpServerSpec `json:"mcp_servers"`
// 成本核算:model 价格来源 + 默认预算上限(均可空)。
ModelID *string `json:"model_id"`
MaxCostUSD *float64 `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
CreatedBy uuid.UUID `json:"created_by"` CreatedBy uuid.UUID `json:"created_by"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -46,6 +51,11 @@ type CreateAgentKindReq struct {
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
MCPServers []McpServerSpec `json:"mcp_servers"` MCPServers []McpServerSpec `json:"mcp_servers"`
// 成本核算:model_id 为字符串 UUID(空/缺省 = 不设置);预算上限均可选。
ModelID *string `json:"model_id"`
MaxCostUSD *float64 `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
} }
type UpdateAgentKindReq struct { type UpdateAgentKindReq struct {
@@ -58,6 +68,12 @@ type UpdateAgentKindReq struct {
ToolAllowlist []string `json:"tool_allowlist,omitempty"` ToolAllowlist []string `json:"tool_allowlist,omitempty"`
ClientType *string `json:"client_type,omitempty"` ClientType *string `json:"client_type,omitempty"`
MCPServers []McpServerSpec `json:"mcp_servers,omitempty"` // nil = 不改;非 nil(含空)= 替换 MCPServers []McpServerSpec `json:"mcp_servers,omitempty"` // nil = 不改;非 nil(含空)= 替换
// 成本核算三态:字段存在(非 nil)= 设置;缺省(nil)= 不改。
// ModelID 空串 = 清空;预算上限值 <=0 = 清空。
ModelID *string `json:"model_id,omitempty"`
MaxCostUSD *float64 `json:"max_cost_usd,omitempty"`
MaxTokens *int64 `json:"max_tokens,omitempty"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds,omitempty"`
} }
// ConfigFileDTO 是 AgentKind 配置文件的对外视图(admin only,content 明文)。 // ConfigFileDTO 是 AgentKind 配置文件的对外视图(admin only,content 明文)。
@@ -133,6 +149,15 @@ type SessionDTO struct {
LastError *string `json:"last_error"` LastError *string `json:"last_error"`
StartedAt string `json:"started_at"` StartedAt string `json:"started_at"`
EndedAt *string `json:"ended_at"` EndedAt *string `json:"ended_at"`
// 成本核算:运行时累计 + 有效预算上限 + 终止原因。
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUSD float64 `json:"total_cost_usd"`
BudgetMaxCostUSD *float64 `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
TerminatedReason *string `json:"terminated_reason"`
} }
func sessionToDTO(s *Session) SessionDTO { func sessionToDTO(s *Session) SessionDTO {
@@ -167,5 +192,148 @@ func sessionToDTO(s *Session) SessionDTO {
LastError: s.LastError, LastError: s.LastError,
StartedAt: s.StartedAt.UTC().Format("2006-01-02T15:04:05Z"), StartedAt: s.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
EndedAt: ended, EndedAt: ended,
PromptTokens: s.PromptTokens,
CompletionTokens: s.CompletionTokens,
ThinkingTokens: s.ThinkingTokens,
TotalCostUSD: s.TotalCostUSD,
BudgetMaxCostUSD: s.BudgetMaxCostUSD,
BudgetMaxTokens: s.BudgetMaxTokens,
BudgetMaxWallClockSeconds: s.BudgetMaxWallClockSeconds,
TerminatedReason: s.TerminatedReason,
}
}
// SessionUsageDTO 是单条 per-turn 账目的对外视图。
type SessionUsageDTO struct {
ID int64 `json:"id"`
ModelID *string `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUSD float64 `json:"cost_usd"`
CreatedAt string `json:"created_at"`
}
// SessionUsageResponse 是 GET /sessions/{id}/usage 的响应:会话累计 + 最近账目。
type SessionUsageResponse struct {
SessionID string `json:"session_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUSD float64 `json:"total_cost_usd"`
BudgetMaxCostUSD *float64 `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
Recent []SessionUsageDTO `json:"recent"`
}
// AcpUsageItem 是 admin ACP 用量聚合的一行(镜像 chat AdminUsageItem)。
type AcpUsageItem struct {
Key string `json:"key"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUSD float64 `json:"cost_usd"`
}
// AcpUsageResponse 包裹分组用量项。
type AcpUsageResponse struct {
Items []AcpUsageItem `json:"items"`
}
// ===== run-history dashboard DTO =====
// DashboardResponse 是 GET /api/v1/acp/dashboard 的响应:总览 + 按天序列。
type DashboardResponse struct {
From string `json:"from"`
To string `json:"to"`
Rollup SessionRollupDTO `json:"rollup"`
ByDay []SessionDayDTO `json:"by_day"`
}
// SessionRollupDTO 是会话总览(计数 + 成功率 + 成本 + 平均时长)。
type SessionRollupDTO struct {
Total int64 `json:"total"`
Succeeded int64 `json:"succeeded"`
Crashed int64 `json:"crashed"`
Active int64 `json:"active"`
SuccessRate float64 `json:"success_rate"`
TotalCostUSD float64 `json:"total_cost_usd"`
TotalTokens int64 `json:"total_tokens"`
AvgDurationSeconds float64 `json:"avg_duration_seconds"`
}
// SessionDayDTO 是按天的时间序列一行。
type SessionDayDTO struct {
Day string `json:"day"`
Total int64 `json:"total"`
Succeeded int64 `json:"succeeded"`
Crashed int64 `json:"crashed"`
TotalCostUSD float64 `json:"total_cost_usd"`
}
// SessionTimelineDTO 是单会话事件时间线一行(按 method/kind 分桶)。
type SessionTimelineDTO struct {
Direction string `json:"direction"`
Kind string `json:"kind"`
Method string `json:"method"`
EventCount int64 `json:"event_count"`
FirstAt string `json:"first_at"`
LastAt string `json:"last_at"`
}
func dashboardToResponse(r *DashboardResult) DashboardResponse {
resp := DashboardResponse{
From: r.From.UTC().Format(time.RFC3339),
To: r.To.UTC().Format(time.RFC3339),
Rollup: SessionRollupDTO{
Total: r.Rollup.Total,
Succeeded: r.Rollup.Succeeded,
Crashed: r.Rollup.Crashed,
Active: r.Rollup.Active,
SuccessRate: r.Rollup.SuccessRate(),
TotalCostUSD: r.Rollup.TotalCostUSD,
TotalTokens: r.Rollup.TotalTokens,
AvgDurationSeconds: r.Rollup.AvgDurationSeconds,
},
ByDay: make([]SessionDayDTO, 0, len(r.ByDay)),
}
for _, d := range r.ByDay {
resp.ByDay = append(resp.ByDay, SessionDayDTO{
Day: d.Day.UTC().Format(time.RFC3339),
Total: d.Total,
Succeeded: d.Succeeded,
Crashed: d.Crashed,
TotalCostUSD: d.TotalCostUSD,
})
}
return resp
}
func sessionTimelineToDTO(b SessionTimelineBucket) SessionTimelineDTO {
return SessionTimelineDTO{
Direction: b.Direction,
Kind: b.RPCKind,
Method: b.Method,
EventCount: b.EventCount,
FirstAt: b.FirstAt.UTC().Format(time.RFC3339),
LastAt: b.LastAt.UTC().Format(time.RFC3339),
}
}
func sessionUsageToDTO(r *SessionUsageRecord) SessionUsageDTO {
var modelID *string
if r.ModelID != nil {
v := r.ModelID.String()
modelID = &v
}
return SessionUsageDTO{
ID: r.ID,
ModelID: modelID,
PromptTokens: r.PromptTokens,
CompletionTokens: r.CompletionTokens,
ThinkingTokens: r.ThinkingTokens,
CostUSD: r.CostUSD,
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
} }
} }
+305
View File
@@ -6,6 +6,7 @@ package acp
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
@@ -83,12 +84,27 @@ func (h *Handler) Mount(r chi.Router) {
r.Get("/{id}/events", h.getEvents) r.Get("/{id}/events", h.getEvents)
r.Get("/{id}/ws", h.sessionWS) r.Get("/{id}/ws", h.sessionWS)
r.Get("/{id}/permissions", h.listSessionPermissions) r.Get("/{id}/permissions", h.listSessionPermissions)
r.Get("/{id}/usage", h.getSessionUsage)
// run-history 单会话事件时间线(owner 或 admin)。
r.Get("/{id}/timeline", h.getSessionTimeline)
})
// run-history dashboard 汇总(owner scope;admin 可全量)。
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Get("/api/v1/acp/dashboard", h.getDashboard)
}) })
r.Route("/api/v1/acp/permissions", func(r chi.Router) { r.Route("/api/v1/acp/permissions", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
// 跨会话审批 inbox:调用者所有会话的待审权限请求(HITL)。
r.Get("/", h.listPendingPermissions)
r.Post("/{reqID}/approve", h.approvePermission) r.Post("/{reqID}/approve", h.approvePermission)
r.Post("/{reqID}/deny", h.denyPermission) r.Post("/{reqID}/deny", h.denyPermission)
}) })
// Admin ACP 用量聚合(镜像 chat adminUsage);service 层 requireAdmin。
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Get("/api/v1/acp/usage", h.adminAcpUsage)
})
} }
// listSessionPermissions 返回某会话的待审权限请求(owner 或 admin)。 // listSessionPermissions 返回某会话的待审权限请求(owner 或 admin)。
@@ -115,6 +131,25 @@ func (h *Handler) listSessionPermissions(w http.ResponseWriter, r *http.Request)
httpx.WriteJSON(w, http.StatusOK, out) httpx.WriteJSON(w, http.StatusOK, out)
} }
// listPendingPermissions 返回调用者跨所有会话的待审权限请求(HITL inbox)。
func (h *Handler) listPendingPermissions(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
reqs, err := h.permSvc.ListPendingForUser(r.Context(), c)
if err != nil {
writeErr(w, r, err)
return
}
out := make([]PermissionRequestDTO, 0, len(reqs))
for _, p := range reqs {
out = append(out, permissionRequestToDTO(p))
}
httpx.WriteJSON(w, http.StatusOK, out)
}
type approvePermissionReq struct { type approvePermissionReq struct {
OptionID string `json:"option_id"` OptionID string `json:"option_id"`
} }
@@ -205,6 +240,11 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
if req.Enabled != nil { if req.Enabled != nil {
enabled = *req.Enabled enabled = *req.Enabled
} }
modelID, perr := parseOptionalUUID(req.ModelID)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
return
}
in := CreateAgentKindInput{ in := CreateAgentKindInput{
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
@@ -216,6 +256,10 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
ToolAllowlist: req.ToolAllowlist, ToolAllowlist: req.ToolAllowlist,
ClientType: ClientType(req.ClientType), ClientType: ClientType(req.ClientType),
MCPServers: req.MCPServers, MCPServers: req.MCPServers,
ModelID: modelID,
MaxCostUSD: req.MaxCostUSD,
MaxTokens: req.MaxTokens,
MaxWallClockSeconds: req.MaxWallClockSeconds,
} }
out, err := h.akSvc.Create(r.Context(), c, in) out, err := h.akSvc.Create(r.Context(), c, in)
if err != nil { if err != nil {
@@ -278,6 +322,35 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
ct := ClientType(*req.ClientType) ct := ClientType(*req.ClientType)
in.ClientType = &ct in.ClientType = &ct
} }
if req.ModelID != nil {
in.SetModelID = true
if *req.ModelID == "" {
in.ModelID = nil // 显式清空
} else {
mid, merr := uuid.Parse(*req.ModelID)
if merr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id"))
return
}
in.ModelID = &mid
}
}
if req.MaxCostUSD != nil {
in.SetMaxCostUSD = true
in.MaxCostUSD = positiveOrNil(req.MaxCostUSD)
}
if req.MaxTokens != nil {
in.SetMaxTokens = true
if *req.MaxTokens > 0 {
in.MaxTokens = req.MaxTokens
}
}
if req.MaxWallClockSeconds != nil {
in.SetMaxWallClock = true
if *req.MaxWallClockSeconds > 0 {
in.MaxWallClockSeconds = req.MaxWallClockSeconds
}
}
out, err := h.akSvc.Update(r.Context(), c, id, in) out, err := h.akSvc.Update(r.Context(), c, id, in)
if err != nil { if err != nil {
writeErr(w, r, err) writeErr(w, r, err)
@@ -457,6 +530,123 @@ func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, sessionToDTO(s)) httpx.WriteJSON(w, http.StatusOK, sessionToDTO(s))
} }
// getSessionUsage 返回会话累计成本/token + 最近账目(owner 或 admin)。
func (h *Handler) getSessionUsage(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, perr := uuid.Parse(chi.URLParam(r, "id"))
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
return
}
// 复用 sessSvc.Get 做 owner/admin 访问校验(NotFound/Forbidden 由其返回)。
s, err := h.sessSvc.Get(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
const recentLimit = 50
ledger, err := h.repo.ListSessionUsage(r.Context(), id, recentLimit)
if err != nil {
writeErr(w, r, err)
return
}
recent := make([]SessionUsageDTO, 0, len(ledger))
for _, rec := range ledger {
recent = append(recent, sessionUsageToDTO(rec))
}
httpx.WriteJSON(w, http.StatusOK, SessionUsageResponse{
SessionID: s.ID.String(),
PromptTokens: s.PromptTokens,
CompletionTokens: s.CompletionTokens,
ThinkingTokens: s.ThinkingTokens,
TotalCostUSD: s.TotalCostUSD,
BudgetMaxCostUSD: s.BudgetMaxCostUSD,
BudgetMaxTokens: s.BudgetMaxTokens,
Recent: recent,
})
}
// adminAcpUsage 返回 ACP 用量聚合(admin only),group=user|model|day,镜像 chat。
func (h *Handler) adminAcpUsage(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
if !c.IsAdmin {
writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
return
}
q := r.URL.Query()
to := time.Now().UTC()
from := to.AddDate(0, 0, -30)
if v := q.Get("from"); v != "" {
if t, e := time.Parse(time.RFC3339, v); e == nil {
from = t
}
}
if v := q.Get("to"); v != "" {
if t, e := time.Parse(time.RFC3339, v); e == nil {
to = t
}
}
groupBy := q.Get("group")
if groupBy == "" {
groupBy = q.Get("group_by")
}
if groupBy == "" {
groupBy = "day"
}
items := make([]AcpUsageItem, 0)
switch groupBy {
case "user":
rows, rerr := h.repo.SummarizeAcpUsageByUser(r.Context(), from, to)
if rerr != nil {
writeErr(w, r, rerr)
return
}
for _, row := range rows {
items = append(items, AcpUsageItem{
Key: row.UserID.String(), PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
})
}
case "model":
rows, rerr := h.repo.SummarizeAcpUsageByModel(r.Context(), from, to)
if rerr != nil {
writeErr(w, r, rerr)
return
}
for _, row := range rows {
items = append(items, AcpUsageItem{
Key: row.ModelID.String(), PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
})
}
case "day":
rows, rerr := h.repo.SummarizeAcpUsageByDay(r.Context(), from, to)
if rerr != nil {
writeErr(w, r, rerr)
return
}
for _, row := range rows {
items = append(items, AcpUsageItem{
Key: row.Day.Format(time.RFC3339), PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD,
})
}
default:
writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group"))
return
}
httpx.WriteJSON(w, http.StatusOK, AcpUsageResponse{Items: items})
}
func (h *Handler) terminateSession(w http.ResponseWriter, r *http.Request) { func (h *Handler) terminateSession(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r) c, err := h.caller(r)
if err != nil { if err != nil {
@@ -519,6 +709,80 @@ func (h *Handler) getEvents(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, out) httpx.WriteJSON(w, http.StatusOK, out)
} }
// getDashboard 返回 run-history 汇总(owner scope;admin 全量)。可选 query:
// project_id / requirement_id / from / to(RFC3339)。
func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
q := r.URL.Query()
var in DashboardInput
if v := q.Get("project_id"); v != "" {
id, perr := uuid.Parse(v)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad project_id"))
return
}
in.ProjectID = &id
}
if v := q.Get("requirement_id"); v != "" {
id, perr := uuid.Parse(v)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad requirement_id"))
return
}
in.RequirementID = &id
}
if v := q.Get("from"); v != "" {
t, terr := time.Parse(time.RFC3339, v)
if terr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad from"))
return
}
in.From = t
}
if v := q.Get("to"); v != "" {
t, terr := time.Parse(time.RFC3339, v)
if terr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad to"))
return
}
in.To = t
}
res, err := h.sessSvc.Dashboard(r.Context(), c, in)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, dashboardToResponse(res))
}
// getSessionTimeline 返回单会话事件时间线(owner 或 admin)。
func (h *Handler) getSessionTimeline(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, perr := uuid.Parse(chi.URLParam(r, "id"))
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id"))
return
}
buckets, err := h.sessSvc.Timeline(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
out := make([]SessionTimelineDTO, 0, len(buckets))
for _, b := range buckets {
out = append(out, sessionTimelineToDTO(b))
}
httpx.WriteJSON(w, http.StatusOK, out)
}
// ===== WebSocket ===== // ===== WebSocket =====
func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) { func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
@@ -548,6 +812,7 @@ func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
conn, err := ws.Accept(w, r, &ws.AcceptOptions{ conn, err := ws.Accept(w, r, &ws.AcceptOptions{
InsecureSkipVerify: false, InsecureSkipVerify: false,
Subprotocols: []string{"acp-v1"},
}) })
if err != nil { if err != nil {
return return
@@ -674,6 +939,17 @@ func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) {
}) })
continue continue
} }
// 回合相关联:用户从前端发起 session/prompt 时也登记回合,使其完成时
// 走与 auto-prompt 相同的检测路径。client 自生成的字符串 id 作为相关联键。
if m.Method == "session/prompt" {
if tr := relay.Tracker(); tr != nil {
if pid, ok := m.IDString(); ok && pid != "" {
if _, err := tr.StartTurn(ctx, pid); err != nil {
slog.Warn("acp.ws.start_turn", "session_id", sess.ID, "err", err.Error())
}
}
}
}
if err := relay.SendClient(m); err != nil { if err := relay.SendClient(m); err != nil {
_ = wsjson.Write(ctx, conn, map[string]any{ _ = wsjson.Write(ctx, conn, map[string]any{
"kind": "client_error", "kind": "client_error",
@@ -701,6 +977,11 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil { if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil {
mcpServers = servers mcpServers = servers
} }
var modelID *string
if k.ModelID != nil {
v := k.ModelID.String()
modelID = &v
}
return AgentKindAdminDTO{ return AgentKindAdminDTO{
ID: k.ID, ID: k.ID,
Name: k.Name, Name: k.Name,
@@ -713,12 +994,36 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
ToolAllowlist: append([]string(nil), k.ToolAllowlist...), ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
ClientType: string(k.ClientType), ClientType: string(k.ClientType),
MCPServers: mcpServers, MCPServers: mcpServers,
ModelID: modelID,
MaxCostUSD: k.MaxCostUSD,
MaxTokens: k.MaxTokens,
MaxWallClockSeconds: k.MaxWallClockSeconds,
CreatedBy: k.CreatedBy, CreatedBy: k.CreatedBy,
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
} }
} }
// parseOptionalUUID 解析一个可选的字符串 UUID。nil/空串 → (nil, nil);非法 → 错误。
func parseOptionalUUID(s *string) (*uuid.UUID, error) {
if s == nil || *s == "" {
return nil, nil
}
id, err := uuid.Parse(*s)
if err != nil {
return nil, err
}
return &id, nil
}
// positiveOrNil 返回正数指针,否则 nil(用于"值 <=0 视为清空"语义)。
func positiveOrNil(f *float64) *float64 {
if f == nil || *f <= 0 {
return nil
}
return f
}
func agentKindToPublicDTO(k *AgentKind) AgentKindPublicDTO { func agentKindToPublicDTO(k *AgentKind) AgentKindPublicDTO {
return AgentKindPublicDTO{ return AgentKindPublicDTO{
ID: k.ID, ID: k.ID,
+139 -1
View File
@@ -33,6 +33,7 @@ import (
"github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/project"
"github.com/yan1h/agent-coding-workflow/internal/workspace" "github.com/yan1h/agent-coding-workflow/internal/workspace"
@@ -152,7 +153,7 @@ func TestHandler_Session_CreateAndWS_E2E(t *testing.T) {
paStub := &stubProjectAccess{pj: &project.Project{ID: pid, Slug: "p-test"}} paStub := &stubProjectAccess{pj: &project.Project{ID: pid, Slug: "p-test"}}
svc := acp.NewSessionService( svc := acp.NewSessionService(
repo, wsStub, nil, nil, paStub, sup, repo, wsStub, nil, nil, paStub, nil, nil, sup,
audit.NewPostgresRecorder(pool), audit.NewPostgresRecorder(pool),
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5}, acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5},
nil, // mcpTokens — not exercised by handler e2e nil, // mcpTokens — not exercised by handler e2e
@@ -276,3 +277,140 @@ func TestHandler_Session_CreateAndWS_E2E(t *testing.T) {
} }
} }
} }
// TestSession_TurnCompletion_E2E drives a full session with an InitialPrompt
// against the fake-acp-agent (which emits session/update chunks then a
// session/prompt response with stopReason=end_turn). It asserts:
// - an acp_turns row is created and completed with stop_reason=end_turn
// - update_count == FAKE_ACP_PROMPT_UPDATES
// - acp_sessions.last_stop_reason is set to end_turn
// - a test TurnBus subscriber receives turn_completed + session_idle
func TestSession_TurnCompletion_E2E(t *testing.T) {
if testing.Short() {
t.Skip("e2e: spawns subprocess + testcontainer pg")
}
t.Parallel()
ctx := context.Background()
repo, pool := setupRepo(t)
uid := mustInsertUser(t, ctx, pool, "turn-e2e@local", false)
pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid)
cwd := t.TempDir()
_, err := pool.Exec(ctx, `UPDATE workspaces SET main_path = $1 WHERE id = $2`, cwd, wsid)
require.NoError(t, err)
bin := fakeAgentBinary(t)
enc := testEncryptor(t)
const wantUpdates = 4
kind, err := repo.CreateAgentKind(ctx, &acp.AgentKind{
ID: uuid.New(), Name: "turn-e2e-fake", DisplayName: "Fake",
BinaryPath: bin, Args: []string{}, Enabled: true, CreatedBy: uid,
// FAKE_ACP_PROMPT_UPDATES via encrypted env: emit N session/update before response.
EncryptedEnv: mustEncryptEnv(t, enc, map[string]string{"FAKE_ACP_PROMPT_UPDATES": "4"}),
})
require.NoError(t, err)
wsRow, err := workspace.NewPostgresRepository(pool).GetWorkspaceByID(ctx, wsid)
require.NoError(t, err)
disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard())
sup := acp.NewSupervisor(
repo, audit.NewPostgresRecorder(pool), disp,
nil, nil, nil, enc,
acp.SupervisorConfig{
SpawnTimeout: 5 * time.Second,
KillGrace: 1 * time.Second,
StderrBufferLines: 50,
StderrTailBytes: 1000,
EventMaxPayload: 65536,
EventTruncateField: 4096,
ShutdownGrace: 5 * time.Second,
},
slogDiscard(),
)
defer sup.ShutdownAll(context.Background())
// Test TurnBus subscriber records published events.
bus := acp.NewTurnBus(slogDiscard())
evCh := make(chan acp.TurnEvent, 8)
bus.Subscribe("test", func(_ context.Context, ev acp.TurnEvent) { evCh <- ev })
sup.SetTurnBus(bus)
wsStub := &stubWsService{wsRow: wsRow}
paStub := &stubProjectAccess{pj: &project.Project{ID: pid, Slug: "p-turn"}}
svc := acp.NewSessionService(
repo, wsStub, nil, nil, paStub, nil, nil, sup,
audit.NewPostgresRecorder(pool),
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5},
nil,
)
permSvc := acp.NewPermissionService(repo, nil, 0, nil)
sup.SetPermissionService(permSvc)
permSvc.SetRelayLocator(sup)
prompt := "do the thing"
branch := "main"
sess, err := svc.Create(ctx, acp.Caller{UserID: uid}, acp.CreateSessionInput{
WorkspaceID: wsid, AgentKindID: kind.ID, Branch: &branch, InitialPrompt: &prompt,
})
require.NoError(t, err)
// Poll for the turn to complete.
var completedTurn *acp.Turn
deadline := time.After(15 * time.Second)
for completedTurn == nil {
ts, _ := repo.ListTurnsBySession(ctx, sess.ID)
for _, tn := range ts {
if tn.Status == acp.TurnCompleted {
completedTurn = tn
}
}
if completedTurn != nil {
break
}
select {
case <-deadline:
t.Fatalf("turn never completed; turns=%+v", ts)
case <-time.After(150 * time.Millisecond):
}
}
require.NotNil(t, completedTurn.StopReason)
assert.Equal(t, "end_turn", *completedTurn.StopReason)
assert.Equal(t, wantUpdates, completedTurn.UpdateCount)
got, _ := repo.GetSessionByID(ctx, sess.ID)
require.NotNil(t, got.LastStopReason)
assert.Equal(t, "end_turn", *got.LastStopReason)
// Collect bus events: expect a turn_completed and a session_idle.
var sawCompleted, sawIdle bool
evDeadline := time.After(3 * time.Second)
for !(sawCompleted && sawIdle) {
select {
case ev := <-evCh:
switch ev.Kind {
case acp.TurnCompletedEvent:
sawCompleted = true
assert.Equal(t, acp.StopEndTurn, ev.StopReason)
case acp.SessionIdleEvent:
sawIdle = true
}
case <-evDeadline:
t.Fatalf("did not receive both turn_completed and session_idle (completed=%v idle=%v)", sawCompleted, sawIdle)
}
}
}
// mustEncryptEnv marshals env to JSON and encrypts it (mirrors acp.encryptEnv,
// which is unexported), so the supervisor can decrypt it back into the spawn env.
func mustEncryptEnv(t *testing.T, enc *crypto.Encryptor, env map[string]string) []byte {
t.Helper()
b, err := json.Marshal(env)
require.NoError(t, err)
out, err := enc.Encrypt(b)
require.NoError(t, err)
return out
}
+8 -1
View File
@@ -43,9 +43,16 @@ type FsError struct {
type FsHandler struct { type FsHandler struct {
Read *FsReadHandler Read *FsReadHandler
Write *FsWriteHandler Write *FsWriteHandler
List *FsListHandler
Grep *GrepHandler
} }
// NewFsHandler constructs a composite FsHandler with default sub-handlers. // NewFsHandler constructs a composite FsHandler with default sub-handlers.
func NewFsHandler() *FsHandler { func NewFsHandler() *FsHandler {
return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()} return &FsHandler{
Read: NewFsReadHandler(),
Write: NewFsWriteHandler(),
List: NewFsListHandler(),
Grep: NewGrepHandler(),
}
} }
+88
View File
@@ -0,0 +1,88 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"sort"
)
// DefaultListMaxEntries caps the number of directory entries returned to bound
// payload size for large directories.
const DefaultListMaxEntries = 1000
// FsListHandler handles fs/list: a bounded directory listing inside the worktree.
type FsListHandler struct {
MaxEntries int
}
// NewFsListHandler constructs an FsListHandler with default caps.
func NewFsListHandler() *FsListHandler { return &FsListHandler{MaxEntries: DefaultListMaxEntries} }
func (h *FsListHandler) maxEntries() int {
if h.MaxEntries > 0 {
return h.MaxEntries
}
return DefaultListMaxEntries
}
type fsListParams struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
}
type fsDirEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}
type fsListResult struct {
Entries []fsDirEntry `json:"entries"`
Truncated bool `json:"truncated"`
}
// Handle lists the directory at sess.CwdPath/path. Returns FsError for path
// escape (-32001) or read failure. Results are sorted (dirs first, then name).
func (h *FsListHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
var p fsListParams
if err := json.Unmarshal(paramsJSON, &p); err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
}
abs, err := resolveSafePath(sess.CwdPath, p.Path)
if err != nil {
return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}}
}
dirents, err := os.ReadDir(abs)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return FsResult{Err: &FsError{Code: -32603, Message: "directory not found"}}
}
return FsResult{Err: &FsError{Code: -32603, Message: "list: " + err.Error()}}
}
max := h.maxEntries()
out := fsListResult{Entries: make([]fsDirEntry, 0, len(dirents))}
for _, de := range dirents {
if len(out.Entries) >= max {
out.Truncated = true
break
}
e := fsDirEntry{Name: de.Name(), IsDir: de.IsDir()}
if info, ierr := de.Info(); ierr == nil && !de.IsDir() {
e.Size = info.Size()
}
out.Entries = append(out.Entries, e)
}
sort.SliceStable(out.Entries, func(i, j int) bool {
if out.Entries[i].IsDir != out.Entries[j].IsDir {
return out.Entries[i].IsDir // dirs first
}
return out.Entries[i].Name < out.Entries[j].Name
})
res, _ := json.Marshal(out)
return FsResult{OK: res}
}
@@ -0,0 +1,82 @@
package handlers_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
func TestFsList_HappyPath(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("hi"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(tmp, "sub"), 0o755))
h := handlers.NewFsListHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "."}))
require.Nil(t, res.Err)
var out struct {
Entries []struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
} `json:"entries"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Entries, 2)
// Dirs sort first.
assert.Equal(t, "sub", out.Entries[0].Name)
assert.True(t, out.Entries[0].IsDir)
assert.Equal(t, "a.txt", out.Entries[1].Name)
assert.Equal(t, int64(2), out.Entries[1].Size)
}
func TestFsList_PathOutsideWorktree(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewFsListHandler()
sess := handlers.SessionContext{CwdPath: tmp}
outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc")
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": outside}))
require.NotNil(t, res.Err)
assert.Equal(t, -32001, res.Err.Code)
}
func TestFsList_NotFound(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewFsListHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "missing"}))
require.NotNil(t, res.Err)
assert.Equal(t, -32603, res.Err.Code)
}
func TestFsList_MaxEntriesCap(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
for i := 0; i < 5; i++ {
require.NoError(t, os.WriteFile(filepath.Join(tmp, string(rune('a'+i))+".txt"), []byte("x"), 0o644))
}
h := &handlers.FsListHandler{MaxEntries: 2}
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "."}))
require.Nil(t, res.Err)
var out struct {
Entries []any `json:"entries"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Entries, 2)
require.True(t, out.Truncated)
}
+4 -1
View File
@@ -68,7 +68,10 @@ func sliceLines(s string, line, limit *int) string {
} }
end := len(lines) end := len(lines)
if limit != nil && *limit >= 0 { if limit != nil && *limit >= 0 {
if start+*limit < end { // overflow-safe:用剩余行数为上限做钳制,避免 start+*limit 在 *limit 很大
// (如 math.MaxInt)时整数溢出为负,绕过下界判断后导致 lines[start:end] panic。
remaining := len(lines) - start
if *limit < remaining {
end = start + *limit end = start + *limit
} }
} }
@@ -3,6 +3,7 @@ package handlers_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"math"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -81,6 +82,37 @@ func TestFsRead_LineLimit(t *testing.T) {
assert.Equal(t, "L1\nL2", out.Content) assert.Equal(t, "L1\nL2", out.Content)
} }
// TestFsRead_HugeLimit_NoOverflow 锁定 bug #2:limit 为 math.MaxInt 时
// start+limit 会整数溢出为负,旧逻辑下 lines[start:end] 会 panic。修复后应安全
// 钳制到文件末尾,返回从 line 起的剩余内容。
func TestFsRead_HugeLimit_NoOverflow(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "f.txt"),
[]byte("L0\nL1\nL2\nL3\n"), 0o644))
h := handlers.NewFsReadHandler()
sess := handlers.SessionContext{CwdPath: tmp}
line := 1
limit := math.MaxInt
params := mustJSON(t, map[string]any{
"sessionId": "x", "path": filepath.Join(tmp, "f.txt"),
"line": line, "limit": limit,
})
var res handlers.FsResult
require.NotPanics(t, func() {
res = h.Handle(context.Background(), sess, params)
})
require.Nil(t, res.Err)
var out struct {
Content string `json:"content"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
// 从 line=1 到末尾(split 出 5 个元素:L0 L1 L2 L3 "")。
assert.Equal(t, "L1\nL2\nL3\n", out.Content)
}
func mustJSON(t *testing.T, v any) json.RawMessage { func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper() t.Helper()
b, err := json.Marshal(v) b, err := json.Marshal(v)
+209
View File
@@ -0,0 +1,209 @@
package handlers
import (
"bufio"
"context"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)
// Grep caps. These bound the cost of a single grep request to avoid a DoS via a
// crafted pattern / huge tree. All are overridable on the handler.
const (
DefaultGrepMaxMatches = 200
DefaultGrepMaxFiles = 5000
DefaultGrepMaxFileSize = 1 << 20 // 1 MiB: skip larger files
DefaultGrepMaxLineLen = 1000 // truncate long matched lines
)
// GrepHandler handles fs/grep: a bounded regexp search under the worktree using a
// pure-Go walk (no shell, no git grep interpolation) confined to the pathsafe
// root. Binary files and oversize files are skipped.
type GrepHandler struct {
MaxMatches int
MaxFiles int
MaxFileSize int64
}
// NewGrepHandler constructs a GrepHandler with default caps.
func NewGrepHandler() *GrepHandler {
return &GrepHandler{
MaxMatches: DefaultGrepMaxMatches,
MaxFiles: DefaultGrepMaxFiles,
MaxFileSize: DefaultGrepMaxFileSize,
}
}
func (h *GrepHandler) maxMatches() int {
if h.MaxMatches > 0 {
return h.MaxMatches
}
return DefaultGrepMaxMatches
}
func (h *GrepHandler) maxFiles() int {
if h.MaxFiles > 0 {
return h.MaxFiles
}
return DefaultGrepMaxFiles
}
func (h *GrepHandler) maxFileSize() int64 {
if h.MaxFileSize > 0 {
return h.MaxFileSize
}
return DefaultGrepMaxFileSize
}
type fsGrepParams struct {
SessionID string `json:"sessionId"`
Pattern string `json:"pattern"`
Path string `json:"path,omitempty"` // sub-path to scope the search; defaults to cwd root
CaseSensitive bool `json:"case_sensitive,omitempty"`
}
type grepMatch struct {
File string `json:"file"` // worktree-relative
Line int `json:"line"` // 1-based
Text string `json:"text"`
}
type fsGrepResult struct {
Matches []grepMatch `json:"matches"`
Truncated bool `json:"truncated"`
}
// Handle runs a regexp search. Returns FsError for path escape (-32001), invalid
// pattern (-32602), or read failure. Match/file caps are honored.
func (h *GrepHandler) Handle(ctx context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
var p fsGrepParams
if err := json.Unmarshal(paramsJSON, &p); err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
}
if strings.TrimSpace(p.Pattern) == "" {
return FsResult{Err: &FsError{Code: -32602, Message: "pattern required"}}
}
expr := p.Pattern
if !p.CaseSensitive {
expr = "(?i)" + expr
}
re, err := regexp.Compile(expr)
if err != nil {
return FsResult{Err: &FsError{Code: -32602, Message: "invalid pattern: " + err.Error()}}
}
searchPath := p.Path
if searchPath == "" {
searchPath = "."
}
root, err := resolveSafePath(sess.CwdPath, searchPath)
if err != nil {
return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}}
}
cwd := filepath.Clean(sess.CwdPath)
out := fsGrepResult{Matches: make([]grepMatch, 0, 16)}
maxMatches := h.maxMatches()
maxFiles := h.maxFiles()
maxSize := h.maxFileSize()
filesScanned := 0
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return nil // skip unreadable entries
}
select {
case <-ctx.Done():
return filepath.SkipAll
default:
}
if d.IsDir() {
if isSkippedDir(d.Name()) {
return filepath.SkipDir
}
return nil
}
if filesScanned >= maxFiles {
out.Truncated = true
return filepath.SkipAll
}
info, ierr := d.Info()
if ierr != nil || info.Size() > maxSize {
return nil
}
filesScanned++
rel, rerr := filepath.Rel(cwd, path)
if rerr != nil {
rel = path
}
rel = filepath.ToSlash(rel)
if grepFile(path, rel, re, &out, maxMatches) {
out.Truncated = true
return filepath.SkipAll
}
return nil
})
if walkErr != nil && walkErr != filepath.SkipAll {
return FsResult{Err: &FsError{Code: -32603, Message: "grep walk: " + walkErr.Error()}}
}
res, _ := json.Marshal(out)
return FsResult{OK: res}
}
// grepFile scans one file line-by-line. Returns true when the global match cap
// was reached (caller should stop the walk).
func grepFile(abs, rel string, re *regexp.Regexp, out *fsGrepResult, maxMatches int) bool {
f, err := os.Open(abs)
if err != nil {
return false
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
lineNo := 0
for sc.Scan() {
lineNo++
line := sc.Bytes()
// Skip binary content (NUL in line).
if hasNUL(line) {
return false
}
if re.Match(line) {
text := string(line)
if len(text) > DefaultGrepMaxLineLen {
text = text[:DefaultGrepMaxLineLen] + "...[truncated]"
}
out.Matches = append(out.Matches, grepMatch{File: rel, Line: lineNo, Text: text})
if len(out.Matches) >= maxMatches {
return true
}
}
}
return false
}
func hasNUL(b []byte) bool {
for _, c := range b {
if c == 0 {
return true
}
}
return false
}
// skippedDirs are never descended into during grep.
var skippedDirs = map[string]struct{}{
".git": {}, "node_modules": {}, "vendor": {}, "dist": {}, "build": {},
".venv": {}, "__pycache__": {}, "third_party": {},
}
func isSkippedDir(name string) bool {
_, ok := skippedDirs[name]
return ok
}
+125
View File
@@ -0,0 +1,125 @@
package handlers_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
func TestGrep_HappyPath(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.go"), []byte("package a\n\nfunc Foo() {}\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "b.go"), []byte("package b\n\nfunc Bar() {}\n"), 0o644))
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "func Foo"}))
require.Nil(t, res.Err)
var out struct {
Matches []struct {
File string `json:"file"`
Line int `json:"line"`
Text string `json:"text"`
} `json:"matches"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 1)
assert.Equal(t, "a.go", out.Matches[0].File)
assert.Equal(t, 3, out.Matches[0].Line)
assert.Contains(t, out.Matches[0].Text, "func Foo")
}
func TestGrep_CaseInsensitiveByDefault(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.go"), []byte("HELLO world\n"), 0o644))
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "hello"}))
require.Nil(t, res.Err)
var out struct {
Matches []any `json:"matches"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 1)
}
func TestGrep_PathOutsideWorktree(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc")
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "x", "path": outside}))
require.NotNil(t, res.Err)
assert.Equal(t, -32001, res.Err.Code)
}
func TestGrep_InvalidPattern(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "[invalid("}))
require.NotNil(t, res.Err)
assert.Equal(t, -32602, res.Err.Code)
}
func TestGrep_EmptyPattern(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": ""}))
require.NotNil(t, res.Err)
assert.Equal(t, -32602, res.Err.Code)
}
func TestGrep_MaxMatchesCap(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
// 10 matching lines; cap at 3.
content := "m\nm\nm\nm\nm\nm\nm\nm\nm\nm\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte(content), 0o644))
h := &handlers.GrepHandler{MaxMatches: 3, MaxFiles: 100, MaxFileSize: 1 << 20}
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "m"}))
require.Nil(t, res.Err)
var out struct {
Matches []any `json:"matches"`
Truncated bool `json:"truncated"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 3)
require.True(t, out.Truncated)
}
func TestGrep_SkipsGitDir(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".git"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(tmp, ".git", "config"), []byte("secret pattern\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("normal pattern\n"), 0o644))
h := handlers.NewGrepHandler()
sess := handlers.SessionContext{CwdPath: tmp}
res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "pattern"}))
require.Nil(t, res.Err)
var out struct {
Matches []struct {
File string `json:"file"`
} `json:"matches"`
}
require.NoError(t, json.Unmarshal(res.OK, &out))
require.Len(t, out.Matches, 1)
assert.Equal(t, "a.txt", out.Matches[0].File)
}
+91 -2
View File
@@ -12,15 +12,17 @@ import (
) )
// MCPBridge 适配 AgentKind/Session/Permission 三个服务到 mcp.ACPBridge。 // MCPBridge 适配 AgentKind/Session/Permission 三个服务到 mcp.ACPBridge。
// repo 仅用于只读的回合列表(ListSessionTurns)——scope 校验先经 sessions.Get 完成。
type MCPBridge struct { type MCPBridge struct {
kinds AgentKindService kinds AgentKindService
sessions SessionService sessions SessionService
perms *PermissionService perms *PermissionService
repo Repository
} }
// NewMCPBridge 构造桥接适配器,由 app.go 装配 mcp.ServerDeps 时调用。 // NewMCPBridge 构造桥接适配器,由 app.go 装配 mcp.ServerDeps 时调用。
func NewMCPBridge(kinds AgentKindService, sessions SessionService, perms *PermissionService) *MCPBridge { func NewMCPBridge(kinds AgentKindService, sessions SessionService, perms *PermissionService, repo Repository) *MCPBridge {
return &MCPBridge{kinds: kinds, sessions: sessions, perms: perms} return &MCPBridge{kinds: kinds, sessions: sessions, perms: perms, repo: repo}
} }
var _ mcp.ACPBridge = (*MCPBridge)(nil) var _ mcp.ACPBridge = (*MCPBridge)(nil)
@@ -109,6 +111,86 @@ func (b *MCPBridge) ListPendingPermissions(ctx context.Context, userID uuid.UUID
return out, nil return out, nil
} }
// ListPendingApprovals 返回调用者跨所有会话的待审权限请求(HITL inbox,read-only)。
func (b *MCPBridge) ListPendingApprovals(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]mcp.ACPPermission, error) {
ps, err := b.perms.ListPendingForUser(ctx, Caller{UserID: userID, IsAdmin: isAdmin})
if err != nil {
return nil, err
}
out := make([]mcp.ACPPermission, 0, len(ps))
for _, p := range ps {
out = append(out, mcp.ACPPermission{
ID: p.ID,
SessionID: p.SessionID,
AgentRequestID: p.AgentRequestID,
ToolName: p.ToolName,
Status: string(p.Status),
CreatedAt: p.CreatedAt,
})
}
return out, nil
}
// ListSessionTurns 返回会话的回合列表(只读)。先经 sessions.Get 做用户 + 项目
// scope 校验(与 GetSession 同语义),再用 repo 读 acp_turns。
func (b *MCPBridge) ListSessionTurns(ctx context.Context, userID uuid.UUID, isAdmin bool, sessionID uuid.UUID) ([]mcp.ACPTurn, error) {
if _, err := b.sessions.Get(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, sessionID); err != nil {
return nil, err
}
ts, err := b.repo.ListTurnsBySession(ctx, sessionID)
if err != nil {
return nil, err
}
out := make([]mcp.ACPTurn, 0, len(ts))
for _, t := range ts {
out = append(out, mcp.ACPTurn{
TurnIndex: t.TurnIndex,
Status: string(t.Status),
StopReason: t.StopReason,
UpdateCount: t.UpdateCount,
StartedAt: t.StartedAt,
CompletedAt: t.CompletedAt,
})
}
return out, nil
}
// GetRunDashboard 返回 run-history 汇总(owner scope;admin 全量,read-only)。
func (b *MCPBridge) GetRunDashboard(ctx context.Context, userID uuid.UUID, isAdmin bool, in mcp.ACPRunDashboardInput) (*mcp.ACPRunDashboard, error) {
res, err := b.sessions.Dashboard(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, DashboardInput{
ProjectID: in.ProjectID,
RequirementID: in.RequirementID,
From: in.From,
To: in.To,
})
if err != nil {
return nil, err
}
out := &mcp.ACPRunDashboard{
From: res.From,
To: res.To,
Total: res.Rollup.Total,
Succeeded: res.Rollup.Succeeded,
Crashed: res.Rollup.Crashed,
Active: res.Rollup.Active,
SuccessRate: res.Rollup.SuccessRate(),
TotalCostUSD: res.Rollup.TotalCostUSD,
TotalTokens: res.Rollup.TotalTokens,
AvgDurationSeconds: res.Rollup.AvgDurationSeconds,
ByDay: make([]mcp.ACPRunDayRow, 0, len(res.ByDay)),
}
for _, d := range res.ByDay {
out.ByDay = append(out.ByDay, mcp.ACPRunDayRow{
Day: d.Day,
Total: d.Total,
Succeeded: d.Succeeded,
Crashed: d.Crashed,
TotalCostUSD: d.TotalCostUSD,
})
}
return out, nil
}
func agentKindToMCP(k *AgentKind) mcp.ACPAgentKind { func agentKindToMCP(k *AgentKind) mcp.ACPAgentKind {
return mcp.ACPAgentKind{ return mcp.ACPAgentKind{
ID: k.ID, ID: k.ID,
@@ -133,7 +215,14 @@ func sessionToMCP(s *Session) mcp.ACPSession {
IsMainWorktree: s.IsMainWorktree, IsMainWorktree: s.IsMainWorktree,
Status: string(s.Status), Status: string(s.Status),
LastError: s.LastError, LastError: s.LastError,
LastStopReason: s.LastStopReason,
StartedAt: s.StartedAt, StartedAt: s.StartedAt,
EndedAt: s.EndedAt, EndedAt: s.EndedAt,
PromptTokens: s.PromptTokens,
CompletionTokens: s.CompletionTokens,
ThinkingTokens: s.ThinkingTokens,
TotalCostUSD: s.TotalCostUSD,
BudgetMaxCostUSD: s.BudgetMaxCostUSD,
BudgetMaxTokens: s.BudgetMaxTokens,
} }
} }
+153
View File
@@ -0,0 +1,153 @@
package acp_test
import (
"context"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
)
// spyNotifier captures Dispatch calls for the HITL pending path.
type spyNotifier struct {
mu sync.Mutex
msgs []notify.Message
}
func (s *spyNotifier) Dispatch(_ context.Context, m notify.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = append(s.msgs, m)
return nil
}
func (s *spyNotifier) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.msgs)
}
func (s *spyNotifier) first() notify.Message {
s.mu.Lock()
defer s.mu.Unlock()
return s.msgs[0]
}
// spyMetrics captures permission decision outcomes.
type spyMetrics struct {
mu sync.Mutex
outcomes []string
}
func (s *spyMetrics) RecordPermissionDecision(outcome string) {
s.mu.Lock()
defer s.mu.Unlock()
s.outcomes = append(s.outcomes, outcome)
}
func (s *spyMetrics) snapshot() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, len(s.outcomes))
copy(out, s.outcomes)
return out
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
func TestPermission_Pending_DispatchesNotifyBeforeBlocking(t *testing.T) {
repo := newPermFakeRepo()
uid := uuid.New()
sid := uuid.New()
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
spy := &spyNotifier{}
met := &spyMetrics{}
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
svc.SetNotifier(spy)
svc.SetMetrics(met)
sess := handlers.SessionContext{SessionID: sid, UserID: uid} // no allowlist -> pending
resCh := make(chan string, 1)
go func() {
resCh <- svc.Decide(context.Background(), sess, "req-1",
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
}()
// Notification must fire while Decide is still blocked (before resolve).
require.Eventually(t, func() bool { return spy.count() == 1 }, time.Second, 5*time.Millisecond)
m := spy.first()
require.Equal(t, "acp.permission_pending", m.Topic)
require.Equal(t, uid, m.UserID)
require.Equal(t, notify.SeverityWarning, m.Severity)
require.Equal(t, "/approvals", m.Link)
require.Equal(t, sid.String(), m.Metadata["session_id"])
require.Equal(t, "danger.tool", m.Metadata["tool_name"])
// Resolve so Decide unblocks; exactly one dispatch (not on resolve).
var reqID uuid.UUID
require.Eventually(t, func() bool {
reqID = repo.onlyReqID()
return reqID != uuid.Nil
}, time.Second, 5*time.Millisecond)
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once"))
<-resCh
require.Equal(t, 1, spy.count(), "dispatch must fire once (pending), not on resolve")
// Metrics: approved recorded.
require.True(t, contains(met.snapshot(), "approved"))
}
func TestPermission_Metrics_AutoAndExpired(t *testing.T) {
repo := newPermFakeRepo()
uid := uuid.New()
sid := uuid.New()
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
met := &spyMetrics{}
svc := acp.NewPermissionService(repo, nil, 30*time.Millisecond, nil)
svc.SetMetrics(met)
// auto path
svc.Decide(context.Background(), handlers.SessionContext{
SessionID: sid, UserID: uid, ToolAllowlist: []string{"*"},
}, "req-auto", []byte(`{"name":"safe.tool"}`), optionsAllowReject())
require.True(t, contains(met.snapshot(), "auto"))
// expired path (no allowlist, times out)
chosen := svc.Decide(context.Background(), handlers.SessionContext{
SessionID: sid, UserID: uid,
}, "req-exp", []byte(`{"name":"danger.tool"}`), optionsAllowReject())
require.Equal(t, "", chosen)
require.True(t, contains(met.snapshot(), "expired"))
}
func TestPermission_ListPendingForUser(t *testing.T) {
repo := newPermFakeRepo()
uid := uuid.New()
other := uuid.New()
sidA := uuid.New()
sidB := uuid.New()
repo.sessions[sidA] = &acp.Session{ID: sidA, UserID: uid}
repo.sessions[sidB] = &acp.Session{ID: sidB, UserID: other}
// Two pending for uid (across sessions), one for other.
repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidA, Status: acp.PermissionPending}
repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidA, Status: acp.PermissionPending}
repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidB, Status: acp.PermissionPending}
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
got, err := svc.ListPendingForUser(context.Background(), acp.Caller{UserID: uid})
require.NoError(t, err)
require.Len(t, got, 2)
}
+122 -6
View File
@@ -24,17 +24,42 @@ import (
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers" "github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
"github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
) )
// DefaultPermissionTimeout 是未配置时的人工决策超时;到期默认拒绝。 // DefaultPermissionTimeout 是未配置时的人工决策超时;到期默认拒绝。
const DefaultPermissionTimeout = 5 * time.Minute const DefaultPermissionTimeout = 5 * time.Minute
// permissionTopic 是 HITL 待审权限通知的 topic(inbox + 跨通道路由用)。
const permissionTopic = "acp.permission_pending"
// PermissionNotifier 是 PermissionService 投递"待人工审批"通知所需的窄接口。
// notify.Dispatcher 满足它;用窄接口便于测试断言 Dispatch 调用。
type PermissionNotifier interface {
Dispatch(ctx context.Context, msg notify.Message) error
}
// PermissionMetrics 是 PermissionService 记录决策计数所需的窄接口。
// metrics.Metrics 满足它;nil 时不记录(部分测试 / metrics 关闭)。
type PermissionMetrics interface {
RecordPermissionDecision(outcome string)
}
// RelayLocator 让 PermissionService 按 session id 取到 *Relay 以推送 WS 事件。 // RelayLocator 让 PermissionService 按 session id 取到 *Relay 以推送 WS 事件。
// Supervisor 满足该接口(GetRelay)。用窄接口避免 permSvc 依赖整个 Supervisor。 // Supervisor 满足该接口(GetRelay)。用窄接口避免 permSvc 依赖整个 Supervisor。
type RelayLocator interface { type RelayLocator interface {
GetRelay(sid uuid.UUID) *Relay GetRelay(sid uuid.UUID) *Relay
} }
// ProjectMaintainer 让 PermissionService 判定某用户是否对会话所属 project 有写权限
// (project maintainer:owner / global-admin / project admin|member 角色)。允许任意
// project maintainer 处理待审权限请求,而不仅是会话 owner(autonomy roadmap §11)。
// app 层用 projectAccessAdapter.ResolveByID 实现;nil 时退化为仅 owner/admin。
type ProjectMaintainer interface {
// CanWriteProject 报告 (callerID, isAdmin) 是否对 projectID 有写权限。
CanWriteProject(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error)
}
// decision 是投递给阻塞中 Decide 的人工/系统决策结果。 // decision 是投递给阻塞中 Decide 的人工/系统决策结果。
type decision struct { type decision struct {
optionID string optionID string
@@ -58,8 +83,27 @@ type PermissionService struct {
locMu sync.RWMutex locMu sync.RWMutex
loc RelayLocator loc RelayLocator
// HITL:待审权限通知 + 决策计数。装配期经 SetNotifier/SetMetrics 注入,
// 二者均可为 nil(部分测试 / metrics 关闭)。
notify PermissionNotifier
metrics PermissionMetrics
// maintainer 允许任意 project maintainer(非仅会话 owner)处理待审权限请求。
// 装配期经 SetProjectMaintainer 注入;nil 时退化为仅 owner/global-admin。
maintainer ProjectMaintainer
} }
// SetNotifier 注入待审权限通知投递器(装配期,best-effort)。
func (s *PermissionService) SetNotifier(n PermissionNotifier) { s.notify = n }
// SetMetrics 注入权限决策计数器(装配期)。
func (s *PermissionService) SetMetrics(m PermissionMetrics) { s.metrics = m }
// SetProjectMaintainer 注入 project maintainer 判定器(装配期)。注入后,会话
// 所属 project 的任意 maintainer 均可处理该会话的待审权限请求。
func (s *PermissionService) SetProjectMaintainer(m ProjectMaintainer) { s.maintainer = m }
// NewPermissionService 构造 PermissionService。timeout<=0 时用 DefaultPermissionTimeout。 // NewPermissionService 构造 PermissionService。timeout<=0 时用 DefaultPermissionTimeout。
func NewPermissionService(repo Repository, rec audit.Recorder, timeout time.Duration, log *slog.Logger) *PermissionService { func NewPermissionService(repo Repository, rec audit.Recorder, timeout time.Duration, log *slog.Logger) *PermissionService {
if log == nil { if log == nil {
@@ -109,6 +153,7 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
s.log.Error("acp.permission.insert_auto", "session_id", sess.SessionID, "err", err.Error()) s.log.Error("acp.permission.insert_auto", "session_id", sess.SessionID, "err", err.Error())
} }
s.recordDecision(ctx, sess.UserID, sess.SessionID, toolName, "auto", nil) s.recordDecision(ctx, sess.UserID, sess.SessionID, toolName, "auto", nil)
s.recordMetric("auto")
return chosen return chosen
} }
@@ -140,6 +185,10 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
Payload: mustJSON(permissionRequestPayload(row)), Payload: mustJSON(permissionRequestPayload(row)),
}) })
// HITL:在阻塞前 best-effort 推送 inbox 通知,使关闭页面的用户也能被提醒去
// /approvals 审批(fire-and-forget,错误不影响 deny-on-timeout 安全默认)。
s.notifyPending(ctx, sess, row, toolName)
select { select {
case d := <-w.ch: case d := <-w.ch:
return d.optionID return d.optionID
@@ -147,6 +196,7 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
// 超时 → CAS 标记 expired(系统决策,decided_by=NULL)。 // 超时 → CAS 标记 expired(系统决策,decided_by=NULL)。
if _, ok, _ := s.repo.DecidePermissionRequest(context.WithoutCancel(ctx), reqID, PermissionExpired, nil, nil); ok { if _, ok, _ := s.repo.DecidePermissionRequest(context.WithoutCancel(ctx), reqID, PermissionExpired, nil, nil); ok {
s.fanout(sess.SessionID, resolvedEnvelope(reqID, PermissionExpired, "")) s.fanout(sess.SessionID, resolvedEnvelope(reqID, PermissionExpired, ""))
s.recordMetric("expired")
} }
return "" return ""
case <-ctx.Done(): case <-ctx.Done():
@@ -155,7 +205,8 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon
} }
} }
// Resolve 落地一次人工决策(approve/deny)。鉴权:session owneradmin // Resolve 落地一次人工决策(approve/deny)。鉴权:session owner、global-admin,或
// 会话所属 project 的任意 maintainer(autonomy roadmap §11)。
func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UUID, approve bool, optionID string) error { func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UUID, approve bool, optionID string) error {
req, err := s.repo.GetPermissionRequestByID(ctx, reqID) req, err := s.repo.GetPermissionRequestByID(ctx, reqID)
if err != nil { if err != nil {
@@ -165,7 +216,7 @@ func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UU
if err != nil { if err != nil {
return err return err
} }
if !c.IsAdmin && sessRow.UserID != c.UserID { if !s.canManageSession(ctx, c, sessRow) {
return errs.New(errs.CodeAcpSessionNotOwned, "无权处理该会话的权限请求") return errs.New(errs.CodeAcpSessionNotOwned, "无权处理该会话的权限请求")
} }
@@ -200,21 +251,50 @@ func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UU
s.wake(reqID, decision{optionID: chosen}) s.wake(reqID, decision{optionID: chosen})
s.fanout(req.SessionID, resolvedEnvelope(reqID, status, chosen)) s.fanout(req.SessionID, resolvedEnvelope(reqID, status, chosen))
s.recordDecision(ctx, c.UserID, req.SessionID, req.ToolName, string(status), &c.UserID) s.recordDecision(ctx, c.UserID, req.SessionID, req.ToolName, string(status), &c.UserID)
if approve {
s.recordMetric("approved")
} else {
s.recordMetric("denied")
}
return nil return nil
} }
// ListPendingForUser 返回该用户跨所有会话的待审权限请求(HITL 跨会话审批 inbox)。
// 包装 repo.ListPendingPermissionRequestsByUser(已 JOIN acp_sessions.user_id)。
// admin 仅看自己拥有会话的待审项(与现有 inbox 语义一致;全局视图走 dashboard)。
func (s *PermissionService) ListPendingForUser(ctx context.Context, c Caller) ([]*PermissionRequest, error) {
return s.repo.ListPendingPermissionRequestsByUser(ctx, c.UserID)
}
// ListPendingBySession 返回某会话的待审请求。鉴权同 Resolve。 // ListPendingBySession 返回某会话的待审请求。鉴权同 Resolve。
func (s *PermissionService) ListPendingBySession(ctx context.Context, c Caller, sessionID uuid.UUID) ([]*PermissionRequest, error) { func (s *PermissionService) ListPendingBySession(ctx context.Context, c Caller, sessionID uuid.UUID) ([]*PermissionRequest, error) {
sessRow, err := s.repo.GetSessionByID(ctx, sessionID) sessRow, err := s.repo.GetSessionByID(ctx, sessionID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !c.IsAdmin && sessRow.UserID != c.UserID { if !s.canManageSession(ctx, c, sessRow) {
return nil, errs.New(errs.CodeAcpSessionNotOwned, "无权查看该会话") return nil, errs.New(errs.CodeAcpSessionNotOwned, "无权查看该会话")
} }
return s.repo.ListPendingPermissionRequestsBySession(ctx, sessionID) return s.repo.ListPendingPermissionRequestsBySession(ctx, sessionID)
} }
// canManageSession 报告 caller 是否可处理/查看该会话的权限请求:session owner、
// global-admin,或会话所属 project 的任意 maintainer(注入 ProjectMaintainer 时)。
// maintainer 查询失败按"无权"处理(fail-closed),但 owner/admin 短路不受影响。
func (s *PermissionService) canManageSession(ctx context.Context, c Caller, sess *Session) bool {
if c.IsAdmin || sess.UserID == c.UserID {
return true
}
if s.maintainer == nil {
return false
}
ok, err := s.maintainer.CanWriteProject(ctx, c.UserID, c.IsAdmin, sess.ProjectID)
if err != nil {
return false
}
return ok
}
// CancelSession 在会话终止时把其全部 pending 请求标 expired,并唤醒阻塞的 Decide // CancelSession 在会话终止时把其全部 pending 请求标 expired,并唤醒阻塞的 Decide
// goroutine 使其立即返回(agent 收默认拒绝)。supervisor.onExit 调用。 // goroutine 使其立即返回(agent 收默认拒绝)。supervisor.onExit 调用。
func (s *PermissionService) CancelSession(ctx context.Context, sessionID uuid.UUID) { func (s *PermissionService) CancelSession(ctx context.Context, sessionID uuid.UUID) {
@@ -291,6 +371,37 @@ func (s *PermissionService) recordDecision(ctx context.Context, actor, sessionID
}) })
} }
// recordMetric 记录一次权限决策计数(auto/approved/denied/expired)。metrics 为 nil 时 no-op。
func (s *PermissionService) recordMetric(outcome string) {
if s.metrics == nil {
return
}
s.metrics.RecordPermissionDecision(outcome)
}
// notifyPending best-effort 向会话所有者推送"待人工审批"通知。错误仅记日志,
// 绝不影响 Decide 的阻塞 / deny-on-timeout 安全默认。
func (s *PermissionService) notifyPending(ctx context.Context, sess handlers.SessionContext, row *PermissionRequest, toolName string) {
if s.notify == nil {
return
}
if err := s.notify.Dispatch(context.WithoutCancel(ctx), notify.Message{
UserID: sess.UserID,
Topic: permissionTopic,
Severity: notify.SeverityWarning,
Title: "Approval needed",
Body: "An agent is requesting permission to run a tool and needs your approval.",
Link: "/approvals",
Metadata: map[string]any{
"request_id": row.ID.String(),
"session_id": sess.SessionID.String(),
"tool_name": toolName,
},
}); err != nil {
s.log.Warn("acp.permission.notify_pending_failed", "session_id", sess.SessionID, "err", err.Error())
}
}
func permissionRequestPayload(p *PermissionRequest) map[string]any { func permissionRequestPayload(p *PermissionRequest) map[string]any {
return map[string]any{ return map[string]any{
"request_id": p.ID.String(), "request_id": p.ID.String(),
@@ -314,8 +425,13 @@ func resolvedEnvelope(reqID uuid.UUID, status PermissionStatus, chosen string) *
} }
} }
// extractToolName 尝试从 toolCall JSON 中提取工具标识。多个 agent 实现的字段名不同, // extractToolName 尝试从 toolCall JSON 中提取真实工具标识。
// 依次尝试 name/toolName/kind/title;都取不到则返回空(保守走人工审批)。 //
// 安全约束(自动放行依据):只接受真实工具名字段(name / toolName)。绝不回退到
// ACP toolCall 的 'kind'(粗粒度枚举 read/edit/execute…)或 'title'(自由文本)——
// 二者都由不可信 agent 自行填写,agent 可把一个 execute/shell 调用标成 kind='read'
// 或冒用白名单里的 title 来绕过人工审批闸门。取不到真实名时返回空串,使 allowlistHit
// 一律不放行(保守走人工审批),同时保留空名默认拒绝的语义。
func extractToolName(toolCall json.RawMessage) string { func extractToolName(toolCall json.RawMessage) string {
if len(toolCall) == 0 { if len(toolCall) == 0 {
return "" return ""
@@ -324,7 +440,7 @@ func extractToolName(toolCall json.RawMessage) string {
if err := json.Unmarshal(toolCall, &m); err != nil { if err := json.Unmarshal(toolCall, &m); err != nil {
return "" return ""
} }
for _, k := range []string{"name", "toolName", "kind", "title"} { for _, k := range []string{"name", "toolName"} {
if v, ok := m[k].(string); ok && v != "" { if v, ok := m[k].(string); ok && v != "" {
return v return v
} }
@@ -1,6 +1,9 @@
package acp package acp
import "testing" import (
"encoding/json"
"testing"
)
// TestAllowlistHit 覆盖白名单匹配的边界:空 toolName 一律不放行(含 "*"), // TestAllowlistHit 覆盖白名单匹配的边界:空 toolName 一律不放行(含 "*"),
// 白名单中的空串条目不误匹配。 // 白名单中的空串条目不误匹配。
@@ -26,3 +29,44 @@ func TestAllowlistHit(t *testing.T) {
}) })
} }
} }
// TestExtractToolName_IgnoresAgentAssertedKindTitle 锁定安全约束:extractToolName
// 只信任真实工具名(name / toolName),绝不回退到 agent 自填的 kind / title。否则
// agent 可把 execute/shell 调用伪装成 kind='read' 或冒用白名单 title 绕过人工审批。
func TestExtractToolName_IgnoresAgentAssertedKindTitle(t *testing.T) {
cases := []struct {
name string
toolCall string
want string
}{
{"真实 name", `{"name":"shell.exec"}`, "shell.exec"},
{"真实 toolName", `{"toolName":"shell.exec"}`, "shell.exec"},
{"仅 kind 不取", `{"kind":"read"}`, ""},
{"仅 title 不取", `{"title":"Read file"}`, ""},
{"kind+title 都不取", `{"kind":"execute","title":"safe.tool"}`, ""},
{"name 优先于 kind/title", `{"name":"real.tool","kind":"read","title":"x"}`, "real.tool"},
{"空 toolCall", ``, ""},
{"无可识别字段", `{"unknownField":1}`, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractToolName(json.RawMessage(c.toolCall))
if got != c.want {
t.Fatalf("extractToolName(%q) = %q, want %q", c.toolCall, got, c.want)
}
})
}
}
// TestAllowlist_DoesNotHonorAgentKind 端到端验证 bug #3:即使 allowlist 含 'read'
// 这类 kind 值,agent 把 execute 调用标成 kind='read' 也不会自动放行(extractToolName
// 返回空 → allowlistHit 为 false)。
func TestAllowlist_DoesNotHonorAgentKind(t *testing.T) {
toolName := extractToolName(json.RawMessage(`{"kind":"read","title":"safe"}`))
if toolName != "" {
t.Fatalf("kind/title 不应被当作工具名, got %q", toolName)
}
if allowlistHit([]string{"read", "safe", "*"}, toolName) {
t.Fatal("kind/title 伪装的调用不应命中白名单(含通配符)")
}
}
+90
View File
@@ -61,6 +61,13 @@ func (r *permFakeRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Ses
out := *s out := *s
return &out, nil return &out, nil
} }
func (r *permFakeRepo) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) {
return nil, errs.New(errs.CodeAcpSessionNotFound, "not found")
}
func (r *permFakeRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) {
return nil, errs.New(errs.CodeAcpSessionNotFound, "not found")
}
func (r *permFakeRepo) ResetSessionForResume(context.Context, uuid.UUID) error { return nil }
func (r *permFakeRepo) DecidePermissionRequest(_ context.Context, id uuid.UUID, status acp.PermissionStatus, chosen *string, decidedBy *uuid.UUID) (*acp.PermissionRequest, bool, error) { func (r *permFakeRepo) DecidePermissionRequest(_ context.Context, id uuid.UUID, status acp.PermissionStatus, chosen *string, decidedBy *uuid.UUID) (*acp.PermissionRequest, bool, error) {
r.mu.Lock() r.mu.Lock()
@@ -89,6 +96,24 @@ func (r *permFakeRepo) ExpirePendingPermissionRequestsBySession(_ context.Contex
return n, nil return n, nil
} }
func (r *permFakeRepo) ListPendingPermissionRequestsByUser(_ context.Context, userID uuid.UUID) ([]*acp.PermissionRequest, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []*acp.PermissionRequest
for _, p := range r.reqs {
if p.Status != acp.PermissionPending {
continue
}
sess, ok := r.sessions[p.SessionID]
if !ok || sess.UserID != userID {
continue
}
cp := *p
out = append(out, &cp)
}
return out, nil
}
func (r *permFakeRepo) statusOf(id uuid.UUID) acp.PermissionStatus { func (r *permFakeRepo) statusOf(id uuid.UUID) acp.PermissionStatus {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -310,3 +335,68 @@ func TestPermission_CancelSession_UnblocksDecide(t *testing.T) {
} }
require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID())) require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID()))
} }
// fakeMaintainer 满足 acp.ProjectMaintainer:仅当 projectID 命中 allow 集合时放行。
type fakeMaintainer struct {
allow map[uuid.UUID]bool
}
func (f fakeMaintainer) CanWriteProject(_ context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) {
if isAdmin {
return true, nil
}
return f.allow[projectID], nil
}
// 注入 ProjectMaintainer 后:会话所属 project 的任意 maintainer(非会话 owner)
// 也可处理待审权限请求(autonomy roadmap §11)。
func TestPermission_Resolve_ProjectMaintainer_Allowed(t *testing.T) {
repo := newPermFakeRepo()
owner := uuid.New()
maintainer := uuid.New()
pid := uuid.New()
sid := uuid.New()
repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner, ProjectID: pid}
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
svc.SetProjectMaintainer(fakeMaintainer{allow: map[uuid.UUID]bool{pid: true}})
sess := handlers.SessionContext{SessionID: sid, UserID: owner}
go svc.Decide(context.Background(), sess, "req-1",
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
var reqID uuid.UUID
require.Eventually(t, func() bool {
reqID = repo.onlyReqID()
return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending
}, time.Second, 5*time.Millisecond)
// 非 owner 但是 project maintainer → 可处理。
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: maintainer}, reqID, true, "allow_once"))
require.Equal(t, acp.PermissionApproved, repo.statusOf(reqID))
}
// 非 maintainer 的外部用户仍被拒(fail-closed);项目不在 allow 集合时也拒。
func TestPermission_Resolve_NonMaintainer_Forbidden(t *testing.T) {
repo := newPermFakeRepo()
owner := uuid.New()
pid := uuid.New()
otherPid := uuid.New()
sid := uuid.New()
repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner, ProjectID: pid}
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
// maintainer 仅对 otherPid 有权,而会话属于 pid。
svc.SetProjectMaintainer(fakeMaintainer{allow: map[uuid.UUID]bool{otherPid: true}})
sess := handlers.SessionContext{SessionID: sid, UserID: owner}
go svc.Decide(context.Background(), sess, "req-1",
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
var reqID uuid.UUID
require.Eventually(t, func() bool {
reqID = repo.onlyReqID()
return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending
}, time.Second, 5*time.Millisecond)
err := svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New()}, reqID, true, "allow_once")
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeAcpSessionNotOwned, ae.Code)
}
@@ -1,5 +1,5 @@
-- name: ListAgentKindConfigFiles :many -- name: ListAgentKindConfigFiles :many
SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version
FROM acp_agent_kind_config_files FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1 WHERE agent_kind_id = $1
ORDER BY rel_path ASC; ORDER BY rel_path ASC;
@@ -12,7 +12,7 @@ ON CONFLICT (agent_kind_id, rel_path) DO UPDATE
SET encrypted_content = EXCLUDED.encrypted_content, SET encrypted_content = EXCLUDED.encrypted_content,
updated_by = EXCLUDED.updated_by, updated_by = EXCLUDED.updated_by,
updated_at = now() updated_at = now()
RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at; RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version;
-- name: DeleteAgentKindConfigFile :execrows -- name: DeleteAgentKindConfigFile :execrows
DELETE FROM acp_agent_kind_config_files DELETE FROM acp_agent_kind_config_files
+19 -8
View File
@@ -1,31 +1,37 @@
-- name: CreateAgentKind :one -- name: CreateAgentKind :one
INSERT INTO acp_agent_kinds ( INSERT INTO acp_agent_kinds (
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) model_id, max_cost_usd, max_tokens, max_wall_clock_seconds
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers; enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version;
-- name: GetAgentKindByID :one -- name: GetAgentKindByID :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
WHERE id = $1; WHERE id = $1;
-- name: GetAgentKindByName :one -- name: GetAgentKindByName :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
WHERE name = $1; WHERE name = $1;
-- name: ListAgentKinds :many -- name: ListAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
ORDER BY created_at DESC; ORDER BY created_at DESC;
-- name: ListEnabledAgentKinds :many -- name: ListEnabledAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
WHERE enabled = TRUE WHERE enabled = TRUE
ORDER BY name ASC; ORDER BY name ASC;
@@ -41,10 +47,15 @@ SET display_name = $2,
tool_allowlist = $8, tool_allowlist = $8,
client_type = $9, client_type = $9,
encrypted_mcp_servers = $10, encrypted_mcp_servers = $10,
model_id = $11,
max_cost_usd = $12,
max_tokens = $13,
max_wall_clock_seconds = $14,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers; enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version;
-- name: DeleteAgentKind :exec -- name: DeleteAgentKind :exec
DELETE FROM acp_agent_kinds WHERE id = $1; DELETE FROM acp_agent_kinds WHERE id = $1;
+59
View File
@@ -0,0 +1,59 @@
-- dashboard.sql: run-history dashboard 读模型(只读聚合)。
-- - SessionTimeline: 单会话的 RPC 事件按 (direction, rpc_kind, method) 分桶,
-- 给出条数 + 首/末时间戳,用于会话时间线概览。
-- - SessionRollup: 在 owner/admin scope + 可选 project/requirement 过滤下,
-- 统计会话总数、成功率(exited 视为成功)、总成本、平均时长。
-- - SessionsByDay: 按天的 总数 / 成功 / 失败 / 成本 时间序列。
-- scope 约定(与 ListSessionsByUser/ListAllSessions 一致):
-- $1 user_id 为 NULL 时不按用户过滤(admin 全量),非 NULL 时仅该用户。
-- name: SessionTimeline :many
-- 单会话事件时间线:按 method/kind 分桶聚合(条数 + 首末时间)。
SELECT direction,
rpc_kind,
COALESCE(method, '') AS method,
COUNT(*)::bigint AS event_count,
MIN(created_at)::timestamptz AS first_at,
MAX(created_at)::timestamptz AS last_at
FROM acp_events
WHERE session_id = $1
GROUP BY direction, rpc_kind, COALESCE(method, '')
ORDER BY first_at ASC;
-- name: SessionRollup :one
-- owner/admin scope + 可选 project/requirement + 时间窗内的会话汇总。
-- success = status='exited';avg_duration_seconds 仅统计已结束(ended_at 非空)会话。
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded,
COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed,
COUNT(*) FILTER (WHERE status IN ('starting','running'))::bigint AS active,
COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd,
COALESCE(SUM(tokens_total), 0)::bigint AS total_tokens,
COALESCE(
AVG(EXTRACT(EPOCH FROM (ended_at - started_at)))
FILTER (WHERE ended_at IS NOT NULL),
0)::float8 AS avg_duration_seconds
FROM acp_sessions
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR requirement_id = $3)
AND started_at >= $4
AND started_at < $5;
-- name: SessionsByDay :many
-- 按天的会话时间序列(总数 / 成功 / 失败 / 成本),同 SessionRollup 的 scope。
SELECT
date_trunc('day', started_at)::timestamptz AS day,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded,
COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed,
COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd
FROM acp_sessions
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR requirement_id = $3)
AND started_at >= $4
AND started_at < $5
GROUP BY day
ORDER BY day ASC;
+9
View File
@@ -0,0 +1,9 @@
-- name: InsertSessionUsage :one
-- 写一条 per-turn 用量账目。source_event_id 非空时按唯一索引去重(同一事件
-- 重复 Observe 不会重复入账);返回受影响行数判定是否实际插入。
INSERT INTO acp_session_usage (
session_id, user_id, project_id, agent_kind_id, model_id,
prompt_tokens, completion_tokens, thinking_tokens, cost_usd, source_event_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (source_event_id) WHERE source_event_id IS NOT NULL DO NOTHING
RETURNING id;
+170 -6
View File
@@ -1,26 +1,75 @@
-- name: InsertSession :one -- name: InsertSession :one
INSERT INTO acp_sessions ( INSERT INTO acp_sessions (
id, workspace_id, project_id, agent_kind_id, user_id, id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, branch, cwd_path, is_main_worktree, status issue_id, requirement_id, branch, cwd_path, is_main_worktree, status,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) orchestrator_step_id,
budget_max_cost_usd, budget_max_tokens, budget_max_wall_clock_seconds
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, workspace_id, project_id, agent_kind_id, user_id, RETURNING id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at; pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total;
-- name: GetSessionByID :one -- name: GetSessionByID :one
SELECT id, workspace_id, project_id, agent_kind_id, user_id, SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions FROM acp_sessions
WHERE id = $1; WHERE id = $1;
-- name: GetSessionByStepID :one
SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions
WHERE orchestrator_step_id = $1
ORDER BY started_at DESC
LIMIT 1;
-- name: GetCrashedSessionForResume :one
-- 返回某 step 最近一次处于可恢复终态(crashed/exited)的 session。
SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions
WHERE orchestrator_step_id = $1
AND status IN ('crashed','exited')
ORDER BY started_at DESC
LIMIT 1;
-- name: ListSessionsByUser :many -- name: ListSessionsByUser :many
SELECT id, workspace_id, project_id, agent_kind_id, user_id, SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions FROM acp_sessions
WHERE user_id = $1 WHERE user_id = $1
AND ($2::uuid IS NULL OR requirement_id = $2) AND ($2::uuid IS NULL OR requirement_id = $2)
@@ -30,7 +79,12 @@ ORDER BY started_at DESC;
SELECT id, workspace_id, project_id, agent_kind_id, user_id, SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions FROM acp_sessions
WHERE ($1::uuid IS NULL OR requirement_id = $1) WHERE ($1::uuid IS NULL OR requirement_id = $1)
ORDER BY started_at DESC; ORDER BY started_at DESC;
@@ -40,6 +94,17 @@ UPDATE acp_sessions
SET status = 'running', agent_session_id = $2, pid = $3 SET status = 'running', agent_session_id = $2, pid = $3
WHERE id = $1; WHERE id = $1;
-- name: UpdateSessionLastStopReason :exec
UPDATE acp_sessions
SET last_stop_reason = $2
WHERE id = $1;
-- name: UpdateSessionSandboxMode :exec
-- 记录本 session 运行所用的沙箱模式(审计/取证)。
UPDATE acp_sessions
SET sandbox_mode = $2
WHERE id = $1;
-- name: UpdateSessionFinished :exec -- name: UpdateSessionFinished :exec
UPDATE acp_sessions UPDATE acp_sessions
SET status = $2, exit_code = $3, last_error = $4, ended_at = now() SET status = $2, exit_code = $3, last_error = $4, ended_at = now()
@@ -50,6 +115,20 @@ UPDATE acp_sessions
SET status = 'crashed', last_error = $2, ended_at = now() SET status = 'crashed', last_error = $2, ended_at = now()
WHERE id = $1 AND status IN ('starting', 'running'); WHERE id = $1 AND status IN ('starting', 'running');
-- name: ResetSessionForResume :exec
-- 把崩溃/退出的 session 复用为新一轮:回到 starting、清空上次运行时字段。
-- 编排器 resume 在重新 Spawn 前调用,使同一行 session 在同 cwd/worktree 上复用。
UPDATE acp_sessions
SET status = 'starting',
agent_session_id = NULL,
pid = NULL,
exit_code = NULL,
last_error = NULL,
ended_at = NULL,
terminated_reason = NULL,
last_activity_at = now()
WHERE id = $1;
-- name: CountActiveSessions :one -- name: CountActiveSessions :one
SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running'); SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running');
@@ -72,3 +151,88 @@ WHERE id = $1 AND active_main_session_id = $2;
-- name: AcquireMainWorktree :execrows -- name: AcquireMainWorktree :execrows
UPDATE workspaces SET active_main_session_id = $1 UPDATE workspaces SET active_main_session_id = $1
WHERE id = $2 AND active_main_session_id IS NULL; WHERE id = $2 AND active_main_session_id IS NULL;
-- name: AddSessionUsageTotals :one
-- 单 round-trip 累加 session 运行时 token/cost 总量并刷新 last_activity_at,
-- 返回累加后的权威总量供预算判定。
-- 同时把 dashboard rollup 列(cost_usd / tokens_total)与运行时累加器对齐,
-- 使 run-history dashboard 在会话进行中及退出后都能读到 agent 上报的成本。
UPDATE acp_sessions
SET prompt_tokens = prompt_tokens + $2,
completion_tokens = completion_tokens + $3,
thinking_tokens = thinking_tokens + $4,
total_cost_usd = total_cost_usd + $5,
cost_usd = cost_usd + $5,
tokens_total = tokens_total + $2 + $3 + $4,
last_activity_at = now()
WHERE id = $1
RETURNING prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd;
-- name: SumProjectCostUSD :one
-- 某 project 自 since 起的 ACP 累计花费(per-project 软预算)。
SELECT COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
FROM acp_session_usage
WHERE project_id = $1 AND created_at >= $2;
-- name: MarkSessionTerminatedReason :exec
-- 写入终止原因;已有非空 terminated_reason 时不覆盖(reaper / budget 互斥保护)。
UPDATE acp_sessions
SET terminated_reason = $2
WHERE id = $1 AND terminated_reason IS NULL;
-- name: ListSessionsForReaper :many
-- 返回应被回收的活跃 session:
-- 超过自身 wall-clock 上限(budget_max_wall_clock_seconds 非空且 started_at 过早),或
-- 超过全局 wall-clock 上限($1::timestamptz = now - 全局上限),或
-- 空闲超时(last_activity_at < $2::timestamptz)。
SELECT id, user_id, started_at, last_activity_at, budget_max_wall_clock_seconds
FROM acp_sessions
WHERE status IN ('starting','running')
AND (
(budget_max_wall_clock_seconds IS NOT NULL
AND started_at < now() - make_interval(secs => budget_max_wall_clock_seconds))
OR ($1::timestamptz IS NOT NULL AND started_at < $1::timestamptz)
OR last_activity_at < $2::timestamptz
);
-- name: SummarizeAcpUsageByUser :many
SELECT user_id,
SUM(prompt_tokens)::bigint AS prompt_tokens,
SUM(completion_tokens)::bigint AS completion_tokens,
SUM(thinking_tokens)::bigint AS thinking_tokens,
SUM(cost_usd)::numeric AS cost_usd
FROM acp_session_usage
WHERE created_at >= $1 AND created_at < $2
GROUP BY user_id
ORDER BY cost_usd DESC;
-- name: SummarizeAcpUsageByModel :many
SELECT model_id,
SUM(prompt_tokens)::bigint AS prompt_tokens,
SUM(completion_tokens)::bigint AS completion_tokens,
SUM(thinking_tokens)::bigint AS thinking_tokens,
SUM(cost_usd)::numeric AS cost_usd
FROM acp_session_usage
WHERE created_at >= $1 AND created_at < $2
GROUP BY model_id
ORDER BY cost_usd DESC;
-- name: SummarizeAcpUsageByDay :many
SELECT date_trunc('day', created_at)::timestamptz AS day,
SUM(prompt_tokens)::bigint AS prompt_tokens,
SUM(completion_tokens)::bigint AS completion_tokens,
SUM(thinking_tokens)::bigint AS thinking_tokens,
SUM(cost_usd)::numeric AS cost_usd
FROM acp_session_usage
WHERE created_at >= $1 AND created_at < $2
GROUP BY day
ORDER BY day;
-- name: ListSessionUsageBySession :many
SELECT id, session_id, user_id, project_id, agent_kind_id, model_id,
prompt_tokens, completion_tokens, thinking_tokens, cost_usd,
source_event_id, created_at
FROM acp_session_usage
WHERE session_id = $1
ORDER BY created_at DESC
LIMIT $2;
+39
View File
@@ -0,0 +1,39 @@
-- name: InsertTurn :one
INSERT INTO acp_turns (
session_id, turn_index, prompt_request_id, status
) VALUES ($1, $2, $3, $4)
RETURNING id, session_id, turn_index, prompt_request_id, status,
stop_reason, update_count, started_at, completed_at;
-- name: MarkTurnCompleted :exec
UPDATE acp_turns
SET status = 'completed', stop_reason = $3, update_count = $4, completed_at = now()
WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress';
-- name: MarkTurnAborted :exec
UPDATE acp_turns
SET status = 'aborted', completed_at = now()
WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress';
-- name: IncrementTurnUpdateCount :exec
UPDATE acp_turns
SET update_count = update_count + 1
WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress';
-- name: GetLatestTurnBySession :one
SELECT id, session_id, turn_index, prompt_request_id, status,
stop_reason, update_count, started_at, completed_at
FROM acp_turns
WHERE session_id = $1
ORDER BY turn_index DESC
LIMIT 1;
-- name: ListTurnsBySession :many
SELECT id, session_id, turn_index, prompt_request_id, status,
stop_reason, update_count, started_at, completed_at
FROM acp_turns
WHERE session_id = $1
ORDER BY turn_index ASC;
-- name: PurgeTurnsBefore :execrows
DELETE FROM acp_turns WHERE started_at < $1;
+60 -2
View File
@@ -71,6 +71,14 @@ type Relay struct {
log *slog.Logger log *slog.Logger
cfg RelayConfig cfg RelayConfig
// 回合完成检测:被动解析 session/update + session/prompt 响应的 stopReason。
// 由 supervisor.Spawn 通过 SetTracker 注入;可为 nil(部分测试)。
tracker *TurnTracker
// 成本核算:被动解析 agent→client 消息中的 token/cost 用量并累加 + 预算判定。
// 由 supervisor.Spawn 通过 SetUsageSink 注入;可为 nil(部分测试 / 未配置)。
usageSink usageSink
// 终止控制 // 终止控制
closed atomic.Bool closed atomic.Bool
done chan struct{} done chan struct{}
@@ -106,6 +114,17 @@ func NewRelay(sessionID uuid.UUID, dec *Decoder, enc *Encoder,
} }
} }
// SetTracker 注入回合追踪器。由 supervisor.Spawn 在 NewRelay 之后调用。
func (r *Relay) SetTracker(t *TurnTracker) { r.tracker = t }
// SetUsageSink 注入成本核算 sink。由 supervisor.Spawn 在 NewRelay 之后调用;
// 可为 nil(部分测试 / 未配置 model 价格时由 supervisor 自行决定是否注入)。
func (r *Relay) SetUsageSink(s usageSink) { r.usageSink = s }
// Tracker 返回回合追踪器(可能为 nil)。session_service / handler 在发送
// session/prompt 前用它登记回合 id。
func (r *Relay) Tracker() *TurnTracker { return r.tracker }
// Subscribe 注册一个 WS tab 订阅。 // Subscribe 注册一个 WS tab 订阅。
func (r *Relay) Subscribe(userID uuid.UUID) *Subscriber { func (r *Relay) Subscribe(userID uuid.UUID) *Subscriber {
sub := &Subscriber{ sub := &Subscriber{
@@ -228,6 +247,12 @@ func (r *Relay) reader(ctx context.Context, sess handlers.SessionContext) {
envelope := r.persistAndEnvelope(ctx, msg, "out") envelope := r.persistAndEnvelope(ctx, msg, "out")
if envelope != nil { if envelope != nil {
r.fanout(envelope) r.fanout(envelope)
// 成本核算:在事件落库后用持久化的 event id 喂给 usageSink。usageSink
// 内部仅在真实可计费回合时累加,并在预算突破时以 detached goroutine 触发
// kill(绝不阻塞 reader)。
if r.usageSink != nil {
r.usageSink.Observe(ctx, msg, envelope.ID)
}
} }
switch { switch {
@@ -238,10 +263,23 @@ func (r *Relay) reader(ctx context.Context, sess handlers.SessionContext) {
} else { } else {
r.log.Warn("acp.relay.orphan_response", "session_id", r.sessionID, "id", id) r.log.Warn("acp.relay.orphan_response", "session_id", r.sessionID, "id", id)
} }
} else if sid, ok := msg.IDString(); ok && r.tracker.IsTrackedPrompt(sid) {
// 字符串-id 的 session/prompt 响应:回合完成检测。
// 仅当 id 匹配当前开放回合时拦截;其它字符串 id 仍走既有 fanout(上方已 fanout)。
if msg.Error != nil {
// JSON-RPC error 响应:本回合失败,标 aborted 并发 session_idle(reason=error),
// 而非误记为 completed(stopReason 空)。agent 仍存活,可接受下一条 prompt。
r.tracker.Abort(ctx, "error")
} else {
r.tracker.OnPromptResponse(ctx, sid, ParseStopReason(msg.Result))
} }
// String IDs go to clients via fanout (handled above); no server action. }
// 其它 String IDs go to clients via fanout (handled above); no server action.
case msg.IsNotification(): case msg.IsNotification():
// Already persisted + fanout'd. No reply. // Already persisted + fanout'd. No reply. 回合检测:累加 update 计数。
if msg.Method == "session/update" {
r.tracker.OnUpdate(ctx)
}
case msg.IsRequest(): case msg.IsRequest():
go r.handleAgentRequest(ctx, sess, msg) go r.handleAgentRequest(ctx, sess, msg)
} }
@@ -249,7 +287,21 @@ func (r *Relay) reader(ctx context.Context, sess handlers.SessionContext) {
} }
// handleAgentRequest dispatches an agent → server request to the appropriate handler. // handleAgentRequest dispatches an agent → server request to the appropriate handler.
// 在独立 goroutine 中运行,故 defer recover 兜底任何 handler panic:记录日志并回送
// JSON-RPC internal error,避免 agent 构造的请求 panic 掉整个进程(DoS)。
func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionContext, req *Message) { func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionContext, req *Message) {
defer func() {
if rec := recover(); rec != nil {
r.log.Error("acp.relay.handle_request_panic",
"session_id", r.sessionID, "method", req.Method, "panic", fmt.Sprintf("%v", rec))
resp := NewResponseErr(req.ID, JSONRPCInternalError, "internal error")
select {
case r.serverInitiated <- resp:
case <-ctx.Done():
}
}
}()
var resp *Message var resp *Message
switch req.Method { switch req.Method {
case "fs/read_text_file": case "fs/read_text_file":
@@ -258,6 +310,12 @@ func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionCon
case "fs/write_text_file": case "fs/write_text_file":
out := r.fsHandler.Write.Handle(ctx, sess, req.Params) out := r.fsHandler.Write.Handle(ctx, sess, req.Params)
resp = r.fsResultToResponse(req.ID, out) resp = r.fsResultToResponse(req.ID, out)
case "fs/list":
out := r.fsHandler.List.Handle(ctx, sess, req.Params)
resp = r.fsResultToResponse(req.ID, out)
case "fs/grep":
out := r.fsHandler.Grep.Handle(ctx, sess, req.Params)
resp = r.fsResultToResponse(req.ID, out)
case "session/request_permission": case "session/request_permission":
out := r.permHandler.Handle(ctx, sess, string(req.ID), req.Params) out := r.permHandler.Handle(ctx, sess, string(req.ID), req.Params)
resp = r.fsResultToResponse(req.ID, out) resp = r.fsResultToResponse(req.ID, out)
+107
View File
@@ -0,0 +1,107 @@
// relay_discovery_test.go: relay dispatch tests for the fs/list + fs/grep agent
// discovery handlers (mirrors TestRelay_AgentFsRequest_Echoed).
package acp_test
import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
)
func relayRoundTrip(t *testing.T, rig *relayTestRig, id int64, method string, params map[string]any) *acp.Message {
t.Helper()
agentDec := acp.NewDecoder(rig.agentReader, 0)
agentEnc := acp.NewEncoder(rig.agentWriter)
req, err := acp.NewRequestInt(id, method, params)
require.NoError(t, err)
require.NoError(t, agentEnc.Encode(req))
respCh := make(chan *acp.Message, 1)
errCh := make(chan error, 1)
go func() {
m, derr := agentDec.DecodeMessage()
if derr != nil {
errCh <- derr
return
}
respCh <- m
}()
select {
case resp := <-respCh:
return resp
case err := <-errCh:
if errors.Is(err, io.EOF) {
t.Fatal("agent saw EOF before relay responded")
}
t.Fatalf("agent decoder error: %v", err)
case <-time.After(3 * time.Second):
t.Fatalf("relay did not respond to %s within 3s", method)
}
return nil
}
func TestRelay_AgentFsList_Dispatched(t *testing.T) {
t.Parallel()
rig := newRelayTestRig(t)
cancel, doneCh := rig.runRelay(t)
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
require.NoError(t, os.WriteFile(filepath.Join(rig.sess.CwdPath, "f.txt"), []byte("hi"), 0o644))
resp := relayRoundTrip(t, rig, 201, "fs/list", map[string]any{"path": ".", "sessionId": "a"})
require.True(t, resp.IsResponse())
require.Nil(t, resp.Error)
var out struct {
Entries []struct {
Name string `json:"name"`
} `json:"entries"`
}
require.NoError(t, json.Unmarshal(resp.Result, &out))
require.Len(t, out.Entries, 1)
assert.Equal(t, "f.txt", out.Entries[0].Name)
}
func TestRelay_AgentFsGrep_Dispatched(t *testing.T) {
t.Parallel()
rig := newRelayTestRig(t)
cancel, doneCh := rig.runRelay(t)
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
require.NoError(t, os.WriteFile(filepath.Join(rig.sess.CwdPath, "code.go"), []byte("package x\nfunc Needle() {}\n"), 0o644))
resp := relayRoundTrip(t, rig, 202, "fs/grep", map[string]any{"pattern": "Needle", "sessionId": "a"})
require.True(t, resp.IsResponse())
require.Nil(t, resp.Error)
var out struct {
Matches []struct {
File string `json:"file"`
Line int `json:"line"`
} `json:"matches"`
}
require.NoError(t, json.Unmarshal(resp.Result, &out))
require.Len(t, out.Matches, 1)
assert.Equal(t, "code.go", out.Matches[0].File)
assert.Equal(t, 2, out.Matches[0].Line)
}
func TestRelay_AgentFsGrep_PathEscapeRejected(t *testing.T) {
t.Parallel()
rig := newRelayTestRig(t)
cancel, doneCh := rig.runRelay(t)
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
outside := filepath.Join(filepath.VolumeName(rig.sess.CwdPath)+string(filepath.Separator), "etc")
resp := relayRoundTrip(t, rig, 203, "fs/grep", map[string]any{"pattern": "x", "path": outside, "sessionId": "a"})
require.True(t, resp.IsResponse())
require.NotNil(t, resp.Error)
assert.Equal(t, -32001, resp.Error.Code)
}
+267
View File
@@ -31,6 +31,18 @@ import (
type fakeEventRepo struct { type fakeEventRepo struct {
mu sync.Mutex mu sync.Mutex
events []*acp.Event events []*acp.Event
// 回合状态机:relay turn 测试用。
turns []*acp.Turn
lastStopReason *string
// 成本核算:relay usage 测试用。
usageLedger []*acp.SessionUsageRecord
totPrompt int64
totComplete int64
totThink int64
totCost float64
dedupSources map[int64]struct{}
} }
func (f *fakeEventRepo) InsertEvent(_ context.Context, e *acp.Event) (*acp.Event, error) { func (f *fakeEventRepo) InsertEvent(_ context.Context, e *acp.Event) (*acp.Event, error) {
@@ -81,6 +93,15 @@ func (f *fakeEventRepo) InsertSession(context.Context, *acp.Session) (*acp.Sessi
func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a") panic("n/a")
} }
func (f *fakeEventRepo) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a")
}
func (f *fakeEventRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) {
panic("n/a")
}
func (f *fakeEventRepo) ResetSessionForResume(context.Context, uuid.UUID) error {
panic("n/a")
}
func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) { func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) {
panic("n/a") panic("n/a")
} }
@@ -90,6 +111,9 @@ func (f *fakeEventRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Ses
func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error {
panic("n/a") panic("n/a")
} }
func (f *fakeEventRepo) UpdateSessionSandboxMode(context.Context, uuid.UUID, string) error {
panic("n/a")
}
func (f *fakeEventRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error { func (f *fakeEventRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error {
panic("n/a") panic("n/a")
} }
@@ -115,6 +139,67 @@ func (f *fakeEventRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32
func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) { func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) {
panic("n/a") panic("n/a")
} }
func (f *fakeEventRepo) InsertSessionUsage(_ context.Context, rec *acp.SessionUsageRecord) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if rec.SourceEventID != nil {
if f.dedupSources == nil {
f.dedupSources = map[int64]struct{}{}
}
if _, seen := f.dedupSources[*rec.SourceEventID]; seen {
return false, nil
}
f.dedupSources[*rec.SourceEventID] = struct{}{}
}
cp := *rec
f.usageLedger = append(f.usageLedger, &cp)
return true, nil
}
func (f *fakeEventRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.totPrompt += dp
f.totComplete += dc
f.totThink += dt
f.totCost += dCost
return f.totPrompt, f.totComplete, f.totThink, f.totCost, nil
}
func (f *fakeEventRepo) SumProjectCostUSD(context.Context, uuid.UUID, time.Time) (float64, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.totCost, nil
}
func (f *fakeEventRepo) MarkSessionTerminatedReason(context.Context, uuid.UUID, string) error {
return nil
}
func (f *fakeEventRepo) ListSessionsForReaper(context.Context, time.Time, time.Time) ([]acp.ReaperSession, error) {
panic("n/a")
}
func (f *fakeEventRepo) ListSessionUsage(context.Context, uuid.UUID, int32) ([]*acp.SessionUsageRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]*acp.SessionUsageRecord, len(f.usageLedger))
copy(out, f.usageLedger)
return out, nil
}
func (f *fakeEventRepo) SummarizeAcpUsageByUser(context.Context, time.Time, time.Time) ([]acp.AcpUsageUserRow, error) {
panic("n/a")
}
func (f *fakeEventRepo) SummarizeAcpUsageByModel(context.Context, time.Time, time.Time) ([]acp.AcpUsageModelRow, error) {
panic("n/a")
}
func (f *fakeEventRepo) SummarizeAcpUsageByDay(context.Context, time.Time, time.Time) ([]acp.AcpUsageDayRow, error) {
panic("n/a")
}
func (f *fakeEventRepo) SessionRollup(context.Context, acp.DashboardFilter) (acp.SessionRollup, error) {
panic("n/a")
}
func (f *fakeEventRepo) SessionsByDay(context.Context, acp.DashboardFilter) ([]acp.SessionDayRow, error) {
panic("n/a")
}
func (f *fakeEventRepo) SessionTimeline(context.Context, uuid.UUID) ([]acp.SessionTimelineBucket, error) {
panic("n/a")
}
func (f *fakeEventRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) { func (f *fakeEventRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) {
panic("n/a") panic("n/a")
} }
@@ -133,6 +218,83 @@ func (f *fakeEventRepo) WithTx(pgx.Tx) acp.Repository {
return f return f
} }
// ----- Turn 方法(relay turn 测试用,功能性实现)-----
func (f *fakeEventRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) {
f.mu.Lock()
defer f.mu.Unlock()
cp := *t
cp.ID = int64(len(f.turns) + 1)
cp.Status = acp.TurnInProgress
f.turns = append(f.turns, &cp)
out := cp
return &out, nil
}
func (f *fakeEventRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, turnIndex int, stop string, updateCount int) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, t := range f.turns {
if t.TurnIndex == turnIndex {
t.Status = acp.TurnCompleted
s := stop
t.StopReason = &s
t.UpdateCount = updateCount
}
}
return nil
}
func (f *fakeEventRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, turnIndex int) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, t := range f.turns {
if t.TurnIndex == turnIndex {
t.Status = acp.TurnAborted
}
}
return nil
}
func (f *fakeEventRepo) IncrementTurnUpdateCount(context.Context, uuid.UUID, int) error { return nil }
func (f *fakeEventRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.turns) == 0 {
return nil, nil
}
out := *f.turns[len(f.turns)-1]
return &out, nil
}
func (f *fakeEventRepo) ListTurnsBySession(context.Context, uuid.UUID) ([]*acp.Turn, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]*acp.Turn, len(f.turns))
copy(out, f.turns)
return out, nil
}
func (f *fakeEventRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, reason string) error {
f.mu.Lock()
defer f.mu.Unlock()
r := reason
f.lastStopReason = &r
return nil
}
func (f *fakeEventRepo) PurgeTurnsBefore(context.Context, time.Time) (int64, error) { return 0, nil }
func (f *fakeEventRepo) turnSnapshot() []*acp.Turn {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]*acp.Turn, len(f.turns))
for i, t := range f.turns {
cp := *t
out[i] = &cp
}
return out
}
func (f *fakeEventRepo) lastStop() *string {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastStopReason
}
// relayTestRig wires up a Relay with two io.Pipe pairs that simulate the agent // relayTestRig wires up a Relay with two io.Pipe pairs that simulate the agent
// subprocess. Returns: // subprocess. Returns:
// - r: the relay under test // - r: the relay under test
@@ -470,3 +632,108 @@ func (f *fakeEventRepo) ExpirePendingPermissionRequestsBySession(context.Context
func (f *fakeEventRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { func (f *fakeEventRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) {
return 0, nil return 0, nil
} }
// ---------- Turn detection ----------
// TestRelay_TurnDetection feeds a session/update notification then a string-id
// session/prompt response with stopReason, asserting the tracker hooks fire:
// the turn is created, completed with the stop reason, and update_count tracked.
func TestRelay_TurnDetection(t *testing.T) {
t.Parallel()
rig := newRelayTestRig(t)
bus := &fakeBus{}
tr := acp.NewTurnTracker(rig.repo, bus, rig.sess, nil)
rig.r.SetTracker(tr)
cancel, doneCh := rig.runRelay(t)
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
// Register the turn (as session_service would before sending the prompt).
_, err := tr.StartTurn(context.Background(), "client-init")
require.NoError(t, err)
agentEnc := acp.NewEncoder(rig.agentWriter)
// Agent emits two session/update notifications then the prompt response.
notif, err := acp.NewNotification("session/update", map[string]any{
"sessionId": "agent-sess-1",
"update": map[string]any{"sessionUpdate": "agent_message_chunk"},
})
require.NoError(t, err)
require.NoError(t, agentEnc.Encode(notif))
require.NoError(t, agentEnc.Encode(notif))
idJSON, _ := json.Marshal("client-init")
resp := &acp.Message{
JSONRPC: "2.0", ID: idJSON,
Result: json.RawMessage(`{"stopReason":"max_tokens"}`),
}
require.NoError(t, agentEnc.Encode(resp))
// The turn must complete with stop_reason=max_tokens and update_count=2.
require.Eventually(t, func() bool {
ts := rig.repo.turnSnapshot()
if len(ts) != 1 {
return false
}
return ts[0].Status == acp.TurnCompleted &&
ts[0].StopReason != nil && *ts[0].StopReason == "max_tokens" &&
ts[0].UpdateCount == 2
}, 3*time.Second, 20*time.Millisecond, "turn not completed as expected")
// last_stop_reason persisted on the session.
require.Eventually(t, func() bool {
ls := rig.repo.lastStop()
return ls != nil && *ls == "max_tokens"
}, 2*time.Second, 20*time.Millisecond)
// Bus received turn_completed then session_idle.
require.Eventually(t, func() bool {
return len(bus.snapshot()) >= 2
}, 2*time.Second, 20*time.Millisecond)
evs := bus.snapshot()
assert.Equal(t, acp.TurnCompletedEvent, evs[0].Kind)
assert.Equal(t, acp.SessionIdleEvent, evs[1].Kind)
assert.Equal(t, acp.StopMaxTokens, evs[0].StopReason)
}
// TestRelay_IntResponseStillRoutesToInflight is a regression guard: with a
// tracker installed, an int64-id response must still route to inflight (the
// existing Call mechanism), not the turn path.
func TestRelay_IntResponseStillRoutesToInflight(t *testing.T) {
t.Parallel()
rig := newRelayTestRig(t)
rig.r.SetTracker(acp.NewTurnTracker(rig.repo, &fakeBus{}, rig.sess, nil))
cancel, doneCh := rig.runRelay(t)
t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) })
agentDec := acp.NewDecoder(rig.agentReader, 0)
agentEnc := acp.NewEncoder(rig.agentWriter)
agentDone := make(chan error, 1)
go func() {
req, err := agentDec.DecodeMessage()
if err != nil {
agentDone <- err
return
}
respMsg, err := acp.NewResponseOK(req.ID, map[string]any{"protocolVersion": 1})
if err != nil {
agentDone <- err
return
}
agentDone <- agentEnc.Encode(respMsg)
}()
ctx, cancelCall := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelCall()
resp, err := rig.r.Call(ctx, "initialize", map[string]any{"protocolVersion": 1})
require.NoError(t, err)
require.True(t, resp.IsResponse())
select {
case err := <-agentDone:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("agent goroutine did not finish")
}
}
+202
View File
@@ -36,14 +36,22 @@ type Repository interface {
// Session // Session
InsertSession(ctx context.Context, s *Session) (*Session, error) InsertSession(ctx context.Context, s *Session) (*Session, error)
GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error)
// GetSessionByStepID 返回某编排器 step 最近的 session(任意状态);无则 NotFound。
GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*Session, error)
// GetCrashedSessionForResume 返回某编排器 step 最近一次 crashed/exited 的 session(用于 resume)。
GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*Session, error)
// requirementID 非 nil 时仅返回关联该需求的 sessions。 // requirementID 非 nil 时仅返回关联该需求的 sessions。
ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error)
ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error) ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error)
UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error
// UpdateSessionSandboxMode 记录本 session 运行所用的沙箱模式(审计/取证)。
UpdateSessionSandboxMode(ctx context.Context, id uuid.UUID, mode string) error
UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error
// MarkSessionFailedIfActive 把仍处于 starting/running 的 session 标记为 crashed // MarkSessionFailedIfActive 把仍处于 starting/running 的 session 标记为 crashed
// 并写入 lastErr;已是终态时不更新(守卫式,避免覆盖 onExit 写入的终态)。 // 并写入 lastErr;已是终态时不更新(守卫式,避免覆盖 onExit 写入的终态)。
MarkSessionFailedIfActive(ctx context.Context, id uuid.UUID, lastErr string) (bool, error) MarkSessionFailedIfActive(ctx context.Context, id uuid.UUID, lastErr string) (bool, error)
// ResetSessionForResume 把崩溃/退出 session 复用为新一轮(回 starting、清运行时字段)。
ResetSessionForResume(ctx context.Context, id uuid.UUID) error
CountActiveSessions(ctx context.Context) (int64, error) CountActiveSessions(ctx context.Context) (int64, error)
CountActiveSessionsByUser(ctx context.Context, userID uuid.UUID) (int64, error) CountActiveSessionsByUser(ctx context.Context, userID uuid.UUID) (int64, error)
ResetStuckSessionsOnRestart(ctx context.Context) ([]*Session, error) ResetStuckSessionsOnRestart(ctx context.Context) ([]*Session, error)
@@ -57,6 +65,32 @@ type Repository interface {
ListEventsSince(ctx context.Context, sessionID uuid.UUID, sinceID int64, limit int32) ([]*Event, error) ListEventsSince(ctx context.Context, sessionID uuid.UUID, sinceID int64, limit int32) ([]*Event, error)
PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error)
// 成本核算 / 预算 / reaper
InsertSessionUsage(ctx context.Context, r *SessionUsageRecord) (inserted bool, err error)
AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (totPrompt, totCompletion, totThinking int64, totCost float64, err error)
SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error)
MarkSessionTerminatedReason(ctx context.Context, sid uuid.UUID, reason string) error
ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error)
ListSessionUsage(ctx context.Context, sessionID uuid.UUID, limit int32) ([]*SessionUsageRecord, error)
SummarizeAcpUsageByUser(ctx context.Context, from, to time.Time) ([]AcpUsageUserRow, error)
SummarizeAcpUsageByModel(ctx context.Context, from, to time.Time) ([]AcpUsageModelRow, error)
SummarizeAcpUsageByDay(ctx context.Context, from, to time.Time) ([]AcpUsageDayRow, error)
// run-history dashboard(只读聚合)
SessionRollup(ctx context.Context, f DashboardFilter) (SessionRollup, error)
SessionsByDay(ctx context.Context, f DashboardFilter) ([]SessionDayRow, error)
SessionTimeline(ctx context.Context, sessionID uuid.UUID) ([]SessionTimelineBucket, error)
// Turn(回合完成检测)
InsertTurn(ctx context.Context, t *Turn) (*Turn, error)
MarkTurnCompleted(ctx context.Context, sessionID uuid.UUID, turnIndex int, stop string, updateCount int) error
MarkTurnAborted(ctx context.Context, sessionID uuid.UUID, turnIndex int) error
IncrementTurnUpdateCount(ctx context.Context, sessionID uuid.UUID, turnIndex int) error
GetLatestTurnBySession(ctx context.Context, sessionID uuid.UUID) (*Turn, error)
ListTurnsBySession(ctx context.Context, sessionID uuid.UUID) ([]*Turn, error)
UpdateSessionLastStopReason(ctx context.Context, sessionID uuid.UUID, reason string) error
PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error)
// PermissionRequest // PermissionRequest
InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error)
GetPermissionRequestByID(ctx context.Context, id uuid.UUID) (*PermissionRequest, error) GetPermissionRequestByID(ctx context.Context, id uuid.UUID) (*PermissionRequest, error)
@@ -159,6 +193,10 @@ func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind,
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ClientType: string(k.ClientType), ClientType: string(k.ClientType),
EncryptedMcpServers: k.EncryptedMCPServers, EncryptedMcpServers: k.EncryptedMCPServers,
ModelID: toPgUUIDPtr(k.ModelID),
MaxCostUsd: float64PtrToNumeric(k.MaxCostUSD),
MaxTokens: k.MaxTokens,
MaxWallClockSeconds: k.MaxWallClockSeconds,
}) })
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind") return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind")
@@ -218,6 +256,10 @@ func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind,
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
ClientType: string(k.ClientType), ClientType: string(k.ClientType),
EncryptedMcpServers: k.EncryptedMCPServers, EncryptedMcpServers: k.EncryptedMCPServers,
ModelID: toPgUUIDPtr(k.ModelID),
MaxCostUsd: float64PtrToNumeric(k.MaxCostUSD),
MaxTokens: k.MaxTokens,
MaxWallClockSeconds: k.MaxWallClockSeconds,
}) })
if err != nil { if err != nil {
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found") return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found")
@@ -300,6 +342,10 @@ func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
CreatedBy: fromPgUUID(row.CreatedBy), CreatedBy: fromPgUUID(row.CreatedBy),
CreatedAt: row.CreatedAt.Time, CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
ModelID: fromPgUUIDPtr(row.ModelID),
MaxCostUSD: numericToFloat64Ptr(row.MaxCostUsd),
MaxTokens: row.MaxTokens,
MaxWallClockSeconds: row.MaxWallClockSeconds,
} }
} }
@@ -339,6 +385,10 @@ func (r *pgRepo) InsertSession(ctx context.Context, s *Session) (*Session, error
CwdPath: s.CwdPath, CwdPath: s.CwdPath,
IsMainWorktree: s.IsMainWorktree, IsMainWorktree: s.IsMainWorktree,
Status: string(s.Status), Status: string(s.Status),
OrchestratorStepID: toPgUUIDPtr(s.OrchestratorStepID),
BudgetMaxCostUsd: float64PtrToNumeric(s.BudgetMaxCostUSD),
BudgetMaxTokens: s.BudgetMaxTokens,
BudgetMaxWallClockSeconds: s.BudgetMaxWallClockSeconds,
}) })
if err != nil { if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "insert session") return nil, errs.Wrap(err, errs.CodeInternal, "insert session")
@@ -354,6 +404,22 @@ func (r *pgRepo) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, er
return rowToSession(row), nil return rowToSession(row), nil
} }
func (r *pgRepo) GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*Session, error) {
row, err := r.q.GetSessionByStepID(ctx, toPgUUID(stepID))
if err != nil {
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpSessionNotFound, "session not found for step")
}
return rowToSession(row), nil
}
func (r *pgRepo) GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*Session, error) {
row, err := r.q.GetCrashedSessionForResume(ctx, toPgUUID(stepID))
if err != nil {
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpSessionNotFound, "no crashed session to resume")
}
return rowToSession(row), nil
}
func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) { func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) {
rows, err := r.q.ListSessionsByUser(ctx, acpsqlc.ListSessionsByUserParams{ rows, err := r.q.ListSessionsByUser(ctx, acpsqlc.ListSessionsByUserParams{
UserID: toPgUUID(userID), UserID: toPgUUID(userID),
@@ -392,6 +458,17 @@ func (r *pgRepo) UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSe
return nil return nil
} }
func (r *pgRepo) UpdateSessionSandboxMode(ctx context.Context, id uuid.UUID, mode string) error {
m := mode
if err := r.q.UpdateSessionSandboxMode(ctx, acpsqlc.UpdateSessionSandboxModeParams{
ID: toPgUUID(id),
SandboxMode: &m,
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "update session sandbox mode")
}
return nil
}
func (r *pgRepo) UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error { func (r *pgRepo) UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error {
if err := r.q.UpdateSessionFinished(ctx, acpsqlc.UpdateSessionFinishedParams{ if err := r.q.UpdateSessionFinished(ctx, acpsqlc.UpdateSessionFinishedParams{
ID: toPgUUID(id), ID: toPgUUID(id),
@@ -415,6 +492,13 @@ func (r *pgRepo) MarkSessionFailedIfActive(ctx context.Context, id uuid.UUID, la
return n > 0, nil return n > 0, nil
} }
func (r *pgRepo) ResetSessionForResume(ctx context.Context, id uuid.UUID) error {
if err := r.q.ResetSessionForResume(ctx, toPgUUID(id)); err != nil {
return errs.Wrap(err, errs.CodeInternal, "reset session for resume")
}
return nil
}
func (r *pgRepo) CountActiveSessions(ctx context.Context) (int64, error) { func (r *pgRepo) CountActiveSessions(ctx context.Context) (int64, error) {
n, err := r.q.CountActiveSessions(ctx) n, err := r.q.CountActiveSessions(ctx)
if err != nil { if err != nil {
@@ -497,6 +581,17 @@ func rowToSession(row acpsqlc.AcpSession) *Session {
LastError: row.LastError, LastError: row.LastError,
StartedAt: row.StartedAt.Time, StartedAt: row.StartedAt.Time,
EndedAt: endedAt, EndedAt: endedAt,
LastStopReason: row.LastStopReason,
OrchestratorStepID: fromPgUUIDPtr(row.OrchestratorStepID),
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
TotalCostUSD: numericToFloat64(row.TotalCostUsd),
LastActivityAt: row.LastActivityAt.Time,
BudgetMaxCostUSD: numericToFloat64Ptr(row.BudgetMaxCostUsd),
BudgetMaxTokens: row.BudgetMaxTokens,
BudgetMaxWallClockSeconds: row.BudgetMaxWallClockSeconds,
TerminatedReason: row.TerminatedReason,
} }
} }
@@ -556,6 +651,113 @@ func rowToEvent(row acpsqlc.AcpEvent) *Event {
} }
} }
// ===== Turn(回合完成检测)=====
func (r *pgRepo) InsertTurn(ctx context.Context, t *Turn) (*Turn, error) {
row, err := r.q.InsertTurn(ctx, acpsqlc.InsertTurnParams{
SessionID: toPgUUID(t.SessionID),
TurnIndex: int32(t.TurnIndex),
PromptRequestID: t.PromptRequestID,
Status: string(t.Status),
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "insert acp turn")
}
return rowToTurn(row), nil
}
func (r *pgRepo) MarkTurnCompleted(ctx context.Context, sessionID uuid.UUID, turnIndex int, stop string, updateCount int) error {
if err := r.q.MarkTurnCompleted(ctx, acpsqlc.MarkTurnCompletedParams{
SessionID: toPgUUID(sessionID),
TurnIndex: int32(turnIndex),
StopReason: &stop,
UpdateCount: int32(updateCount),
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "mark turn completed")
}
return nil
}
func (r *pgRepo) MarkTurnAborted(ctx context.Context, sessionID uuid.UUID, turnIndex int) error {
if err := r.q.MarkTurnAborted(ctx, acpsqlc.MarkTurnAbortedParams{
SessionID: toPgUUID(sessionID),
TurnIndex: int32(turnIndex),
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "mark turn aborted")
}
return nil
}
func (r *pgRepo) IncrementTurnUpdateCount(ctx context.Context, sessionID uuid.UUID, turnIndex int) error {
if err := r.q.IncrementTurnUpdateCount(ctx, acpsqlc.IncrementTurnUpdateCountParams{
SessionID: toPgUUID(sessionID),
TurnIndex: int32(turnIndex),
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "increment turn update count")
}
return nil
}
func (r *pgRepo) GetLatestTurnBySession(ctx context.Context, sessionID uuid.UUID) (*Turn, error) {
row, err := r.q.GetLatestTurnBySession(ctx, toPgUUID(sessionID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, errs.Wrap(err, errs.CodeInternal, "get latest turn by session")
}
return rowToTurn(row), nil
}
func (r *pgRepo) ListTurnsBySession(ctx context.Context, sessionID uuid.UUID) ([]*Turn, error) {
rows, err := r.q.ListTurnsBySession(ctx, toPgUUID(sessionID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list turns by session")
}
out := make([]*Turn, 0, len(rows))
for _, row := range rows {
out = append(out, rowToTurn(row))
}
return out, nil
}
func (r *pgRepo) UpdateSessionLastStopReason(ctx context.Context, sessionID uuid.UUID, reason string) error {
if err := r.q.UpdateSessionLastStopReason(ctx, acpsqlc.UpdateSessionLastStopReasonParams{
ID: toPgUUID(sessionID),
LastStopReason: &reason,
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "update session last stop reason")
}
return nil
}
func (r *pgRepo) PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error) {
n, err := r.q.PurgeTurnsBefore(ctx, pgtype.Timestamptz{Time: before, Valid: true})
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "purge acp turns")
}
return n, nil
}
func rowToTurn(row acpsqlc.AcpTurn) *Turn {
var completedAt *time.Time
if row.CompletedAt.Valid {
t := row.CompletedAt.Time
completedAt = &t
}
return &Turn{
ID: row.ID,
SessionID: fromPgUUID(row.SessionID),
TurnIndex: int(row.TurnIndex),
PromptRequestID: row.PromptRequestID,
Status: TurnStatus(row.Status),
StopReason: row.StopReason,
UpdateCount: int(row.UpdateCount),
StartedAt: row.StartedAt.Time,
CompletedAt: completedAt,
}
}
// ===== PermissionRequest ===== // ===== PermissionRequest =====
func (r *pgRepo) InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) { func (r *pgRepo) InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) {
+218
View File
@@ -234,6 +234,86 @@ func TestPgRepo_Event_OrderAndPurge(t *testing.T) {
assert.Equal(t, int64(3), n) assert.Equal(t, int64(3), n)
} }
func TestPgRepo_Turn_Lifecycle(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo, pool := setupRepo(t)
uid := mustInsertUser(t, ctx, pool, "u6@local", false)
pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid)
k, _ := repo.CreateAgentKind(ctx, &acp.AgentKind{
ID: uuid.New(), Name: "kind6", DisplayName: "x",
BinaryPath: "x", Enabled: true, CreatedBy: uid,
})
sess, _ := repo.InsertSession(ctx, &acp.Session{
ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid,
AgentKindID: k.ID, UserID: uid,
Branch: "main", CwdPath: "/tmp", IsMainWorktree: true, Status: acp.SessionRunning,
})
// 新 session 无回合
latest, err := repo.GetLatestTurnBySession(ctx, sess.ID)
require.NoError(t, err)
assert.Nil(t, latest)
// 插入回合 0
pidStr := "client-init"
turn, err := repo.InsertTurn(ctx, &acp.Turn{
SessionID: sess.ID, TurnIndex: 0, PromptRequestID: &pidStr, Status: acp.TurnInProgress,
})
require.NoError(t, err)
assert.Equal(t, 0, turn.TurnIndex)
assert.Equal(t, acp.TurnInProgress, turn.Status)
// IncrementTurnUpdateCount ×2(开放回合)
require.NoError(t, repo.IncrementTurnUpdateCount(ctx, sess.ID, 0))
require.NoError(t, repo.IncrementTurnUpdateCount(ctx, sess.ID, 0))
// 完成回合:MarkTurnCompleted 用内存计数覆盖 update_count
require.NoError(t, repo.MarkTurnCompleted(ctx, sess.ID, 0, "end_turn", 5))
require.NoError(t, repo.UpdateSessionLastStopReason(ctx, sess.ID, "end_turn"))
ts, err := repo.ListTurnsBySession(ctx, sess.ID)
require.NoError(t, err)
require.Len(t, ts, 1)
assert.Equal(t, acp.TurnCompleted, ts[0].Status)
require.NotNil(t, ts[0].StopReason)
assert.Equal(t, "end_turn", *ts[0].StopReason)
assert.Equal(t, 5, ts[0].UpdateCount)
require.NotNil(t, ts[0].CompletedAt)
// last_stop_reason round-trips on session SELECT
got, _ := repo.GetSessionByID(ctx, sess.ID)
require.NotNil(t, got.LastStopReason)
assert.Equal(t, "end_turn", *got.LastStopReason)
// GetLatestTurnBySession 返回 index 最大的回合
latest, err = repo.GetLatestTurnBySession(ctx, sess.ID)
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, 0, latest.TurnIndex)
// UNIQUE(session_id, turn_index) 约束
_, err = repo.InsertTurn(ctx, &acp.Turn{SessionID: sess.ID, TurnIndex: 0, Status: acp.TurnInProgress})
require.Error(t, err, "duplicate (session_id, turn_index) must violate UNIQUE")
// 插入并中止回合 1
_, err = repo.InsertTurn(ctx, &acp.Turn{SessionID: sess.ID, TurnIndex: 1, Status: acp.TurnInProgress})
require.NoError(t, err)
require.NoError(t, repo.MarkTurnAborted(ctx, sess.ID, 1))
latest, _ = repo.GetLatestTurnBySession(ctx, sess.ID)
assert.Equal(t, 1, latest.TurnIndex)
assert.Equal(t, acp.TurnAborted, latest.Status)
// Purge before now+1m → 全删
purged, err := repo.PurgeTurnsBefore(ctx, time.Now().Add(time.Minute))
require.NoError(t, err)
assert.Equal(t, int64(2), purged)
// 幂等
purged2, _ := repo.PurgeTurnsBefore(ctx, time.Now().Add(time.Minute))
assert.Equal(t, int64(0), purged2)
}
// ===== test helpers ===== // ===== test helpers =====
func mustInsertUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, email string, admin bool) uuid.UUID { func mustInsertUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, email string, admin bool) uuid.UUID {
@@ -259,3 +339,141 @@ func mustInsertProjectWorkspace(t *testing.T, ctx context.Context, pool *pgxpool
require.NoError(t, err) require.NoError(t, err)
return pid, wsid return pid, wsid
} }
// TestPgRepo_UsagePersist_BumpsDashboardColumns 验证 AddSessionUsageTotals 同时
// 把 dashboard rollup 列(cost_usd / tokens_total)与运行时累加器对齐。
func TestPgRepo_UsagePersist_BumpsDashboardColumns(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo, pool := setupRepo(t)
uid := mustInsertUser(t, ctx, pool, "dash-usage@local", false)
pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid)
k, _ := repo.CreateAgentKind(ctx, &acp.AgentKind{
ID: uuid.New(), Name: "kind-dash-usage", DisplayName: "x",
BinaryPath: "x", Enabled: true, CreatedBy: uid,
})
sess, _ := repo.InsertSession(ctx, &acp.Session{
ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid,
AgentKindID: k.ID, UserID: uid,
Branch: "main", CwdPath: "/tmp", IsMainWorktree: true, Status: acp.SessionRunning,
})
// 两次回合累加。
_, _, _, _, err := repo.AddSessionUsageTotals(ctx, sess.ID, 100, 50, 10, 0.30)
require.NoError(t, err)
_, _, _, totCost, err := repo.AddSessionUsageTotals(ctx, sess.ID, 200, 40, 0, 0.20)
require.NoError(t, err)
assert.InDelta(t, 0.50, totCost, 1e-9)
got, err := repo.GetSessionByID(ctx, sess.ID)
require.NoError(t, err)
assert.InDelta(t, 0.50, got.TotalCostUSD, 1e-9)
// 运行时累加器 totals。
assert.Equal(t, int64(300), got.PromptTokens)
assert.Equal(t, int64(90), got.CompletionTokens)
assert.Equal(t, int64(10), got.ThinkingTokens)
// dashboard 汇总应读到与运行时一致的 cost / tokens。
rollup, err := repo.SessionRollup(ctx, acp.DashboardFilter{
UserID: &uid, From: time.Now().Add(-time.Hour), To: time.Now().Add(time.Hour),
})
require.NoError(t, err)
assert.Equal(t, int64(1), rollup.Total)
assert.Equal(t, int64(1), rollup.Active)
assert.InDelta(t, 0.50, rollup.TotalCostUSD, 1e-9)
assert.Equal(t, int64(400), rollup.TotalTokens) // 100+50+10 + 200+40+0
}
// TestPgRepo_Dashboard_RollupTimelineByDay 覆盖三个 dashboard 查询:
// SessionRollup(按状态计数 + 成本 + scope)、SessionsByDay、SessionTimeline。
func TestPgRepo_Dashboard_RollupTimelineByDay(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo, pool := setupRepo(t)
owner := mustInsertUser(t, ctx, pool, "dash-owner@local", false)
other := mustInsertUser(t, ctx, pool, "dash-other@local", false)
pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, owner)
k, _ := repo.CreateAgentKind(ctx, &acp.AgentKind{
ID: uuid.New(), Name: "kind-dash", DisplayName: "x",
BinaryPath: "x", Enabled: true, CreatedBy: owner,
})
mk := func(u uuid.UUID, status acp.SessionStatus) *acp.Session {
s, err := repo.InsertSession(ctx, &acp.Session{
ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid,
AgentKindID: k.ID, UserID: u,
Branch: "b", CwdPath: "/tmp", IsMainWorktree: false, Status: status,
})
require.NoError(t, err)
return s
}
exited := mk(owner, acp.SessionRunning)
crashed := mk(owner, acp.SessionRunning)
mk(other, acp.SessionRunning) // 另一用户:owner-scope 下不计入
// owner 的两个会话各累加成本并结束。
_, _, _, _, err := repo.AddSessionUsageTotals(ctx, exited.ID, 100, 100, 0, 1.00)
require.NoError(t, err)
_, _, _, _, err = repo.AddSessionUsageTotals(ctx, crashed.ID, 50, 50, 0, 0.50)
require.NoError(t, err)
ec := int32(0)
require.NoError(t, repo.UpdateSessionFinished(ctx, exited.ID, acp.SessionExited, &ec, nil))
require.NoError(t, repo.UpdateSessionFinished(ctx, crashed.ID, acp.SessionCrashed, &ec, nil))
from := time.Now().Add(-time.Hour)
to := time.Now().Add(time.Hour)
// owner scope:只统计 owner 的 2 个会话。
ownerScope := acp.DashboardFilter{UserID: &owner, From: from, To: to}
rollup, err := repo.SessionRollup(ctx, ownerScope)
require.NoError(t, err)
assert.Equal(t, int64(2), rollup.Total)
assert.Equal(t, int64(1), rollup.Succeeded) // exited
assert.Equal(t, int64(1), rollup.Crashed)
assert.Equal(t, int64(0), rollup.Active)
assert.InDelta(t, 1.50, rollup.TotalCostUSD, 1e-9)
assert.True(t, rollup.AvgDurationSeconds >= 0)
// admin scope(UserID nil):跨用户 3 个会话。
adminRollup, err := repo.SessionRollup(ctx, acp.DashboardFilter{From: from, To: to})
require.NoError(t, err)
assert.Equal(t, int64(3), adminRollup.Total)
// SessionsByDay:owner scope 当天 1 行,total=2。
byDay, err := repo.SessionsByDay(ctx, ownerScope)
require.NoError(t, err)
require.Len(t, byDay, 1)
assert.Equal(t, int64(2), byDay[0].Total)
assert.Equal(t, int64(1), byDay[0].Succeeded)
assert.InDelta(t, 1.50, byDay[0].TotalCostUSD, 1e-9)
// SessionTimeline:插入两类事件,按桶聚合。
method := "session/update"
for i := 0; i < 3; i++ {
_, err := repo.InsertEvent(ctx, &acp.Event{
SessionID: exited.ID, Direction: acp.DirectionOut, RPCKind: acp.RPCNotification,
Method: &method, Payload: []byte(`{}`), PayloadSize: 2,
})
require.NoError(t, err)
}
reqMethod := "session/prompt"
_, err = repo.InsertEvent(ctx, &acp.Event{
SessionID: exited.ID, Direction: acp.DirectionOut, RPCKind: acp.RPCRequest,
Method: &reqMethod, Payload: []byte(`{}`), PayloadSize: 2,
})
require.NoError(t, err)
timeline, err := repo.SessionTimeline(ctx, exited.ID)
require.NoError(t, err)
require.Len(t, timeline, 2) // (out,notification,session/update) + (out,request,session/prompt)
total := int64(0)
for _, b := range timeline {
total += b.EventCount
assert.False(t, b.FirstAt.IsZero())
assert.False(t, b.LastAt.IsZero())
}
assert.Equal(t, int64(4), total)
}
+115
View File
@@ -0,0 +1,115 @@
// Package sandbox provides a pluggable per-session execution sandbox that sits
// between acp.session_service.Create and acp.Supervisor.Spawn. It confines an
// agent child process to a low-privilege UID, bind-mounts only that session's
// worktree + the per-user agent home, applies OS resource limits, and routes
// egress through an allowlist proxy — all WITHOUT touching proc.Group's
// Setpgid-based tree-kill (Apply only ADDS to cmd.SysProcAttr, never replaces).
//
// Three modes, config-gated via acp.sandbox.mode:
// - none : no-op (default; Windows dev + CI + any non-Linux box)
// - uid : drop to a low-priv UID + setrlimit (+ optional namespace/bind
// mounts) — Linux only
// - container : run the agent inside a rootless runc/bwrap or docker container
// on an egress-restricted network — Linux only
//
// The Linux-specific mechanics live in build-tagged files; this file holds the
// cross-platform contract and the factory so app.go and supervisor.go compile
// on every platform.
package sandbox
import "os/exec"
// Mode selects the sandbox strategy.
type Mode string
const (
// ModeNone disables sandboxing (no-op Apply). Default everywhere except
// production Linux.
ModeNone Mode = "none"
// ModeUID drops to a low-priv UID + setrlimit (+ optional bind mounts).
ModeUID Mode = "uid"
// ModeContainer runs the child inside a rootless container.
ModeContainer Mode = "container"
)
// Limits captures the OS resource limits applied to the child (and its tree).
// A zero field means "do not set this limit" (inherit the parent's).
type Limits struct {
AddressSpaceBytes uint64 // RLIMIT_AS — caps total virtual memory (anti big-alloc)
NProc uint64 // RLIMIT_NPROC — caps process/thread count (anti fork-bomb)
CPUSeconds uint64 // RLIMIT_CPU — caps CPU seconds
PIDs uint64 // cgroup pids.max (container mode); mirrors NProc for uid
DiskBytes uint64 // RLIMIT_FSIZE — caps max file size the child can write
}
// Spec is the per-spawn sandbox request, derived from the session + agent kind.
type Spec struct {
Mode Mode
UID int // target low-priv uid (uid mode); base+offset computed by caller
GID int // target low-priv gid
HomeDir string // per-user agent home (writable, bind-mounted in)
WorktreeDir string // this session's worktree (writable, bind-mounted in)
DataRoot string // /data root to mask everything else under
Rlimits Limits
ProxyURL string // HTTP(S)_PROXY value injected into the child env (egress allowlist)
NoProxy string // NO_PROXY value (e.g. localhost,127.0.0.1)
}
// Sandbox applies a Spec to an exec.Cmd before it is started.
type Sandbox interface {
// Apply mutates cmd.SysProcAttr (Credential, namespace/chroot flags) and
// cmd.Env (HTTP(S)_PROXY/NO_PROXY) BEFORE proc.Group.Prepare and cmd.Start.
// It returns a cleanup closure (unmount binds, remove ephemeral dirs, stop
// container) that the supervisor calls from monitorProcess AFTER
// group.Close(). cleanup is always non-nil (a no-op when there is nothing to
// clean) and must be safe to call exactly once. Apply MUST NOT set Setpgid —
// that is proc.Group's job; on Linux it ADDS to the existing SysProcAttr.
Apply(cmd *exec.Cmd, spec Spec) (cleanup func(), err error)
}
// New returns the Sandbox implementation for the given mode. On non-Linux
// platforms (or mode=none) it always returns the no-op sandbox. The actual
// uid/container constructors are provided by build-tagged files; newPlatform
// returns nil when the mode is unsupported on this OS, in which case we fall
// back to no-op so dev/CI never break.
func New(mode Mode) Sandbox {
switch mode {
case ModeUID, ModeContainer:
if sb := newPlatform(mode); sb != nil {
return sb
}
// Unsupported on this platform → safe no-op fallback.
return noopSandbox{}
default:
return noopSandbox{}
}
}
// noopSandbox makes no changes and returns a no-op cleanup. It is the default
// and the fallback for unsupported modes/platforms.
type noopSandbox struct{}
func (noopSandbox) Apply(_ *exec.Cmd, _ Spec) (func(), error) {
return func() {}, nil
}
// injectProxyEnv appends egress-proxy env vars to cmd.Env when a proxy URL is
// configured. Shared by uid + container modes (both honor HTTP(S)_PROXY). Kept
// here so the cross-platform contract is testable without build tags.
func injectProxyEnv(cmd *exec.Cmd, spec Spec) {
if spec.ProxyURL == "" {
return
}
noProxy := spec.NoProxy
if noProxy == "" {
noProxy = "localhost,127.0.0.1,::1"
}
cmd.Env = append(cmd.Env,
"HTTP_PROXY="+spec.ProxyURL,
"HTTPS_PROXY="+spec.ProxyURL,
"http_proxy="+spec.ProxyURL,
"https_proxy="+spec.ProxyURL,
"NO_PROXY="+noProxy,
"no_proxy="+noProxy,
)
}
@@ -0,0 +1,118 @@
//go:build linux
package sandbox
import (
"fmt"
"os/exec"
"strconv"
"time"
"github.com/google/uuid"
)
// containerSandbox runs the agent binary inside a rootless container with an
// egress-restricted network and the two writable paths bind-mounted in. This is
// the hard isolation boundary (UID + filesystem mask + network policy + cgroup
// limits) — unlike uid mode, a non-cooperating binary cannot bypass the egress
// allowlist because the container network only routes through the proxy/network
// policy.
//
// It rewrites cmd to `docker run --rm --name <n> --user uid:gid --read-only
// --network <egress-net> --pids-limit --memory --cpus --mount <home> --mount
// <worktree> -w <worktree> <image> <binary> <args>` where the image is the same
// runtime image (so the agent CLI is present). The cleanup closure runs
// `docker stop <n>` so proc.Group's tree-kill (which signals the `docker run`
// client's pgid) is reinforced by stopping the actual container — otherwise
// killing the client could orphan the container.
//
// Tree-kill invariant: Apply does NOT set Setpgid (proc.Group owns it). docker
// run becomes the group leader; killing -pgid signals it, and cleanup() issues
// docker stop for the container it spawned. The mapping client->container is via
// the deterministic --name we assign.
//
// The container runtime + egress network are provisioned by the deployment
// (Dockerfile installs the runtime; docker-compose defines the egress-net + the
// forward-proxy sidecar). Image/network/runtime are read from the spec's
// DataRoot convention or sensible defaults; here we keep them parameterized via
// package-level defaults so app.go can override without a code change later.
type containerSandbox struct{}
// container runtime defaults; overridable by deployment via env in a later pass.
const (
defaultRuntime = "docker"
defaultImage = "agent-coding-workflow-sandbox:latest"
defaultEgressNet = "egress-net"
containerStopWait = 5 * time.Second
)
func (s *containerSandbox) Apply(cmd *exec.Cmd, spec Spec) (func(), error) {
if spec.WorktreeDir == "" {
return func() {}, fmt.Errorf("sandbox container: worktree dir required")
}
runtime, err := exec.LookPath(defaultRuntime)
if err != nil {
// Runtime missing: fall back to no-op cleanup but surface the error so
// the supervisor can decide (it currently logs and proceeds unsandboxed
// only if configured to; otherwise spawn fails).
return func() {}, fmt.Errorf("sandbox container: %s not found: %w", defaultRuntime, err)
}
name := "acw-sbx-" + uuid.NewString()
orig := append([]string{cmd.Path}, cmd.Args[1:]...)
runArgs := []string{
"run", "--rm", "--name", name,
"--read-only",
"--network", defaultEgressNet,
"-w", spec.WorktreeDir,
"--mount", "type=bind,source=" + spec.WorktreeDir + ",target=" + spec.WorktreeDir,
}
if spec.HomeDir != "" {
runArgs = append(runArgs,
"--mount", "type=bind,source="+spec.HomeDir+",target="+spec.HomeDir,
"-e", "HOME="+spec.HomeDir)
}
if spec.UID > 0 {
gid := spec.GID
if gid <= 0 {
gid = spec.UID
}
runArgs = append(runArgs, "--user", strconv.Itoa(spec.UID)+":"+strconv.Itoa(gid))
}
if spec.Rlimits.AddressSpaceBytes > 0 {
runArgs = append(runArgs, "--memory", strconv.FormatUint(spec.Rlimits.AddressSpaceBytes, 10))
}
if spec.Rlimits.PIDs > 0 {
runArgs = append(runArgs, "--pids-limit", strconv.FormatUint(spec.Rlimits.PIDs, 10))
} else if spec.Rlimits.NProc > 0 {
runArgs = append(runArgs, "--pids-limit", strconv.FormatUint(spec.Rlimits.NProc, 10))
}
if spec.Rlimits.CPUSeconds > 0 {
// docker has no direct CPU-seconds cap; approximate with --cpus=1 and
// rely on the wall-clock/budget reaper for hard CPU-time bounds.
runArgs = append(runArgs, "--cpus", "1")
}
// Egress proxy env (the proxy itself enforces the allowlist on egress-net).
if spec.ProxyURL != "" {
noProxy := spec.NoProxy
if noProxy == "" {
noProxy = "localhost,127.0.0.1,::1"
}
runArgs = append(runArgs,
"-e", "HTTP_PROXY="+spec.ProxyURL,
"-e", "HTTPS_PROXY="+spec.ProxyURL,
"-e", "NO_PROXY="+noProxy)
}
runArgs = append(runArgs, defaultImage)
runArgs = append(runArgs, orig...)
cmd.Path = runtime
cmd.Args = append([]string{runtime}, runArgs...)
cleanup := func() {
stop := exec.Command(runtime, "stop", "-t", strconv.Itoa(int(containerStopWait.Seconds())), name)
_ = stop.Run() // best-effort; --rm removes it after stop
}
return cleanup, nil
}
+15
View File
@@ -0,0 +1,15 @@
//go:build linux
package sandbox
// newPlatform dispatches the Linux sandbox modes to their concrete impls.
func newPlatform(mode Mode) Sandbox {
switch mode {
case ModeUID:
return &uidSandbox{}
case ModeContainer:
return &containerSandbox{}
default:
return nil
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package sandbox
// newPlatform returns nil on non-Linux platforms: UID drop, setrlimit and
// bind-mount namespaces are Linux-only, so Windows dev and macOS always fall
// back to the no-op sandbox (see New). This is the documented contract that
// mode=none is the only supported mode off Linux.
func newPlatform(_ Mode) Sandbox { return nil }
+59
View File
@@ -0,0 +1,59 @@
package sandbox
import (
"os/exec"
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
func TestNew_NoneAlwaysNoop(t *testing.T) {
sb := New(ModeNone)
require.IsType(t, noopSandbox{}, sb)
}
func TestNew_UnsupportedPlatformFallsBackToNoop(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("uid/container are supported on linux; this asserts the non-linux fallback")
}
// On non-linux, uid/container must fall back to no-op so dev/CI never break.
require.IsType(t, noopSandbox{}, New(ModeUID))
require.IsType(t, noopSandbox{}, New(ModeContainer))
}
func TestNoopSandbox_MutatesNothing(t *testing.T) {
cmd := exec.Command("echo", "hi")
before := cmd.SysProcAttr
cleanup, err := noopSandbox{}.Apply(cmd, Spec{Mode: ModeNone, ProxyURL: "http://proxy:8080"})
require.NoError(t, err)
require.NotNil(t, cleanup)
// no-op must NOT inject proxy env nor touch SysProcAttr.
require.Equal(t, before, cmd.SysProcAttr)
require.Empty(t, cmd.Env)
// cleanup is safe to call.
cleanup()
}
func TestInjectProxyEnv(t *testing.T) {
cmd := exec.Command("echo", "hi")
cmd.Env = []string{"PATH=/usr/bin"}
injectProxyEnv(cmd, Spec{ProxyURL: "http://proxy:3128"})
require.Contains(t, cmd.Env, "HTTP_PROXY=http://proxy:3128")
require.Contains(t, cmd.Env, "HTTPS_PROXY=http://proxy:3128")
require.Contains(t, cmd.Env, "NO_PROXY=localhost,127.0.0.1,::1")
// original env preserved.
require.Contains(t, cmd.Env, "PATH=/usr/bin")
}
func TestInjectProxyEnv_NoProxyURLIsNoop(t *testing.T) {
cmd := exec.Command("echo", "hi")
injectProxyEnv(cmd, Spec{})
require.Empty(t, cmd.Env)
}
func TestInjectProxyEnv_CustomNoProxy(t *testing.T) {
cmd := exec.Command("echo", "hi")
injectProxyEnv(cmd, Spec{ProxyURL: "http://p", NoProxy: "internal.local"})
require.Contains(t, cmd.Env, "NO_PROXY=internal.local")
}
+92
View File
@@ -0,0 +1,92 @@
//go:build linux
package sandbox
import (
"fmt"
"os/exec"
"strconv"
"syscall"
)
// uidSandbox confines the agent child to a low-privilege UID/GID and applies OS
// resource limits, while leaving proc.Group's Setpgid (tree-kill) intact.
//
// Tree-kill invariant: we ONLY add Credential to the existing SysProcAttr. We do
// NOT touch Setpgid (proc.Group sets it after Apply via group.Prepare). The
// negative-pgid SIGTERM/SIGKILL path keeps working because the new process group
// leader is the same child — it simply runs as the low-priv uid now.
//
// Resource limits are applied by wrapping the real binary in `prlimit(1)`:
// `prlimit --as=N --nproc=N --cpu=N --fsize=N -- <binary> <args...>`. prlimit
// sets the limits on the child it execs, so they apply to the agent and (since
// limits are inherited across fork) its whole subtree — exactly the tree the
// pgid covers. Wrapping is additive to Credential/Setpgid and does not change
// the process-group topology (prlimit execs the target in place, becoming the
// group leader). If prlimit is unavailable, UID drop still applies and we log no
// rlimits (best-effort; container mode is the hard boundary).
//
// Bind-mount masking (CLONE_NEWNS + remount /data with only HomeDir+WorktreeDir
// visible) is intentionally NOT auto-enabled here: it requires the server to
// hold CAP_SYS_ADMIN / a user namespace, which the rootless production container
// (USER app) does not have by default. The robust isolation path is
// ModeContainer. uidSandbox therefore provides UID drop + rlimits + egress proxy
// as the minimum viable confinement, and documents that filesystem masking is a
// container-mode guarantee.
type uidSandbox struct{}
func (s *uidSandbox) Apply(cmd *exec.Cmd, spec Spec) (func(), error) {
if spec.UID <= 0 {
return func() {}, fmt.Errorf("sandbox uid: invalid target uid %d", spec.UID)
}
// UID/GID drop — ADDITIVE to whatever proc.Group will set (Setpgid).
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
gid := spec.GID
if gid <= 0 {
gid = spec.UID
}
cmd.SysProcAttr.Credential = &syscall.Credential{
Uid: uint32(spec.UID),
Gid: uint32(gid),
}
// Resource limits via prlimit wrapper (if available).
if prlimit, err := exec.LookPath("prlimit"); err == nil {
args := prlimitArgs(spec.Rlimits)
if len(args) > 0 {
// Re-point the command through prlimit, preserving the original
// binary + args after the `--` separator.
orig := append([]string{cmd.Path}, cmd.Args[1:]...)
cmd.Path = prlimit
cmd.Args = append(append([]string{prlimit}, args...), append([]string{"--"}, orig...)...)
}
}
// Egress allowlist: inject HTTP(S)_PROXY/NO_PROXY into the stripped child env.
injectProxyEnv(cmd, spec)
// No ephemeral resources to tear down in uid mode (no bind mounts here);
// return a no-op cleanup.
return func() {}, nil
}
// prlimitArgs builds the prlimit flags for the non-zero limits in l.
func prlimitArgs(l Limits) []string {
var args []string
if l.AddressSpaceBytes > 0 {
args = append(args, "--as="+strconv.FormatUint(l.AddressSpaceBytes, 10))
}
if l.NProc > 0 {
args = append(args, "--nproc="+strconv.FormatUint(l.NProc, 10))
}
if l.CPUSeconds > 0 {
args = append(args, "--cpu="+strconv.FormatUint(l.CPUSeconds, 10))
}
if l.DiskBytes > 0 {
args = append(args, "--fsize="+strconv.FormatUint(l.DiskBytes, 10))
}
return args
}
+148
View File
@@ -0,0 +1,148 @@
package acp
import (
"context"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/brief"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
// recordingAudit 捕获 Record 调用,供断言 brief_composed 是否写入。
type recordingAudit struct {
mu sync.Mutex
entries []audit.Entry
}
func (r *recordingAudit) Record(_ context.Context, e audit.Entry) error {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, e)
return nil
}
func (r *recordingAudit) actions() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.entries))
for _, e := range r.entries {
out = append(out, e.Action)
}
return out
}
// fakeComposer 是 brief.Composer 的测试替身:返回固定文本并记录是否被调用。
type fakeComposer struct {
res brief.BriefResult
err error
called bool
gotIn brief.BriefInput
}
func (f *fakeComposer) ComposeTaskBrief(_ context.Context, in brief.BriefInput) (brief.BriefResult, error) {
f.called = true
f.gotIn = in
return f.res, f.err
}
// fakeArtifactAccess 是 ArtifactAccess 的测试替身。
type fakeArtifactAccess struct {
arts []*project.Artifact
}
func (f fakeArtifactAccess) ListLatestArtifacts(_ context.Context, _ uuid.UUID) ([]*project.Artifact, error) {
return f.arts, nil
}
func testSession() *Session {
return &Session{
ID: uuid.New(),
ProjectID: uuid.New(),
Branch: "req/demo-7",
CwdPath: "/tmp/wt",
}
}
func TestComposeInitialPrompt_RequirementContextComposed(t *testing.T) {
t.Parallel()
rec := &recordingAudit{}
fc := &fakeComposer{res: brief.BriefResult{
Text: "你是一名资深产品规划助手。\n\n## 当前需求\n需求 #7:导出报表",
Tokens: 20,
Sections: []string{"role", "requirement_core"},
}}
s := &sessionService{
audit: rec,
composer: fc,
aa: fakeArtifactAccess{arts: []*project.Artifact{{Phase: project.PhasePlanning, Version: 2}}},
cfg: SessionServiceConfig{BriefTokenBudget: 6000},
}
req := &project.Requirement{ID: uuid.New(), Number: 7, Title: "导出报表", Phase: project.PhasePlanning}
c := Caller{UserID: uuid.New()}
out := s.composeInitialPrompt(context.Background(), c, testSession(), nil, req, nil, "请开始")
require.True(t, fc.called)
assert.Contains(t, out, "需求 #7:导出报表")
assert.Contains(t, out, "资深产品规划助手")
// 组装入参带上了 requirement + artifacts + cwd + 用户文本 + budget。
assert.Equal(t, req, fc.gotIn.Requirement)
assert.Equal(t, "请开始", fc.gotIn.UserPrompt)
assert.Equal(t, "/tmp/wt", fc.gotIn.RepoCwd)
assert.Equal(t, 6000, fc.gotIn.TokenBudget)
assert.Equal(t, brief.KindACPSession, fc.gotIn.Kind)
assert.Len(t, fc.gotIn.Artifacts, 1)
// 写了 brief_composed audit。
assert.Contains(t, rec.actions(), "acp.session.brief_composed")
}
func TestComposeInitialPrompt_IssueContextComposed(t *testing.T) {
t.Parallel()
rec := &recordingAudit{}
fc := &fakeComposer{res: brief.BriefResult{Text: "## 当前 Issue\nIssue #3:bug", Tokens: 8}}
s := &sessionService{audit: rec, composer: fc}
iss := &project.Issue{ID: uuid.New(), Number: 3, Title: "bug"}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, nil, iss, "fix it")
require.True(t, fc.called)
assert.Contains(t, out, "Issue #3:bug")
assert.Equal(t, iss, fc.gotIn.Issue)
// issue-only 时不调用 ArtifactAccess(aa 为 nil 也不 panic)。
}
func TestComposeInitialPrompt_NoPMContextFallsBackToRaw(t *testing.T) {
t.Parallel()
fc := &fakeComposer{res: brief.BriefResult{Text: "SHOULD NOT BE USED"}}
s := &sessionService{audit: &recordingAudit{}, composer: fc}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, nil, nil, "raw text only")
assert.Equal(t, "raw text only", out)
assert.False(t, fc.called, "composer must not be called without PM context")
}
func TestComposeInitialPrompt_NilComposerFallsBackToRaw(t *testing.T) {
t.Parallel()
s := &sessionService{audit: &recordingAudit{}, composer: nil}
req := &project.Requirement{ID: uuid.New(), Number: 7, Title: "x", Phase: project.PhasePlanning}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, req, nil, "raw")
assert.Equal(t, "raw", out)
}
func TestComposeInitialPrompt_EmptyComposedTextFallsBackToRaw(t *testing.T) {
t.Parallel()
rec := &recordingAudit{}
fc := &fakeComposer{res: brief.BriefResult{Text: ""}}
s := &sessionService{audit: rec, composer: fc}
req := &project.Requirement{ID: uuid.New(), Number: 1, Title: "t", Phase: project.PhasePlanning}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, req, nil, "fallback raw")
assert.Equal(t, "fallback raw", out)
// 空简报不写 brief_composed audit。
assert.NotContains(t, rec.actions(), "acp.session.brief_composed")
}
+313 -7
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
@@ -13,6 +14,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/brief"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/mcp" "github.com/yan1h/agent-coding-workflow/internal/mcp"
"github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/project"
@@ -25,6 +27,7 @@ type SessionServiceConfig struct {
MaxActivePerUser int MaxActivePerUser int
SystemTokenTTL time.Duration SystemTokenTTL time.Duration
MCPPublicURL string MCPPublicURL string
BriefTokenBudget int // 初始 prompt 简报的 token 硬上限(<=0 用 brief 默认值)
} }
// ProjectAccess 是 acp 模块对 project 模块的窄接口;adapter 在 app.go 实现。 // ProjectAccess 是 acp 模块对 project 模块的窄接口;adapter 在 app.go 实现。
@@ -34,26 +37,37 @@ type ProjectAccess interface {
GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error)
} }
// ArtifactAccess 是 acp 模块拉取某 requirement 各阶段最新产物的窄接口;adapter 在
// app.go 实现(基于 project.Repository.ListArtifactsByRequirement,按阶段取最大版本)。
type ArtifactAccess interface {
ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error)
}
type sessionService struct { type sessionService struct {
repo Repository repo Repository
wsSvc workspace.WorkspaceService wsSvc workspace.WorkspaceService
wtSvc workspace.WorktreeService wtSvc workspace.WorktreeService
wsRepo workspace.Repository wsRepo workspace.Repository
pa ProjectAccess pa ProjectAccess
aa ArtifactAccess // 可为 nil(无产物注入)
composer brief.Composer // 可为 nil(退回 raw passthrough)
sup *Supervisor sup *Supervisor
audit audit.Recorder audit audit.Recorder
cfg SessionServiceConfig cfg SessionServiceConfig
mcpTokens mcp.TokenService // spec §6.1 — system token issuance mcpTokens mcp.TokenService // spec §6.1 — system token issuance
} }
// NewSessionService 构造 SessionService。 // NewSessionService 构造 SessionService。aa / composer 可为 nil:缺任一时
// initial prompt 退回原始透传(不组装简报),保持旧行为。
func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService, func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService,
wtSvc workspace.WorktreeService, wsRepo workspace.Repository, wtSvc workspace.WorktreeService, wsRepo workspace.Repository,
pa ProjectAccess, sup *Supervisor, rec audit.Recorder, pa ProjectAccess, aa ArtifactAccess, composer brief.Composer,
sup *Supervisor, rec audit.Recorder,
cfg SessionServiceConfig, mcpTokens mcp.TokenService) SessionService { cfg SessionServiceConfig, mcpTokens mcp.TokenService) SessionService {
return &sessionService{ return &sessionService{
repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo, repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo,
pa: pa, sup: sup, audit: rec, cfg: cfg, mcpTokens: mcpTokens, pa: pa, aa: aa, composer: composer, sup: sup, audit: rec,
cfg: cfg, mcpTokens: mcpTokens,
} }
} }
@@ -199,28 +213,41 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
worktreeID = &wid worktreeID = &wid
} }
// 6. PM 关联(best-effort:拿不到 ID 不阻塞 session 创建) // 6. PM 关联(best-effort:拿不到 ID 不阻塞 session 创建)
var issueID, reqID *uuid.UUID // 同时保留解析出的 issue/requirement 对象,供初始 prompt 组装简报复用。
var (
issueID, reqID *uuid.UUID
pmIssue *project.Issue
pmReq *project.Requirement
)
if in.IssueNumber != nil { if in.IssueNumber != nil {
if iss, _ := s.pa.GetIssueByNumber(ctx, ws.ProjectID, *in.IssueNumber); iss != nil { if iss, _ := s.pa.GetIssueByNumber(ctx, ws.ProjectID, *in.IssueNumber); iss != nil {
id := iss.ID id := iss.ID
issueID = &id issueID = &id
pmIssue = iss
} }
} }
if in.RequirementNumber != nil { if in.RequirementNumber != nil {
if req, _ := s.pa.GetRequirementByNumber(ctx, ws.ProjectID, *in.RequirementNumber); req != nil { if req, _ := s.pa.GetRequirementByNumber(ctx, ws.ProjectID, *in.RequirementNumber); req != nil {
id := req.ID id := req.ID
reqID = &id reqID = &id
pmReq = req
} }
} }
// 7. INSERT acp_sessions + system MCP token(spec §6.1 atomic guarantee) // 7. INSERT acp_sessions + system MCP token(spec §6.1 atomic guarantee)
// 预算快照:当前无 session 级覆盖入口,故直接继承 agent-kind 默认上限。快照到
// session 行使 supervisor / reaper 读取自洽,不必每次回查 agent-kind。
sess := &Session{ sess := &Session{
ID: sessionID, WorkspaceID: ws.ID, ProjectID: ws.ProjectID, ID: sessionID, WorkspaceID: ws.ID, ProjectID: ws.ProjectID,
AgentKindID: kind.ID, UserID: c.UserID, AgentKindID: kind.ID, UserID: c.UserID,
IssueID: issueID, RequirementID: reqID, IssueID: issueID, RequirementID: reqID,
Branch: branch, CwdPath: cwdPath, IsMainWorktree: isMain, Branch: branch, CwdPath: cwdPath, IsMainWorktree: isMain,
Status: SessionStarting, Status: SessionStarting,
OrchestratorStepID: in.OrchestratorStepID,
BudgetMaxCostUSD: kind.MaxCostUSD,
BudgetMaxTokens: kind.MaxTokens,
BudgetMaxWallClockSeconds: kind.MaxWallClockSeconds,
} }
var ( var (
out *Session out *Session
@@ -279,14 +306,91 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
} }
}() }()
// 10. initial prompt:握手成功后异步转发 // 10. initial prompt:握手成功后异步转发。先在请求路径同步组装服务端简报
// (需求/Issue + 阶段产物 + 仓库定向 + 操作者自由文本),失败/无 PM 上下文时
// 退回原始透传,保持旧行为。
if in.InitialPrompt != nil && *in.InitialPrompt != "" { if in.InitialPrompt != nil && *in.InitialPrompt != "" {
go s.sendInitialPrompt(out.ID, *in.InitialPrompt) text := s.composeInitialPrompt(ctx, c, out, pj, pmReq, pmIssue, *in.InitialPrompt)
go s.sendInitialPrompt(out.ID, text)
} }
return out, nil return out, nil
} }
// composeInitialPrompt 组装 ACP session 的初始 prompt 简报。无 composer 或无 PM 上下文
// (既无 requirement 也无 issue)时返回原始 userPrompt(raw passthrough)。组装失败也
// 静默退回原始文本,绝不阻塞 session。成功组装后写一条 audit。
func (s *sessionService) composeInitialPrompt(ctx context.Context, c Caller, sess *Session,
pj *project.Project, req *project.Requirement, iss *project.Issue, userPrompt string) string {
if s.composer == nil || (req == nil && iss == nil) {
return userPrompt
}
var artifacts []*project.Artifact
if req != nil && s.aa != nil {
if arts, err := s.aa.ListLatestArtifacts(ctx, req.ID); err == nil {
artifacts = arts
}
}
res, err := s.composer.ComposeTaskBrief(ctx, brief.BriefInput{
Project: pj,
Requirement: req,
Issue: iss,
Artifacts: artifacts,
RepoCwd: sess.CwdPath,
Branch: sess.Branch,
UserPrompt: userPrompt,
TokenBudget: s.cfg.BriefTokenBudget,
Kind: brief.KindACPSession,
})
if err != nil || res.Text == "" {
return userPrompt
}
s.recordAudit(ctx, c, "acp.session.brief_composed", sess.ID.String(), map[string]any{
"requirement_id": idOrNil(req),
"issue_id": issueIDOrNil(iss),
"artifact_versions": artifactVersions(artifacts),
"brief_tokens": res.Tokens,
"truncated": res.Truncated,
"sections": res.Sections,
})
return res.Text
}
func idOrNil(req *project.Requirement) *uuid.UUID {
if req == nil {
return nil
}
id := req.ID
return &id
}
func issueIDOrNil(iss *project.Issue) *uuid.UUID {
if iss == nil {
return nil
}
id := iss.ID
return &id
}
// artifactVersions 把产物列表归约为 {phase: version} 映射(每阶段取出现的最大版本),
// 仅用于 audit 元数据。
func artifactVersions(arts []*project.Artifact) map[string]int {
out := make(map[string]int, len(arts))
for _, a := range arts {
if a == nil {
continue
}
if v, ok := out[string(a.Phase)]; !ok || a.Version > v {
out[string(a.Phase)] = a.Version
}
}
return out
}
// findOrCreateWorktree 在指定 workspace 内查找 branch 同名 worktree, // findOrCreateWorktree 在指定 workspace 内查找 branch 同名 worktree,
// 不存在则创建。caller 已通过 wsSvc.Get 完成鉴权。 // 不存在则创建。caller 已通过 wsSvc.Get 完成鉴权。
func (s *sessionService) findOrCreateWorktree(ctx context.Context, c workspace.Caller, wsID uuid.UUID, branch string) (*workspace.Worktree, error) { func (s *sessionService) findOrCreateWorktree(ctx context.Context, c workspace.Caller, wsID uuid.UUID, branch string) (*workspace.Worktree, error) {
@@ -342,11 +446,167 @@ func (s *sessionService) sendInitialPrompt(sid uuid.UUID, text string) {
msg := &Message{ msg := &Message{
JSONRPC: "2.0", ID: idJSON, Method: "session/prompt", Params: params, JSONRPC: "2.0", ID: idJSON, Method: "session/prompt", Params: params,
} }
// 回合相关联:在发送前登记回合 id("client-init"),使响应的 stopReason 可被
// 相关联到这个回合。tracker 可能为 nil(部分测试)——StartTurn 已做 nil 守卫。
if tr := relay.Tracker(); tr != nil {
if _, err := tr.StartTurn(context.Background(), "client-init"); err != nil {
slog.Warn("acp.session.start_turn", "session_id", sid, "err", err.Error())
}
}
_ = relay.SendClient(msg) _ = relay.SendClient(msg)
return return
} }
} }
// 编译期断言:sessionService 同时实现 SessionService 与 TurnRunner(编排器依赖后者)。
var _ TurnRunner = (*sessionService)(nil)
// promptResult 是 session/prompt 响应 Result 中我们关心的字段。
type promptResult struct {
StopReason string `json:"stopReason"`
}
// SendPromptAndWait 发送一条 session/prompt 并阻塞等待回合完成,返回 agent 上报的原始
// stopReason。这是编排器驱动一个阻塞回合的核心原语:先轮询等待 supervisor handshake
// 完成(relay + agent_session_id 就绪,复用 sendInitialPrompt 的就绪检测),随后走
// server-initiated relay.Call('session/prompt') 取响应里的 stopReason。
//
// ctx 控制整体超时(编排器以 cfg.TurnTimeout 约束,须 < jobReaper LeaseTimeout)。
func (s *sessionService) SendPromptAndWait(ctx context.Context, sessionID uuid.UUID, prompt string) (string, error) {
relay, agentSessionID, err := s.waitForRelayReady(ctx, sessionID)
if err != nil {
return "", err
}
params := map[string]any{
"sessionId": agentSessionID,
"prompt": []map[string]any{{"type": "text", "text": prompt}},
}
resp, err := relay.Call(ctx, "session/prompt", params)
if err != nil {
return "", errs.Wrap(err, errs.CodeInternal, "session/prompt call")
}
var pr promptResult
if len(resp.Result) > 0 {
if uerr := json.Unmarshal(resp.Result, &pr); uerr != nil {
return "", errs.Wrap(uerr, errs.CodeInternal, "parse session/prompt result")
}
}
if pr.StopReason != "" {
// 落库最近一次 stopReason(best-effort),供观测/网关复用。
_ = s.repo.UpdateSessionLastStopReason(context.WithoutCancel(ctx), sessionID, pr.StopReason)
}
return pr.StopReason, nil
}
// waitForRelayReady 轮询直到 relay 与 agent_session_id 同时就绪或 ctx 截止。
// 200ms 一次,与 sendInitialPrompt 一致。
func (s *sessionService) waitForRelayReady(ctx context.Context, sessionID uuid.UUID) (*Relay, string, error) {
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for {
relay := s.sup.GetRelay(sessionID)
if relay != nil {
sess, _ := s.repo.GetSessionByID(ctx, sessionID)
if sess != nil && sess.AgentSessionID != nil {
return relay, *sess.AgentSessionID, nil
}
}
select {
case <-ctx.Done():
return nil, "", ctx.Err()
case <-tick.C:
}
}
}
// ResumeSession 在崩溃/退出 session 的同一 cwd/worktree 上重新拉起 agent。复用原
// session 的 workspace/branch/cwd/main 标记与编排器 step 关联:重新占用 worktree
// (main → AcquireMainWorktree CAS;子 worktree → 按 holder Acquire),签发新 system
// token,再 sup.Spawn。Spawn 失败时回滚 worktree(releaseOnFailure)。
//
// 返回的是被复用并重置为 starting 状态的同一行 session(同 ID),供编排器随后
// SendPromptAndWait。仅供编排器内部调用(caller 为 run.owner)。
func (s *sessionService) ResumeSession(ctx context.Context, c Caller, sessionID uuid.UUID) (*Session, error) {
sess, err := s.repo.GetSessionByID(ctx, sessionID)
if err != nil {
return nil, err
}
if sess.Status.IsActive() {
// 已经活跃:无需 resume,直接返回。
return sess, nil
}
kind, err := s.repo.GetAgentKindByID(ctx, sess.AgentKindID)
if err != nil {
return nil, err
}
sessCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin, SessionID: &sess.ID}
if sess.IsMainWorktree {
ok, aerr := s.repo.AcquireMainWorktree(ctx, sess.ID, sess.WorkspaceID)
if aerr != nil {
return nil, aerr
}
if !ok {
return nil, errs.New(errs.CodeAcpSessionBranchConflict, "main worktree in use; cannot resume")
}
} else {
wt, ferr := s.findOrCreateWorktree(ctx, sessCaller, sess.WorkspaceID, sess.Branch)
if ferr != nil {
return nil, ferr
}
if _, aerr := s.wtSvc.Acquire(ctx, sessCaller, wt.ID); aerr != nil {
return nil, aerr
}
}
// 重新签发 system token + 标记 session 回到 starting(清空上次终态字段)。
var (
tokenRes mcp.IssueResult
)
txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error {
if rerr := s.repo.WithTx(tx).ResetSessionForResume(txCtx, sess.ID); rerr != nil {
return rerr
}
res, ierr := s.mcpTokens.IssueSystemTokenWithTx(txCtx, tx, mcp.IssueSystemTokenInput{
UserID: c.UserID,
ACPSessionID: sess.ID,
ProjectIDs: []uuid.UUID{sess.ProjectID},
ExpiresIn: s.cfg.SystemTokenTTL,
})
if ierr != nil {
return ierr
}
tokenRes = res
return nil
})
if txErr != nil {
s.releaseOnFailure(ctx, sess, sessCaller, nil)
return nil, txErr
}
sess.Status = SessionStarting
extraEnv := map[string]string{
EnvMCPToken: tokenRes.Plaintext,
EnvMCPURL: s.cfg.MCPPublicURL,
}
if _, serr := s.sup.Spawn(ctx, sess, kind, extraEnv); serr != nil {
if _, merr := s.repo.MarkSessionFailedIfActive(ctx, sess.ID, serr.Error()); merr != nil {
s.sup.log.Error("acp.resume.mark_session", "session_id", sess.ID, "err", merr.Error())
}
_ = s.mcpTokens.RevokeBySession(ctx, sess.ID)
s.releaseOnFailure(ctx, sess, sessCaller, nil)
return nil, serr
}
s.recordAudit(ctx, c, "acp.session.resume", sess.ID.String(), map[string]any{
"branch": sess.Branch,
"cwd_path": sess.CwdPath,
})
return sess, nil
}
func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) { func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) {
sess, err := s.repo.GetSessionByID(ctx, id) sess, err := s.repo.GetSessionByID(ctx, id)
if err != nil { if err != nil {
@@ -380,6 +640,52 @@ func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID)
return nil return nil
} }
// defaultDashboardWindow 是 run-history 汇总在调用方未指定时间窗时的默认回溯。
const defaultDashboardWindow = 30 * 24 * time.Hour
// Dashboard 返回 run-history 汇总 + 按天序列。非 admin 强制按 caller scope。
func (s *sessionService) Dashboard(ctx context.Context, c Caller, in DashboardInput) (*DashboardResult, error) {
to := in.To
if to.IsZero() {
to = time.Now().UTC()
}
from := in.From
if from.IsZero() {
from = to.Add(-defaultDashboardWindow)
}
if !from.Before(to) {
return nil, errs.New(errs.CodeInvalidInput, "from must be before to")
}
f := DashboardFilter{
ProjectID: in.ProjectID,
RequirementID: in.RequirementID,
From: from,
To: to,
}
// 非 admin 只能看自己的会话:UserID 固定为 caller。admin 留空(全量)。
if !c.IsAdmin {
uid := c.UserID
f.UserID = &uid
}
rollup, err := s.repo.SessionRollup(ctx, f)
if err != nil {
return nil, err
}
byDay, err := s.repo.SessionsByDay(ctx, f)
if err != nil {
return nil, err
}
return &DashboardResult{Rollup: rollup, ByDay: byDay, From: from, To: to}, nil
}
// Timeline 返回单会话事件时间线(先经 Get 做 owner/admin 访问校验)。
func (s *sessionService) Timeline(ctx context.Context, c Caller, sessionID uuid.UUID) ([]SessionTimelineBucket, error) {
if _, err := s.Get(ctx, c, sessionID); err != nil {
return nil, err
}
return s.repo.SessionTimeline(ctx, sessionID)
}
func (s *sessionService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { func (s *sessionService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
if s.audit == nil { if s.audit == nil {
return return
+304 -5
View File
@@ -123,6 +123,14 @@ type fakeAcpRepo struct {
// when fn returns nil (simulating real transaction semantics). // when fn returns nil (simulating real transaction semantics).
txStaging []*acp.Session txStaging []*acp.Session
inTx bool inTx bool
// run-history dashboard: 记录入参 filter 供断言;返回预置结果。
rollupFilter acp.DashboardFilter
byDayFilter acp.DashboardFilter
rollupResult acp.SessionRollup
byDayResult []acp.SessionDayRow
timelineResult []acp.SessionTimelineBucket
timelineSID uuid.UUID
} }
func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) error) error { func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) error) error {
@@ -175,6 +183,43 @@ func (r *fakeAcpRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Sess
} }
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found")
} }
func (r *fakeAcpRepo) GetSessionByStepID(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.sessions {
if s.OrchestratorStepID != nil && *s.OrchestratorStepID == stepID {
return s, nil
}
}
return nil, fmt.Errorf("not found")
}
func (r *fakeAcpRepo) GetCrashedSessionForResume(_ context.Context, stepID uuid.UUID) (*acp.Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.sessions {
if s.OrchestratorStepID != nil && *s.OrchestratorStepID == stepID &&
(s.Status == acp.SessionCrashed || s.Status == acp.SessionExited) {
return s, nil
}
}
return nil, fmt.Errorf("not found")
}
func (r *fakeAcpRepo) ResetSessionForResume(_ context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.sessions {
if s.ID == id {
s.Status = acp.SessionStarting
s.AgentSessionID = nil
s.PID = nil
s.ExitCode = nil
s.LastError = nil
s.EndedAt = nil
return nil
}
}
return fmt.Errorf("not found")
}
func (r *fakeAcpRepo) GetAgentKindByID(_ context.Context, id uuid.UUID) (*acp.AgentKind, error) { func (r *fakeAcpRepo) GetAgentKindByID(_ context.Context, id uuid.UUID) (*acp.AgentKind, error) {
for _, k := range r.kinds { for _, k := range r.kinds {
if k.ID == id { if k.ID == id {
@@ -252,6 +297,9 @@ func (r *fakeAcpRepo) ListAllSessions(_ context.Context, reqID *uuid.UUID) ([]*a
func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error { func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error {
return nil return nil
} }
func (r *fakeAcpRepo) UpdateSessionSandboxMode(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
func (r *fakeAcpRepo) UpdateSessionFinished(_ context.Context, _ uuid.UUID, _ acp.SessionStatus, _ *int32, _ *string) error { func (r *fakeAcpRepo) UpdateSessionFinished(_ context.Context, _ uuid.UUID, _ acp.SessionStatus, _ *int32, _ *string) error {
return nil return nil
} }
@@ -276,6 +324,69 @@ func (r *fakeAcpRepo) ListEventsSince(_ context.Context, _ uuid.UUID, _ int64, _
return nil, nil return nil, nil
} }
func (r *fakeAcpRepo) PurgeEventsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil } func (r *fakeAcpRepo) PurgeEventsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
func (r *fakeAcpRepo) InsertSessionUsage(_ context.Context, _ *acp.SessionUsageRecord) (bool, error) {
return true, nil
}
func (r *fakeAcpRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) {
return dp, dc, dt, dCost, nil
}
func (r *fakeAcpRepo) SumProjectCostUSD(_ context.Context, _ uuid.UUID, _ time.Time) (float64, error) {
return 0, nil
}
func (r *fakeAcpRepo) MarkSessionTerminatedReason(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
func (r *fakeAcpRepo) ListSessionsForReaper(_ context.Context, _, _ time.Time) ([]acp.ReaperSession, error) {
return nil, nil
}
func (r *fakeAcpRepo) ListSessionUsage(_ context.Context, _ uuid.UUID, _ int32) ([]*acp.SessionUsageRecord, error) {
return nil, nil
}
func (r *fakeAcpRepo) SummarizeAcpUsageByUser(_ context.Context, _, _ time.Time) ([]acp.AcpUsageUserRow, error) {
return nil, nil
}
func (r *fakeAcpRepo) SummarizeAcpUsageByModel(_ context.Context, _, _ time.Time) ([]acp.AcpUsageModelRow, error) {
return nil, nil
}
func (r *fakeAcpRepo) SummarizeAcpUsageByDay(_ context.Context, _, _ time.Time) ([]acp.AcpUsageDayRow, error) {
return nil, nil
}
func (r *fakeAcpRepo) SessionRollup(_ context.Context, f acp.DashboardFilter) (acp.SessionRollup, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.rollupFilter = f
return r.rollupResult, nil
}
func (r *fakeAcpRepo) SessionsByDay(_ context.Context, f acp.DashboardFilter) ([]acp.SessionDayRow, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.byDayFilter = f
return r.byDayResult, nil
}
func (r *fakeAcpRepo) SessionTimeline(_ context.Context, sid uuid.UUID) ([]acp.SessionTimelineBucket, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.timelineSID = sid
return r.timelineResult, nil
}
func (r *fakeAcpRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) { return t, nil }
func (r *fakeAcpRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, _ int, _ string, _ int) error {
return nil
}
func (r *fakeAcpRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, _ int) error { return nil }
func (r *fakeAcpRepo) IncrementTurnUpdateCount(_ context.Context, _ uuid.UUID, _ int) error {
return nil
}
func (r *fakeAcpRepo) GetLatestTurnBySession(_ context.Context, _ uuid.UUID) (*acp.Turn, error) {
return nil, nil
}
func (r *fakeAcpRepo) ListTurnsBySession(_ context.Context, _ uuid.UUID) ([]*acp.Turn, error) {
return nil, nil
}
func (r *fakeAcpRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, _ string) error {
return nil
}
func (r *fakeAcpRepo) PurgeTurnsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
// fakeMCPTokenSvc satisfies mcp.TokenService for unit tests. // fakeMCPTokenSvc satisfies mcp.TokenService for unit tests.
type fakeMCPTokenSvc struct { type fakeMCPTokenSvc struct {
@@ -382,7 +493,7 @@ func TestSessionService_Create_IssuesSystemToken(t *testing.T) {
) )
svc := acp.NewSessionService( svc := acp.NewSessionService(
repo, wsSvc, nil, nil, pa, sup, nil, repo, wsSvc, nil, nil, pa, nil, nil, sup, nil,
acp.SessionServiceConfig{ acp.SessionServiceConfig{
MaxActiveGlobal: 10, MaxActiveGlobal: 10,
MaxActivePerUser: 5, MaxActivePerUser: 5,
@@ -441,7 +552,7 @@ func TestSessionService_Create_TokenIssueFails_RollsBack(t *testing.T) {
) )
svc := acp.NewSessionService( svc := acp.NewSessionService(
repo, wsSvc, nil, nil, pa, sup, nil, repo, wsSvc, nil, nil, pa, nil, nil, sup, nil,
acp.SessionServiceConfig{ acp.SessionServiceConfig{
MaxActiveGlobal: 10, MaxActiveGlobal: 10,
MaxActivePerUser: 5, MaxActivePerUser: 5,
@@ -495,7 +606,7 @@ func TestSessionService_Create_SpawnFails_MarksSessionCrashed(t *testing.T) {
) )
svc := acp.NewSessionService( svc := acp.NewSessionService(
repo, wsSvc, nil, nil, pa, sup, nil, repo, wsSvc, nil, nil, pa, nil, nil, sup, nil,
acp.SessionServiceConfig{ acp.SessionServiceConfig{
MaxActiveGlobal: 10, MaxActiveGlobal: 10,
MaxActivePerUser: 5, MaxActivePerUser: 5,
@@ -535,7 +646,7 @@ func TestSessionService_Create_MutuallyExclusiveInputs(t *testing.T) {
akID := uuid.New() akID := uuid.New()
svc := acp.NewSessionService( svc := acp.NewSessionService(
&fakeAcpRepo{}, nil, nil, nil, nil, nil, nil, &fakeAcpRepo{}, nil, nil, nil, nil, nil, nil, nil, nil,
acp.SessionServiceConfig{}, nil, acp.SessionServiceConfig{}, nil,
) )
@@ -598,7 +709,7 @@ func TestSessionService_List_RequirementFilter(t *testing.T) {
} }
svc := acp.NewSessionService( svc := acp.NewSessionService(
repo, nil, nil, nil, nil, nil, nil, repo, nil, nil, nil, nil, nil, nil, nil, nil,
acp.SessionServiceConfig{}, nil, acp.SessionServiceConfig{}, nil,
) )
@@ -616,3 +727,191 @@ func TestSessionService_List_RequirementFilter(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, list, 2) require.Len(t, list, 2)
} }
// TestSessionService_ResumeSession_ReacquiresMainWorktree 验证:在崩溃的 main-worktree
// session 上 ResumeSession 会重新占用 main worktree(CAS)、重新签发 system token、把
// session 复位为 starting,然后尝试 spawn。binary 为空致 spawn 失败,但前置的占用/签发/
// 复位都应已发生。
func TestSessionService_ResumeSession_ReacquiresMainWorktree(t *testing.T) {
t.Parallel()
uid := uuid.New()
wsID := uuid.New()
pid := uuid.New()
akID := uuid.New()
sessID := uuid.New()
exitCode := int32(1)
crashed := &acp.Session{
ID: sessID, WorkspaceID: wsID, ProjectID: pid, AgentKindID: akID, UserID: uid,
Branch: "main", CwdPath: "/tmp/main", IsMainWorktree: true,
Status: acp.SessionCrashed, ExitCode: &exitCode,
}
repo := &fakeAcpRepo{
kinds: []*acp.AgentKind{{ID: akID, Name: "claude", Enabled: true}},
sessions: []*acp.Session{crashed},
}
mcpTokens := &fakeMCPTokenSvc{}
sup := acp.NewSupervisor(
repo, nil, nil, nil, nil, mcpTokens, nil,
acp.SupervisorConfig{SpawnTimeout: 2 * time.Second, KillGrace: time.Second},
nil,
)
svc := acp.NewSessionService(
repo, &fakeWsSvc{ws: &workspace.Workspace{ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main"}},
nil, nil, fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "p"}}, nil, nil, sup, nil,
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5, SystemTokenTTL: time.Hour, MCPPublicURL: "http://x/mcp"},
mcpTokens,
)
tr, ok := svc.(acp.TurnRunner)
require.True(t, ok, "sessionService must implement acp.TurnRunner")
// spawn 会失败(binary 空),ResumeSession 返回错误是预期的。
_, _ = tr.ResumeSession(context.Background(), acp.Caller{UserID: uid}, sessID)
// 但 token 应已重新签发(占用 + InTx 复位在 spawn 之前)。
mcpTokens.mu.Lock()
issuedCount := len(mcpTokens.issued)
mcpTokens.mu.Unlock()
require.GreaterOrEqual(t, issuedCount, 1, "resume should re-issue a system token")
assert.Equal(t, sessID, mcpTokens.issued[0].ACPSessionID)
}
// TestSessionService_ResumeSession_ActiveSessionNoOp 验证:对已活跃 session 调用
// ResumeSession 直接返回该 session,不重新占用/签发。
func TestSessionService_ResumeSession_ActiveSessionNoOp(t *testing.T) {
t.Parallel()
uid := uuid.New()
wsID := uuid.New()
pid := uuid.New()
akID := uuid.New()
sessID := uuid.New()
active := &acp.Session{
ID: sessID, WorkspaceID: wsID, ProjectID: pid, AgentKindID: akID, UserID: uid,
Branch: "main", CwdPath: "/tmp/main", IsMainWorktree: true, Status: acp.SessionRunning,
}
repo := &fakeAcpRepo{
kinds: []*acp.AgentKind{{ID: akID, Name: "claude", Enabled: true}},
sessions: []*acp.Session{active},
}
mcpTokens := &fakeMCPTokenSvc{}
sup := acp.NewSupervisor(repo, nil, nil, nil, nil, mcpTokens, nil,
acp.SupervisorConfig{SpawnTimeout: time.Second, KillGrace: time.Second}, nil)
svc := acp.NewSessionService(repo, &fakeWsSvc{ws: &workspace.Workspace{ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main"}},
nil, nil, fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "p"}}, nil, nil, sup, nil,
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5, SystemTokenTTL: time.Hour, MCPPublicURL: "http://x/mcp"},
mcpTokens)
tr := svc.(acp.TurnRunner)
out, err := tr.ResumeSession(context.Background(), acp.Caller{UserID: uid}, sessID)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, sessID, out.ID)
mcpTokens.mu.Lock()
defer mcpTokens.mu.Unlock()
assert.Len(t, mcpTokens.issued, 0, "active session resume must not re-issue tokens")
}
// ===== run-history dashboard =====
func newDashboardSvc(repo *fakeAcpRepo) acp.SessionService {
return acp.NewSessionService(repo, nil, nil, nil, nil, nil, nil, nil, nil,
acp.SessionServiceConfig{}, nil)
}
func TestDashboard_NonAdmin_ScopedToCaller(t *testing.T) {
t.Parallel()
uid := uuid.New()
repo := &fakeAcpRepo{
rollupResult: acp.SessionRollup{Total: 4, Succeeded: 3, Crashed: 1, TotalCostUSD: 1.5, TotalTokens: 900},
byDayResult: []acp.SessionDayRow{{Day: time.Now().UTC(), Total: 4, Succeeded: 3, Crashed: 1, TotalCostUSD: 1.5}},
}
svc := newDashboardSvc(repo)
res, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uid}, acp.DashboardInput{})
require.NoError(t, err)
require.NotNil(t, res)
// 非 admin 强制按 caller scope。
require.NotNil(t, repo.rollupFilter.UserID)
assert.Equal(t, uid, *repo.rollupFilter.UserID)
require.NotNil(t, repo.byDayFilter.UserID)
assert.Equal(t, uid, *repo.byDayFilter.UserID)
// 默认时间窗为半开且 from 早于 to。
assert.True(t, repo.rollupFilter.From.Before(repo.rollupFilter.To))
// rollup 透传 + 成功率计算。
assert.Equal(t, int64(4), res.Rollup.Total)
assert.InDelta(t, 0.75, res.Rollup.SuccessRate(), 1e-9)
assert.InDelta(t, 1.5, res.Rollup.TotalCostUSD, 1e-9)
assert.Len(t, res.ByDay, 1)
}
func TestDashboard_Admin_NoUserScope_WithFilters(t *testing.T) {
t.Parallel()
pid := uuid.New()
reqID := uuid.New()
from := time.Now().Add(-48 * time.Hour).UTC()
to := time.Now().UTC()
repo := &fakeAcpRepo{rollupResult: acp.SessionRollup{Total: 10, Succeeded: 10}}
svc := newDashboardSvc(repo)
res, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uuid.New(), IsAdmin: true},
acp.DashboardInput{ProjectID: &pid, RequirementID: &reqID, From: from, To: to})
require.NoError(t, err)
// admin → 无 user scope;project/requirement/时间窗透传。
assert.Nil(t, repo.rollupFilter.UserID)
require.NotNil(t, repo.rollupFilter.ProjectID)
assert.Equal(t, pid, *repo.rollupFilter.ProjectID)
require.NotNil(t, repo.rollupFilter.RequirementID)
assert.Equal(t, reqID, *repo.rollupFilter.RequirementID)
assert.Equal(t, from, repo.rollupFilter.From)
assert.Equal(t, to, repo.rollupFilter.To)
assert.InDelta(t, 1.0, res.Rollup.SuccessRate(), 1e-9)
}
func TestDashboard_InvalidWindow(t *testing.T) {
t.Parallel()
to := time.Now().UTC()
from := to.Add(time.Hour) // from after to
svc := newDashboardSvc(&fakeAcpRepo{})
_, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uuid.New()},
acp.DashboardInput{From: from, To: to})
require.Error(t, err)
}
func TestTimeline_OwnerAccess(t *testing.T) {
t.Parallel()
uid := uuid.New()
sid := uuid.New()
buckets := []acp.SessionTimelineBucket{
{Direction: "out", RPCKind: "request", Method: "session/prompt", EventCount: 2},
{Direction: "in", RPCKind: "notification", Method: "session/update", EventCount: 5},
}
repo := &fakeAcpRepo{timelineResult: buckets}
repo.sessions = []*acp.Session{{ID: sid, UserID: uid}}
svc := newDashboardSvc(repo)
got, err := svc.Timeline(context.Background(), acp.Caller{UserID: uid}, sid)
require.NoError(t, err)
assert.Equal(t, sid, repo.timelineSID)
require.Len(t, got, 2)
assert.Equal(t, "session/prompt", got[0].Method)
}
func TestTimeline_NonOwner_NotFound(t *testing.T) {
t.Parallel()
sid := uuid.New()
repo := &fakeAcpRepo{}
repo.sessions = []*acp.Session{{ID: sid, UserID: uuid.New()}}
svc := newDashboardSvc(repo)
_, err := svc.Timeline(context.Background(), acp.Caller{UserID: uuid.New()}, sid)
require.Error(t, err) // Get 拒绝 → 不泄漏会话
}
@@ -30,7 +30,7 @@ func (q *Queries) DeleteAgentKindConfigFile(ctx context.Context, arg DeleteAgent
} }
const listAgentKindConfigFiles = `-- name: ListAgentKindConfigFiles :many const listAgentKindConfigFiles = `-- name: ListAgentKindConfigFiles :many
SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version
FROM acp_agent_kind_config_files FROM acp_agent_kind_config_files
WHERE agent_kind_id = $1 WHERE agent_kind_id = $1
ORDER BY rel_path ASC ORDER BY rel_path ASC
@@ -53,6 +53,7 @@ func (q *Queries) ListAgentKindConfigFiles(ctx context.Context, agentKindID pgty
&i.UpdatedBy, &i.UpdatedBy,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.KeyVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -72,7 +73,7 @@ ON CONFLICT (agent_kind_id, rel_path) DO UPDATE
SET encrypted_content = EXCLUDED.encrypted_content, SET encrypted_content = EXCLUDED.encrypted_content,
updated_by = EXCLUDED.updated_by, updated_by = EXCLUDED.updated_by,
updated_at = now() updated_at = now()
RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version
` `
type UpsertAgentKindConfigFileParams struct { type UpsertAgentKindConfigFileParams struct {
@@ -100,6 +101,7 @@ func (q *Queries) UpsertAgentKindConfigFile(ctx context.Context, arg UpsertAgent
&i.UpdatedBy, &i.UpdatedBy,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.KeyVersion,
) )
return i, err return i, err
} }
+65 -8
View File
@@ -24,10 +24,12 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU
const createAgentKind = `-- name: CreateAgentKind :one const createAgentKind = `-- name: CreateAgentKind :one
INSERT INTO acp_agent_kinds ( INSERT INTO acp_agent_kinds (
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) model_id, max_cost_usd, max_tokens, max_wall_clock_seconds
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
` `
type CreateAgentKindParams struct { type CreateAgentKindParams struct {
@@ -43,6 +45,10 @@ type CreateAgentKindParams struct {
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
} }
func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) { func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) {
@@ -59,6 +65,10 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
arg.ToolAllowlist, arg.ToolAllowlist,
arg.ClientType, arg.ClientType,
arg.EncryptedMcpServers, arg.EncryptedMcpServers,
arg.ModelID,
arg.MaxCostUsd,
arg.MaxTokens,
arg.MaxWallClockSeconds,
) )
var i AcpAgentKind var i AcpAgentKind
err := row.Scan( err := row.Scan(
@@ -76,6 +86,11 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType, &i.ClientType,
&i.EncryptedMcpServers, &i.EncryptedMcpServers,
&i.ModelID,
&i.MaxCostUsd,
&i.MaxTokens,
&i.MaxWallClockSeconds,
&i.KeyVersion,
) )
return i, err return i, err
} }
@@ -91,7 +106,8 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error {
const getAgentKindByID = `-- name: GetAgentKindByID :one const getAgentKindByID = `-- name: GetAgentKindByID :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
WHERE id = $1 WHERE id = $1
` `
@@ -114,13 +130,19 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType, &i.ClientType,
&i.EncryptedMcpServers, &i.EncryptedMcpServers,
&i.ModelID,
&i.MaxCostUsd,
&i.MaxTokens,
&i.MaxWallClockSeconds,
&i.KeyVersion,
) )
return i, err return i, err
} }
const getAgentKindByName = `-- name: GetAgentKindByName :one const getAgentKindByName = `-- name: GetAgentKindByName :one
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
WHERE name = $1 WHERE name = $1
` `
@@ -143,13 +165,19 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType, &i.ClientType,
&i.EncryptedMcpServers, &i.EncryptedMcpServers,
&i.ModelID,
&i.MaxCostUsd,
&i.MaxTokens,
&i.MaxWallClockSeconds,
&i.KeyVersion,
) )
return i, err return i, err
} }
const listAgentKinds = `-- name: ListAgentKinds :many const listAgentKinds = `-- name: ListAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -178,6 +206,11 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType, &i.ClientType,
&i.EncryptedMcpServers, &i.EncryptedMcpServers,
&i.ModelID,
&i.MaxCostUsd,
&i.MaxTokens,
&i.MaxWallClockSeconds,
&i.KeyVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -191,7 +224,8 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many
SELECT id, name, display_name, description, binary_path, args, encrypted_env, SELECT id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
FROM acp_agent_kinds FROM acp_agent_kinds
WHERE enabled = TRUE WHERE enabled = TRUE
ORDER BY name ASC ORDER BY name ASC
@@ -221,6 +255,11 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType, &i.ClientType,
&i.EncryptedMcpServers, &i.EncryptedMcpServers,
&i.ModelID,
&i.MaxCostUsd,
&i.MaxTokens,
&i.MaxWallClockSeconds,
&i.KeyVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -243,10 +282,15 @@ SET display_name = $2,
tool_allowlist = $8, tool_allowlist = $8,
client_type = $9, client_type = $9,
encrypted_mcp_servers = $10, encrypted_mcp_servers = $10,
model_id = $11,
max_cost_usd = $12,
max_tokens = $13,
max_wall_clock_seconds = $14,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id, name, display_name, description, binary_path, args, encrypted_env, RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers,
model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version
` `
type UpdateAgentKindParams struct { type UpdateAgentKindParams struct {
@@ -260,6 +304,10 @@ type UpdateAgentKindParams struct {
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
} }
func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) { func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) {
@@ -274,6 +322,10 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
arg.ToolAllowlist, arg.ToolAllowlist,
arg.ClientType, arg.ClientType,
arg.EncryptedMcpServers, arg.EncryptedMcpServers,
arg.ModelID,
arg.MaxCostUsd,
arg.MaxTokens,
arg.MaxWallClockSeconds,
) )
var i AcpAgentKind var i AcpAgentKind
err := row.Scan( err := row.Scan(
@@ -291,6 +343,11 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
&i.ToolAllowlist, &i.ToolAllowlist,
&i.ClientType, &i.ClientType,
&i.EncryptedMcpServers, &i.EncryptedMcpServers,
&i.ModelID,
&i.MaxCostUsd,
&i.MaxTokens,
&i.MaxWallClockSeconds,
&i.KeyVersion,
) )
return i, err return i, err
} }
+201
View File
@@ -0,0 +1,201 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: dashboard.sql
package acpsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const sessionRollup = `-- name: SessionRollup :one
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded,
COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed,
COUNT(*) FILTER (WHERE status IN ('starting','running'))::bigint AS active,
COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd,
COALESCE(SUM(tokens_total), 0)::bigint AS total_tokens,
COALESCE(
AVG(EXTRACT(EPOCH FROM (ended_at - started_at)))
FILTER (WHERE ended_at IS NOT NULL),
0)::float8 AS avg_duration_seconds
FROM acp_sessions
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR requirement_id = $3)
AND started_at >= $4
AND started_at < $5
`
type SessionRollupParams struct {
Column1 pgtype.UUID `json:"column_1"`
Column2 pgtype.UUID `json:"column_2"`
Column3 pgtype.UUID `json:"column_3"`
StartedAt pgtype.Timestamptz `json:"started_at"`
StartedAt_2 pgtype.Timestamptz `json:"started_at_2"`
}
type SessionRollupRow struct {
Total int64 `json:"total"`
Succeeded int64 `json:"succeeded"`
Crashed int64 `json:"crashed"`
Active int64 `json:"active"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
TotalTokens int64 `json:"total_tokens"`
AvgDurationSeconds float64 `json:"avg_duration_seconds"`
}
// owner/admin scope + 可选 project/requirement + 时间窗内的会话汇总。
// success = status='exited';avg_duration_seconds 仅统计已结束(ended_at 非空)会话。
func (q *Queries) SessionRollup(ctx context.Context, arg SessionRollupParams) (SessionRollupRow, error) {
row := q.db.QueryRow(ctx, sessionRollup,
arg.Column1,
arg.Column2,
arg.Column3,
arg.StartedAt,
arg.StartedAt_2,
)
var i SessionRollupRow
err := row.Scan(
&i.Total,
&i.Succeeded,
&i.Crashed,
&i.Active,
&i.TotalCostUsd,
&i.TotalTokens,
&i.AvgDurationSeconds,
)
return i, err
}
const sessionTimeline = `-- name: SessionTimeline :many
SELECT direction,
rpc_kind,
COALESCE(method, '') AS method,
COUNT(*)::bigint AS event_count,
MIN(created_at)::timestamptz AS first_at,
MAX(created_at)::timestamptz AS last_at
FROM acp_events
WHERE session_id = $1
GROUP BY direction, rpc_kind, COALESCE(method, '')
ORDER BY first_at ASC
`
type SessionTimelineRow struct {
Direction string `json:"direction"`
RpcKind string `json:"rpc_kind"`
Method string `json:"method"`
EventCount int64 `json:"event_count"`
FirstAt pgtype.Timestamptz `json:"first_at"`
LastAt pgtype.Timestamptz `json:"last_at"`
}
// dashboard.sql: run-history dashboard 读模型(只读聚合)。
// - SessionTimeline: 单会话的 RPC 事件按 (direction, rpc_kind, method) 分桶,
// 给出条数 + 首/末时间戳,用于会话时间线概览。
// - SessionRollup: 在 owner/admin scope + 可选 project/requirement 过滤下,
// 统计会话总数、成功率(exited 视为成功)、总成本、平均时长。
// - SessionsByDay: 按天的 总数 / 成功 / 失败 / 成本 时间序列。
//
// scope 约定(与 ListSessionsByUser/ListAllSessions 一致):
//
// $1 user_id 为 NULL 时不按用户过滤(admin 全量),非 NULL 时仅该用户。
//
// 单会话事件时间线:按 method/kind 分桶聚合(条数 + 首末时间)。
func (q *Queries) SessionTimeline(ctx context.Context, sessionID pgtype.UUID) ([]SessionTimelineRow, error) {
rows, err := q.db.Query(ctx, sessionTimeline, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SessionTimelineRow
for rows.Next() {
var i SessionTimelineRow
if err := rows.Scan(
&i.Direction,
&i.RpcKind,
&i.Method,
&i.EventCount,
&i.FirstAt,
&i.LastAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const sessionsByDay = `-- name: SessionsByDay :many
SELECT
date_trunc('day', started_at)::timestamptz AS day,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded,
COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed,
COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd
FROM acp_sessions
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2::uuid IS NULL OR project_id = $2)
AND ($3::uuid IS NULL OR requirement_id = $3)
AND started_at >= $4
AND started_at < $5
GROUP BY day
ORDER BY day ASC
`
type SessionsByDayParams struct {
Column1 pgtype.UUID `json:"column_1"`
Column2 pgtype.UUID `json:"column_2"`
Column3 pgtype.UUID `json:"column_3"`
StartedAt pgtype.Timestamptz `json:"started_at"`
StartedAt_2 pgtype.Timestamptz `json:"started_at_2"`
}
type SessionsByDayRow struct {
Day pgtype.Timestamptz `json:"day"`
Total int64 `json:"total"`
Succeeded int64 `json:"succeeded"`
Crashed int64 `json:"crashed"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
}
// 按天的会话时间序列(总数 / 成功 / 失败 / 成本),同 SessionRollup 的 scope。
func (q *Queries) SessionsByDay(ctx context.Context, arg SessionsByDayParams) ([]SessionsByDayRow, error) {
rows, err := q.db.Query(ctx, sessionsByDay,
arg.Column1,
arg.Column2,
arg.Column3,
arg.StartedAt,
arg.StartedAt_2,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SessionsByDayRow
for rows.Next() {
var i SessionsByDayRow
if err := rows.Scan(
&i.Day,
&i.Total,
&i.Succeeded,
&i.Crashed,
&i.TotalCostUsd,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+223
View File
@@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/pgvector/pgvector-go"
) )
type AcpAgentKind struct { type AcpAgentKind struct {
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
KeyVersion int16 `json:"key_version"`
} }
type AcpAgentKindConfigFile struct { type AcpAgentKindConfigFile struct {
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
UpdatedBy pgtype.UUID `json:"updated_by"` UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type AcpEvent struct { type AcpEvent struct {
@@ -81,6 +88,47 @@ type AcpSession struct {
LastError *string `json:"last_error"` LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"` EndedAt pgtype.Timestamptz `json:"ended_at"`
LastStopReason *string `json:"last_stop_reason"`
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
TerminatedReason *string `json:"terminated_reason"`
SandboxMode *string `json:"sandbox_mode"`
CostUsd pgtype.Numeric `json:"cost_usd"`
TokensTotal int64 `json:"tokens_total"`
}
type AcpSessionUsage struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
SourceEventID *int64 `json:"source_event_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpTurn struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
PromptRequestID *string `json:"prompt_request_id"`
Status string `json:"status"`
StopReason *string `json:"stop_reason"`
UpdateCount int32 `json:"update_count"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
} }
type AuditLog struct { type AuditLog struct {
@@ -94,6 +142,81 @@ type AuditLog struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type ChangeRequest struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
IssueID pgtype.UUID `json:"issue_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CiState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalUrl string `json:"external_url"`
MergeCommitSha string `json:"merge_commit_sha"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
CreatedBy pgtype.UUID `json:"created_by"`
MergedAt pgtype.Timestamptz `json:"merged_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CodeChunk struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CommitSha string `json:"commit_sha"`
FilePath string `json:"file_path"`
Lang *string `json:"lang"`
StartLine int32 `json:"start_line"`
EndLine int32 `json:"end_line"`
Content string `json:"content"`
ContentSha []byte `json:"content_sha"`
TokenCount *int32 `json:"token_count"`
Embedding *pgvector.Vector `json:"embedding"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CodeIndexRun struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Status string `json:"status"`
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
EmbeddingModel string `json:"embedding_model"`
Dims int32 `json:"dims"`
FilesIndexed int32 `json:"files_indexed"`
ChunksIndexed int32 `json:"chunks_indexed"`
Error *string `json:"error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CommitSummary struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Kind string `json:"kind"`
Title *string `json:"title"`
BodyMd string `json:"body_md"`
Model *string `json:"model"`
Status string `json:"status"`
Error *string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Conversation struct { type Conversation struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
@@ -110,6 +233,14 @@ type Conversation struct {
RequirementID pgtype.UUID `json:"requirement_id"` RequirementID pgtype.UUID `json:"requirement_id"`
} }
type CryptoKey struct {
Version int32 `json:"version"`
Provider string `json:"provider"`
KeyRef *string `json:"key_ref"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type GitCredential struct { type GitCredential struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"` WorkspaceID pgtype.UUID `json:"workspace_id"`
@@ -119,6 +250,7 @@ type GitCredential struct {
Fingerprint *string `json:"fingerprint"` Fingerprint *string `json:"fingerprint"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type Issue struct { type Issue struct {
@@ -135,6 +267,17 @@ type Issue struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"` WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentID pgtype.UUID `json:"parent_id"`
Priority int32 `json:"priority"`
}
type IssueDependency struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
BlockedID pgtype.UUID `json:"blocked_id"`
BlockerID pgtype.UUID `json:"blocker_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type Job struct { type Job struct {
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type LlmModel struct { type LlmModel struct {
@@ -252,6 +396,50 @@ type Notification struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type NotificationPref struct {
UserID pgtype.UUID `json:"user_id"`
EmailEnabled bool `json:"email_enabled"`
ImEnabled bool `json:"im_enabled"`
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
MinSeverity string `json:"min_severity"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type OrchestratorRun struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OwnerID pgtype.UUID `json:"owner_id"`
Status string `json:"status"`
CurrentPhase string `json:"current_phase"`
Config []byte `json:"config"`
LastError *string `json:"last_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type OrchestratorStep struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
Phase string `json:"phase"`
Attempt int32 `json:"attempt"`
ParentStepID pgtype.UUID `json:"parent_step_id"`
Depth int32 `json:"depth"`
Status string `json:"status"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
AcpSessionID pgtype.UUID `json:"acp_session_id"`
Prompt string `json:"prompt"`
StopReason *string `json:"stop_reason"`
GateResult []byte `json:"gate_result"`
LastError *string `json:"last_error"`
JobID pgtype.UUID `json:"job_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type Project struct { type Project struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
@@ -264,6 +452,30 @@ type Project struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type ProjectMember struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
AddedBy pgtype.UUID `json:"added_by"`
}
type ProjectMemory struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Source string `json:"source"`
Embedding *pgvector.Vector `json:"embedding"`
EmbeddingModel *string `json:"embedding_model"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptTemplate struct { type PromptTemplate struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
SourceMessageID *int64 `json:"source_message_id"` SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
Verdict string `json:"verdict"`
}
type RequirementPhasePointer struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type User struct { type User struct {
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type WorkspaceWorktree struct { type WorkspaceWorktree struct {
+54
View File
@@ -0,0 +1,54 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: session_usage.sql
package acpsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertSessionUsage = `-- name: InsertSessionUsage :one
INSERT INTO acp_session_usage (
session_id, user_id, project_id, agent_kind_id, model_id,
prompt_tokens, completion_tokens, thinking_tokens, cost_usd, source_event_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (source_event_id) WHERE source_event_id IS NOT NULL DO NOTHING
RETURNING id
`
type InsertSessionUsageParams struct {
SessionID pgtype.UUID `json:"session_id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
SourceEventID *int64 `json:"source_event_id"`
}
// 写一条 per-turn 用量账目。source_event_id 非空时按唯一索引去重(同一事件
// 重复 Observe 不会重复入账);返回受影响行数判定是否实际插入。
func (q *Queries) InsertSessionUsage(ctx context.Context, arg InsertSessionUsageParams) (int64, error) {
row := q.db.QueryRow(ctx, insertSessionUsage,
arg.SessionID,
arg.UserID,
arg.ProjectID,
arg.AgentKindID,
arg.ModelID,
arg.PromptTokens,
arg.CompletionTokens,
arg.ThinkingTokens,
arg.CostUsd,
arg.SourceEventID,
)
var id int64
err := row.Scan(&id)
return id, err
}
+600 -6
View File
@@ -29,6 +29,56 @@ func (q *Queries) AcquireMainWorktree(ctx context.Context, arg AcquireMainWorktr
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const addSessionUsageTotals = `-- name: AddSessionUsageTotals :one
UPDATE acp_sessions
SET prompt_tokens = prompt_tokens + $2,
completion_tokens = completion_tokens + $3,
thinking_tokens = thinking_tokens + $4,
total_cost_usd = total_cost_usd + $5,
cost_usd = cost_usd + $5,
tokens_total = tokens_total + $2 + $3 + $4,
last_activity_at = now()
WHERE id = $1
RETURNING prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd
`
type AddSessionUsageTotalsParams struct {
ID pgtype.UUID `json:"id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
}
type AddSessionUsageTotalsRow struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
}
// 单 round-trip 累加 session 运行时 token/cost 总量并刷新 last_activity_at,
// 返回累加后的权威总量供预算判定。
// 同时把 dashboard rollup 列(cost_usd / tokens_total)与运行时累加器对齐,
// 使 run-history dashboard 在会话进行中及退出后都能读到 agent 上报的成本。
func (q *Queries) AddSessionUsageTotals(ctx context.Context, arg AddSessionUsageTotalsParams) (AddSessionUsageTotalsRow, error) {
row := q.db.QueryRow(ctx, addSessionUsageTotals,
arg.ID,
arg.PromptTokens,
arg.CompletionTokens,
arg.ThinkingTokens,
arg.TotalCostUsd,
)
var i AddSessionUsageTotalsRow
err := row.Scan(
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.TotalCostUsd,
)
return i, err
}
const countActiveSessions = `-- name: CountActiveSessions :one const countActiveSessions = `-- name: CountActiveSessions :one
SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running') SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running')
` `
@@ -52,11 +102,73 @@ func (q *Queries) CountActiveSessionsByUser(ctx context.Context, userID pgtype.U
return count, err return count, err
} }
const getCrashedSessionForResume = `-- name: GetCrashedSessionForResume :one
SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions
WHERE orchestrator_step_id = $1
AND status IN ('crashed','exited')
ORDER BY started_at DESC
LIMIT 1
`
// 返回某 step 最近一次处于可恢复终态(crashed/exited)的 session。
func (q *Queries) GetCrashedSessionForResume(ctx context.Context, orchestratorStepID pgtype.UUID) (AcpSession, error) {
row := q.db.QueryRow(ctx, getCrashedSessionForResume, orchestratorStepID)
var i AcpSession
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.ProjectID,
&i.AgentKindID,
&i.UserID,
&i.IssueID,
&i.RequirementID,
&i.AgentSessionID,
&i.Branch,
&i.CwdPath,
&i.IsMainWorktree,
&i.Status,
&i.Pid,
&i.ExitCode,
&i.LastError,
&i.StartedAt,
&i.EndedAt,
&i.LastStopReason,
&i.OrchestratorStepID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.TotalCostUsd,
&i.LastActivityAt,
&i.BudgetMaxCostUsd,
&i.BudgetMaxTokens,
&i.BudgetMaxWallClockSeconds,
&i.TerminatedReason,
&i.SandboxMode,
&i.CostUsd,
&i.TokensTotal,
)
return i, err
}
const getSessionByID = `-- name: GetSessionByID :one const getSessionByID = `-- name: GetSessionByID :one
SELECT id, workspace_id, project_id, agent_kind_id, user_id, SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions FROM acp_sessions
WHERE id = $1 WHERE id = $1
` `
@@ -82,6 +194,75 @@ func (q *Queries) GetSessionByID(ctx context.Context, id pgtype.UUID) (AcpSessio
&i.LastError, &i.LastError,
&i.StartedAt, &i.StartedAt,
&i.EndedAt, &i.EndedAt,
&i.LastStopReason,
&i.OrchestratorStepID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.TotalCostUsd,
&i.LastActivityAt,
&i.BudgetMaxCostUsd,
&i.BudgetMaxTokens,
&i.BudgetMaxWallClockSeconds,
&i.TerminatedReason,
&i.SandboxMode,
&i.CostUsd,
&i.TokensTotal,
)
return i, err
}
const getSessionByStepID = `-- name: GetSessionByStepID :one
SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions
WHERE orchestrator_step_id = $1
ORDER BY started_at DESC
LIMIT 1
`
func (q *Queries) GetSessionByStepID(ctx context.Context, orchestratorStepID pgtype.UUID) (AcpSession, error) {
row := q.db.QueryRow(ctx, getSessionByStepID, orchestratorStepID)
var i AcpSession
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.ProjectID,
&i.AgentKindID,
&i.UserID,
&i.IssueID,
&i.RequirementID,
&i.AgentSessionID,
&i.Branch,
&i.CwdPath,
&i.IsMainWorktree,
&i.Status,
&i.Pid,
&i.ExitCode,
&i.LastError,
&i.StartedAt,
&i.EndedAt,
&i.LastStopReason,
&i.OrchestratorStepID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.TotalCostUsd,
&i.LastActivityAt,
&i.BudgetMaxCostUsd,
&i.BudgetMaxTokens,
&i.BudgetMaxWallClockSeconds,
&i.TerminatedReason,
&i.SandboxMode,
&i.CostUsd,
&i.TokensTotal,
) )
return i, err return i, err
} }
@@ -89,12 +270,19 @@ func (q *Queries) GetSessionByID(ctx context.Context, id pgtype.UUID) (AcpSessio
const insertSession = `-- name: InsertSession :one const insertSession = `-- name: InsertSession :one
INSERT INTO acp_sessions ( INSERT INTO acp_sessions (
id, workspace_id, project_id, agent_kind_id, user_id, id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, branch, cwd_path, is_main_worktree, status issue_id, requirement_id, branch, cwd_path, is_main_worktree, status,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) orchestrator_step_id,
budget_max_cost_usd, budget_max_tokens, budget_max_wall_clock_seconds
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, workspace_id, project_id, agent_kind_id, user_id, RETURNING id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
` `
type InsertSessionParams struct { type InsertSessionParams struct {
@@ -109,6 +297,10 @@ type InsertSessionParams struct {
CwdPath string `json:"cwd_path"` CwdPath string `json:"cwd_path"`
IsMainWorktree bool `json:"is_main_worktree"` IsMainWorktree bool `json:"is_main_worktree"`
Status string `json:"status"` Status string `json:"status"`
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
} }
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (AcpSession, error) { func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (AcpSession, error) {
@@ -124,6 +316,10 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (A
arg.CwdPath, arg.CwdPath,
arg.IsMainWorktree, arg.IsMainWorktree,
arg.Status, arg.Status,
arg.OrchestratorStepID,
arg.BudgetMaxCostUsd,
arg.BudgetMaxTokens,
arg.BudgetMaxWallClockSeconds,
) )
var i AcpSession var i AcpSession
err := row.Scan( err := row.Scan(
@@ -144,6 +340,20 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (A
&i.LastError, &i.LastError,
&i.StartedAt, &i.StartedAt,
&i.EndedAt, &i.EndedAt,
&i.LastStopReason,
&i.OrchestratorStepID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.TotalCostUsd,
&i.LastActivityAt,
&i.BudgetMaxCostUsd,
&i.BudgetMaxTokens,
&i.BudgetMaxWallClockSeconds,
&i.TerminatedReason,
&i.SandboxMode,
&i.CostUsd,
&i.TokensTotal,
) )
return i, err return i, err
} }
@@ -152,7 +362,12 @@ const listAllSessions = `-- name: ListAllSessions :many
SELECT id, workspace_id, project_id, agent_kind_id, user_id, SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions FROM acp_sessions
WHERE ($1::uuid IS NULL OR requirement_id = $1) WHERE ($1::uuid IS NULL OR requirement_id = $1)
ORDER BY started_at DESC ORDER BY started_at DESC
@@ -185,6 +400,68 @@ func (q *Queries) ListAllSessions(ctx context.Context, dollar_1 pgtype.UUID) ([]
&i.LastError, &i.LastError,
&i.StartedAt, &i.StartedAt,
&i.EndedAt, &i.EndedAt,
&i.LastStopReason,
&i.OrchestratorStepID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.TotalCostUsd,
&i.LastActivityAt,
&i.BudgetMaxCostUsd,
&i.BudgetMaxTokens,
&i.BudgetMaxWallClockSeconds,
&i.TerminatedReason,
&i.SandboxMode,
&i.CostUsd,
&i.TokensTotal,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSessionUsageBySession = `-- name: ListSessionUsageBySession :many
SELECT id, session_id, user_id, project_id, agent_kind_id, model_id,
prompt_tokens, completion_tokens, thinking_tokens, cost_usd,
source_event_id, created_at
FROM acp_session_usage
WHERE session_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListSessionUsageBySessionParams struct {
SessionID pgtype.UUID `json:"session_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListSessionUsageBySession(ctx context.Context, arg ListSessionUsageBySessionParams) ([]AcpSessionUsage, error) {
rows, err := q.db.Query(ctx, listSessionUsageBySession, arg.SessionID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AcpSessionUsage
for rows.Next() {
var i AcpSessionUsage
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.UserID,
&i.ProjectID,
&i.AgentKindID,
&i.ModelID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.CostUsd,
&i.SourceEventID,
&i.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -200,7 +477,12 @@ const listSessionsByUser = `-- name: ListSessionsByUser :many
SELECT id, workspace_id, project_id, agent_kind_id, user_id, SELECT id, workspace_id, project_id, agent_kind_id, user_id,
issue_id, requirement_id, agent_session_id, issue_id, requirement_id, agent_session_id,
branch, cwd_path, is_main_worktree, status, branch, cwd_path, is_main_worktree, status,
pid, exit_code, last_error, started_at, ended_at pid, exit_code, last_error, started_at, ended_at, last_stop_reason,
orchestrator_step_id,
prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd,
last_activity_at, budget_max_cost_usd, budget_max_tokens,
budget_max_wall_clock_seconds, terminated_reason, sandbox_mode,
cost_usd, tokens_total
FROM acp_sessions FROM acp_sessions
WHERE user_id = $1 WHERE user_id = $1
AND ($2::uuid IS NULL OR requirement_id = $2) AND ($2::uuid IS NULL OR requirement_id = $2)
@@ -239,6 +521,76 @@ func (q *Queries) ListSessionsByUser(ctx context.Context, arg ListSessionsByUser
&i.LastError, &i.LastError,
&i.StartedAt, &i.StartedAt,
&i.EndedAt, &i.EndedAt,
&i.LastStopReason,
&i.OrchestratorStepID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.TotalCostUsd,
&i.LastActivityAt,
&i.BudgetMaxCostUsd,
&i.BudgetMaxTokens,
&i.BudgetMaxWallClockSeconds,
&i.TerminatedReason,
&i.SandboxMode,
&i.CostUsd,
&i.TokensTotal,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSessionsForReaper = `-- name: ListSessionsForReaper :many
SELECT id, user_id, started_at, last_activity_at, budget_max_wall_clock_seconds
FROM acp_sessions
WHERE status IN ('starting','running')
AND (
(budget_max_wall_clock_seconds IS NOT NULL
AND started_at < now() - make_interval(secs => budget_max_wall_clock_seconds))
OR ($1::timestamptz IS NOT NULL AND started_at < $1::timestamptz)
OR last_activity_at < $2::timestamptz
)
`
type ListSessionsForReaperParams struct {
Column1 pgtype.Timestamptz `json:"column_1"`
Column2 pgtype.Timestamptz `json:"column_2"`
}
type ListSessionsForReaperRow struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
StartedAt pgtype.Timestamptz `json:"started_at"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
}
// 返回应被回收的活跃 session:
//
// 超过自身 wall-clock 上限(budget_max_wall_clock_seconds 非空且 started_at 过早),或
// 超过全局 wall-clock 上限($1::timestamptz = now - 全局上限),或
// 空闲超时(last_activity_at < $2::timestamptz)。
func (q *Queries) ListSessionsForReaper(ctx context.Context, arg ListSessionsForReaperParams) ([]ListSessionsForReaperRow, error) {
rows, err := q.db.Query(ctx, listSessionsForReaper, arg.Column1, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListSessionsForReaperRow
for rows.Next() {
var i ListSessionsForReaperRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.StartedAt,
&i.LastActivityAt,
&i.BudgetMaxWallClockSeconds,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -269,6 +621,23 @@ func (q *Queries) MarkSessionFailedIfActive(ctx context.Context, arg MarkSession
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const markSessionTerminatedReason = `-- name: MarkSessionTerminatedReason :exec
UPDATE acp_sessions
SET terminated_reason = $2
WHERE id = $1 AND terminated_reason IS NULL
`
type MarkSessionTerminatedReasonParams struct {
ID pgtype.UUID `json:"id"`
TerminatedReason *string `json:"terminated_reason"`
}
// 写入终止原因;已有非空 terminated_reason 时不覆盖(reaper / budget 互斥保护)。
func (q *Queries) MarkSessionTerminatedReason(ctx context.Context, arg MarkSessionTerminatedReasonParams) error {
_, err := q.db.Exec(ctx, markSessionTerminatedReason, arg.ID, arg.TerminatedReason)
return err
}
const releaseMainWorktree = `-- name: ReleaseMainWorktree :execrows const releaseMainWorktree = `-- name: ReleaseMainWorktree :execrows
UPDATE workspaces SET active_main_session_id = NULL UPDATE workspaces SET active_main_session_id = NULL
WHERE id = $1 AND active_main_session_id = $2 WHERE id = $1 AND active_main_session_id = $2
@@ -287,6 +656,26 @@ func (q *Queries) ReleaseMainWorktree(ctx context.Context, arg ReleaseMainWorktr
return result.RowsAffected(), nil return result.RowsAffected(), nil
} }
const resetSessionForResume = `-- name: ResetSessionForResume :exec
UPDATE acp_sessions
SET status = 'starting',
agent_session_id = NULL,
pid = NULL,
exit_code = NULL,
last_error = NULL,
ended_at = NULL,
terminated_reason = NULL,
last_activity_at = now()
WHERE id = $1
`
// 把崩溃/退出的 session 复用为新一轮:回到 starting、清空上次运行时字段。
// 编排器 resume 在重新 Spawn 前调用,使同一行 session 在同 cwd/worktree 上复用。
func (q *Queries) ResetSessionForResume(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, resetSessionForResume, id)
return err
}
const resetStuckSessionsOnRestart = `-- name: ResetStuckSessionsOnRestart :many const resetStuckSessionsOnRestart = `-- name: ResetStuckSessionsOnRestart :many
UPDATE acp_sessions UPDATE acp_sessions
SET status = 'crashed', SET status = 'crashed',
@@ -332,6 +721,178 @@ func (q *Queries) ResetStuckSessionsOnRestart(ctx context.Context) ([]ResetStuck
return items, nil return items, nil
} }
const sumProjectCostUSD = `-- name: SumProjectCostUSD :one
SELECT COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
FROM acp_session_usage
WHERE project_id = $1 AND created_at >= $2
`
type SumProjectCostUSDParams struct {
ProjectID pgtype.UUID `json:"project_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// 某 project 自 since 起的 ACP 累计花费(per-project 软预算)。
func (q *Queries) SumProjectCostUSD(ctx context.Context, arg SumProjectCostUSDParams) (pgtype.Numeric, error) {
row := q.db.QueryRow(ctx, sumProjectCostUSD, arg.ProjectID, arg.CreatedAt)
var cost_usd pgtype.Numeric
err := row.Scan(&cost_usd)
return cost_usd, err
}
const summarizeAcpUsageByDay = `-- name: SummarizeAcpUsageByDay :many
SELECT date_trunc('day', created_at)::timestamptz AS day,
SUM(prompt_tokens)::bigint AS prompt_tokens,
SUM(completion_tokens)::bigint AS completion_tokens,
SUM(thinking_tokens)::bigint AS thinking_tokens,
SUM(cost_usd)::numeric AS cost_usd
FROM acp_session_usage
WHERE created_at >= $1 AND created_at < $2
GROUP BY day
ORDER BY day
`
type SummarizeAcpUsageByDayParams struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
CreatedAt_2 pgtype.Timestamptz `json:"created_at_2"`
}
type SummarizeAcpUsageByDayRow struct {
Day pgtype.Timestamptz `json:"day"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
}
func (q *Queries) SummarizeAcpUsageByDay(ctx context.Context, arg SummarizeAcpUsageByDayParams) ([]SummarizeAcpUsageByDayRow, error) {
rows, err := q.db.Query(ctx, summarizeAcpUsageByDay, arg.CreatedAt, arg.CreatedAt_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SummarizeAcpUsageByDayRow
for rows.Next() {
var i SummarizeAcpUsageByDayRow
if err := rows.Scan(
&i.Day,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.CostUsd,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const summarizeAcpUsageByModel = `-- name: SummarizeAcpUsageByModel :many
SELECT model_id,
SUM(prompt_tokens)::bigint AS prompt_tokens,
SUM(completion_tokens)::bigint AS completion_tokens,
SUM(thinking_tokens)::bigint AS thinking_tokens,
SUM(cost_usd)::numeric AS cost_usd
FROM acp_session_usage
WHERE created_at >= $1 AND created_at < $2
GROUP BY model_id
ORDER BY cost_usd DESC
`
type SummarizeAcpUsageByModelParams struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
CreatedAt_2 pgtype.Timestamptz `json:"created_at_2"`
}
type SummarizeAcpUsageByModelRow struct {
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
}
func (q *Queries) SummarizeAcpUsageByModel(ctx context.Context, arg SummarizeAcpUsageByModelParams) ([]SummarizeAcpUsageByModelRow, error) {
rows, err := q.db.Query(ctx, summarizeAcpUsageByModel, arg.CreatedAt, arg.CreatedAt_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SummarizeAcpUsageByModelRow
for rows.Next() {
var i SummarizeAcpUsageByModelRow
if err := rows.Scan(
&i.ModelID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.CostUsd,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const summarizeAcpUsageByUser = `-- name: SummarizeAcpUsageByUser :many
SELECT user_id,
SUM(prompt_tokens)::bigint AS prompt_tokens,
SUM(completion_tokens)::bigint AS completion_tokens,
SUM(thinking_tokens)::bigint AS thinking_tokens,
SUM(cost_usd)::numeric AS cost_usd
FROM acp_session_usage
WHERE created_at >= $1 AND created_at < $2
GROUP BY user_id
ORDER BY cost_usd DESC
`
type SummarizeAcpUsageByUserParams struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
CreatedAt_2 pgtype.Timestamptz `json:"created_at_2"`
}
type SummarizeAcpUsageByUserRow struct {
UserID pgtype.UUID `json:"user_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
}
func (q *Queries) SummarizeAcpUsageByUser(ctx context.Context, arg SummarizeAcpUsageByUserParams) ([]SummarizeAcpUsageByUserRow, error) {
rows, err := q.db.Query(ctx, summarizeAcpUsageByUser, arg.CreatedAt, arg.CreatedAt_2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SummarizeAcpUsageByUserRow
for rows.Next() {
var i SummarizeAcpUsageByUserRow
if err := rows.Scan(
&i.UserID,
&i.PromptTokens,
&i.CompletionTokens,
&i.ThinkingTokens,
&i.CostUsd,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateSessionFinished = `-- name: UpdateSessionFinished :exec const updateSessionFinished = `-- name: UpdateSessionFinished :exec
UPDATE acp_sessions UPDATE acp_sessions
SET status = $2, exit_code = $3, last_error = $4, ended_at = now() SET status = $2, exit_code = $3, last_error = $4, ended_at = now()
@@ -355,6 +916,22 @@ func (q *Queries) UpdateSessionFinished(ctx context.Context, arg UpdateSessionFi
return err return err
} }
const updateSessionLastStopReason = `-- name: UpdateSessionLastStopReason :exec
UPDATE acp_sessions
SET last_stop_reason = $2
WHERE id = $1
`
type UpdateSessionLastStopReasonParams struct {
ID pgtype.UUID `json:"id"`
LastStopReason *string `json:"last_stop_reason"`
}
func (q *Queries) UpdateSessionLastStopReason(ctx context.Context, arg UpdateSessionLastStopReasonParams) error {
_, err := q.db.Exec(ctx, updateSessionLastStopReason, arg.ID, arg.LastStopReason)
return err
}
const updateSessionRunning = `-- name: UpdateSessionRunning :exec const updateSessionRunning = `-- name: UpdateSessionRunning :exec
UPDATE acp_sessions UPDATE acp_sessions
SET status = 'running', agent_session_id = $2, pid = $3 SET status = 'running', agent_session_id = $2, pid = $3
@@ -371,3 +948,20 @@ func (q *Queries) UpdateSessionRunning(ctx context.Context, arg UpdateSessionRun
_, err := q.db.Exec(ctx, updateSessionRunning, arg.ID, arg.AgentSessionID, arg.Pid) _, err := q.db.Exec(ctx, updateSessionRunning, arg.ID, arg.AgentSessionID, arg.Pid)
return err return err
} }
const updateSessionSandboxMode = `-- name: UpdateSessionSandboxMode :exec
UPDATE acp_sessions
SET sandbox_mode = $2
WHERE id = $1
`
type UpdateSessionSandboxModeParams struct {
ID pgtype.UUID `json:"id"`
SandboxMode *string `json:"sandbox_mode"`
}
// 记录本 session 运行所用的沙箱模式(审计/取证)。
func (q *Queries) UpdateSessionSandboxMode(ctx context.Context, arg UpdateSessionSandboxModeParams) error {
_, err := q.db.Exec(ctx, updateSessionSandboxMode, arg.ID, arg.SandboxMode)
return err
}
+180
View File
@@ -0,0 +1,180 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: turns.sql
package acpsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getLatestTurnBySession = `-- name: GetLatestTurnBySession :one
SELECT id, session_id, turn_index, prompt_request_id, status,
stop_reason, update_count, started_at, completed_at
FROM acp_turns
WHERE session_id = $1
ORDER BY turn_index DESC
LIMIT 1
`
func (q *Queries) GetLatestTurnBySession(ctx context.Context, sessionID pgtype.UUID) (AcpTurn, error) {
row := q.db.QueryRow(ctx, getLatestTurnBySession, sessionID)
var i AcpTurn
err := row.Scan(
&i.ID,
&i.SessionID,
&i.TurnIndex,
&i.PromptRequestID,
&i.Status,
&i.StopReason,
&i.UpdateCount,
&i.StartedAt,
&i.CompletedAt,
)
return i, err
}
const incrementTurnUpdateCount = `-- name: IncrementTurnUpdateCount :exec
UPDATE acp_turns
SET update_count = update_count + 1
WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress'
`
type IncrementTurnUpdateCountParams struct {
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
}
func (q *Queries) IncrementTurnUpdateCount(ctx context.Context, arg IncrementTurnUpdateCountParams) error {
_, err := q.db.Exec(ctx, incrementTurnUpdateCount, arg.SessionID, arg.TurnIndex)
return err
}
const insertTurn = `-- name: InsertTurn :one
INSERT INTO acp_turns (
session_id, turn_index, prompt_request_id, status
) VALUES ($1, $2, $3, $4)
RETURNING id, session_id, turn_index, prompt_request_id, status,
stop_reason, update_count, started_at, completed_at
`
type InsertTurnParams struct {
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
PromptRequestID *string `json:"prompt_request_id"`
Status string `json:"status"`
}
func (q *Queries) InsertTurn(ctx context.Context, arg InsertTurnParams) (AcpTurn, error) {
row := q.db.QueryRow(ctx, insertTurn,
arg.SessionID,
arg.TurnIndex,
arg.PromptRequestID,
arg.Status,
)
var i AcpTurn
err := row.Scan(
&i.ID,
&i.SessionID,
&i.TurnIndex,
&i.PromptRequestID,
&i.Status,
&i.StopReason,
&i.UpdateCount,
&i.StartedAt,
&i.CompletedAt,
)
return i, err
}
const listTurnsBySession = `-- name: ListTurnsBySession :many
SELECT id, session_id, turn_index, prompt_request_id, status,
stop_reason, update_count, started_at, completed_at
FROM acp_turns
WHERE session_id = $1
ORDER BY turn_index ASC
`
func (q *Queries) ListTurnsBySession(ctx context.Context, sessionID pgtype.UUID) ([]AcpTurn, error) {
rows, err := q.db.Query(ctx, listTurnsBySession, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AcpTurn
for rows.Next() {
var i AcpTurn
if err := rows.Scan(
&i.ID,
&i.SessionID,
&i.TurnIndex,
&i.PromptRequestID,
&i.Status,
&i.StopReason,
&i.UpdateCount,
&i.StartedAt,
&i.CompletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markTurnAborted = `-- name: MarkTurnAborted :exec
UPDATE acp_turns
SET status = 'aborted', completed_at = now()
WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress'
`
type MarkTurnAbortedParams struct {
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
}
func (q *Queries) MarkTurnAborted(ctx context.Context, arg MarkTurnAbortedParams) error {
_, err := q.db.Exec(ctx, markTurnAborted, arg.SessionID, arg.TurnIndex)
return err
}
const markTurnCompleted = `-- name: MarkTurnCompleted :exec
UPDATE acp_turns
SET status = 'completed', stop_reason = $3, update_count = $4, completed_at = now()
WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress'
`
type MarkTurnCompletedParams struct {
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
StopReason *string `json:"stop_reason"`
UpdateCount int32 `json:"update_count"`
}
func (q *Queries) MarkTurnCompleted(ctx context.Context, arg MarkTurnCompletedParams) error {
_, err := q.db.Exec(ctx, markTurnCompleted,
arg.SessionID,
arg.TurnIndex,
arg.StopReason,
arg.UpdateCount,
)
return err
}
const purgeTurnsBefore = `-- name: PurgeTurnsBefore :execrows
DELETE FROM acp_turns WHERE started_at < $1
`
func (q *Queries) PurgeTurnsBefore(ctx context.Context, startedAt pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, purgeTurnsBefore, startedAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
+281 -9
View File
@@ -17,6 +17,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -24,6 +25,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers" "github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
"github.com/yan1h/agent-coding-workflow/internal/acp/sandbox"
"github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/infra/notify"
@@ -44,6 +46,25 @@ type SupervisorConfig struct {
// AgentHomesDir 是 agent CLI 受管 home 的根目录(<DataDir>/agent-homes)。 // AgentHomesDir 是 agent CLI 受管 home 的根目录(<DataDir>/agent-homes)。
// 空串时跳过配置物化与重定向 env 注入(部分测试)。 // 空串时跳过配置物化与重定向 env 注入(部分测试)。
AgentHomesDir string AgentHomesDir string
// DefaultProjectBudgetUSD 是 per-project ACP 累计花费的全局软上限(0 = 不限)。
// 当 session 与 agent-kind 均未设置成本上限时,用作 fallback 的预算判定基线。
DefaultProjectBudgetUSD float64
// ===== 沙箱(per-session 隔离)=====
// Sandbox 是 pluggable 沙箱实现(none|uid|container),在 cmd/env 构建后、
// proc.Group.Prepare 之前 Apply。为 nil 时等同 mode=none(无隔离)。
Sandbox sandbox.Sandbox
// SandboxMode 记录当前 mode,用于落库 acp_sessions.sandbox_mode(审计/取证)。
SandboxMode sandbox.Mode
// SandboxBaseUID 是 per-session 低权 UID 的基址:实际 uid = BaseUID + offset,
// offset 由 session 派生(保证不同活跃 session 不复用同一 uid)。0 = 不分配。
SandboxBaseUID int
// DataRoot 是要被屏蔽的 /data 根(除本 session worktree + per-user home 外)。
DataRoot string
// EgressProxyURL 是注入子进程 env 的 HTTP(S)_PROXY(egress 白名单代理)。
EgressProxyURL string
// SandboxRlimits 是套用到子进程(及其进程树)的 OS 资源上限。
SandboxRlimits sandbox.Limits
} }
// Supervisor 是 ACP 子进程总管。 // Supervisor 是 ACP 子进程总管。
@@ -59,14 +80,79 @@ type Supervisor struct {
mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token
crypto *crypto.Encryptor crypto *crypto.Encryptor
permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试) permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试)
turnBus TurnBus // 回合完成事件总线;可为 nil(部分测试)
orchRedrive OrchestratorRedrive // 崩溃的编排器 session 再驱动回调;可为 nil
prices ModelPriceLookup // model 价格查询;可为 nil(无 model 价格时成本=0/agent 自报)
agentsMD AgentsMDRenderer // AGENTS.md 渲染回调(project memory);可为 nil
metrics SessionExitRecorder // 会话退出计数;可为 nil(metrics 关闭)
cfg SupervisorConfig cfg SupervisorConfig
log *slog.Logger log *slog.Logger
} }
// SessionExitRecorder 是 Supervisor 上报会话退出指标所需的窄接口。
// metrics.Metrics 满足它;nil 时不记录。
type SessionExitRecorder interface {
RecordSessionExit(status string)
}
// SetMetrics 注入会话退出计数器(装配期)。
func (s *Supervisor) SetMetrics(m SessionExitRecorder) { s.metrics = m }
// AgentsMDRenderer renders the AGENTS.md content for a (project, workspace) from
// project memory. Injected via SetAgentsMDRenderer to avoid an acp →
// projectmemory import cycle. May be nil (no seeding).
type AgentsMDRenderer func(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error)
// SetAgentsMDRenderer 注入 AGENTS.md 渲染回调(装配期)。
func (s *Supervisor) SetAgentsMDRenderer(fn AgentsMDRenderer) { s.agentsMD = fn }
// agentsMDFileName is the generated file written into the worktree root.
const agentsMDFileName = "AGENTS.md"
// seedAgentsMD renders project memory into <cwd>/AGENTS.md before spawn. Skipped
// when no renderer is configured, CwdPath is empty, or ProjectID is nil. Failures
// are logged, never fatal — the agent still spawns without seeded knowledge.
func (s *Supervisor) seedAgentsMD(ctx context.Context, sess *Session) {
if s.agentsMD == nil || sess.CwdPath == "" || sess.ProjectID == uuid.Nil {
return
}
var wsID *uuid.UUID
if sess.WorkspaceID != uuid.Nil {
id := sess.WorkspaceID
wsID = &id
}
content, err := s.agentsMD(ctx, sess.ProjectID, wsID)
if err != nil {
s.log.Warn("acp.agents_md.render_failed", "session_id", sess.ID, "err", err.Error())
return
}
dst := filepath.Join(sess.CwdPath, agentsMDFileName)
if err := os.WriteFile(dst, []byte(content), 0o644); err != nil {
s.log.Warn("acp.agents_md.write_failed", "session_id", sess.ID, "path", dst, "err", err.Error())
}
}
// OrchestratorRedrive 在一个由编排器 step 驱动的 session 崩溃退出时被调用,由编排器
// 据此把对应 step 重新入队(带 backoff),让回合在同一 worktree 上恢复。注入为 func
// 以避免 acp → orchestrator 的 import 循环(与 notify dispatcher / permission service
// 的注入方式一致)。实现必须是非阻塞、不持 supervisor 锁的纯 DB 写(jobs Enqueue)。
type OrchestratorRedrive func(ctx context.Context, stepID uuid.UUID, reason string)
// SetOrchestratorRedrive 回填编排器再驱动回调(装配期)。
func (s *Supervisor) SetOrchestratorRedrive(fn OrchestratorRedrive) { s.orchRedrive = fn }
// SetPermissionService 注入权限审批服务。与 Supervisor 互相依赖,故装配时回填 // SetPermissionService 注入权限审批服务。与 Supervisor 互相依赖,故装配时回填
// (permSvc 需要 Supervisor 作为 RelayLocator,Supervisor 需要 permSvc 注入 handler)。 // (permSvc 需要 Supervisor 作为 RelayLocator,Supervisor 需要 permSvc 注入 handler)。
func (s *Supervisor) SetPermissionService(p *PermissionService) { s.permSvc = p } func (s *Supervisor) SetPermissionService(p *PermissionService) { s.permSvc = p }
// SetTurnBus 注入回合事件总线。Spawn 时每个 relay 构造一个 TurnTracker 复用它。
// 可为 nil——此时回合仍落库(若 repo 可用),但不发布内部事件。
func (s *Supervisor) SetTurnBus(b TurnBus) { s.turnBus = b }
// SetModelPriceLookup 注入 model 价格查询(成本核算用)。装配期回填,避免 acp →
// chat 的构造期依赖。可为 nil——此时无 model 价格的会话成本为 0 或取 agent 自报。
func (s *Supervisor) SetModelPriceLookup(p ModelPriceLookup) { s.prices = p }
// NewSupervisor 构造空 Supervisor。Start 不需要 — supervisor 是被动的(spawn 时 // NewSupervisor 构造空 Supervisor。Start 不需要 — supervisor 是被动的(spawn 时
// 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。 // 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。
func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher, func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
@@ -108,6 +194,16 @@ type Process struct {
StartedAt time.Time StartedAt time.Time
KilledByUs atomic.Bool // true → exited,false → crashed KilledByUs atomic.Bool // true → exited,false → crashed
done chan struct{} // monitor 退出时 close done chan struct{} // monitor 退出时 close
// relayCancel 取消 relay 的会话级 context。该 context 由 Spawn 用
// context.Background() 派生,独立于调用方的 spawn ctx(spawn ctx 在 Spawn
// 返回后立即取消),故 relay 的 reader/writer/persist/permission Decide 在整个
// 会话生命周期内都使用未取消的 ctx。onExit 调用 relayCancel 完成 relay teardown。
relayCancel context.CancelFunc
// sandboxCleanup 拆除沙箱临时资源(卸载 bind mount、停止容器、删临时目录)。
// 由 monitorProcess 在 group.Close() 之后调用,仅调用一次。可为 nil(mode=none)。
sandboxCleanup func()
} }
// stderrRing 是固定行数 ring buffer,monitor 退出时取 tail 写 last_error。 // stderrRing 是固定行数 ring buffer,monitor 退出时取 tail 写 last_error。
@@ -157,14 +253,20 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay {
// handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的 // handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的
// worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。 // worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。
func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, extraEnv map[string]string) (*Process, error) { func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, extraEnv map[string]string) (*Process, error) {
// AGENTS.md seeding:在 spawn 前把 project memory 渲染成 worktree 根的
// AGENTS.md,让 agent 启动即有结构化项目知识。best-effort,失败仅告警不阻断。
s.seedAgentsMD(ctx, sess)
// 受管 home:物化 DB 中的配置文件并注入重定向 env(优先级最低,可被 // 受管 home:物化 DB 中的配置文件并注入重定向 env(优先级最低,可被
// AgentKind env / extraEnv 覆盖)。AgentHomesDir 未配置时跳过(部分测试)。 // AgentKind env / extraEnv 覆盖)。AgentHomesDir 未配置时跳过(部分测试)。
envMap := map[string]string{} envMap := map[string]string{}
var agentHome string
if s.cfg.AgentHomesDir != "" { if s.cfg.AgentHomesDir != "" {
home, err := s.materializeAgentHome(ctx, kind) home, err := s.materializeAgentHome(ctx, sess, kind)
if err != nil { if err != nil {
return nil, fmt.Errorf("materialize agent home: %w", err) return nil, fmt.Errorf("materialize agent home: %w", err)
} }
agentHome = home
envMap = agentHomeEnv(home, kind.ClientType) envMap = agentHomeEnv(home, kind.ClientType)
} }
@@ -191,6 +293,28 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
cmd.Dir = sess.CwdPath cmd.Dir = sess.CwdPath
cmd.Env = osEnv cmd.Env = osEnv
// 沙箱:在 cmd/env 构建完成后、proc.Group.Prepare(Setpgid)之前 Apply。
// Apply 只 ADD 到 cmd.SysProcAttr(Credential / namespace),绝不替换,
// 故不破坏后续 group.Prepare 的 Setpgid 与负 pgid 整树终止。返回的 cleanup
// 在 monitorProcess 的 group.Close() 之后调用一次。mode=none 时为 no-op。
sandboxCleanup := func() {}
if s.cfg.Sandbox != nil {
spec := s.buildSandboxSpec(sess, agentHome)
cleanup, serr := s.cfg.Sandbox.Apply(cmd, spec)
if serr != nil {
return nil, fmt.Errorf("sandbox apply: %w", serr)
}
if cleanup != nil {
sandboxCleanup = cleanup
}
// 记录本 session 运行所用的 sandbox mode(审计/取证)。best-effort。
if s.cfg.SandboxMode != "" {
if err := s.repo.UpdateSessionSandboxMode(ctx, sess.ID, string(s.cfg.SandboxMode)); err != nil {
s.log.Warn("acp.sandbox.record_mode", "session_id", sess.ID, "err", err.Error())
}
}
}
// 进程树管理:Prepare 须在 Start 前(Unix Setpgid),Adopt 须在 Start 后 // 进程树管理:Prepare 须在 Start 前(Unix Setpgid),Adopt 须在 Start 后
// (Windows Job Object)。终止时整树清理,避免 agent 派生的子孙进程残留。 // (Windows Job Object)。终止时整树清理,避免 agent 派生的子孙进程残留。
group := procgrp.NewGroup() group := procgrp.NewGroup()
@@ -237,6 +361,56 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
s.log, s.log,
) )
// 回合追踪器:被动解析 session/update + session/prompt 响应。复用同一 acpRepo
// 与共享 TurnBus,无需额外连接池。onExit 通过 relay.Tracker().Abort 闭合悬挂回合。
sessCtx := handlers.SessionContext{
SessionID: sess.ID,
WorkspaceID: sess.WorkspaceID,
UserID: sess.UserID,
CwdPath: sess.CwdPath,
AgentSessionID: sess.AgentSessionID,
ToolAllowlist: kind.ToolAllowlist,
}
relay.SetTracker(NewTurnTracker(s.repo, s.turnBus, sessCtx, s.log))
// 成本核算:构造 per-session 累加器 + usage sink,seeded with 会话快照的有效
// 预算上限与 agent-kind 的 model 价格来源。预算突破时以 detached goroutine 标记
// terminated_reason 后 Kill(绝不阻塞 relay.reader,且 Kill 幂等)。
caps := BudgetCaps{
MaxCostUSD: sess.BudgetMaxCostUSD,
MaxTokens: sess.BudgetMaxTokens,
MaxWallClockSeconds: sess.BudgetMaxWallClockSeconds,
}
acc := newUsageAccumulator(AccumulatorParams{
Repo: s.repo,
Prices: s.prices,
Log: s.log,
SessionID: sess.ID,
UserID: sess.UserID,
ProjectID: sess.ProjectID,
AgentKindID: sess.AgentKindID,
ModelID: kind.ModelID,
StartedAt: time.Now(),
Caps: caps,
ProjectBudgetUSD: s.cfg.DefaultProjectBudgetUSD,
})
sid := sess.ID
killForBudget := func(reason BreachReason) {
bgCtx := context.Background()
if err := s.repo.MarkSessionTerminatedReason(bgCtx, sid, string(reason)); err != nil {
s.log.Error("acp.budget.mark_terminated", "session_id", sid, "err", err.Error())
}
s.log.Warn("acp.budget.breach_kill", "session_id", sid, "reason", string(reason))
s.Kill(bgCtx, sid, s.cfg.KillGrace)
}
relay.SetUsageSink(newAccumulatorSink(acc, killForBudget, s.log))
// relay 的会话级 context:独立于调用方的 spawn ctx(生产调用方在 Spawn 返回后
// 立即 cancel spawn ctx)。relay 的 reader/writer/persist/permission Decide 必须
// 在整个会话生命周期内使用未取消的 ctx,否则 prompt 被丢、事件停止持久化、权限
// 审批被强制拒绝。teardown 由 onExit 调 relayCancel + Relay.Close(关 r.done)完成。
relayCtx, relayCancel := context.WithCancel(context.Background())
proc := &Process{ proc := &Process{
SessionID: sess.ID, SessionID: sess.ID,
WorkspaceID: sess.WorkspaceID, WorkspaceID: sess.WorkspaceID,
@@ -251,6 +425,8 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
StderrBuf: newStderrRing(s.cfg.StderrBufferLines), StderrBuf: newStderrRing(s.cfg.StderrBufferLines),
StartedAt: time.Now(), StartedAt: time.Now(),
done: make(chan struct{}), done: make(chan struct{}),
relayCancel: relayCancel,
sandboxCleanup: sandboxCleanup,
} }
s.mu.Lock() s.mu.Lock()
@@ -259,15 +435,9 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
go s.drainStderr(proc) go s.drainStderr(proc)
go s.monitorProcess(proc) go s.monitorProcess(proc)
go relay.Run(ctx, handlers.SessionContext{ go relay.Run(relayCtx, sessCtx)
SessionID: sess.ID,
WorkspaceID: sess.WorkspaceID,
UserID: sess.UserID,
CwdPath: sess.CwdPath,
AgentSessionID: sess.AgentSessionID,
ToolAllowlist: kind.ToolAllowlist,
})
// 握手仍走调用方的 spawn ctx,保留 SpawnTimeout 截止语义。
if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); err != nil { if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); err != nil {
s.log.Error("acp.handshake_failed", "session_id", sess.ID, "err", err.Error()) s.log.Error("acp.handshake_failed", "session_id", sess.ID, "err", err.Error())
s.Kill(ctx, sess.ID, s.cfg.KillGrace) s.Kill(ctx, sess.ID, s.cfg.KillGrace)
@@ -340,6 +510,30 @@ func (s *Supervisor) BuildSpawnEnv(kind *AgentKind, extraEnv map[string]string)
return out, nil return out, nil
} }
// buildSandboxSpec 从 session + 受管 home 派生本次 spawn 的沙箱 Spec。
// per-session UID = BaseUID + offset,offset 由 session ID 派生(低 16 位),
// 保证不同 session 大概率落在不同 uid(碰撞仅影响隔离粒度,不影响正确性)。
// HomeDir = 受管 home(per-user),WorktreeDir = session cwd,二者为唯一可写路径。
func (s *Supervisor) buildSandboxSpec(sess *Session, agentHome string) sandbox.Spec {
uid := 0
if s.cfg.SandboxBaseUID > 0 {
// 取 session ID 的低 16 位作为 offset,限制 uid 漂移范围。
b := sess.ID
offset := int(b[14])<<8 | int(b[15])
uid = s.cfg.SandboxBaseUID + offset
}
return sandbox.Spec{
Mode: s.cfg.SandboxMode,
UID: uid,
GID: uid,
HomeDir: agentHome,
WorktreeDir: sess.CwdPath,
DataRoot: s.cfg.DataRoot,
Rlimits: s.cfg.SandboxRlimits,
ProxyURL: s.cfg.EgressProxyURL,
}
}
// Kill 终止指定 session 的子进程。在 Unix 平台上先发 SIGTERM 等待 grace 时间, // Kill 终止指定 session 的子进程。在 Unix 平台上先发 SIGTERM 等待 grace 时间,
// 仍未退出再 Process.Kill;Windows 没有可靠 SIGTERM 实现,直接 Process.Kill。 // 仍未退出再 Process.Kill;Windows 没有可靠 SIGTERM 实现,直接 Process.Kill。
// 始终阻塞直到 monitorProcess 关闭 done channel。Kill 是幂等的——目标 session // 始终阻塞直到 monitorProcess 关闭 done channel。Kill 是幂等的——目标 session
@@ -426,6 +620,12 @@ func (s *Supervisor) monitorProcess(proc *Process) {
// 进程已 Wait 返回,此处 Close 不影响 onExit-must-not-relock 不变量。 // 进程已 Wait 返回,此处 Close 不影响 onExit-must-not-relock 不变量。
proc.group.Close() proc.group.Close()
// 沙箱清理:在 group.Close() 之后调用一次(卸载 bind mount、停容器、删临时目录)。
// 不持 supervisor.mu,遵守 onExit-must-not-relock 不变量。mode=none 时为 no-op。
if proc.sandboxCleanup != nil {
proc.sandboxCleanup()
}
status := SessionExited status := SessionExited
if !proc.KilledByUs.Load() { if !proc.KilledByUs.Load() {
status = SessionCrashed status = SessionCrashed
@@ -437,6 +637,9 @@ func (s *Supervisor) monitorProcess(proc *Process) {
// 不能再 acquire s.mu(monitorProcess 已 delete map 后调用本函数,但 Kill // 不能再 acquire s.mu(monitorProcess 已 delete map 后调用本函数,但 Kill
// 也持锁等 done,会形成 monitor → Kill → onExit 的链式 deadlock)。 // 也持锁等 done,会形成 monitor → Kill → onExit 的链式 deadlock)。
func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionStatus, exitCode int32, waitErr error) { func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionStatus, exitCode int32, waitErr error) {
if s.metrics != nil {
s.metrics.RecordSessionExit(string(status))
}
stderrTail := proc.StderrBuf.Tail(s.cfg.StderrTailBytes) stderrTail := proc.StderrBuf.Tail(s.cfg.StderrTailBytes)
var lastErr *string var lastErr *string
if status == SessionCrashed && stderrTail != "" { if status == SessionCrashed && stderrTail != "" {
@@ -446,6 +649,14 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
s.log.Error("acp.on_exit.update_session", "session_id", proc.SessionID, "err", err.Error()) s.log.Error("acp.on_exit.update_session", "session_id", proc.SessionID, "err", err.Error())
} }
// 成本/预算终止:若 terminated_reason 已被预算门或 reaper 写入,发 audit + notify。
// 读 DB(UpdateSessionFinished 之后)拿最新 terminated_reason;不覆盖既有值。
if finished, gerr := s.repo.GetSessionByID(ctx, proc.SessionID); gerr == nil && finished != nil &&
finished.TerminatedReason != nil && *finished.TerminatedReason != "" {
s.recordBudgetTerminated(ctx, proc.UserID, proc.SessionID, *finished.TerminatedReason,
finished.TotalCostUSD, finished.PromptTokens+finished.CompletionTokens+finished.ThinkingTokens)
}
// Release worktree。主 worktree 走 acp 表自己的 CAS;子 worktree 按 holder // Release worktree。主 worktree 走 acp 表自己的 CAS;子 worktree 按 holder
// 释放(holder = "session:<sid>",见 workspace.Caller.Holder),与启动 reaper // 释放(holder = "session:<sid>",见 workspace.Caller.Holder),与启动 reaper
// 一致,不需要知道 worktree ID。 // 一致,不需要知道 worktree ID。
@@ -516,6 +727,67 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
} }
} }
// 回合检测:闭合任何开放的 in_progress 回合并发布 session_idle,确保崩溃/被杀时
// 编排层也能收到空闲信号。Abort 只触碰 repo+bus(不持 supervisor.mu),且必须在
// Relay.Close 之前——Close 后 tracker 仍可用,但语义上回合应在 teardown 前闭合。
if tracker := proc.Relay.Tracker(); tracker != nil {
reason := string(StopCancelled)
if status == SessionCrashed {
reason = "crashed"
}
tracker.Abort(ctx, reason)
}
// 取消 relay 的会话级 context:停止 reader/writer,并让仍阻塞在 Decide / 已派生
// 的 handleAgentRequest goroutine 收到 ctx.Done 后退出。在 Relay.Close 之前调,
// 二者共同完成 teardown(relayCancel 停 ctx 派生的 goroutine,Close 关 r.done +
// 广播 session_terminated)。relayCancel 在早期 spawn 失败路径也可能为 nil。
if proc.relayCancel != nil {
proc.relayCancel()
}
// 最后关 relay:广播 session_terminated 给 WS subscribers。 // 最后关 relay:广播 session_terminated 给 WS subscribers。
proc.Relay.Close(string(status), &exitCode) proc.Relay.Close(string(status), &exitCode)
// 编排器再驱动:若该 session 由编排器某 step 驱动且本次为崩溃退出,把对应 step
// 重新入队(带 backoff),让回合在同一 worktree 上恢复。只做 DB 读 + jobs Enqueue,
// 不持 supervisor 锁(保持 onExit-must-not-relock 不变量)。
if status == SessionCrashed && s.orchRedrive != nil {
if sess, gerr := s.repo.GetSessionByID(ctx, proc.SessionID); gerr == nil &&
sess != nil && sess.OrchestratorStepID != nil {
s.orchRedrive(ctx, *sess.OrchestratorStepID, "session_crashed")
}
}
}
// recordBudgetTerminated 在会话因预算/reaper 被强制终止时发 audit + owner notify。
func (s *Supervisor) recordBudgetTerminated(ctx context.Context, userID, sessionID uuid.UUID, reason string, totalCost float64, totalTokens int64) {
if s.audit != nil {
uid := userID
_ = s.audit.Record(ctx, audit.Entry{
UserID: &uid,
Action: "acp.session.budget_exceeded",
TargetType: "acp_session",
TargetID: sessionID.String(),
Metadata: map[string]any{
"reason": reason,
"total_cost_usd": totalCost,
"total_tokens": totalTokens,
},
})
}
if s.notify != nil {
_ = s.notify.Dispatch(ctx, notify.Message{
UserID: userID,
Topic: "acp.session_budget_exceeded",
Severity: notify.SeverityWarning,
Title: "ACP session terminated (budget/reaper)",
Body: fmt.Sprintf("Session %s was terminated: %s.", sessionID, reason),
Metadata: map[string]any{
"session_id": sessionID.String(),
"reason": reason,
"total_cost_usd": totalCost,
},
})
}
} }
+242
View File
@@ -0,0 +1,242 @@
// turn.go 实现 TurnTracker:单 relay 维度的回合状态机,外加 stopReason 解析辅助。
//
// 回合(turn)定义:一次 session/prompt 请求到其响应之间的窗口。relay reader 在解析到
// - session/update 通知 → OnUpdate(累加 update_count,内存计数,完成时一次性写库)
// - 该 prompt 的字符串-id 响应 → OnPromptResponse(标完成 + 写 last_stop_reason + 发事件)
//
// MVP 假设:同一 session 同时只有一个开放回合。若在已有开放回合时收到新的 StartTurn,
// 自动中止前一个(auto-abort),避免悬挂。所有状态由 mu 保护;Abort 只触碰 repo+bus,
// 不持有 supervisor 锁,故可安全地从 onExit 调用(不违反 onExit-must-not-relock 不变量)。
package acp
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
// TurnRepo 是 TurnTracker 需要的最小 repo 子集,由 acp.Repository 满足。
type TurnRepo interface {
InsertTurn(ctx context.Context, t *Turn) (*Turn, error)
MarkTurnCompleted(ctx context.Context, sessionID uuid.UUID, turnIndex int, stop string, updateCount int) error
MarkTurnAborted(ctx context.Context, sessionID uuid.UUID, turnIndex int) error
GetLatestTurnBySession(ctx context.Context, sessionID uuid.UUID) (*Turn, error)
UpdateSessionLastStopReason(ctx context.Context, sessionID uuid.UUID, reason string) error
}
// TurnTracker 是单 relay 的回合状态机。
type TurnTracker struct {
repo TurnRepo
bus TurnBus
sess handlers.SessionContext
log *slog.Logger
mu sync.Mutex
open bool // 是否有开放回合
turnIndex int // 当前/最近回合的 index
promptID string // 当前开放回合的 prompt 请求 id(用于响应相关联)
updateCount int // 当前回合的 session/update 计数(内存累计,完成时落库)
nextIndex int // 下一个回合的 index(构造时由 GetLatestTurnBySession 播种)
seeded bool // nextIndex 是否已播种
}
// NewTurnTracker 构造一个 TurnTracker。bus / repo 为 nil 时方法均为安全 no-op
// (部分测试可不注入)。
func NewTurnTracker(repo TurnRepo, bus TurnBus, sess handlers.SessionContext, log *slog.Logger) *TurnTracker {
if log == nil {
log = slog.Default()
}
return &TurnTracker{repo: repo, bus: bus, sess: sess, log: log}
}
// seedLocked 懒播种 nextIndex:从 DB 取最近回合 index+1。失败时从 0 开始(best-effort)。
// 调用方必须已持有 t.mu。
func (t *TurnTracker) seedLocked(ctx context.Context) {
if t.seeded {
return
}
t.seeded = true
if t.repo == nil {
return
}
latest, err := t.repo.GetLatestTurnBySession(ctx, t.sess.SessionID)
if err != nil {
t.log.Warn("acp.turn.seed_index", "session_id", t.sess.SessionID, "err", err.Error())
return
}
if latest != nil {
t.nextIndex = latest.TurnIndex + 1
}
}
// StartTurn 在发送 session/prompt 之前调用,登记一个 in_progress 回合并把 promptRequestID
// 与之关联。若已有开放回合,先 auto-abort 之(MVP 单回合假设)。返回新回合的 index。
func (t *TurnTracker) StartTurn(ctx context.Context, promptRequestID string) (int, error) {
if t == nil {
return 0, nil
}
t.mu.Lock()
defer t.mu.Unlock()
t.seedLocked(ctx)
// 已有开放回合 → auto-abort,避免悬挂。
if t.open {
t.abortLocked(ctx, string(StopCancelled))
}
idx := t.nextIndex
if t.repo != nil {
var pid *string
if promptRequestID != "" {
p := promptRequestID
pid = &p
}
if _, err := t.repo.InsertTurn(ctx, &Turn{
SessionID: t.sess.SessionID,
TurnIndex: idx,
PromptRequestID: pid,
Status: TurnInProgress,
}); err != nil {
return 0, err
}
}
t.open = true
t.turnIndex = idx
t.promptID = promptRequestID
t.updateCount = 0
t.nextIndex = idx + 1
return idx, nil
}
// OnUpdate 在收到一条 session/update 通知时调用,累加当前开放回合的 update_count
// (内存计数,避免每 chunk 一次 UPDATE;完成时一次性落库)。无开放回合时 no-op。
func (t *TurnTracker) OnUpdate(_ context.Context) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.open {
t.updateCount++
}
}
// IsTrackedPrompt 报告 id 是否为当前开放回合关联的 prompt 请求 id。relay reader 用它
// 判断一条字符串-id 响应是否应触发 OnPromptResponse(而非沿用既有 fanout 路径)。
func (t *TurnTracker) IsTrackedPrompt(id string) bool {
if t == nil || id == "" {
return false
}
t.mu.Lock()
defer t.mu.Unlock()
return t.open && t.promptID == id
}
// OnPromptResponse 在收到匹配当前回合的 session/prompt 响应时调用:标完成、写
// session.last_stop_reason、发布 turn_completed 与 session_idle 事件。id 不匹配时 no-op。
func (t *TurnTracker) OnPromptResponse(ctx context.Context, promptRequestID string, rawStopReason string) {
if t == nil {
return
}
t.mu.Lock()
if !t.open || t.promptID != promptRequestID {
t.mu.Unlock()
return
}
idx := t.turnIndex
updateCount := t.updateCount
t.open = false
t.promptID = ""
t.mu.Unlock()
norm := NormalizeStopReason(rawStopReason)
completedAt := time.Now()
if t.repo != nil {
if err := t.repo.MarkTurnCompleted(ctx, t.sess.SessionID, idx, rawStopReason, updateCount); err != nil {
t.log.Error("acp.turn.mark_completed", "session_id", t.sess.SessionID, "turn_index", idx, "err", err.Error())
}
if err := t.repo.UpdateSessionLastStopReason(ctx, t.sess.SessionID, rawStopReason); err != nil {
t.log.Error("acp.turn.update_last_stop_reason", "session_id", t.sess.SessionID, "err", err.Error())
}
}
if t.bus != nil {
base := TurnEvent{
SessionID: t.sess.SessionID,
UserID: t.sess.UserID,
WorkspaceID: t.sess.WorkspaceID,
TurnIndex: idx,
StopReason: norm,
RawStopReason: rawStopReason,
CompletedAt: completedAt,
}
completed := base
completed.Kind = TurnCompletedEvent
t.bus.Publish(ctx, completed)
idle := base
idle.Kind = SessionIdleEvent
t.bus.Publish(ctx, idle)
}
}
// Abort 中止当前开放回合(如果有)并发布 session_idle 事件。从 supervisor.onExit
// 在子进程退出时调用,确保崩溃/被杀时也能闭合悬挂回合。无开放回合时 no-op。
func (t *TurnTracker) Abort(ctx context.Context, reason string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if !t.open {
return
}
t.abortLocked(ctx, reason)
}
// abortLocked 闭合当前开放回合并发布 session_idle。调用方必须已持有 t.mu。
func (t *TurnTracker) abortLocked(ctx context.Context, reason string) {
idx := t.turnIndex
t.open = false
t.promptID = ""
if t.repo != nil {
if err := t.repo.MarkTurnAborted(ctx, t.sess.SessionID, idx); err != nil {
t.log.Error("acp.turn.mark_aborted", "session_id", t.sess.SessionID, "turn_index", idx, "err", err.Error())
}
}
if t.bus != nil {
norm := NormalizeStopReason(reason)
t.bus.Publish(ctx, TurnEvent{
SessionID: t.sess.SessionID,
UserID: t.sess.UserID,
WorkspaceID: t.sess.WorkspaceID,
TurnIndex: idx,
Kind: SessionIdleEvent,
StopReason: norm,
RawStopReason: reason,
CompletedAt: time.Now(),
})
}
}
// ParseStopReason 从 session/prompt 响应的 Result JSON 中提取 stopReason 字段。
// 字段缺失或解析失败时返回空串(调用方归一化为 StopOther)。
func ParseStopReason(result json.RawMessage) string {
if len(result) == 0 {
return ""
}
var r struct {
StopReason string `json:"stopReason"`
}
if err := json.Unmarshal(result, &r); err != nil {
return ""
}
return r.StopReason
}
+248
View File
@@ -0,0 +1,248 @@
package acp_test
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
)
// ----- NormalizeStopReason -----
func TestNormalizeStopReason(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
want acp.StopReason
}{
{"end_turn", acp.StopEndTurn},
{"max_tokens", acp.StopMaxTokens},
{"refusal", acp.StopRefusal},
{"cancelled", acp.StopCancelled},
{"vendor_specific_thing", acp.StopOther},
{"", acp.StopOther},
}
for _, c := range cases {
assert.Equal(t, c.want, acp.NormalizeStopReason(c.raw), "raw=%q", c.raw)
}
}
func TestParseStopReason(t *testing.T) {
t.Parallel()
assert.Equal(t, "end_turn", acp.ParseStopReason(json.RawMessage(`{"stopReason":"end_turn"}`)))
assert.Equal(t, "max_tokens", acp.ParseStopReason(json.RawMessage(`{"stopReason":"max_tokens","other":1}`)))
assert.Equal(t, "", acp.ParseStopReason(json.RawMessage(`{}`)))
assert.Equal(t, "", acp.ParseStopReason(nil))
assert.Equal(t, "", acp.ParseStopReason(json.RawMessage(`not json`)))
}
// ----- fakeTurnRepo: 记录 turn 调用 -----
type fakeTurnRepo struct {
mu sync.Mutex
inserted []*acp.Turn
completed []completedCall
aborted []int
lastStopUpdates []string
}
type completedCall struct {
turnIndex int
stop string
updateCount int
}
func (f *fakeTurnRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) {
f.mu.Lock()
defer f.mu.Unlock()
cp := *t
cp.ID = int64(len(f.inserted) + 1)
cp.Status = acp.TurnInProgress
f.inserted = append(f.inserted, &cp)
out := cp
return &out, nil
}
func (f *fakeTurnRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, turnIndex int, stop string, updateCount int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.completed = append(f.completed, completedCall{turnIndex, stop, updateCount})
return nil
}
func (f *fakeTurnRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, turnIndex int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.aborted = append(f.aborted, turnIndex)
return nil
}
func (f *fakeTurnRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) {
return nil, nil
}
func (f *fakeTurnRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, reason string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastStopUpdates = append(f.lastStopUpdates, reason)
return nil
}
// ----- fakeBus: 记录发布的事件,保留顺序 -----
type fakeBus struct {
mu sync.Mutex
events []acp.TurnEvent
}
func (b *fakeBus) Subscribe(string, func(context.Context, acp.TurnEvent)) {}
func (b *fakeBus) Publish(_ context.Context, ev acp.TurnEvent) {
b.mu.Lock()
defer b.mu.Unlock()
b.events = append(b.events, ev)
}
func (b *fakeBus) snapshot() []acp.TurnEvent {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]acp.TurnEvent, len(b.events))
copy(out, b.events)
return out
}
func newTestSessCtx() handlers.SessionContext {
return handlers.SessionContext{
SessionID: uuid.New(),
WorkspaceID: uuid.New(),
UserID: uuid.New(),
}
}
// ----- TurnTracker lifecycle -----
func TestTurnTracker_Lifecycle(t *testing.T) {
t.Parallel()
repo := &fakeTurnRepo{}
bus := &fakeBus{}
sess := newTestSessCtx()
tr := acp.NewTurnTracker(repo, bus, sess, nil)
ctx := context.Background()
idx, err := tr.StartTurn(ctx, "client-init")
require.NoError(t, err)
assert.Equal(t, 0, idx)
require.Len(t, repo.inserted, 1)
assert.Equal(t, 0, repo.inserted[0].TurnIndex)
// 3 个 update
const n = 3
for i := 0; i < n; i++ {
tr.OnUpdate(ctx)
}
// id 不匹配 → 不应完成
tr.OnPromptResponse(ctx, "wrong-id", "end_turn")
assert.Empty(t, repo.completed, "mismatched id must not complete")
// 匹配 → 完成
tr.OnPromptResponse(ctx, "client-init", "end_turn")
require.Len(t, repo.completed, 1)
assert.Equal(t, 0, repo.completed[0].turnIndex)
assert.Equal(t, "end_turn", repo.completed[0].stop)
assert.Equal(t, n, repo.completed[0].updateCount, "update_count must equal observed updates")
require.Len(t, repo.lastStopUpdates, 1)
assert.Equal(t, "end_turn", repo.lastStopUpdates[0])
// 事件顺序:turn_completed 然后 session_idle
evs := bus.snapshot()
require.Len(t, evs, 2)
assert.Equal(t, acp.TurnCompletedEvent, evs[0].Kind)
assert.Equal(t, acp.SessionIdleEvent, evs[1].Kind)
assert.Equal(t, acp.StopEndTurn, evs[0].StopReason)
assert.Equal(t, "end_turn", evs[0].RawStopReason)
assert.Equal(t, sess.SessionID, evs[0].SessionID)
assert.Equal(t, sess.UserID, evs[0].UserID)
// 重复响应 → no-op(回合已闭合)
tr.OnPromptResponse(ctx, "client-init", "end_turn")
assert.Len(t, repo.completed, 1, "duplicate response must be ignored")
}
func TestTurnTracker_UnknownStopReason(t *testing.T) {
t.Parallel()
repo := &fakeTurnRepo{}
bus := &fakeBus{}
tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil)
ctx := context.Background()
_, err := tr.StartTurn(ctx, "p1")
require.NoError(t, err)
tr.OnPromptResponse(ctx, "p1", "weird_vendor_reason")
require.Len(t, repo.completed, 1)
// 原始字符串原样落库
assert.Equal(t, "weird_vendor_reason", repo.completed[0].stop)
assert.Equal(t, "weird_vendor_reason", repo.lastStopUpdates[0])
// 归一化为 other
evs := bus.snapshot()
require.Len(t, evs, 2)
assert.Equal(t, acp.StopOther, evs[0].StopReason)
assert.Equal(t, "weird_vendor_reason", evs[0].RawStopReason)
}
func TestTurnTracker_Abort(t *testing.T) {
t.Parallel()
repo := &fakeTurnRepo{}
bus := &fakeBus{}
tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil)
ctx := context.Background()
_, err := tr.StartTurn(ctx, "p1")
require.NoError(t, err)
tr.Abort(ctx, "crashed")
require.Len(t, repo.aborted, 1)
assert.Equal(t, 0, repo.aborted[0])
evs := bus.snapshot()
require.Len(t, evs, 1)
assert.Equal(t, acp.SessionIdleEvent, evs[0].Kind)
// 无开放回合再 Abort → no-op
tr.Abort(ctx, "crashed")
assert.Len(t, repo.aborted, 1, "abort with no open turn must be no-op")
}
func TestTurnTracker_AutoAbortPriorTurn(t *testing.T) {
t.Parallel()
repo := &fakeTurnRepo{}
bus := &fakeBus{}
tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil)
ctx := context.Background()
idx0, err := tr.StartTurn(ctx, "p1")
require.NoError(t, err)
assert.Equal(t, 0, idx0)
// 第二次 StartTurn 在前一个开放时 → auto-abort 前一个
idx1, err := tr.StartTurn(ctx, "p2")
require.NoError(t, err)
assert.Equal(t, 1, idx1)
require.Len(t, repo.aborted, 1)
assert.Equal(t, 0, repo.aborted[0], "prior open turn must be auto-aborted")
require.Len(t, repo.inserted, 2)
}
func TestTurnTracker_NilSafe(t *testing.T) {
t.Parallel()
var tr *acp.TurnTracker
ctx := context.Background()
// 所有方法在 nil receiver 上安全 no-op
_, err := tr.StartTurn(ctx, "x")
assert.NoError(t, err)
tr.OnUpdate(ctx)
tr.OnPromptResponse(ctx, "x", "end_turn")
tr.Abort(ctx, "x")
assert.False(t, tr.IsTrackedPrompt("x"))
}
+131
View File
@@ -0,0 +1,131 @@
// turnbus.go 实现 TurnBus:进程内的极简 observer / pub-sub,作为"回合完成 /
// 会话空闲"的内部事件原语。与 notify.Dispatcher(面向用户的外发通知)解耦——
// TurnBus 是给未来的编排层(auto-continue / gate / hand-off)订阅用的内部信号,
// 不落库、不外发。
//
// 关键不变量:Publish 必须永不阻塞 relay reader 热路径。订阅者以 fire-and-forget
// 方式在独立 goroutine 中调用,并用 recover() 隔离 panic——一个慢/有 bug 的
// 订阅者不能拖垮协议 I/O。语义对齐 notify.Dispatcher 的非阻塞 fan-out。
package acp
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
)
// TurnEventKind 标识 TurnEvent 的类别。
type TurnEventKind string
const (
// TurnCompletedEvent 表示一次回合正常完成(收到 session/prompt 响应的 stopReason)。
TurnCompletedEvent TurnEventKind = "turn_completed"
// SessionIdleEvent 表示会话当前空闲(回合完成或被中止后),编排层可据此决策。
SessionIdleEvent TurnEventKind = "session_idle"
)
// TurnEvent 是 TurnBus 上发布的事件载荷。RawStopReason 是 agent 上报的原始字符串
// (可能为空,例如 abort 路径),StopReason 是其归一化枚举。
type TurnEvent struct {
SessionID uuid.UUID
UserID uuid.UUID
WorkspaceID uuid.UUID
TurnIndex int
Kind TurnEventKind
StopReason StopReason
RawStopReason string
CompletedAt time.Time
}
// TurnBus 是回合事件的进程内 pub-sub 抽象。
type TurnBus interface {
// Subscribe 注册一个具名订阅者。name 仅用于日志/排障。重复 name 会追加,
// 不去重(与 notify 一致,调用方自行保证唯一)。
Subscribe(name string, fn func(context.Context, TurnEvent))
// Publish 把事件 fire-and-forget 派发给所有订阅者,永不阻塞调用方。
Publish(ctx context.Context, ev TurnEvent)
}
type deliver struct {
ctx context.Context
ev TurnEvent
}
type subscriber struct {
name string
fn func(context.Context, TurnEvent)
ch chan deliver
}
// subBuffer 是每个订阅者的投递缓冲容量。
const subBuffer = 256
// defaultTurnBus 是 TurnBus 的默认实现:每个订阅者一个常驻 worker goroutine,从带缓冲
// channel 顺序消费事件——因此对同一订阅者保证 per-subscriber FIFO(turn_completed 必先于
// 同一次 Publish 序列里随后的 session_idle 到达)。panic 用 recover() 隔离。Publish 以非阻塞
// 方式投递(缓冲满则丢弃并告警),永不阻塞 relay reader 热路径。
type defaultTurnBus struct {
mu sync.RWMutex
subs []*subscriber
log *slog.Logger
}
// NewTurnBus 构造默认 TurnBus。
func NewTurnBus(log *slog.Logger) TurnBus {
if log == nil {
log = slog.Default()
}
return &defaultTurnBus{log: log}
}
func (b *defaultTurnBus) Subscribe(name string, fn func(context.Context, TurnEvent)) {
if fn == nil {
return
}
s := &subscriber{name: name, fn: fn, ch: make(chan deliver, subBuffer)}
b.mu.Lock()
b.subs = append(b.subs, s)
b.mu.Unlock()
// 每个订阅者一个常驻 worker,顺序消费保证 FIFO。worker 与 app 同生命周期。
go b.worker(s)
}
func (b *defaultTurnBus) worker(s *subscriber) {
for d := range s.ch {
b.dispatch(s, d)
}
}
func (b *defaultTurnBus) dispatch(s *subscriber, d deliver) {
defer func() {
if rec := recover(); rec != nil {
b.log.Error("acp.turnbus.subscriber_panic",
"subscriber", s.name,
"session_id", d.ev.SessionID,
"panic", fmt.Sprintf("%v", rec))
}
}()
s.fn(d.ctx, d.ev)
}
func (b *defaultTurnBus) Publish(ctx context.Context, ev TurnEvent) {
b.mu.RLock()
subs := b.subs
b.mu.RUnlock()
// 解绑调用方 ctx 的取消(relay teardown 会 cancel ctx,但订阅者应能完成它的短暂处理)。
d := deliver{ctx: context.WithoutCancel(ctx), ev: ev}
for _, s := range subs {
select {
case s.ch <- d:
default:
// 缓冲满:丢弃以保证 Publish 永不阻塞 relay reader(正常负载不应发生)。
b.log.Warn("acp.turnbus.drop_full",
"subscriber", s.name, "session_id", ev.SessionID, "kind", string(ev.Kind))
}
}
}
+75
View File
@@ -0,0 +1,75 @@
package acp_test
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
)
func TestTurnBus_FanoutToMultipleSubscribers(t *testing.T) {
t.Parallel()
bus := acp.NewTurnBus(nil)
var wg sync.WaitGroup
wg.Add(2)
var a, b atomic.Int32
bus.Subscribe("a", func(_ context.Context, _ acp.TurnEvent) { a.Add(1); wg.Done() })
bus.Subscribe("b", func(_ context.Context, _ acp.TurnEvent) { b.Add(1); wg.Done() })
bus.Publish(context.Background(), acp.TurnEvent{SessionID: uuid.New(), Kind: acp.TurnCompletedEvent})
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("subscribers not invoked in time")
}
assert.Equal(t, int32(1), a.Load())
assert.Equal(t, int32(1), b.Load())
}
func TestTurnBus_PanicIsolatedAndNonBlocking(t *testing.T) {
t.Parallel()
bus := acp.NewTurnBus(nil)
var good atomic.Int32
var wg sync.WaitGroup
wg.Add(1)
bus.Subscribe("panicky", func(_ context.Context, _ acp.TurnEvent) { panic("boom") })
bus.Subscribe("good", func(_ context.Context, _ acp.TurnEvent) { good.Add(1); wg.Done() })
// Publish 必须立即返回(非阻塞),且 panicky 订阅者的 panic 被 recover 隔离,
// 不影响 good 订阅者执行。
bus.Publish(context.Background(), acp.TurnEvent{Kind: acp.SessionIdleEvent})
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("good subscriber not invoked despite panicky peer")
}
assert.Equal(t, int32(1), good.Load())
}
func TestTurnBus_PublishDoesNotBlockOnSlowSubscriber(t *testing.T) {
t.Parallel()
bus := acp.NewTurnBus(nil)
release := make(chan struct{})
bus.Subscribe("slow", func(_ context.Context, _ acp.TurnEvent) { <-release })
start := time.Now()
bus.Publish(context.Background(), acp.TurnEvent{Kind: acp.SessionIdleEvent})
// Publish 应远快于订阅者的阻塞时长。
require.Less(t, time.Since(start), 500*time.Millisecond, "Publish must not block on slow subscriber")
close(release)
}
+396
View File
@@ -0,0 +1,396 @@
// usage.go implements ACP session cost accounting:
// - ExtractUsage: a tolerant parser pulling per-turn billable token usage out
// of agent messages (session/prompt RESPONSE result.usage, snake/camel) and
// opportunistic cost out of usage_update notifications.
// - usageAccumulator: applies one parsed turn — prices it (ModelPriceLookup or
// agent self-reported cost fallback), persists ledger + running totals via
// the repo, and reports a budget breach when a cap is now exceeded.
//
// Billing source of truth (spec §7 risk #1): claude-agent-acp returns billable
// usage in the session/prompt response ({stopReason, usage:{input_tokens,...}}).
// The ACP usage_update notification carries a context-window GAUGE (used/size),
// NOT a per-turn delta — summing it would massively overcount, so it is used
// only for the optional cost{amount} fallback, never for token deltas.
package acp
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
)
// ParsedUsage is one extracted turn. HasUsage=false means no billable usage was
// present (ordinary chunk / malformed payload) and the caller must skip it.
type ParsedUsage struct {
PromptTokens int
CompletionTokens int
ThinkingTokens int
// CostUSD is the agent-self-reported cost (usage_update.cost.amount), used as
// a pricing fallback when no model price is configured. nil = not reported.
CostUSD *float64
HasUsage bool
}
// ModelPrice is the per-million-token price triple for a model.
type ModelPrice struct {
PromptPerM float64
CompletionPerM float64
ThinkingPerM float64
Found bool
}
// ModelPriceLookup resolves a model_id to its prices. Implemented in app.go by
// an adapter over the chat endpoint/model service.
type ModelPriceLookup interface {
PriceForModel(ctx context.Context, modelID uuid.UUID) (ModelPrice, error)
}
// UsageAccumulator applies parsed turns to one session: persists the ledger +
// running totals and reports budget breaches.
type UsageAccumulator interface {
// Observe applies one parsed turn. sourceEventID (>0) de-dups via the ledger
// unique index. Returns a non-empty breach reason when a cap is now exceeded.
Observe(ctx context.Context, p ParsedUsage, sourceEventID int64) (breach BreachReason, breached bool)
}
// usageSink is fed raw messages from relay.reader after persistence; it extracts
// usage and forwards to the UsageAccumulator. Separated from the accumulator so
// the relay does not depend on pricing/repo details.
type usageSink interface {
Observe(ctx context.Context, msg *Message, eventID int64)
}
// accumulatorSink adapts a UsageAccumulator to the relay's usageSink: it parses
// each agent->client message and, on a real billable turn, forwards to Observe.
// A breach invokes onBreach exactly once on a detached goroutine (so the relay
// reader never blocks on sup.Kill).
type accumulatorSink struct {
acc UsageAccumulator
onBreach func(reason BreachReason)
fired sync.Once
log *slog.Logger
}
// newAccumulatorSink builds the relay usage sink.
func newAccumulatorSink(acc UsageAccumulator, onBreach func(reason BreachReason), log *slog.Logger) *accumulatorSink {
if log == nil {
log = slog.Default()
}
return &accumulatorSink{acc: acc, onBreach: onBreach, log: log}
}
func (s *accumulatorSink) Observe(ctx context.Context, msg *Message, eventID int64) {
if s == nil || s.acc == nil {
return
}
p := ExtractUsage(msg)
if !p.HasUsage {
return
}
breach, ok := s.acc.Observe(ctx, p, eventID)
if !ok || s.onBreach == nil {
return
}
// Fire the kill exactly once, detached so the reader keeps draining.
s.fired.Do(func() {
reason := breach
go s.onBreach(reason)
})
}
// ===== ExtractUsage =====
// promptResponseUsage matches both snake_case (claude-agent-acp stable) and
// camelCase (unstable v2 Usage) usage objects on a session/prompt response.
type promptResponseResult struct {
StopReason string `json:"stopReason"`
Usage *struct {
// snake_case (stable)
InputTokens *int `json:"input_tokens"`
OutputTokens *int `json:"output_tokens"`
// camelCase (unstable v2)
InputTokensCamel *int `json:"inputTokens"`
OutputTokensCamel *int `json:"outputTokens"`
ThoughtTokens *int `json:"thoughtTokens"`
CachedReadTokens *int `json:"cachedReadTokens"`
CachedWriteTokens *int `json:"cachedWriteTokens"`
// thinking_tokens snake fallback
ThinkingTokens *int `json:"thinking_tokens"`
} `json:"usage"`
}
// usageUpdateParams matches a session/update notification carrying usage_update.
type usageUpdateParams struct {
Update *struct {
SessionUpdate string `json:"sessionUpdate"`
Cost *struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"cost"`
} `json:"update"`
}
// ExtractUsage is the tolerant entry point. It inspects an agent->client message
// for billable usage. Never panics on garbage — returns HasUsage=false instead.
func ExtractUsage(msg *Message) ParsedUsage {
if msg == nil {
return ParsedUsage{}
}
// 1) session/prompt RESPONSE result.usage — the reliable billable source.
if len(msg.Result) > 0 {
if p, ok := parsePromptResponseUsage(msg.Result); ok {
return p
}
}
// 2) usage_update notification — opportunistic cost only (used/size is a
// context-window gauge, NOT a billable token delta; do not sum it).
if msg.Method == "session/update" && len(msg.Params) > 0 {
if p, ok := parseUsageUpdateCost(msg.Params); ok {
return p
}
}
return ParsedUsage{}
}
func parsePromptResponseUsage(result json.RawMessage) (ParsedUsage, bool) {
var r promptResponseResult
if err := json.Unmarshal(result, &r); err != nil || r.Usage == nil {
return ParsedUsage{}, false
}
u := r.Usage
prompt := firstNonNil(u.InputTokens, u.InputTokensCamel)
completion := firstNonNil(u.OutputTokens, u.OutputTokensCamel)
thinking := firstNonNil(u.ThoughtTokens, u.ThinkingTokens)
// cachedReadTokens/cachedWriteTokens are reported but not separately priced;
// fold cached writes into prompt-equivalent only if no input token present.
if prompt == 0 && completion == 0 && thinking == 0 {
// No billable token signal in the usage object.
return ParsedUsage{}, false
}
return ParsedUsage{
PromptTokens: prompt,
CompletionTokens: completion,
ThinkingTokens: thinking,
HasUsage: true,
}, true
}
func parseUsageUpdateCost(params json.RawMessage) (ParsedUsage, bool) {
var p usageUpdateParams
if err := json.Unmarshal(params, &p); err != nil || p.Update == nil {
return ParsedUsage{}, false
}
if p.Update.SessionUpdate != "usage_update" || p.Update.Cost == nil {
return ParsedUsage{}, false
}
amt := p.Update.Cost.Amount
if amt <= 0 {
return ParsedUsage{}, false
}
return ParsedUsage{CostUSD: &amt, HasUsage: true}, true
}
func firstNonNil(a, b *int) int {
if a != nil {
return *a
}
if b != nil {
return *b
}
return 0
}
// ===== usageAccumulator (per-session) =====
// usageRepo is the narrow repo surface the accumulator needs.
type usageRepo interface {
InsertSessionUsage(ctx context.Context, r *SessionUsageRecord) (bool, error)
AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error)
SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error)
}
// pgUsageAccumulator is the concrete per-session accumulator. Thread-safe: the
// relay reader is single-goroutine today, but Observe is guarded so future
// concurrent callers (or tests) stay correct.
type pgUsageAccumulator struct {
repo usageRepo
prices ModelPriceLookup
log *slog.Logger
now func() time.Time
mu sync.Mutex
dedup map[int64]struct{}
sessionID uuid.UUID
userID uuid.UUID
projectID uuid.UUID
agentKindID uuid.UUID
modelID *uuid.UUID
startedAt time.Time
caps BudgetCaps
// projectBudgetUSD (>0) enforces a cumulative per-project ACP spend soft cap
// over [projectSince, now]; 0 disables it.
projectBudgetUSD float64
projectSince time.Time
}
// AccumulatorParams seeds a per-session accumulator.
type AccumulatorParams struct {
Repo usageRepo
Prices ModelPriceLookup
Log *slog.Logger
SessionID uuid.UUID
UserID uuid.UUID
ProjectID uuid.UUID
AgentKindID uuid.UUID
ModelID *uuid.UUID
StartedAt time.Time
Caps BudgetCaps
// ProjectBudgetUSD, when >0, enforces a per-project soft cap on cumulative
// ACP spend since ProjectSince.
ProjectBudgetUSD float64
ProjectSince time.Time
Now func() time.Time
}
func newUsageAccumulator(p AccumulatorParams) *pgUsageAccumulator {
log := p.Log
if log == nil {
log = slog.Default()
}
now := p.Now
if now == nil {
now = time.Now
}
since := p.ProjectSince
if since.IsZero() {
since = now().Add(-30 * 24 * time.Hour)
}
return &pgUsageAccumulator{
repo: p.Repo,
prices: p.Prices,
log: log,
now: now,
dedup: map[int64]struct{}{},
sessionID: p.SessionID,
userID: p.UserID,
projectID: p.ProjectID,
agentKindID: p.AgentKindID,
modelID: p.ModelID,
startedAt: p.StartedAt,
caps: p.Caps,
projectBudgetUSD: p.ProjectBudgetUSD,
projectSince: since,
}
}
func (a *pgUsageAccumulator) Observe(ctx context.Context, p ParsedUsage, sourceEventID int64) (BreachReason, bool) {
if !p.HasUsage {
return "", false
}
a.mu.Lock()
defer a.mu.Unlock()
// In-memory de-dup guard (DB unique index is the durable de-dup).
if sourceEventID > 0 {
if _, seen := a.dedup[sourceEventID]; seen {
return "", false
}
}
cost := a.price(ctx, p)
// Persist ledger row first (idempotent per source_event_id), then accumulate
// session totals only when the ledger row was actually inserted (no re-add on
// a duplicate event).
var srcID *int64
if sourceEventID > 0 {
v := sourceEventID
srcID = &v
}
inserted, err := a.repo.InsertSessionUsage(ctx, &SessionUsageRecord{
SessionID: a.sessionID,
UserID: a.userID,
ProjectID: a.projectID,
AgentKindID: a.agentKindID,
ModelID: a.modelID,
PromptTokens: int64(p.PromptTokens),
CompletionTokens: int64(p.CompletionTokens),
ThinkingTokens: int64(p.ThinkingTokens),
CostUSD: cost,
SourceEventID: srcID,
})
if err != nil {
a.log.Error("acp.usage.insert_ledger", "session_id", a.sessionID, "err", err.Error())
return "", false
}
if !inserted {
// Duplicate event id — already accounted.
if sourceEventID > 0 {
a.dedup[sourceEventID] = struct{}{}
}
return "", false
}
if sourceEventID > 0 {
a.dedup[sourceEventID] = struct{}{}
}
totPrompt, totCompletion, totThinking, totCost, err := a.repo.AddSessionUsageTotals(
ctx, a.sessionID,
int64(p.PromptTokens), int64(p.CompletionTokens), int64(p.ThinkingTokens), cost)
if err != nil {
a.log.Error("acp.usage.add_totals", "session_id", a.sessionID, "err", err.Error())
return "", false
}
return a.checkBreach(ctx, totPrompt, totCompletion, totThinking, totCost)
}
// price computes the turn cost: prefer the configured model price; otherwise
// fall back to the agent-self-reported cost; otherwise 0 (unpriced).
func (a *pgUsageAccumulator) price(ctx context.Context, p ParsedUsage) float64 {
if a.modelID != nil && a.prices != nil {
if mp, err := a.prices.PriceForModel(ctx, *a.modelID); err == nil && mp.Found {
return (float64(p.PromptTokens)*mp.PromptPerM +
float64(p.CompletionTokens)*mp.CompletionPerM +
float64(p.ThinkingTokens)*mp.ThinkingPerM) / 1_000_000
}
}
if p.CostUSD != nil {
return *p.CostUSD
}
return 0
}
func (a *pgUsageAccumulator) checkBreach(ctx context.Context, totPrompt, totCompletion, totThinking int64, totCost float64) (BreachReason, bool) {
// Wall-clock: enforced here too (the reaper is the periodic backstop).
if a.caps.MaxWallClockSeconds != nil {
elapsed := a.now().Sub(a.startedAt)
if elapsed >= time.Duration(*a.caps.MaxWallClockSeconds)*time.Second {
return BreachWallClock, true
}
}
if a.caps.MaxTokens != nil {
if totPrompt+totCompletion+totThinking >= *a.caps.MaxTokens {
return BreachTokens, true
}
}
if a.caps.MaxCostUSD != nil && totCost >= *a.caps.MaxCostUSD {
return BreachCost, true
}
// Per-project soft cap (advisory kill, slight overshoot tolerated under
// concurrency — spec §7 risk). Only queried when a project budget is set.
if a.projectBudgetUSD > 0 {
if projCost, err := a.repo.SumProjectCostUSD(ctx, a.projectID, a.projectSince); err == nil {
if projCost >= a.projectBudgetUSD {
return BreachCost, true
}
} else {
a.log.Warn("acp.usage.project_cost_check", "project_id", a.projectID, "err", err.Error())
}
}
return "", false
}
+317
View File
@@ -0,0 +1,317 @@
package acp
import (
"context"
"errors"
"fmt"
"math/big"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
acpsqlc "github.com/yan1h/agent-coding-workflow/internal/acp/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func isNoRows(err error) bool { return errors.Is(err, pgx.ErrNoRows) }
// ===== NUMERIC <-> float64 转换 helper(复制 chat/repository.go 的实现)=====
func numericToFloat64(n pgtype.Numeric) float64 {
if !n.Valid {
return 0
}
f8, err := n.Float64Value()
if err != nil {
return 0
}
return f8.Float64
}
func numericToFloat64Ptr(n pgtype.Numeric) *float64 {
if !n.Valid {
return nil
}
v := numericToFloat64(n)
return &v
}
func float64ToNumeric(f float64) pgtype.Numeric {
var n pgtype.Numeric
if err := n.ScanScientific(fmt.Sprintf("%.10e", f)); err != nil {
bi := new(big.Int)
bi.SetInt64(int64(f))
return pgtype.Numeric{Int: bi, Exp: 0, Valid: true}
}
return n
}
func float64PtrToNumeric(f *float64) pgtype.Numeric {
if f == nil {
return pgtype.Numeric{}
}
return float64ToNumeric(*f)
}
// ===== 成本核算 / 预算 / reaper 仓储方法 =====
func (r *pgRepo) InsertSessionUsage(ctx context.Context, rec *SessionUsageRecord) (bool, error) {
var srcID *int64
if rec.SourceEventID != nil {
v := *rec.SourceEventID
srcID = &v
}
id, err := r.q.InsertSessionUsage(ctx, acpsqlc.InsertSessionUsageParams{
SessionID: toPgUUID(rec.SessionID),
UserID: toPgUUID(rec.UserID),
ProjectID: toPgUUID(rec.ProjectID),
AgentKindID: toPgUUID(rec.AgentKindID),
ModelID: toPgUUIDPtr(rec.ModelID),
PromptTokens: rec.PromptTokens,
CompletionTokens: rec.CompletionTokens,
ThinkingTokens: rec.ThinkingTokens,
CostUsd: float64ToNumeric(rec.CostUSD),
SourceEventID: srcID,
})
if err != nil {
// ON CONFLICT DO NOTHING + no row -> not inserted (idempotent de-dup).
if isNoRows(err) {
return false, nil
}
return false, errs.Wrap(err, errs.CodeInternal, "insert session usage")
}
rec.ID = id
return true, nil
}
func (r *pgRepo) AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) {
row, err := r.q.AddSessionUsageTotals(ctx, acpsqlc.AddSessionUsageTotalsParams{
ID: toPgUUID(sid),
PromptTokens: dp,
CompletionTokens: dc,
ThinkingTokens: dt,
TotalCostUsd: float64ToNumeric(dCost),
})
if err != nil {
return 0, 0, 0, 0, errs.Wrap(err, errs.CodeInternal, "add session usage totals")
}
return row.PromptTokens, row.CompletionTokens, row.ThinkingTokens, numericToFloat64(row.TotalCostUsd), nil
}
func (r *pgRepo) SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error) {
n, err := r.q.SumProjectCostUSD(ctx, acpsqlc.SumProjectCostUSDParams{
ProjectID: toPgUUID(projectID),
CreatedAt: pgtype.Timestamptz{Time: since, Valid: true},
})
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "sum project cost")
}
return numericToFloat64(n), nil
}
func (r *pgRepo) MarkSessionTerminatedReason(ctx context.Context, sid uuid.UUID, reason string) error {
if err := r.q.MarkSessionTerminatedReason(ctx, acpsqlc.MarkSessionTerminatedReasonParams{
ID: toPgUUID(sid),
TerminatedReason: &reason,
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "mark session terminated reason")
}
return nil
}
func (r *pgRepo) ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error) {
var wallTS pgtype.Timestamptz
if !wallClockStartedBefore.IsZero() {
wallTS = pgtype.Timestamptz{Time: wallClockStartedBefore, Valid: true}
}
rows, err := r.q.ListSessionsForReaper(ctx, acpsqlc.ListSessionsForReaperParams{
Column1: wallTS,
Column2: pgtype.Timestamptz{Time: idleSince, Valid: true},
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list sessions for reaper")
}
out := make([]ReaperSession, 0, len(rows))
for _, row := range rows {
out = append(out, ReaperSession{
ID: fromPgUUID(row.ID),
UserID: fromPgUUID(row.UserID),
StartedAt: row.StartedAt.Time,
LastActivityAt: row.LastActivityAt.Time,
MaxWallClockSeconds: row.BudgetMaxWallClockSeconds,
})
}
return out, nil
}
func (r *pgRepo) ListSessionUsage(ctx context.Context, sessionID uuid.UUID, limit int32) ([]*SessionUsageRecord, error) {
if limit <= 0 {
limit = 50
}
rows, err := r.q.ListSessionUsageBySession(ctx, acpsqlc.ListSessionUsageBySessionParams{
SessionID: toPgUUID(sessionID),
Limit: limit,
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list session usage")
}
out := make([]*SessionUsageRecord, 0, len(rows))
for _, row := range rows {
out = append(out, rowToSessionUsage(row))
}
return out, nil
}
func rowToSessionUsage(row acpsqlc.AcpSessionUsage) *SessionUsageRecord {
var srcID *int64
if row.SourceEventID != nil {
v := *row.SourceEventID
srcID = &v
}
return &SessionUsageRecord{
ID: row.ID,
SessionID: fromPgUUID(row.SessionID),
UserID: fromPgUUID(row.UserID),
ProjectID: fromPgUUID(row.ProjectID),
AgentKindID: fromPgUUID(row.AgentKindID),
ModelID: fromPgUUIDPtr(row.ModelID),
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: numericToFloat64(row.CostUsd),
SourceEventID: srcID,
CreatedAt: row.CreatedAt.Time,
}
}
func (r *pgRepo) SummarizeAcpUsageByUser(ctx context.Context, from, to time.Time) ([]AcpUsageUserRow, error) {
rows, err := r.q.SummarizeAcpUsageByUser(ctx, acpsqlc.SummarizeAcpUsageByUserParams{
CreatedAt: pgtype.Timestamptz{Time: from, Valid: true},
CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true},
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by user")
}
out := make([]AcpUsageUserRow, 0, len(rows))
for _, row := range rows {
out = append(out, AcpUsageUserRow{
UserID: fromPgUUID(row.UserID),
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: numericToFloat64(row.CostUsd),
})
}
return out, nil
}
func (r *pgRepo) SummarizeAcpUsageByModel(ctx context.Context, from, to time.Time) ([]AcpUsageModelRow, error) {
rows, err := r.q.SummarizeAcpUsageByModel(ctx, acpsqlc.SummarizeAcpUsageByModelParams{
CreatedAt: pgtype.Timestamptz{Time: from, Valid: true},
CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true},
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by model")
}
out := make([]AcpUsageModelRow, 0, len(rows))
for _, row := range rows {
out = append(out, AcpUsageModelRow{
ModelID: fromPgUUID(row.ModelID),
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: numericToFloat64(row.CostUsd),
})
}
return out, nil
}
func (r *pgRepo) SummarizeAcpUsageByDay(ctx context.Context, from, to time.Time) ([]AcpUsageDayRow, error) {
rows, err := r.q.SummarizeAcpUsageByDay(ctx, acpsqlc.SummarizeAcpUsageByDayParams{
CreatedAt: pgtype.Timestamptz{Time: from, Valid: true},
CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true},
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by day")
}
out := make([]AcpUsageDayRow, 0, len(rows))
for _, row := range rows {
out = append(out, AcpUsageDayRow{
Day: row.Day.Time,
PromptTokens: row.PromptTokens,
CompletionTokens: row.CompletionTokens,
ThinkingTokens: row.ThinkingTokens,
CostUSD: numericToFloat64(row.CostUsd),
})
}
return out, nil
}
// ===== run-history dashboard 聚合 =====
func (r *pgRepo) SessionRollup(ctx context.Context, f DashboardFilter) (SessionRollup, error) {
row, err := r.q.SessionRollup(ctx, acpsqlc.SessionRollupParams{
Column1: toPgUUIDPtr(f.UserID),
Column2: toPgUUIDPtr(f.ProjectID),
Column3: toPgUUIDPtr(f.RequirementID),
StartedAt: pgtype.Timestamptz{Time: f.From, Valid: true},
StartedAt_2: pgtype.Timestamptz{Time: f.To, Valid: true},
})
if err != nil {
return SessionRollup{}, errs.Wrap(err, errs.CodeInternal, "session rollup")
}
return SessionRollup{
Total: row.Total,
Succeeded: row.Succeeded,
Crashed: row.Crashed,
Active: row.Active,
TotalCostUSD: numericToFloat64(row.TotalCostUsd),
TotalTokens: row.TotalTokens,
AvgDurationSeconds: row.AvgDurationSeconds,
}, nil
}
func (r *pgRepo) SessionsByDay(ctx context.Context, f DashboardFilter) ([]SessionDayRow, error) {
rows, err := r.q.SessionsByDay(ctx, acpsqlc.SessionsByDayParams{
Column1: toPgUUIDPtr(f.UserID),
Column2: toPgUUIDPtr(f.ProjectID),
Column3: toPgUUIDPtr(f.RequirementID),
StartedAt: pgtype.Timestamptz{Time: f.From, Valid: true},
StartedAt_2: pgtype.Timestamptz{Time: f.To, Valid: true},
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "sessions by day")
}
out := make([]SessionDayRow, 0, len(rows))
for _, row := range rows {
out = append(out, SessionDayRow{
Day: row.Day.Time,
Total: row.Total,
Succeeded: row.Succeeded,
Crashed: row.Crashed,
TotalCostUSD: numericToFloat64(row.TotalCostUsd),
})
}
return out, nil
}
func (r *pgRepo) SessionTimeline(ctx context.Context, sessionID uuid.UUID) ([]SessionTimelineBucket, error) {
rows, err := r.q.SessionTimeline(ctx, toPgUUID(sessionID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "session timeline")
}
out := make([]SessionTimelineBucket, 0, len(rows))
for _, row := range rows {
out = append(out, SessionTimelineBucket{
Direction: row.Direction,
RPCKind: row.RpcKind,
Method: row.Method,
EventCount: row.EventCount,
FirstAt: row.FirstAt.Time,
LastAt: row.LastAt.Time,
})
}
return out, nil
}
+260
View File
@@ -0,0 +1,260 @@
package acp
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func msgWithResult(t *testing.T, result string) *Message {
t.Helper()
return &Message{JSONRPC: "2.0", ID: json.RawMessage(`"client-prompt-1"`), Result: json.RawMessage(result)}
}
func msgNotification(t *testing.T, method, params string) *Message {
t.Helper()
return &Message{JSONRPC: "2.0", Method: method, Params: json.RawMessage(params)}
}
func TestExtractUsage(t *testing.T) {
tests := []struct {
name string
msg *Message
wantHas bool
wantPrompt int
wantComp int
wantThink int
wantCost *float64
}{
{
name: "snake_case prompt response",
msg: msgWithResult(t, `{"stopReason":"end_turn","usage":{"input_tokens":120,"output_tokens":48}}`),
wantHas: true,
wantPrompt: 120,
wantComp: 48,
},
{
name: "camelCase v2 usage with thoughtTokens + cached",
msg: msgWithResult(t, `{"stopReason":"end_turn","usage":{"inputTokens":200,"outputTokens":90,"thoughtTokens":15,"cachedReadTokens":50}}`),
wantHas: true,
wantPrompt: 200,
wantComp: 90,
wantThink: 15,
},
{
name: "usage_update notification with cost only",
msg: msgNotification(t, "session/update", `{"update":{"sessionUpdate":"usage_update","used":1000,"size":200000,"cost":{"amount":0.0123,"currency":"USD"}}}`),
wantHas: true,
wantCost: f64ptr(0.0123),
},
{
name: "usage_update without cost -> no usage (gauge ignored)",
msg: msgNotification(t, "session/update", `{"update":{"sessionUpdate":"usage_update","used":1000,"size":200000}}`),
wantHas: false,
},
{
name: "ordinary agent_message_chunk -> no usage",
msg: msgNotification(t, "session/update", `{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}}`),
wantHas: false,
},
{
name: "malformed result -> no panic, no usage",
msg: msgWithResult(t, `{"usage":not-json`),
wantHas: false,
},
{
name: "result without usage object -> no usage",
msg: msgWithResult(t, `{"stopReason":"end_turn"}`),
wantHas: false,
},
{
name: "nil message -> no usage",
msg: nil,
wantHas: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ExtractUsage(tc.msg)
assert.Equal(t, tc.wantHas, got.HasUsage)
if !tc.wantHas {
return
}
assert.Equal(t, tc.wantPrompt, got.PromptTokens)
assert.Equal(t, tc.wantComp, got.CompletionTokens)
assert.Equal(t, tc.wantThink, got.ThinkingTokens)
if tc.wantCost == nil {
assert.Nil(t, got.CostUSD)
} else {
require.NotNil(t, got.CostUSD)
assert.InDelta(t, *tc.wantCost, *got.CostUSD, 1e-9)
}
})
}
}
func f64ptr(f float64) *float64 { return &f }
func i64ptr(i int64) *int64 { return &i }
// ===== usageAccumulator =====
type fakeUsageRepo struct {
ledger []*SessionUsageRecord
dedup map[int64]struct{}
totPrompt int64
totComp int64
totThink int64
totCost float64
projectCost float64
}
func (f *fakeUsageRepo) InsertSessionUsage(_ context.Context, r *SessionUsageRecord) (bool, error) {
if r.SourceEventID != nil {
if f.dedup == nil {
f.dedup = map[int64]struct{}{}
}
if _, ok := f.dedup[*r.SourceEventID]; ok {
return false, nil
}
f.dedup[*r.SourceEventID] = struct{}{}
}
cp := *r
f.ledger = append(f.ledger, &cp)
return true, nil
}
func (f *fakeUsageRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) {
f.totPrompt += dp
f.totComp += dc
f.totThink += dt
f.totCost += dCost
return f.totPrompt, f.totComp, f.totThink, f.totCost, nil
}
func (f *fakeUsageRepo) SumProjectCostUSD(_ context.Context, _ uuid.UUID, _ time.Time) (float64, error) {
return f.projectCost, nil
}
type fakePriceLookup struct {
price ModelPrice
}
func (f fakePriceLookup) PriceForModel(_ context.Context, _ uuid.UUID) (ModelPrice, error) {
return f.price, nil
}
func TestAccumulator_PricesViaModelLookup(t *testing.T) {
repo := &fakeUsageRepo{}
mid := uuid.New()
acc := newUsageAccumulator(AccumulatorParams{
Repo: repo,
Prices: fakePriceLookup{price: ModelPrice{PromptPerM: 3, CompletionPerM: 15, ThinkingPerM: 0, Found: true}},
ModelID: &mid,
Caps: BudgetCaps{},
})
_, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, HasUsage: true}, 1)
assert.False(t, breached)
require.Len(t, repo.ledger, 1)
// 3 USD (prompt) + 15 USD (completion) = 18.
assert.InDelta(t, 18.0, repo.ledger[0].CostUSD, 1e-6)
assert.InDelta(t, 18.0, repo.totCost, 1e-6)
}
func TestAccumulator_FallsBackToAgentCost(t *testing.T) {
repo := &fakeUsageRepo{}
acc := newUsageAccumulator(AccumulatorParams{Repo: repo}) // no model price
cost := 0.5
_, _ = acc.Observe(context.Background(), ParsedUsage{CostUSD: &cost, HasUsage: true}, 1)
require.Len(t, repo.ledger, 1)
assert.InDelta(t, 0.5, repo.ledger[0].CostUSD, 1e-9)
}
func TestAccumulator_SkipsWhenNoUsage(t *testing.T) {
repo := &fakeUsageRepo{}
acc := newUsageAccumulator(AccumulatorParams{Repo: repo})
_, breached := acc.Observe(context.Background(), ParsedUsage{HasUsage: false}, 1)
assert.False(t, breached)
assert.Empty(t, repo.ledger)
}
func TestAccumulator_DedupBySourceEventID(t *testing.T) {
repo := &fakeUsageRepo{}
mid := uuid.New()
acc := newUsageAccumulator(AccumulatorParams{
Repo: repo,
Prices: fakePriceLookup{price: ModelPrice{CompletionPerM: 10, Found: true}},
ModelID: &mid,
})
p := ParsedUsage{CompletionTokens: 1000, HasUsage: true}
_, _ = acc.Observe(context.Background(), p, 7)
_, _ = acc.Observe(context.Background(), p, 7) // same event id -> no-op
assert.Len(t, repo.ledger, 1)
assert.InDelta(t, 0.01, repo.totCost, 1e-9)
}
func TestAccumulator_BreachCost(t *testing.T) {
repo := &fakeUsageRepo{}
mid := uuid.New()
acc := newUsageAccumulator(AccumulatorParams{
Repo: repo,
Prices: fakePriceLookup{price: ModelPrice{CompletionPerM: 1000, Found: true}},
ModelID: &mid,
Caps: BudgetCaps{MaxCostUSD: f64ptr(0.5)},
})
// 1000 completion tokens * 1000/M = 1.0 USD >= 0.5 cap.
reason, breached := acc.Observe(context.Background(), ParsedUsage{CompletionTokens: 1000, HasUsage: true}, 1)
assert.True(t, breached)
assert.Equal(t, BreachCost, reason)
}
func TestAccumulator_BreachTokens(t *testing.T) {
repo := &fakeUsageRepo{}
acc := newUsageAccumulator(AccumulatorParams{
Repo: repo,
Caps: BudgetCaps{MaxTokens: i64ptr(1000)},
})
reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 600, CompletionTokens: 600, HasUsage: true}, 1)
assert.True(t, breached)
assert.Equal(t, BreachTokens, reason)
}
func TestAccumulator_BreachWallClock(t *testing.T) {
repo := &fakeUsageRepo{}
start := time.Now().Add(-2 * time.Hour)
acc := newUsageAccumulator(AccumulatorParams{
Repo: repo,
StartedAt: start,
Caps: BudgetCaps{MaxWallClockSeconds: i32ptr(60)}, // 1 minute cap, 2h elapsed
})
reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1, HasUsage: true}, 1)
assert.True(t, breached)
assert.Equal(t, BreachWallClock, reason)
}
func TestAccumulator_BreachProjectBudget(t *testing.T) {
repo := &fakeUsageRepo{projectCost: 100} // already over the project cap
acc := newUsageAccumulator(AccumulatorParams{
Repo: repo,
ProjectBudgetUSD: 50,
})
reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1, HasUsage: true}, 1)
assert.True(t, breached)
assert.Equal(t, BreachCost, reason)
}
func TestAccumulator_NoBreachUnderCaps(t *testing.T) {
repo := &fakeUsageRepo{}
acc := newUsageAccumulator(AccumulatorParams{
Repo: repo,
Caps: BudgetCaps{MaxTokens: i64ptr(1_000_000), MaxCostUSD: f64ptr(100)},
})
_, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 10, CompletionTokens: 10, HasUsage: true}, 1)
assert.False(t, breached)
}
func i32ptr(i int32) *int32 { return &i }
+48
View File
@@ -0,0 +1,48 @@
package app
import (
"testing"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
// projectACL(app 层中央 ACL)必须与 project_service 的 assertReadable/assertWritable
// 语义一致——两侧任一漂移都会让 workspace/ACP/chat 作用域与 project 鉴权分叉
// (autonomy roadmap §11)。本白盒测试直接覆盖 projectACL 的角色叠加矩阵。
func TestProjectACL_RoleMatrix(t *testing.T) {
owner := uuid.New()
stranger := uuid.New()
private := &project.Project{ID: uuid.New(), OwnerID: owner, Visibility: project.VisibilityPrivate}
internal := &project.Project{ID: uuid.New(), OwnerID: owner, Visibility: project.VisibilityInternal}
cases := []struct {
name string
pj *project.Project
caller uuid.UUID
isAdmin bool
role project.Role
wantRead, wantWrt bool
}{
{"owner_private", private, owner, false, project.RoleAdmin, true, true},
{"global_admin_private", private, stranger, true, project.RoleNone, true, true},
{"member_private_write", private, stranger, false, project.RoleMember, true, true},
{"admin_role_private_write", private, stranger, false, project.RoleAdmin, true, true},
{"viewer_private_read_only", private, stranger, false, project.RoleViewer, true, false},
{"nonmember_private_denied", private, stranger, false, project.RoleNone, false, false},
{"nonmember_internal_read", internal, stranger, false, project.RoleNone, true, false},
{"viewer_internal_read", internal, stranger, false, project.RoleViewer, true, false},
{"member_internal_write", internal, stranger, false, project.RoleMember, true, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotRead, gotWrite := projectACL(tc.pj, tc.caller, tc.isAdmin, tc.role)
if gotRead != tc.wantRead || gotWrite != tc.wantWrt {
t.Fatalf("projectACL(%s)=read:%v,write:%v want read:%v,write:%v",
tc.name, gotRead, gotWrite, tc.wantRead, tc.wantWrt)
}
})
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite {
paStub := &acpStubProjectAccess{} paStub := &acpStubProjectAccess{}
sessSvc := acp.NewSessionService( sessSvc := acp.NewSessionService(
acpRepo, wsStub, nil, nil, paStub, sup, auditRec, acpRepo, wsStub, nil, nil, paStub, nil, nil, sup, auditRec,
acp.SessionServiceConfig{MaxActiveGlobal: 20, MaxActivePerUser: 3}, acp.SessionServiceConfig{MaxActiveGlobal: 20, MaxActivePerUser: 3},
nil, // mcpTokens — not exercised by integration tests nil, // mcpTokens — not exercised by integration tests
) )
+925 -25
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -156,7 +156,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) {
projectRepo := project.NewPostgresRepository(pool) projectRepo := project.NewPostgresRepository(pool)
projectSvc := project.NewProjectService(projectRepo, auditRec) projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec) requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil)
issueSvc := project.NewIssueService(projectRepo, auditRec) issueSvc := project.NewIssueService(projectRepo, auditRec)
artifactSvc := project.NewArtifactService(projectRepo, auditRec) artifactSvc := project.NewArtifactService(projectRepo, auditRec)
@@ -324,7 +324,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) {
projectRepo := project.NewPostgresRepository(pool) projectRepo := project.NewPostgresRepository(pool)
projectSvc := project.NewProjectService(projectRepo, auditRec) projectSvc := project.NewProjectService(projectRepo, auditRec)
requirementSvc := project.NewRequirementService(projectRepo, auditRec) requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil)
issueSvc := project.NewIssueService(projectRepo, auditRec) issueSvc := project.NewIssueService(projectRepo, auditRec)
artifactSvc := project.NewArtifactService(projectRepo, auditRec) artifactSvc := project.NewArtifactService(projectRepo, auditRec)
+5 -5
View File
@@ -148,7 +148,7 @@ func newMCPIntegrationSuite(t *testing.T) *mcpIntegrationSuite {
projectRepo := project.NewPostgresRepository(pool) projectRepo := project.NewPostgresRepository(pool)
projectSvc := project.NewProjectService(projectRepo, auditWrapped) projectSvc := project.NewProjectService(projectRepo, auditWrapped)
requirementSvc := project.NewRequirementService(projectRepo, auditWrapped) requirementSvc := project.NewRequirementService(projectRepo, auditWrapped, nil, nil)
issueSvc := project.NewIssueService(projectRepo, auditWrapped) issueSvc := project.NewIssueService(projectRepo, auditWrapped)
// --- Bootstrap users --- // --- Bootstrap users ---
@@ -268,7 +268,7 @@ func (s *mcpIntegrationSuite) callToolViaMemory(
deps := mcp.ServerDeps{ deps := mcp.ServerDeps{
Caller: callerResolver, Caller: callerResolver,
Projects: s.projectSvc, Projects: s.projectSvc,
Reqs: project.NewRequirementService(project.NewPostgresRepository(s.pool), mcp.AuditWrap(s.auditRec)), Reqs: project.NewRequirementService(project.NewPostgresRepository(s.pool), mcp.AuditWrap(s.auditRec), nil, nil),
Issues: s.issueSvc, Issues: s.issueSvc,
// Workspaces/Templates/Messages/Conversations left nil — K3 tests don't call those tools. // Workspaces/Templates/Messages/Conversations left nil — K3 tests don't call those tools.
} }
@@ -928,7 +928,7 @@ func (s *mcpIntegrationSuite) testPromptsListFromTemplates(t *testing.T) {
deps := mcp.ServerDeps{ deps := mcp.ServerDeps{
Caller: callerResolver, Caller: callerResolver,
Projects: s.projectSvc, Projects: s.projectSvc,
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil),
Issues: s.issueSvc, Issues: s.issueSvc,
Templates: tplSvc, Templates: tplSvc,
} }
@@ -970,7 +970,7 @@ func (s *mcpIntegrationSuite) testResourceReadNotFound(t *testing.T) {
deps := mcp.ServerDeps{ deps := mcp.ServerDeps{
Caller: callerResolver, Caller: callerResolver,
Projects: s.projectSvc, Projects: s.projectSvc,
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil),
Issues: s.issueSvc, Issues: s.issueSvc,
} }
srv := mcp.NewMCPServer(deps) srv := mcp.NewMCPServer(deps)
@@ -1004,7 +1004,7 @@ func (s *mcpIntegrationSuite) testResourceListScopeFilter(t *testing.T) {
deps := mcp.ServerDeps{ deps := mcp.ServerDeps{
Caller: callerResolver, Caller: callerResolver,
Projects: s.projectSvc, Projects: s.projectSvc,
Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil),
Issues: s.issueSvc, Issues: s.issueSvc,
} }
srv := mcp.NewMCPServer(deps) srv := mcp.NewMCPServer(deps)
+285
View File
@@ -0,0 +1,285 @@
package app
import (
"context"
"strconv"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/orchestrator"
"github.com/yan1h/agent-coding-workflow/internal/project"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
// orchPMAdapter 把 project 的 Repository + RequirementService 适配为 orchestrator.PMAccess。
// 读走 Repository(无需 Caller 鉴权——编排器内部以 run.owner 为权威身份);ChangePhase
// 走 RequirementService 以复用阶段网关/审计。
type orchPMAdapter struct {
repo project.Repository
reqs project.RequirementService
adminLookup userAdminAdapter
}
func (a orchPMAdapter) GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error) {
return a.repo.GetProjectByID(ctx, id)
}
func (a orchPMAdapter) GetProjectBySlug(ctx context.Context, slug string) (*project.Project, error) {
return a.repo.GetProjectBySlug(ctx, slug)
}
func (a orchPMAdapter) GetRequirementByID(ctx context.Context, id uuid.UUID) (*project.Requirement, error) {
return a.repo.GetRequirementByID(ctx, id)
}
func (a orchPMAdapter) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) {
return a.repo.GetRequirementByNumber(ctx, projectID, number)
}
// ListLatestArtifacts 取某需求各阶段最新产物(每阶段最大版本),供 BuildPrompt 注入。
func (a orchPMAdapter) ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) {
all, err := a.repo.ListArtifactsByRequirement(ctx, requirementID, "")
if err != nil {
return nil, err
}
latest := map[project.Phase]*project.Artifact{}
for _, art := range all {
if art == nil {
continue
}
if cur, ok := latest[art.Phase]; !ok || art.Version > cur.Version {
latest[art.Phase] = art
}
}
out := make([]*project.Artifact, 0, len(latest))
for _, ph := range project.AllPhases {
if art, ok := latest[ph]; ok {
out = append(out, art)
}
}
return out, nil
}
// ChangeRequirementPhase 以 run.owner 身份推进需求阶段(经 RequirementService 复用网关/审计)。
func (a orchPMAdapter) ChangeRequirementPhase(ctx context.Context, ownerID uuid.UUID, isAdmin bool, projectSlug string, number int, to project.Phase) error {
_, err := a.reqs.ChangePhase(ctx, project.Caller{UserID: ownerID, IsAdmin: isAdmin}, projectSlug, number, to)
return err
}
// orchACLAdapter 把 project ACL 适配为 orchestrator.ProjectACL(owner+admin 可写)。
type orchACLAdapter struct {
pa projectAccessAdapter
}
func (a orchACLAdapter) CanRead(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) {
canRead, _, err := a.pa.ResolveByID(ctx, userID, isAdmin, projectID)
if err != nil {
return false, err
}
return canRead, nil
}
func (a orchACLAdapter) CanWrite(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) {
_, canWrite, err := a.pa.ResolveByID(ctx, userID, isAdmin, projectID)
if err != nil {
return false, err
}
return canWrite, nil
}
// orchAuditAdapter 把 audit.Recorder 适配为 orchestrator.auditRecorder(窄 record 方法)。
// 注:orchestrator.auditRecorder 是非导出接口,但 app 在同模块外通过结构方法匹配满足。
type orchAuditAdapter struct {
rec audit.Recorder
}
func (a orchAuditAdapter) Record(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any) {
if a.rec == nil {
return
}
uid := userID
_ = a.rec.Record(ctx, audit.Entry{
UserID: &uid, Action: action, TargetType: "orchestrator_run", TargetID: targetID, Metadata: meta,
})
}
// orchSessionTermAdapter 把 acp.SessionService 适配为 orchestrator.sessionTerminator。
type orchSessionTermAdapter struct {
sess acp.SessionService
}
func (a orchSessionTermAdapter) TerminateSession(ctx context.Context, ownerID uuid.UUID, isAdmin bool, sessionID uuid.UUID) error {
return a.sess.Terminate(ctx, acp.Caller{UserID: ownerID, IsAdmin: isAdmin}, sessionID)
}
// orchGateArtifactAdapter 把 project.Repository 适配为 orchestrator.GateArtifactAccess。
type orchGateArtifactAdapter struct {
repo project.Repository
}
func (a orchGateArtifactAdapter) ListArtifacts(ctx context.Context, projectID, requirementID uuid.UUID, phase project.Phase) ([]*project.Artifact, error) {
return a.repo.ListArtifactsByRequirement(ctx, requirementID, phase)
}
// orchGateIssueAdapter 把 project.Repository 适配为 orchestrator.GateIssueAccess。
type orchGateIssueAdapter struct {
repo project.Repository
}
func (a orchGateIssueAdapter) CountOpenIssues(ctx context.Context, requirementID uuid.UUID) (int, error) {
issues, err := a.repo.ListIssuesByRequirement(ctx, requirementID)
if err != nil {
return 0, err
}
n := 0
for _, iss := range issues {
if iss != nil && iss.Status == project.StatusOpen {
n++
}
}
return n, nil
}
// orchACPLookupAdapter 把 acp.Repository 适配为 orchestrator.ACPSessionLookup。
type orchACPLookupAdapter struct {
repo acp.Repository
}
func (a orchACPLookupAdapter) GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) {
return a.repo.GetSessionByStepID(ctx, stepID)
}
func (a orchACPLookupAdapter) GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) {
return a.repo.GetCrashedSessionForResume(ctx, stepID)
}
// 确保 acp.SessionService 同时满足 orchestrator.ACPSessions(Create)。
var _ orchestrator.ACPSessions = (acp.SessionService)(nil)
// ===== 任务分解并行调度器适配器(autonomy roadmap §10)=====
// schedulerReadySourceAdapter 把 project.Repository 适配为 orchestrator.ReadyTaskSource。
type schedulerReadySourceAdapter struct {
repo project.Repository
}
func (a schedulerReadySourceAdapter) ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) {
return a.repo.ListSchedulableProjects(ctx)
}
func (a schedulerReadySourceAdapter) ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) {
return a.repo.ListReadyLeafSubtasks(ctx, projectID, limit)
}
func (a schedulerReadySourceAdapter) CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) {
return a.repo.CountActiveSessionsForProject(ctx, projectID)
}
var _ orchestrator.ReadyTaskSource = schedulerReadySourceAdapter{}
// schedulerSessionStarterAdapter 为就绪子任务以特权身份(项目 owner)创建 ACP session。
// 解析 workspace(issue.WorkspaceID 优先,否则取项目首个 workspace)与 agent-kind
// (implementing 阶段默认),把 IssueNumber 注入使 SessionService 绑定 issue/<slug>-N worktree。
type schedulerSessionStarterAdapter struct {
sess acp.SessionService
projectRepo project.Repository
wsRepo workspace.Repository
defaultAgentKind func() (uuid.UUID, bool) // 解析 implementing 阶段默认 agent-kind
}
func (a schedulerSessionStarterAdapter) StartForIssue(ctx context.Context, iss *project.Issue) (uuid.UUID, error) {
proj, err := a.projectRepo.GetProjectByID(ctx, iss.ProjectID)
if err != nil {
return uuid.Nil, err
}
// 解析 workspace:issue 显式关联优先,否则取项目首个 workspace。
var wsID uuid.UUID
if iss.WorkspaceID != nil {
wsID = *iss.WorkspaceID
} else {
list, lerr := a.wsRepo.ListWorkspacesByProject(ctx, iss.ProjectID)
if lerr != nil {
return uuid.Nil, lerr
}
if len(list) == 0 {
return uuid.Nil, errs.New(errs.CodeInvalidInput, "project 无可用 workspace,无法为子任务派发 session")
}
wsID = list[0].ID
}
akID, ok := a.defaultAgentKind()
if !ok {
return uuid.Nil, errs.New(errs.CodeInvalidInput, "未配置 implementing 阶段默认 agent-kind,无法派发子任务 session")
}
num := iss.Number
owner := acp.Caller{UserID: proj.OwnerID, IsAdmin: false}
created, err := a.sess.Create(ctx, owner, acp.CreateSessionInput{
WorkspaceID: wsID,
AgentKindID: akID,
IssueNumber: &num,
})
if err != nil {
return uuid.Nil, err
}
return created.ID, nil
}
var _ orchestrator.SessionStarter = schedulerSessionStarterAdapter{}
// schedulerNotifyAdapter 把 notify.Dispatcher 适配为 orchestrator.SchedulerNotifier。
// 通知发给项目 owner(best-effort,失败不阻塞调度)。
type schedulerNotifyAdapter struct {
dispatcher *notify.Dispatcher
projectRepo project.Repository
}
func (a schedulerNotifyAdapter) ownerOf(ctx context.Context, projectID uuid.UUID) (uuid.UUID, bool) {
proj, err := a.projectRepo.GetProjectByID(ctx, projectID)
if err != nil {
return uuid.Nil, false
}
return proj.OwnerID, true
}
func (a schedulerNotifyAdapter) NotifyDispatched(ctx context.Context, iss *project.Issue, sessionID uuid.UUID) {
if a.dispatcher == nil {
return
}
owner, ok := a.ownerOf(ctx, iss.ProjectID)
if !ok {
return
}
_ = a.dispatcher.Dispatch(ctx, notify.Message{
UserID: owner,
Topic: "orchestrator.subtask_dispatched",
Severity: notify.SeverityInfo,
Title: "子任务已派发",
Body: "子任务 #" + itoa(iss.Number) + " 已派发到并行 ACP session",
Metadata: map[string]any{"issue_number": iss.Number, "session_id": sessionID.String()},
})
}
func (a schedulerNotifyAdapter) NotifyQuotaBlocked(ctx context.Context, iss *project.Issue) {
if a.dispatcher == nil {
return
}
owner, ok := a.ownerOf(ctx, iss.ProjectID)
if !ok {
return
}
_ = a.dispatcher.Dispatch(ctx, notify.Message{
UserID: owner,
Topic: "orchestrator.subtask_quota_blocked",
Severity: notify.SeverityWarning,
Title: "子任务派发受配额限制",
Body: "子任务 #" + itoa(iss.Number) + " 因会话配额暂缓派发,下一轮重试",
Metadata: map[string]any{"issue_number": iss.Number},
})
}
var _ orchestrator.SchedulerNotifier = schedulerNotifyAdapter{}
func itoa(n int) string { return strconv.Itoa(n) }
+158
View File
@@ -0,0 +1,158 @@
// handler.go 暴露 admin-only 的审计日志查看接口(spec §11 步骤4)。
//
// GET /api/v1/admin/audit-logs?action=&action_prefix=&target_type=&target_id=
// &user_id=&from=&to=&limit=&offset=
//
// 鉴权:复刻 chat.adminGuard —— Auth 中间件解出 uid,再经 AdminLookup.IsAdmin
// 门禁;非 admin 返回 403。from/to 接受 RFC3339 时间字符串。
package audit
import (
"context"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// AdminLookup resolves whether a user is an admin (narrow interface; mirrors
// chat/acp handler pattern). user.Service satisfies it.
type AdminLookup interface {
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
}
// Handler exposes the admin audit-log read endpoint.
type Handler struct {
reader Reader
resolver middleware.SessionResolver
admin AdminLookup
}
// NewHandler constructs the audit read handler.
func NewHandler(reader Reader, resolver middleware.SessionResolver, admin AdminLookup) *Handler {
return &Handler{reader: reader, resolver: resolver, admin: admin}
}
// Mount registers the admin audit routes under /api/v1/admin.
func (h *Handler) Mount(r chi.Router) {
r.Route("/api/v1/admin/audit-logs", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Use(h.adminGuard)
r.Get("/", h.list)
})
}
func (h *Handler) adminGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
h.writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized"))
return
}
isAdmin, err := h.admin.IsAdmin(r.Context(), uid)
if err != nil {
h.writeErr(w, r, err)
return
}
if !isAdmin {
h.writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
return
}
next.ServeHTTP(w, r)
})
}
type auditLogDTO struct {
ID int64 `json:"id"`
UserID *string `json:"user_id,omitempty"`
Action string `json:"action"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
IP string `json:"ip,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := AuditFilter{
Action: q.Get("action"),
ActionPrefix: q.Get("action_prefix"),
TargetType: q.Get("target_type"),
TargetID: q.Get("target_id"),
}
if v := q.Get("user_id"); v != "" {
uid, err := uuid.Parse(v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 格式错误"))
return
}
f.UserID = &uid
}
if v := q.Get("from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "from 须为 RFC3339"))
return
}
f.From = &t
}
if v := q.Get("to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "to 须为 RFC3339"))
return
}
f.To = &t
}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "limit 须为非负整数"))
return
}
f.Limit = n
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "offset 须为非负整数"))
return
}
f.Offset = n
}
entries, total, err := h.reader.List(r.Context(), f)
if err != nil {
h.writeErr(w, r, err)
return
}
out := make([]auditLogDTO, 0, len(entries))
for _, e := range entries {
dto := auditLogDTO{
ID: e.ID,
Action: e.Action,
TargetType: e.TargetType,
TargetID: e.TargetID,
IP: e.IP,
Metadata: e.Metadata,
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339),
}
if e.UserID != nil {
s := e.UserID.String()
dto.UserID = &s
}
out = append(out, dto)
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": out, "total": total})
}
func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}
+136
View File
@@ -0,0 +1,136 @@
package audit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
type fakeResolver struct{ uid uuid.UUID }
func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
if token == "" {
return uuid.Nil, false, nil
}
return f.uid, true, nil
}
type fakeAdmin struct{ admins map[uuid.UUID]bool }
func (f *fakeAdmin) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) {
return f.admins[id], nil
}
type fakeReader struct {
lastFilter AuditFilter
entries []LogEntry
total int
}
func (f *fakeReader) List(_ context.Context, flt AuditFilter) ([]LogEntry, int, error) {
f.lastFilter = flt
return f.entries, f.total, nil
}
func (f *fakeReader) PurgeBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
func mount(reader Reader, uid uuid.UUID, admins map[uuid.UUID]bool) http.Handler {
r := chi.NewRouter()
NewHandler(reader, &fakeResolver{uid: uid}, &fakeAdmin{admins: admins}).Mount(r)
return r
}
func TestAuditHandlerNonAdminForbidden(t *testing.T) {
uid := uuid.New()
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{}) // not admin
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
}
func TestAuditHandlerUnauthenticated(t *testing.T) {
uid := uuid.New()
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true})
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil) // no token
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestAuditHandlerAdminListWithFilters(t *testing.T) {
uid := uuid.New()
filterUID := uuid.New()
now := time.Now().UTC().Truncate(time.Second)
reader := &fakeReader{
entries: []LogEntry{{
ID: 7,
UserID: &filterUID,
Action: "user.login",
CreatedAt: now,
}},
total: 1,
}
h := mount(reader, uid, map[uuid.UUID]bool{uid: true})
from := now.Add(-time.Hour).Format(time.RFC3339)
url := "/api/v1/admin/audit-logs/?action=user.login&target_type=user&user_id=" +
filterUID.String() + "&from=" + from + "&limit=10&offset=5"
req := httptest.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
// Filter parsed and forwarded to reader.
if reader.lastFilter.Action != "user.login" {
t.Errorf("action filter = %q, want user.login", reader.lastFilter.Action)
}
if reader.lastFilter.TargetType != "user" {
t.Errorf("target_type filter = %q", reader.lastFilter.TargetType)
}
if reader.lastFilter.UserID == nil || *reader.lastFilter.UserID != filterUID {
t.Errorf("user_id filter not parsed: %v", reader.lastFilter.UserID)
}
if reader.lastFilter.From == nil {
t.Errorf("from filter not parsed")
}
if reader.lastFilter.Limit != 10 || reader.lastFilter.Offset != 5 {
t.Errorf("limit/offset = %d/%d, want 10/5", reader.lastFilter.Limit, reader.lastFilter.Offset)
}
var resp struct {
Items []auditLogDTO `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Total != 1 || len(resp.Items) != 1 || resp.Items[0].ID != 7 {
t.Errorf("unexpected response: %+v", resp)
}
}
func TestAuditHandlerBadQueryParam(t *testing.T) {
uid := uuid.New()
h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true})
req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/?from=not-a-date", nil)
req.Header.Set("Authorization", "Bearer tok")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
+29
View File
@@ -1,3 +1,32 @@
-- name: InsertAuditLog :exec -- name: InsertAuditLog :exec
INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata) INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
VALUES ($1, $2, $3, $4, $5, $6); VALUES ($1, $2, $3, $4, $5, $6);
-- name: ListAuditLog :many
-- Paginated read with optional filters. NULL filter args match everything
-- (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits).
SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at
FROM audit_logs
WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id'))
AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action'))
AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%')
AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type'))
AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id'))
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts'))
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts'))
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2;
-- name: CountAuditLog :one
SELECT COUNT(*)
FROM audit_logs
WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id'))
AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action'))
AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%')
AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type'))
AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id'))
AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts'))
AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts'));
-- name: DeleteAuditLogsBefore :execrows
DELETE FROM audit_logs WHERE created_at < $1;
+164
View File
@@ -0,0 +1,164 @@
// reader.go 提供审计日志的只读查询路径(spec §11 步骤4)。
//
// audit.Recorder 是 write-only 门面;Reader 是与之对称的 read 门面,供 admin
// 审计查看器(GET /api/v1/admin/audit-logs)与保留期清理 runner 使用。pgReader
// 复用 auditsqlc 生成的 List/Count/Delete 查询,并在 pgtype <-> 领域类型间转换。
package audit
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
auditsqlc "github.com/yan1h/agent-coding-workflow/internal/audit/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// LogEntry 是一条已落库审计记录的只读领域视图。区别于写入用的 Entry:含 ID/
// 时间戳,IP 以字符串形式返回。
type LogEntry struct {
ID int64
UserID *uuid.UUID
Action string
TargetType string
TargetID string
IP string
Metadata map[string]any
CreatedAt time.Time
}
// AuditFilter 是 List/Count 的可选过滤条件。零值字段表示不过滤。Limit<=0 时由
// Reader 兜底为默认页大小;Limit 上限由 Reader 钳制。
type AuditFilter struct {
UserID *uuid.UUID
Action string // 精确匹配
ActionPrefix string // 前缀匹配(搜索用)
TargetType string
TargetID string
From *time.Time
To *time.Time
Limit int
Offset int
}
const (
// DefaultAuditPageSize 是未指定 Limit 时的默认页大小。
DefaultAuditPageSize = 50
// MaxAuditPageSize 是单页返回的硬上限,避免一次拉全表。
MaxAuditPageSize = 500
)
// Reader 是审计读取的对外契约:分页列表 + 总数 + 保留期清理。
type Reader interface {
List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error)
PurgeBefore(ctx context.Context, before time.Time) (int64, error)
}
type pgReader struct {
q *auditsqlc.Queries
}
// NewPostgresReader 用现有 pgxpool 构造 Reader 实现。
func NewPostgresReader(pool *pgxpool.Pool) Reader {
return &pgReader{q: auditsqlc.New(pool)}
}
// List 返回符合过滤条件的审计记录页 + 满足条件的总数(用于分页)。
func (r *pgReader) List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error) {
limit := f.Limit
if limit <= 0 {
limit = DefaultAuditPageSize
}
if limit > MaxAuditPageSize {
limit = MaxAuditPageSize
}
offset := f.Offset
if offset < 0 {
offset = 0
}
rows, err := r.q.ListAuditLog(ctx, auditsqlc.ListAuditLogParams{
Limit: int32(limit),
Offset: int32(offset),
UserID: pgUUIDFromPtr(f.UserID),
Action: ptr(f.Action),
ActionPrefix: ptr(f.ActionPrefix),
TargetType: ptr(f.TargetType),
TargetID: ptr(f.TargetID),
FromTs: tsFromPtr(f.From),
ToTs: tsFromPtr(f.To),
})
if err != nil {
return nil, 0, errs.Wrap(err, errs.CodeInternal, "list audit logs")
}
total, err := r.q.CountAuditLog(ctx, auditsqlc.CountAuditLogParams{
UserID: pgUUIDFromPtr(f.UserID),
Action: ptr(f.Action),
ActionPrefix: ptr(f.ActionPrefix),
TargetType: ptr(f.TargetType),
TargetID: ptr(f.TargetID),
FromTs: tsFromPtr(f.From),
ToTs: tsFromPtr(f.To),
})
if err != nil {
return nil, 0, errs.Wrap(err, errs.CodeInternal, "count audit logs")
}
out := make([]LogEntry, 0, len(rows))
for _, row := range rows {
out = append(out, rowToEntry(row))
}
return out, int(total), nil
}
// PurgeBefore 删除 created_at < before 的全部审计记录,返回删除行数。保留期 runner 调用。
func (r *pgReader) PurgeBefore(ctx context.Context, before time.Time) (int64, error) {
n, err := r.q.DeleteAuditLogsBefore(ctx, toPgTimestamptz(before))
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "purge audit logs")
}
return n, nil
}
func rowToEntry(row auditsqlc.AuditLog) LogEntry {
e := LogEntry{
ID: row.ID,
Action: row.Action,
CreatedAt: row.CreatedAt.Time,
}
if row.UserID.Valid {
u := uuid.UUID(row.UserID.Bytes)
e.UserID = &u
}
if row.TargetType != nil {
e.TargetType = *row.TargetType
}
if row.TargetID != nil {
e.TargetID = *row.TargetID
}
if row.Ip != nil {
e.IP = row.Ip.String()
}
if len(row.Metadata) > 0 {
_ = json.Unmarshal(row.Metadata, &e.Metadata)
}
return e
}
// tsFromPtr 把 *time.Time 转为 pgtype.Timestamptz;nil 映射为 NULL(不过滤)。
func tsFromPtr(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: *t, Valid: true}
}
// toPgTimestamptz 把非空 time.Time 转为有效 Timestamptz。
func toPgTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}
+116
View File
@@ -12,6 +12,55 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
const countAuditLog = `-- name: CountAuditLog :one
SELECT COUNT(*)
FROM audit_logs
WHERE ($1::uuid IS NULL OR user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR action LIKE $3 || '%')
AND ($4::text IS NULL OR target_type = $4)
AND ($5::text IS NULL OR target_id = $5)
AND ($6::timestamptz IS NULL OR created_at >= $6)
AND ($7::timestamptz IS NULL OR created_at < $7)
`
type CountAuditLogParams struct {
UserID pgtype.UUID `json:"user_id"`
Action *string `json:"action"`
ActionPrefix *string `json:"action_prefix"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
FromTs pgtype.Timestamptz `json:"from_ts"`
ToTs pgtype.Timestamptz `json:"to_ts"`
}
func (q *Queries) CountAuditLog(ctx context.Context, arg CountAuditLogParams) (int64, error) {
row := q.db.QueryRow(ctx, countAuditLog,
arg.UserID,
arg.Action,
arg.ActionPrefix,
arg.TargetType,
arg.TargetID,
arg.FromTs,
arg.ToTs,
)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteAuditLogsBefore = `-- name: DeleteAuditLogsBefore :execrows
DELETE FROM audit_logs WHERE created_at < $1
`
func (q *Queries) DeleteAuditLogsBefore(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) {
result, err := q.db.Exec(ctx, deleteAuditLogsBefore, createdAt)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const insertAuditLog = `-- name: InsertAuditLog :exec const insertAuditLog = `-- name: InsertAuditLog :exec
INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata) INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
@@ -37,3 +86,70 @@ func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams)
) )
return err return err
} }
const listAuditLog = `-- name: ListAuditLog :many
SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at
FROM audit_logs
WHERE ($3::uuid IS NULL OR user_id = $3)
AND ($4::text IS NULL OR action = $4)
AND ($5::text IS NULL OR action LIKE $5 || '%')
AND ($6::text IS NULL OR target_type = $6)
AND ($7::text IS NULL OR target_id = $7)
AND ($8::timestamptz IS NULL OR created_at >= $8)
AND ($9::timestamptz IS NULL OR created_at < $9)
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2
`
type ListAuditLogParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
UserID pgtype.UUID `json:"user_id"`
Action *string `json:"action"`
ActionPrefix *string `json:"action_prefix"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
FromTs pgtype.Timestamptz `json:"from_ts"`
ToTs pgtype.Timestamptz `json:"to_ts"`
}
// Paginated read with optional filters. NULL filter args match everything
// (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits).
func (q *Queries) ListAuditLog(ctx context.Context, arg ListAuditLogParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLog,
arg.Limit,
arg.Offset,
arg.UserID,
arg.Action,
arg.ActionPrefix,
arg.TargetType,
arg.TargetID,
arg.FromTs,
arg.ToTs,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AuditLog
for rows.Next() {
var i AuditLog
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Action,
&i.TargetType,
&i.TargetID,
&i.Ip,
&i.Metadata,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+223
View File
@@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/pgvector/pgvector-go"
) )
type AcpAgentKind struct { type AcpAgentKind struct {
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
KeyVersion int16 `json:"key_version"`
} }
type AcpAgentKindConfigFile struct { type AcpAgentKindConfigFile struct {
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
UpdatedBy pgtype.UUID `json:"updated_by"` UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type AcpEvent struct { type AcpEvent struct {
@@ -81,6 +88,47 @@ type AcpSession struct {
LastError *string `json:"last_error"` LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"` EndedAt pgtype.Timestamptz `json:"ended_at"`
LastStopReason *string `json:"last_stop_reason"`
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
TerminatedReason *string `json:"terminated_reason"`
SandboxMode *string `json:"sandbox_mode"`
CostUsd pgtype.Numeric `json:"cost_usd"`
TokensTotal int64 `json:"tokens_total"`
}
type AcpSessionUsage struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
SourceEventID *int64 `json:"source_event_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpTurn struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
PromptRequestID *string `json:"prompt_request_id"`
Status string `json:"status"`
StopReason *string `json:"stop_reason"`
UpdateCount int32 `json:"update_count"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
} }
type AuditLog struct { type AuditLog struct {
@@ -94,6 +142,81 @@ type AuditLog struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type ChangeRequest struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
IssueID pgtype.UUID `json:"issue_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CiState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalUrl string `json:"external_url"`
MergeCommitSha string `json:"merge_commit_sha"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
CreatedBy pgtype.UUID `json:"created_by"`
MergedAt pgtype.Timestamptz `json:"merged_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CodeChunk struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CommitSha string `json:"commit_sha"`
FilePath string `json:"file_path"`
Lang *string `json:"lang"`
StartLine int32 `json:"start_line"`
EndLine int32 `json:"end_line"`
Content string `json:"content"`
ContentSha []byte `json:"content_sha"`
TokenCount *int32 `json:"token_count"`
Embedding *pgvector.Vector `json:"embedding"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CodeIndexRun struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Status string `json:"status"`
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
EmbeddingModel string `json:"embedding_model"`
Dims int32 `json:"dims"`
FilesIndexed int32 `json:"files_indexed"`
ChunksIndexed int32 `json:"chunks_indexed"`
Error *string `json:"error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CommitSummary struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Kind string `json:"kind"`
Title *string `json:"title"`
BodyMd string `json:"body_md"`
Model *string `json:"model"`
Status string `json:"status"`
Error *string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Conversation struct { type Conversation struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
@@ -110,6 +233,14 @@ type Conversation struct {
RequirementID pgtype.UUID `json:"requirement_id"` RequirementID pgtype.UUID `json:"requirement_id"`
} }
type CryptoKey struct {
Version int32 `json:"version"`
Provider string `json:"provider"`
KeyRef *string `json:"key_ref"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type GitCredential struct { type GitCredential struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"` WorkspaceID pgtype.UUID `json:"workspace_id"`
@@ -119,6 +250,7 @@ type GitCredential struct {
Fingerprint *string `json:"fingerprint"` Fingerprint *string `json:"fingerprint"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type Issue struct { type Issue struct {
@@ -135,6 +267,17 @@ type Issue struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"` WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentID pgtype.UUID `json:"parent_id"`
Priority int32 `json:"priority"`
}
type IssueDependency struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
BlockedID pgtype.UUID `json:"blocked_id"`
BlockerID pgtype.UUID `json:"blocker_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type Job struct { type Job struct {
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type LlmModel struct { type LlmModel struct {
@@ -252,6 +396,50 @@ type Notification struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type NotificationPref struct {
UserID pgtype.UUID `json:"user_id"`
EmailEnabled bool `json:"email_enabled"`
ImEnabled bool `json:"im_enabled"`
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
MinSeverity string `json:"min_severity"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type OrchestratorRun struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OwnerID pgtype.UUID `json:"owner_id"`
Status string `json:"status"`
CurrentPhase string `json:"current_phase"`
Config []byte `json:"config"`
LastError *string `json:"last_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type OrchestratorStep struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
Phase string `json:"phase"`
Attempt int32 `json:"attempt"`
ParentStepID pgtype.UUID `json:"parent_step_id"`
Depth int32 `json:"depth"`
Status string `json:"status"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
AcpSessionID pgtype.UUID `json:"acp_session_id"`
Prompt string `json:"prompt"`
StopReason *string `json:"stop_reason"`
GateResult []byte `json:"gate_result"`
LastError *string `json:"last_error"`
JobID pgtype.UUID `json:"job_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type Project struct { type Project struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
@@ -264,6 +452,30 @@ type Project struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type ProjectMember struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
AddedBy pgtype.UUID `json:"added_by"`
}
type ProjectMemory struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Source string `json:"source"`
Embedding *pgvector.Vector `json:"embedding"`
EmbeddingModel *string `json:"embedding_model"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptTemplate struct { type PromptTemplate struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
SourceMessageID *int64 `json:"source_message_id"` SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
Verdict string `json:"verdict"`
}
type RequirementPhasePointer struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type User struct { type User struct {
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type WorkspaceWorktree struct { type WorkspaceWorktree struct {
+432
View File
@@ -0,0 +1,432 @@
package brief
import (
"context"
"fmt"
"sort"
"strings"
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
// BriefKind 标识简报的使用场景,影响默认角色提示与是否做仓库定向。
type BriefKind string
const (
// KindACPSession 用于 ACP 执行型 session 的初始 prompt(含仓库定向)。
KindACPSession BriefKind = "acp_session"
// KindChatRequirement 用于挂载到 requirement 的 chat 会话 FK 注入。
KindChatRequirement BriefKind = "chat_requirement"
// KindChatIssue 用于挂载到 issue 的 chat 会话 FK 注入。
KindChatIssue BriefKind = "chat_issue"
)
// 默认 token 预算与有界参数。可被 Config 覆盖。
const (
DefaultTokenBudget = 6000
defaultMaxCommits = 10
defaultMaxDirty = 20
// minSectionTokens 是一个 section 至少要能容纳的 token 数;不足以放下哪怕
// 这么多时直接跳过该 section(避免输出只剩一个截断标记的无意义碎片)。
minSectionTokens = 16
truncateMarker = "\n\n……(已截断)"
)
// truncatedSectionTokens 是 truncateMarker 的 token 预算占用估算,预留给截断标记。
var truncatedSectionTokens = llm.EstimateTokens(truncateMarker)
// BriefInput 是组装一份简报所需的全部输入。所有数据由调用方(app.go 适配器)
// 预先解析好传入,brief 不主动访问仓储,从而不产生跨模块依赖。
type BriefInput struct {
Project *project.Project
Requirement *project.Requirement // nil 表示 issue-only 或自由对话
Issue *project.Issue // nil 表示 requirement-only
Artifacts []*project.Artifact // 各阶段产物(可含多版本,composer 自行取每阶段最新)
RepoCwd string // session CwdPath / workspace main path,用于 git 定向
Branch string // 当前分支(session.Branch),填入 RepoOrientation.Branch
UserPrompt string // 操作者的自由文本初始 prompt,永远追加在最后
TokenBudget int // 硬上限;<=0 时用 Config.DefaultTokenBudget
Kind BriefKind
}
// BriefResult 是组装结果。
type BriefResult struct {
Text string // 组装好的、token 受限的简报
Tokens int // llm.EstimateTokens(Text)
Truncated bool // 是否有任意 section 被裁剪以适配预算
Sections []string // 实际纳入的 section 名(用于 audit/debug)
}
// Composer 是 brief 包的唯一入口。
type Composer interface {
ComposeTaskBrief(ctx context.Context, in BriefInput) (BriefResult, error)
}
// Config 控制 Composer 的默认预算与定向参数。
type Config struct {
DefaultTokenBudget int
MaxCommits int
MaxDirtyFiles int
}
func (c Config) withDefaults() Config {
if c.DefaultTokenBudget <= 0 {
c.DefaultTokenBudget = DefaultTokenBudget
}
if c.MaxCommits <= 0 {
c.MaxCommits = defaultMaxCommits
}
if c.MaxDirtyFiles <= 0 {
c.MaxDirtyFiles = defaultMaxDirty
}
return c
}
type composer struct {
orienter RepoOrienter
cfg Config
}
// NewComposer 构造 Composer。orienter 可为 nil(chat 场景通常不做 git 定向以避免
// 请求路径阻塞);nil 时跳过仓库定向 section。
func NewComposer(orienter RepoOrienter, cfg Config) Composer {
return &composer{orienter: orienter, cfg: cfg.withDefaults()}
}
// section 是简报的一段,按 priority 升序加入(数字越小优先级越高、越先放入预算)。
type section struct {
name string
body string
truncatable bool // true 时预算不足可截断;false 时只能整段纳入或整段丢弃
}
// ComposeTaskBrief 按优先级顺序组装 section 并做 section 粒度的 token 预算控制。
//
// 优先级(高→低):
//
// role/instructions > requirement/issue core > acceptance criteria >
// latest auditing artifact > prototyping > planning > repo orientation > user prompt
//
// user prompt 永远最后追加且不被截断(即使它单独就超预算也保留——它是操作者的
// 真实意图,宁可超 budget 也不能丢)。
func (c *composer) ComposeTaskBrief(ctx context.Context, in BriefInput) (BriefResult, error) {
budget := in.TokenBudget
if budget <= 0 {
budget = c.cfg.DefaultTokenBudget
}
secs := c.buildSections(ctx, in)
var (
included []string
bodies []string
used int
trunc bool
)
for _, s := range secs {
if s.body == "" {
continue
}
// user prompt 特殊:永远纳入、永不截断。
if !s.truncatable {
bodies = append(bodies, s.body)
included = append(included, s.name)
used += llm.EstimateTokens(s.body)
continue
}
remaining := budget - used
if remaining < minSectionTokens {
trunc = true
continue
}
cost := llm.EstimateTokens(s.body)
if cost <= remaining {
bodies = append(bodies, s.body)
included = append(included, s.name)
used += cost
continue
}
// 预算不足:截断当前 section 以塞进剩余预算。
cut := truncateToTokens(s.body, remaining-truncatedSectionTokens)
if cut == "" {
trunc = true
continue
}
body := cut + truncateMarker
bodies = append(bodies, body)
included = append(included, s.name)
used += llm.EstimateTokens(body)
trunc = true
}
text := strings.Join(bodies, "\n\n")
return BriefResult{
Text: text,
Tokens: llm.EstimateTokens(text),
Truncated: trunc,
Sections: included,
}, nil
}
// buildSections 把 BriefInput 解析成有序的 section 列表(已按优先级排好)。
func (c *composer) buildSections(ctx context.Context, in BriefInput) []section {
var secs []section
// 1. 角色 / 指令
if role := c.rolePrompt(in); role != "" {
secs = append(secs, section{name: "role", body: role, truncatable: false})
}
if in.Kind == KindACPSession || in.Requirement != nil || in.Issue != nil {
secs = append(secs, section{name: "interactive_protocol", body: InteractiveQuestionProtocol, truncatable: false})
}
// 2. requirement / issue 核心
if core := requirementCore(in.Requirement); core != "" {
secs = append(secs, section{name: "requirement_core", body: core, truncatable: true})
}
if core := issueCore(in.Issue); core != "" {
secs = append(secs, section{name: "issue_core", body: core, truncatable: true})
}
latest := latestArtifactsByPhase(in.Artifacts)
// 3. 验收标准:优先取 requirement 的最新 planning 产物中的"验收标准"段落。
if ac := acceptanceCriteria(in.Requirement, latest); ac != "" {
secs = append(secs, section{name: "acceptance_criteria", body: ac, truncatable: true})
}
// 4-6. 阶段产物(auditing > prototyping > planning)
if a := latest[project.PhaseAuditing]; a != nil {
secs = append(secs, section{name: "artifact_auditing", body: artifactBody(a), truncatable: true})
}
if a := latest[project.PhasePrototyping]; a != nil {
secs = append(secs, section{name: "artifact_prototyping", body: artifactBody(a), truncatable: true})
}
if a := latest[project.PhasePlanning]; a != nil {
secs = append(secs, section{name: "artifact_planning", body: artifactBody(a), truncatable: true})
}
// 7. 仓库定向(仅 ACP 场景且有 orienter + cwd)
if c.orienter != nil && in.RepoCwd != "" {
ori := c.orienter.Orient(ctx, in.RepoCwd, in.Branch, c.cfg.MaxCommits, c.cfg.MaxDirtyFiles)
if body := orientationBody(ori); body != "" {
secs = append(secs, section{name: "repo_orientation", body: body, truncatable: true})
}
}
// 8. 用户自由文本(最后,永不截断)
if up := strings.TrimSpace(in.UserPrompt); up != "" {
secs = append(secs, section{name: "user_prompt", body: "## 操作者指令\n" + up, truncatable: false})
}
return secs
}
func (c *composer) rolePrompt(in BriefInput) string {
// requirement 有阶段时优先用阶段角色(与原前端 PHASE_ROLE_PROMPTS 行为一致)。
if in.Requirement != nil {
if rp, ok := PhaseRolePrompts[in.Requirement.Phase]; ok && rp != "" {
return rp
}
}
if in.Kind == KindACPSession {
return ACPRolePrompt
}
return ""
}
func requirementCore(req *project.Requirement) string {
if req == nil {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "## 当前需求\n需求 #%d:%s", req.Number, req.Title)
if d := strings.TrimSpace(req.Description); d != "" {
fmt.Fprintf(&b, "\n### 需求描述\n%s", d)
}
return b.String()
}
func issueCore(iss *project.Issue) string {
if iss == nil {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "## 当前 Issue\nIssue #%d:%s", iss.Number, iss.Title)
if d := strings.TrimSpace(iss.Description); d != "" {
fmt.Fprintf(&b, "\n### Issue 描述\n%s", d)
}
return b.String()
}
func artifactBody(a *project.Artifact) string {
if a == nil {
return ""
}
content := strings.TrimSpace(a.Content)
if content == "" {
return ""
}
return fmt.Sprintf("## %s 阶段产物 v%d\n%s", a.Phase, a.Version, content)
}
func orientationBody(o RepoOrientation) string {
if o.IsEmpty() {
return ""
}
var b strings.Builder
b.WriteString("## 仓库现状")
if o.Branch != "" {
fmt.Fprintf(&b, "\n当前分支:%s", o.Branch)
}
if len(o.RecentCommits) > 0 {
b.WriteString("\n最近提交:")
for _, c := range o.RecentCommits {
fmt.Fprintf(&b, "\n- %s", c)
}
}
if len(o.DirtyFiles) > 0 {
b.WriteString("\n未提交改动:")
for _, f := range o.DirtyFiles {
fmt.Fprintf(&b, "\n- %s", f)
}
}
return b.String()
}
// latestArtifactsByPhase 按 (phase) 取 version 最大的产物。输入可包含同阶段多版本。
func latestArtifactsByPhase(arts []*project.Artifact) map[project.Phase]*project.Artifact {
out := make(map[project.Phase]*project.Artifact, len(arts))
for _, a := range arts {
if a == nil {
continue
}
cur, ok := out[a.Phase]
if !ok || a.Version > cur.Version {
out[a.Phase] = a
}
}
return out
}
// acceptanceCriteriaHeadings 是从 planning 产物中提取"验收标准"段落时识别的标题关键字。
var acceptanceCriteriaHeadings = []string{"验收标准", "acceptance criteria", "acceptance"}
// acceptanceCriteria 解析验收标准。当前 requirement 无一级字段,回退为从最新
// planning 产物的 Markdown 中提取"验收标准 / Acceptance Criteria"小节。
// 保守策略:找不到明确小节时返回空串(宁可省略也不误纳入)。
func acceptanceCriteria(_ *project.Requirement, latest map[project.Phase]*project.Artifact) string {
planning := latest[project.PhasePlanning]
if planning == nil {
return ""
}
body := extractSection(planning.Content, acceptanceCriteriaHeadings)
if body == "" {
return ""
}
return "## 验收标准\n" + body
}
// extractSection 从 Markdown 文本中提取标题匹配 headings 之一的小节正文(到下一个
// 同级或更高级标题前为止)。匹配大小写不敏感、忽略标题中的标点与空白。
func extractSection(md string, headings []string) string {
lines := strings.Split(md, "\n")
startIdx := -1
startLevel := 0
for i, line := range lines {
level, title, ok := parseHeading(line)
if !ok {
continue
}
if matchesHeading(title, headings) {
startIdx = i
startLevel = level
break
}
}
if startIdx < 0 {
return ""
}
var bodyLines []string
for i := startIdx + 1; i < len(lines); i++ {
level, _, ok := parseHeading(lines[i])
if ok && level <= startLevel {
break
}
bodyLines = append(bodyLines, lines[i])
}
return strings.TrimSpace(strings.Join(bodyLines, "\n"))
}
// parseHeading 解析一行是否为 ATX Markdown 标题(# / ## / ...)。返回级别与标题文本。
func parseHeading(line string) (level int, title string, ok bool) {
trimmed := strings.TrimLeft(line, " ")
n := 0
for n < len(trimmed) && trimmed[n] == '#' {
n++
}
if n == 0 || n > 6 {
return 0, "", false
}
if n < len(trimmed) && trimmed[n] != ' ' {
return 0, "", false
}
return n, strings.TrimSpace(trimmed[n:]), true
}
func matchesHeading(title string, headings []string) bool {
norm := normalizeHeading(title)
for _, h := range headings {
if norm == normalizeHeading(h) {
return true
}
}
return false
}
// normalizeHeading 归一化标题:小写、去掉首尾标点与空白、去掉常见编号前缀。
func normalizeHeading(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.Trim(s, "  ::.。、")
// 去掉形如 "6. " / "6、" 的编号前缀
for i := 0; i < len(s); i++ {
if s[i] >= '0' && s[i] <= '9' {
continue
}
if s[i] == '.' || s[i] == ' ' || s[i] == '\t' {
s = strings.TrimLeft(s[i:], ". \t")
}
break
}
return strings.TrimSpace(s)
}
// truncateToTokens 把 s 裁剪到不超过 maxTokens 的最长前缀(按 rune 与 EstimateTokens
// 的 rune/4 估算反推,再二分微调,保证不超预算)。maxTokens<=0 返回空串。
func truncateToTokens(s string, maxTokens int) string {
if maxTokens <= 0 {
return ""
}
runes := []rune(s)
if llm.EstimateTokens(s) <= maxTokens {
return s
}
// EstimateTokens(x) = ceil(len(runes)/4),故 maxRunes ≈ maxTokens*4。
hi := maxTokens * 4
if hi > len(runes) {
hi = len(runes)
}
// 用 sort.Search 找最大的 n(runes 前缀)使 EstimateTokens 仍 <= maxTokens。
n := sort.Search(hi+1, func(k int) bool {
return llm.EstimateTokens(string(runes[:k])) > maxTokens
})
if n > 0 {
n--
}
return strings.TrimRight(string(runes[:n]), " \n\t")
}
+282
View File
@@ -0,0 +1,282 @@
package brief
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
func newReq(phase project.Phase) *project.Requirement {
return &project.Requirement{
ID: uuid.New(),
Number: 7,
Title: "导出报表",
Description: "支持导出 Excel",
Phase: phase,
}
}
func newIssue() *project.Issue {
return &project.Issue{
ID: uuid.New(),
Number: 12,
Title: "修复导出乱码",
Description: "UTF-8 BOM 缺失",
}
}
func art(phase project.Phase, version int, content string) *project.Artifact {
return &project.Artifact{
ID: uuid.New(),
RequirementID: uuid.New(),
Phase: phase,
Version: version,
Content: content,
}
}
func TestComposeTaskBrief_FullBriefAllSectionsInPriorityOrder(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhaseAuditing),
Artifacts: []*project.Artifact{
art(project.PhasePlanning, 1, "# 规划\n## 验收标准\n- 必须支持 Excel"),
art(project.PhasePrototyping, 1, "# 原型内容"),
art(project.PhaseAuditing, 1, "# 评审内容"),
},
UserPrompt: "请开始实现",
Kind: KindACPSession,
})
require.NoError(t, err)
assert.False(t, res.Truncated)
// 角色(auditing 阶段用阶段角色)+ 交互协议 + 需求核心 + 验收标准 + 三个产物 + 用户指令。
wantOrder := []string{
"role", "interactive_protocol", "requirement_core", "acceptance_criteria",
"artifact_auditing", "artifact_prototyping", "artifact_planning", "user_prompt",
}
assert.Equal(t, wantOrder, res.Sections)
// 验收标准必须出现在 auditing 产物之前(优先级)。
idxAC := strings.Index(res.Text, "验收标准")
idxAudit := strings.Index(res.Text, "评审内容")
require.GreaterOrEqual(t, idxAC, 0)
require.GreaterOrEqual(t, idxAudit, 0)
assert.Less(t, idxAC, idxAudit)
// 用户指令永远在最后。
assert.True(t, strings.HasSuffix(strings.TrimSpace(res.Text), "请开始实现"))
// 角色提示与需求标题都在。
assert.Contains(t, res.Text, PhaseRolePrompts[project.PhaseAuditing])
assert.Contains(t, res.Text, "需求 #7:导出报表")
}
func TestComposeTaskBrief_TightBudgetDropsLowPrioritySections(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{})
bigPlanning := strings.Repeat("规", 4000) // ~1000 tokens
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Artifacts: []*project.Artifact{
art(project.PhasePlanning, 1, bigPlanning),
art(project.PhaseAuditing, 1, strings.Repeat("评", 4000)),
},
UserPrompt: "做",
Kind: KindACPSession,
TokenBudget: 300, // 紧预算
})
require.NoError(t, err)
assert.True(t, res.Truncated)
// 高优先级(角色/需求核心)保留,用户指令永远保留。
assert.Contains(t, res.Sections, "role")
assert.Contains(t, res.Sections, "requirement_core")
assert.Contains(t, res.Sections, "user_prompt")
// 用户指令永不丢。
assert.Contains(t, res.Text, "## 操作者指令\n做")
}
func TestComposeTaskBrief_RequirementOnly_IssueOnly_Both(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
// requirement-only
res1, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning), Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.Contains(t, res1.Sections, "requirement_core")
assert.NotContains(t, res1.Sections, "issue_core")
// issue-only
res2, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Issue: newIssue(), Kind: KindChatIssue,
})
require.NoError(t, err)
assert.Contains(t, res2.Sections, "issue_core")
assert.NotContains(t, res2.Sections, "requirement_core")
assert.Contains(t, res2.Text, "Issue #12:修复导出乱码")
// both
res3, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning), Issue: newIssue(), Kind: KindACPSession,
})
require.NoError(t, err)
assert.Contains(t, res3.Sections, "requirement_core")
assert.Contains(t, res3.Sections, "issue_core")
}
func TestComposeTaskBrief_MissingDataYieldsNoErrorAndOmitsSections(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
// 空描述 + 无产物。
req := newReq(project.PhasePlanning)
req.Description = ""
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: req, Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.NotContains(t, res.Text, "需求描述")
assert.NotContains(t, res.Sections, "artifact_planning")
assert.NotContains(t, res.Sections, "acceptance_criteria")
// 完全空输入(无 PM 上下文,仅 ACP 角色)。
resEmpty, err := c.ComposeTaskBrief(context.Background(), BriefInput{Kind: KindACPSession})
require.NoError(t, err)
// ACP 场景仍给角色 + 交互协议,但无 PM section。
assert.Contains(t, resEmpty.Sections, "role")
assert.NotContains(t, resEmpty.Sections, "requirement_core")
}
func TestComposeTaskBrief_AcceptanceCriteriaExtraction(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
planning := art(project.PhasePlanning, 3, `# 规划文档
## 背景与目标
做导出
## 验收标准
- 能导出 Excel
- 中文不乱码
## 风险
`)
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Artifacts: []*project.Artifact{planning},
Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.Contains(t, res.Sections, "acceptance_criteria")
assert.Contains(t, res.Text, "能导出 Excel")
assert.Contains(t, res.Text, "中文不乱码")
// 验收标准段落不应吞掉后面的"风险"小节。
acStart := strings.Index(res.Text, "## 验收标准")
require.GreaterOrEqual(t, acStart, 0)
}
func TestComposeTaskBrief_AcceptanceCriteriaFallbackOmitsWhenAbsent(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
planning := art(project.PhasePlanning, 1, "# 规划\n## 背景\n仅有背景,没有验收标准小节")
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Artifacts: []*project.Artifact{planning},
Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.NotContains(t, res.Sections, "acceptance_criteria")
}
func TestComposeTaskBrief_UserPromptNeverTruncatedEvenWhenExceedsBudget(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{})
huge := strings.Repeat("做", 5000) // 远超预算
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
UserPrompt: huge,
Kind: KindACPSession,
TokenBudget: 50,
})
require.NoError(t, err)
assert.Contains(t, res.Sections, "user_prompt")
// 用户指令整段保留(不含截断标记)。
assert.Contains(t, res.Text, huge)
assert.NotContains(t, res.Text, "操作者指令\n"+huge+truncateMarker)
}
func TestComposeTaskBrief_LatestArtifactPerPhase(t *testing.T) {
t.Parallel()
c := NewComposer(nil, Config{DefaultTokenBudget: 100000})
// 同阶段多版本,应取 version 最大的。
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePrototyping),
Artifacts: []*project.Artifact{
art(project.PhasePrototyping, 1, "旧原型 v1"),
art(project.PhasePrototyping, 3, "新原型 v3"),
art(project.PhasePrototyping, 2, "中原型 v2"),
},
Kind: KindChatRequirement,
})
require.NoError(t, err)
assert.Contains(t, res.Text, "新原型 v3")
assert.NotContains(t, res.Text, "旧原型 v1")
assert.Contains(t, res.Text, "prototyping 阶段产物 v3")
}
// fakeOrienter 用于验证 ACP 场景纳入仓库定向 section。
type fakeOrienter struct{ ori RepoOrientation }
func (f fakeOrienter) Orient(_ context.Context, _, _ string, _, _ int) RepoOrientation {
return f.ori
}
func TestComposeTaskBrief_RepoOrientationIncludedForACP(t *testing.T) {
t.Parallel()
c := NewComposer(fakeOrienter{ori: RepoOrientation{
Branch: "feat/x",
RecentCommits: []string{"abc1234 init"},
DirtyFiles: []string{"main.go"},
}}, Config{DefaultTokenBudget: 100000})
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
RepoCwd: "/tmp/repo",
Branch: "feat/x",
Kind: KindACPSession,
})
require.NoError(t, err)
assert.Contains(t, res.Sections, "repo_orientation")
assert.Contains(t, res.Text, "当前分支:feat/x")
assert.Contains(t, res.Text, "abc1234 init")
assert.Contains(t, res.Text, "main.go")
}
func TestComposeTaskBrief_NoOrientationWhenCwdEmpty(t *testing.T) {
t.Parallel()
c := NewComposer(fakeOrienter{ori: RepoOrientation{Branch: "x"}}, Config{DefaultTokenBudget: 100000})
res, err := c.ComposeTaskBrief(context.Background(), BriefInput{
Requirement: newReq(project.PhasePlanning),
Kind: KindACPSession, // RepoCwd 为空 → 跳过定向
})
require.NoError(t, err)
assert.NotContains(t, res.Sections, "repo_orientation")
}
func TestExtractSection_NumberedHeadingAndCaseInsensitive(t *testing.T) {
t.Parallel()
md := "## 6. Acceptance Criteria\n- foo\n## next\nbar"
got := extractSection(md, acceptanceCriteriaHeadings)
assert.Equal(t, "- foo", got)
}
+69
View File
@@ -0,0 +1,69 @@
package brief
import (
"context"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
)
// RepoOrientation 是仓库的轻量定向信息:当前分支、最近若干提交摘要、脏文件路径。
// 用于给 agent 一个"我现在在哪个分支、最近发生了什么、工作树是否干净"的速览。
type RepoOrientation struct {
Branch string
RecentCommits []string
DirtyFiles []string
}
// IsEmpty 报告该定向信息是否完全为空(无分支、无提交、无脏文件)。
func (o RepoOrientation) IsEmpty() bool {
return o.Branch == "" && len(o.RecentCommits) == 0 && len(o.DirtyFiles) == 0
}
// RepoOrienter 是 brief 对 git 的窄接口:给定目录返回有界的仓库定向信息。
// 让 brief 可在没有真实仓库的情况下被单测(注入 fake orienter)。
type RepoOrienter interface {
Orient(ctx context.Context, dir, branch string, maxCommits, maxDirty int) RepoOrientation
}
// gitClient 是 orienter 对 git.Runner 的最小子集。*git.DefaultRunner 直接满足。
type gitClient interface {
Log(ctx context.Context, dir string, n int) ([]git.Commit, error)
Status(ctx context.Context, dir string) ([]git.FileStatus, error)
}
// gitOrienter 是 RepoOrienter 的生产实现,基于 infra/git 的 Log + Status。
// 设计为"尽力而为且永不致命":任何 git 调用失败只产生部分/空的定向信息,
// 不返回 error,从而不会拖垮整份简报(git 失败不应阻塞 prompt 注入)。
type gitOrienter struct{ g gitClient }
// NewRepoOrienter 用给定 git runner 构造 RepoOrienter。
func NewRepoOrienter(g gitClient) RepoOrienter { return &gitOrienter{g: g} }
func (o *gitOrienter) Orient(ctx context.Context, dir, branch string, maxCommits, maxDirty int) RepoOrientation {
out := RepoOrientation{Branch: branch}
if dir == "" || o.g == nil {
return out
}
if maxCommits > 0 {
if commits, err := o.g.Log(ctx, dir, maxCommits); err == nil {
for _, c := range commits {
short := c.Hash
if len(short) > 8 {
short = short[:8]
}
out.RecentCommits = append(out.RecentCommits, short+" "+c.Subject)
}
}
}
if maxDirty > 0 {
if files, err := o.g.Status(ctx, dir); err == nil {
for i, f := range files {
if i >= maxDirty {
break
}
out.DirtyFiles = append(out.DirtyFiles, f.Path)
}
}
}
return out
}
+107
View File
@@ -0,0 +1,107 @@
package brief
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
)
func gitAvailable(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not found in PATH")
}
}
func mustGit(t *testing.T, dir string, args ...string) {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
out, err := c.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, string(out))
}
// makeWorkRepo 在临时目录里建一个有 N 次提交、可选脏文件的工作仓库,返回路径。
func makeWorkRepo(t *testing.T, commits int, dirty bool) string {
t.Helper()
gitAvailable(t)
dir := t.TempDir()
mustGit(t, dir, "init", "-b", "main")
mustGit(t, dir, "config", "user.email", "test@example.com")
mustGit(t, dir, "config", "user.name", "Test")
for i := 0; i < commits; i++ {
name := filepath.Join(dir, "f"+string(rune('a'+i))+".txt")
require.NoError(t, os.WriteFile(name, []byte("v"), 0o644))
mustGit(t, dir, "add", ".")
mustGit(t, dir, "commit", "-m", "commit "+string(rune('a'+i)))
}
if dirty {
require.NoError(t, os.WriteFile(filepath.Join(dir, "dirty.txt"), []byte("x"), 0o644))
}
return dir
}
func TestRepoOrienter_RealRepo(t *testing.T) {
t.Parallel()
dir := makeWorkRepo(t, 3, true)
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
got := o.Orient(context.Background(), dir, "main", 10, 10)
assert.Equal(t, "main", got.Branch)
assert.Len(t, got.RecentCommits, 3)
// 脏文件含未跟踪的 dirty.txt。
require.NotEmpty(t, got.DirtyFiles)
assert.Contains(t, got.DirtyFiles, "dirty.txt")
}
func TestRepoOrienter_BoundsCommits(t *testing.T) {
t.Parallel()
dir := makeWorkRepo(t, 5, false)
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
got := o.Orient(context.Background(), dir, "main", 2, 10)
assert.LessOrEqual(t, len(got.RecentCommits), 2)
}
func TestRepoOrienter_NonexistentDirReturnsPartialNoFailure(t *testing.T) {
t.Parallel()
gitAvailable(t)
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
// 不存在的目录:git 调用失败,但 orienter 不 panic、不返回错误,分支仍带入。
got := o.Orient(context.Background(), filepath.Join(t.TempDir(), "nope"), "wip", 10, 10)
assert.Equal(t, "wip", got.Branch)
assert.Empty(t, got.RecentCommits)
assert.Empty(t, got.DirtyFiles)
}
func TestRepoOrienter_EmptyDirSkips(t *testing.T) {
t.Parallel()
o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()}))
got := o.Orient(context.Background(), "", "main", 10, 10)
assert.Equal(t, "main", got.Branch)
assert.Empty(t, got.RecentCommits)
assert.Empty(t, got.DirtyFiles)
}
func TestRepoOrienter_NilGitClient(t *testing.T) {
t.Parallel()
o := NewRepoOrienter(nil)
got := o.Orient(context.Background(), "/tmp/x", "main", 10, 10)
assert.Equal(t, "main", got.Branch)
assert.Empty(t, got.RecentCommits)
}
func TestRepoOrientation_IsEmpty(t *testing.T) {
t.Parallel()
assert.True(t, RepoOrientation{}.IsEmpty())
assert.False(t, RepoOrientation{Branch: "x"}.IsEmpty())
assert.False(t, RepoOrientation{RecentCommits: []string{"a"}}.IsEmpty())
}
+53
View File
@@ -0,0 +1,53 @@
// Package brief 是服务端"上下文组装器":把 (project, requirement|issue, artifacts,
// repo orientation, 用户自由文本) 组装成一份 token 受限的结构化任务简报,供 ACP
// session 初始 prompt 与 chat composeHistory 的 FK 上下文注入复用。
//
// 依赖纪律:brief 只依赖 project 领域类型 + infra/git + infra/llm,绝不导入
// acp/chat(避免 import cycle)。acp/chat 通过 app.go 中的窄适配器反向调用 brief。
package brief
import "github.com/yan1h/agent-coding-workflow/internal/project"
// PhaseRolePrompts 是三个 AI 对话阶段的角色模板,从前端
// web/src/constants/requirementPhasePrompts.ts 的 PHASE_ROLE_PROMPTS 逐字迁移。
// 服务端拥有这套提示词后,ACP session 与 chat 都能获得一致的阶段角色注入。
var PhaseRolePrompts = map[project.Phase]string{
project.PhasePlanning: `你是一名资深产品规划助手你的任务是与用户深入讨论需求澄清目标范围用户场景与约束最终形成一份结构化的规划文档Markdown
讨论时主动提问补全缺失信息输出规划文档时包含背景与目标范围含不做什么用户场景功能拆解风险与依赖验收标准`,
project.PhasePrototyping: `你是一名资深原型设计助手基于已确认的规划与用户讨论并产出原型设计文档Markdown
内容应包含页面/界面结构交互流程状态与边界情况数据展示与输入约束可用文字线框列表与表格描述布局`,
project.PhaseAuditing: `你是一名严格的方案评审助手基于规划与原型对方案进行评审并产出评审报告Markdown
评审维度完整性是否覆盖规划目标一致性原型与规划是否矛盾可行性技术与排期风险边界情况遗漏验收标准可测性明确给出问题清单与修改建议并给出评审结论通过 / 有条件通过 / 不通过`,
}
// InteractiveQuestionProtocol 是结构化澄清问题协议,从前端
// web/src/constants/requirementPhasePrompts.ts 的 INTERACTIVE_QUESTION_PROMPT 逐字迁移。
const InteractiveQuestionProtocol = `## 交互式澄清问题协议
当信息不足且用户直接输入大段文字的成本较高时优先提出一个结构化澄清问题方便用户点选
每次最多输出 1 个结构化问题问题必须有 2-5 个选项只有选项允许多选时 mode 才使用 multiple
结构化问题必须放在 acw-question 代码块中代码块内只能是合法 JSON不能输出 HTML
字段格式如下
` + "```acw-question" + `
{
"version": 1,
"id": "stable_question_id",
"title": "需要用户确认的问题",
"description": "选择这个问题的原因,可省略",
"mode": "single",
"options": [
{
"id": "stable_option_id",
"label": "给用户看的短选项",
"summary": "一句话解释,可省略",
"details": "更完整的影响说明,可省略"
}
],
"allowCustom": true
}
` + "```" + `
用户回答结构化问题后继续基于回答推进本阶段讨论或产物输出`
// ACPRolePrompt 是 ACP session(执行型 agent,非阶段对话)默认的角色说明。
// 与 PhaseRolePrompts(规划/原型/评审三阶段讨论助手)不同,ACP session 通常是
// 直接对代码仓库动手的执行型 agent,因此给出一段中性的工程执行角色。
const ACPRolePrompt = `你是一名在真实代码仓库中工作的资深工程实现助手。请基于下面提供的需求/Issue 背景、阶段产物与仓库现状,谨慎地推进实现:先理解上下文,必要时澄清,再动手修改;保持改动最小且可验证。`
+119
View File
@@ -0,0 +1,119 @@
// Package changerequest models the change_requests entity and the server-side
// merge gate. A change request links a project/workspace/(requirement|issue)
// and a source->target branch to an external (Gitea) pull request, carrying a
// review verdict + CI state. The crux is Service.Merge, which hard-blocks the
// host-side merge unless review_verdict=='approved' (and optionally
// ci_state=='success'): an agent can open and merge PRs via MCP, but cannot
// self-approve — approval is HTTP/web-only by policy.
//
// Layout mirrors internal/workspace (single package, service + repository +
// handler + sqlc) to keep the aggregate cohesive.
package changerequest
import (
"context"
"time"
"github.com/google/uuid"
)
// State mirrors the change_requests.state CHECK constraint.
type State string
// State enum values.
const (
StateOpen State = "open"
StateMerged State = "merged"
StateClosed State = "closed"
)
// Verdict mirrors the change_requests.review_verdict CHECK constraint.
type Verdict string
// Verdict enum values.
const (
VerdictPending Verdict = "pending"
VerdictApproved Verdict = "approved"
VerdictChangesRequested Verdict = "changes_requested"
VerdictRejected Verdict = "rejected"
)
// IsValid reports whether v is in the enum set.
func (v Verdict) IsValid() bool {
switch v {
case VerdictPending, VerdictApproved, VerdictChangesRequested, VerdictRejected:
return true
}
return false
}
// CIState mirrors the change_requests.ci_state CHECK constraint.
type CIState string
// CIState enum values.
const (
CIUnknown CIState = "unknown"
CIPending CIState = "pending"
CISuccess CIState = "success"
CIFailure CIState = "failure"
)
// ChangeRequest is the change_requests row projection.
type ChangeRequest struct {
ID uuid.UUID
ProjectID uuid.UUID
WorkspaceID uuid.UUID
RequirementID *uuid.UUID
IssueID *uuid.UUID
Number int
Title string
Description string
SourceBranch string
TargetBranch string
State State
ReviewVerdict Verdict
CIState CIState
Provider string
ExternalID *int64
ExternalURL string
MergeCommitSHA string
ReviewedBy *uuid.UUID
ReviewedAt *time.Time
CreatedBy uuid.UUID
MergedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// Caller mirrors workspace.Caller: the user context driving auth + audit.
type Caller struct {
UserID uuid.UUID
IsAdmin bool
SessionID *uuid.UUID
}
// CreateInput carries the fields to open a change request (and host PR).
type CreateInput struct {
SourceBranch string
TargetBranch string // empty => workspace default branch
Title string
Description string
RequirementID *uuid.UUID
IssueID *uuid.UUID
}
// Service is the change-request application service.
type Service interface {
Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateInput) (*ChangeRequest, error)
Get(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error)
GetByNumber(ctx context.Context, c Caller, projectSlug string, number int) (*ChangeRequest, error)
ListByWorkspace(ctx context.Context, c Caller, wsID uuid.UUID) ([]*ChangeRequest, error)
// Review records a human verdict (owner/admin only). It is intentionally NOT
// exposed as an MCP tool so agents cannot self-approve their own PRs.
Review(ctx context.Context, c Caller, id uuid.UUID, verdict Verdict, note string) (*ChangeRequest, error)
// Merge is the gate: requires review_verdict=='approved' (+ ci success when
// RequireCIPass), then merges the host PR and persists merged state.
Merge(ctx context.Context, c Caller, id uuid.UUID, method string) (*ChangeRequest, error)
// SyncStatus refreshes ci_state/state from the host (provider.GetPR).
SyncStatus(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error)
}
+70
View File
@@ -0,0 +1,70 @@
// dto.go defines the HTTP request/response shapes for the change-request module.
package changerequest
import (
"time"
"github.com/google/uuid"
)
// ===== request DTO =====
type createReq struct {
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
Title string `json:"title"`
Description string `json:"description"`
RequirementID *uuid.UUID `json:"requirement_id"`
IssueID *uuid.UUID `json:"issue_id"`
}
type reviewReq struct {
Verdict string `json:"verdict"`
Note string `json:"note"`
}
type mergeReq struct {
Method string `json:"method"`
}
// ===== response DTO =====
type changeRequestResp struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
RequirementID *uuid.UUID `json:"requirement_id"`
IssueID *uuid.UUID `json:"issue_id"`
Number int `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CIState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalURL string `json:"external_url"`
MergeCommitSHA string `json:"merge_commit_sha"`
ReviewedBy *uuid.UUID `json:"reviewed_by"`
ReviewedAt *time.Time `json:"reviewed_at"`
CreatedBy uuid.UUID `json:"created_by"`
MergedAt *time.Time `json:"merged_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toResp(cr *ChangeRequest) changeRequestResp {
return changeRequestResp{
ID: cr.ID, ProjectID: cr.ProjectID, WorkspaceID: cr.WorkspaceID,
RequirementID: cr.RequirementID, IssueID: cr.IssueID, Number: cr.Number,
Title: cr.Title, Description: cr.Description,
SourceBranch: cr.SourceBranch, TargetBranch: cr.TargetBranch,
State: string(cr.State), ReviewVerdict: string(cr.ReviewVerdict),
CIState: string(cr.CIState), Provider: cr.Provider,
ExternalID: cr.ExternalID, ExternalURL: cr.ExternalURL, MergeCommitSHA: cr.MergeCommitSHA,
ReviewedBy: cr.ReviewedBy, ReviewedAt: cr.ReviewedAt, CreatedBy: cr.CreatedBy,
MergedAt: cr.MergedAt, CreatedAt: cr.CreatedAt, UpdatedAt: cr.UpdatedAt,
}
}
+218
View File
@@ -0,0 +1,218 @@
// handler.go exposes the change-request HTTP endpoints under middleware.Auth,
// mirroring workspace/handler.go. Routes:
//
// POST /api/v1/workspaces/{wsID}/change-requests create
// GET /api/v1/workspaces/{wsID}/change-requests list
// GET /api/v1/change-requests/{id} get
// POST /api/v1/change-requests/{id}/review review (human approve/reject)
// POST /api/v1/change-requests/{id}/merge gated merge
// POST /api/v1/change-requests/{id}/sync refresh status from host
package changerequest
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// AdminLookup resolves is_admin for a user (narrow interface, same as
// workspace.AdminLookup so the userAdminAdapter can be reused).
type AdminLookup interface {
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
}
// Handler holds the change-request service + auth deps.
type Handler struct {
svc Service
resolver middleware.SessionResolver
users AdminLookup
}
// NewHandler constructs the Handler.
func NewHandler(svc Service, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{svc: svc, resolver: resolver, users: users}
}
// Mount registers the change-request routes on r under the Auth middleware.
func (h *Handler) Mount(r chi.Router) {
auth := middleware.Auth(h.resolver, middleware.AuthOptions{})
r.With(auth).Route("/api/v1/workspaces/{wsID}/change-requests", func(r chi.Router) {
r.Post("/", h.create)
r.Get("/", h.list)
})
r.With(auth).Route("/api/v1/change-requests/{id}", func(r chi.Router) {
r.Get("/", h.get)
r.Post("/review", h.review)
r.Post("/merge", h.merge)
r.Post("/sync", h.sync)
})
}
func (h *Handler) caller(r *http.Request) (Caller, error) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
return Caller{}, errs.New(errs.CodeUnauthorized, "unauthenticated")
}
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
if err != nil {
return Caller{}, err
}
return Caller{UserID: uid, IsAdmin: isAdmin}, nil
}
func writeJSON(w http.ResponseWriter, status int, body any) { httpx.WriteJSON(w, status, body) }
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}
func parseUUID(s string) (uuid.UUID, error) {
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid uuid")
}
return id, nil
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
wsID, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
var req createReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
return
}
cr, err := h.svc.Create(r.Context(), c, wsID, CreateInput{
SourceBranch: req.SourceBranch, TargetBranch: req.TargetBranch,
Title: req.Title, Description: req.Description,
RequirementID: req.RequirementID, IssueID: req.IssueID,
})
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusCreated, toResp(cr))
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
wsID, err := parseUUID(chi.URLParam(r, "wsID"))
if err != nil {
writeErr(w, r, err)
return
}
out, err := h.svc.ListByWorkspace(r.Context(), c, wsID)
if err != nil {
writeErr(w, r, err)
return
}
resp := make([]changeRequestResp, 0, len(out))
for _, cr := range out {
resp = append(resp, toResp(cr))
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeErr(w, r, err)
return
}
cr, err := h.svc.Get(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toResp(cr))
}
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeErr(w, r, err)
return
}
var req reviewReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
return
}
cr, err := h.svc.Review(r.Context(), c, id, Verdict(req.Verdict), req.Note)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toResp(cr))
}
func (h *Handler) merge(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeErr(w, r, err)
return
}
var req mergeReq
// merge body is optional (method default)
_ = json.NewDecoder(r.Body).Decode(&req)
cr, err := h.svc.Merge(r.Context(), c, id, req.Method)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toResp(cr))
}
func (h *Handler) sync(w http.ResponseWriter, r *http.Request) {
c, err := h.caller(r)
if err != nil {
writeErr(w, r, err)
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeErr(w, r, err)
return
}
cr, err := h.svc.SyncStatus(r.Context(), c, id)
if err != nil {
writeErr(w, r, err)
return
}
writeJSON(w, http.StatusOK, toResp(cr))
}
+140
View File
@@ -0,0 +1,140 @@
package changerequest
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// fakeSvc implements Service for handler tests.
type fakeSvc struct {
mergeErr error
cr *ChangeRequest
}
func (f *fakeSvc) Create(_ context.Context, c Caller, wsID uuid.UUID, _ CreateInput) (*ChangeRequest, error) {
return &ChangeRequest{ID: uuid.New(), WorkspaceID: wsID, Number: 1, State: StateOpen, ReviewVerdict: VerdictPending, CreatedBy: c.UserID}, nil
}
func (f *fakeSvc) Get(_ context.Context, _ Caller, id uuid.UUID) (*ChangeRequest, error) {
return &ChangeRequest{ID: id, State: StateOpen}, nil
}
func (f *fakeSvc) GetByNumber(_ context.Context, _ Caller, _ string, n int) (*ChangeRequest, error) {
return &ChangeRequest{ID: uuid.New(), Number: n}, nil
}
func (f *fakeSvc) ListByWorkspace(_ context.Context, _ Caller, wsID uuid.UUID) ([]*ChangeRequest, error) {
return []*ChangeRequest{{ID: uuid.New(), WorkspaceID: wsID, Number: 1}}, nil
}
func (f *fakeSvc) Review(_ context.Context, _ Caller, id uuid.UUID, v Verdict, _ string) (*ChangeRequest, error) {
return &ChangeRequest{ID: id, ReviewVerdict: v}, nil
}
func (f *fakeSvc) Merge(_ context.Context, _ Caller, id uuid.UUID, _ string) (*ChangeRequest, error) {
if f.mergeErr != nil {
return nil, f.mergeErr
}
return &ChangeRequest{ID: id, State: StateMerged}, nil
}
func (f *fakeSvc) SyncStatus(_ context.Context, _ Caller, id uuid.UUID) (*ChangeRequest, error) {
return &ChangeRequest{ID: id, CIState: CISuccess}, nil
}
type fakeResolver struct{ valid map[string]uuid.UUID }
func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
uid, ok := f.valid[token]
return uid, ok, nil
}
type fakeAdmin struct{}
func (fakeAdmin) IsAdmin(_ context.Context, _ uuid.UUID) (bool, error) { return false, nil }
func newTestHandler(svc Service, tok string, uid uuid.UUID) chi.Router {
resolver := &fakeResolver{valid: map[string]uuid.UUID{tok: uid}}
h := NewHandler(svc, resolver, fakeAdmin{})
r := chi.NewRouter()
r.Use(middleware.RequestID)
h.Mount(r)
return r
}
func TestHandler_Unauthenticated(t *testing.T) {
t.Parallel()
r := newTestHandler(&fakeSvc{}, "tok", uuid.New())
req := httptest.NewRequest("GET", "/api/v1/workspaces/"+uuid.NewString()+"/change-requests", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestHandler_CreateAndList(t *testing.T) {
t.Parallel()
uid := uuid.New()
r := newTestHandler(&fakeSvc{}, "tok", uid)
wsID := uuid.New()
body, _ := json.Marshal(createReq{SourceBranch: "feat", Title: "T"})
req := httptest.NewRequest("POST", "/api/v1/workspaces/"+wsID.String()+"/change-requests", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
req = httptest.NewRequest("GET", "/api/v1/workspaces/"+wsID.String()+"/change-requests", nil)
req.Header.Set("Authorization", "Bearer tok")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
}
func TestHandler_MergeGateReturns412(t *testing.T) {
t.Parallel()
svc := &fakeSvc{mergeErr: errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved")}
r := newTestHandler(svc, "tok", uuid.New())
req := httptest.NewRequest("POST", "/api/v1/change-requests/"+uuid.NewString()+"/merge", nil)
req.Header.Set("Authorization", "Bearer tok")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusPreconditionFailed, w.Code)
}
func TestHandler_MergeHappyPath(t *testing.T) {
t.Parallel()
r := newTestHandler(&fakeSvc{}, "tok", uuid.New())
req := httptest.NewRequest("POST", "/api/v1/change-requests/"+uuid.NewString()+"/merge", bytes.NewReader([]byte(`{"method":"squash"}`)))
req.Header.Set("Authorization", "Bearer tok")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp changeRequestResp
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
require.Equal(t, string(StateMerged), resp.State)
}
func TestHandler_Review(t *testing.T) {
t.Parallel()
r := newTestHandler(&fakeSvc{}, "tok", uuid.New())
body, _ := json.Marshal(reviewReq{Verdict: "approved", Note: "lgtm"})
req := httptest.NewRequest("POST", "/api/v1/change-requests/"+uuid.NewString()+"/review", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer tok")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp changeRequestResp
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
require.Equal(t, "approved", resp.ReviewVerdict)
}
@@ -0,0 +1,63 @@
-- name: NextChangeRequestNumber :one
-- Per-project monotonic number; FOR UPDATE not needed because the
-- max(number)+1 INSERT runs inside the same tx, but we lock existing project
-- rows to serialize concurrent creates within a project.
SELECT COALESCE(MAX(number), 0) + 1 FROM change_requests WHERE project_id = $1 FOR UPDATE;
-- name: CreateChangeRequest :one
INSERT INTO change_requests (
id, project_id, workspace_id, requirement_id, issue_id, number,
title, description, source_branch, target_branch,
state, review_verdict, ci_state, provider, external_id, external_url,
merge_commit_sha, created_by
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
'open', 'pending', $11, $12, $13, $14,
'', $15
)
RETURNING *;
-- name: GetChangeRequestByID :one
SELECT * FROM change_requests WHERE id = $1;
-- name: GetChangeRequestByNumber :one
SELECT cr.* FROM change_requests cr
JOIN projects p ON p.id = cr.project_id
WHERE p.slug = $1 AND cr.number = $2;
-- name: ListChangeRequestsByWorkspace :many
SELECT * FROM change_requests
WHERE workspace_id = $1
ORDER BY number DESC;
-- name: ListChangeRequestsByProject :many
SELECT * FROM change_requests
WHERE project_id = $1
ORDER BY number DESC;
-- name: UpdateChangeRequestReview :one
UPDATE change_requests
SET review_verdict = $2,
reviewed_by = $3,
reviewed_at = now(),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: UpdateChangeRequestState :one
UPDATE change_requests
SET state = $2,
merge_commit_sha = $3,
merged_at = $4,
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: UpdateChangeRequestCI :one
UPDATE change_requests
SET ci_state = $2,
state = $3,
updated_at = now()
WHERE id = $1
RETURNING *;
+264
View File
@@ -0,0 +1,264 @@
// repository.go is a thin wrapper over the sqlc layer: pgtype<->domain
// conversion, pgx.ErrNoRows -> errs.NotFound translation, and unique-violation
// detection. Mirrors internal/workspace/repository.go.
package changerequest
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
changerequestsqlc "github.com/yan1h/agent-coding-workflow/internal/changerequest/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// Tx is the transaction handle exposed to the service: NextNumber + Create run
// in one tx so per-project numbering is serialized.
type Tx interface {
NextNumber(ctx context.Context, projectID uuid.UUID) (int, error)
Create(ctx context.Context, cr *ChangeRequest) (*ChangeRequest, error)
}
// Repository is the change-request module's PG dependency.
type Repository interface {
GetByID(ctx context.Context, id uuid.UUID) (*ChangeRequest, error)
GetByNumber(ctx context.Context, projectSlug string, number int) (*ChangeRequest, error)
ListByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*ChangeRequest, error)
ListByProject(ctx context.Context, projectID uuid.UUID) ([]*ChangeRequest, error)
UpdateReview(ctx context.Context, id uuid.UUID, verdict Verdict, reviewedBy uuid.UUID) (*ChangeRequest, error)
UpdateState(ctx context.Context, id uuid.UUID, st State, mergeSHA string, mergedAt *time.Time) (*ChangeRequest, error)
UpdateCI(ctx context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error)
InTx(ctx context.Context, fn func(Tx) error) error
}
// IsUniqueViolation reports whether err is a PG unique constraint violation.
func IsUniqueViolation(err error) bool {
var pg *pgconn.PgError
return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation
}
type pgRepo struct {
pool *pgxpool.Pool
q *changerequestsqlc.Queries
}
// NewPostgresRepository builds a Repository from a pgxpool.
func NewPostgresRepository(pool *pgxpool.Pool) Repository {
return &pgRepo{pool: pool, q: changerequestsqlc.New(pool)}
}
// ===== conversion helpers =====
func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} }
func toPgUUIDPtr(u *uuid.UUID) pgtype.UUID {
if u == nil {
return pgtype.UUID{Valid: false}
}
return pgtype.UUID{Bytes: *u, Valid: true}
}
func fromPgUUID(p pgtype.UUID) uuid.UUID {
if !p.Valid {
return uuid.Nil
}
return uuid.UUID(p.Bytes)
}
func fromPgUUIDPtr(p pgtype.UUID) *uuid.UUID {
if !p.Valid {
return nil
}
u := uuid.UUID(p.Bytes)
return &u
}
func toPgTimePtr(t *time.Time) pgtype.Timestamptz {
if t == nil {
return pgtype.Timestamptz{Valid: false}
}
return pgtype.Timestamptz{Time: *t, Valid: true}
}
func fromPgTimePtr(t pgtype.Timestamptz) *time.Time {
if !t.Valid {
return nil
}
v := t.Time
return &v
}
func rowToCR(r changerequestsqlc.ChangeRequest) *ChangeRequest {
return &ChangeRequest{
ID: fromPgUUID(r.ID),
ProjectID: fromPgUUID(r.ProjectID),
WorkspaceID: fromPgUUID(r.WorkspaceID),
RequirementID: fromPgUUIDPtr(r.RequirementID),
IssueID: fromPgUUIDPtr(r.IssueID),
Number: int(r.Number),
Title: r.Title,
Description: r.Description,
SourceBranch: r.SourceBranch,
TargetBranch: r.TargetBranch,
State: State(r.State),
ReviewVerdict: Verdict(r.ReviewVerdict),
CIState: CIState(r.CiState),
Provider: r.Provider,
ExternalID: r.ExternalID,
ExternalURL: r.ExternalUrl,
MergeCommitSHA: r.MergeCommitSha,
ReviewedBy: fromPgUUIDPtr(r.ReviewedBy),
ReviewedAt: fromPgTimePtr(r.ReviewedAt),
CreatedBy: fromPgUUID(r.CreatedBy),
MergedAt: fromPgTimePtr(r.MergedAt),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
// ===== queries =====
func (r *pgRepo) GetByID(ctx context.Context, id uuid.UUID) (*ChangeRequest, error) {
row, err := r.q.GetChangeRequestByID(ctx, toPgUUID(id))
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "change request not found")
}
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "get change request")
}
return rowToCR(row), nil
}
func (r *pgRepo) GetByNumber(ctx context.Context, projectSlug string, number int) (*ChangeRequest, error) {
row, err := r.q.GetChangeRequestByNumber(ctx, changerequestsqlc.GetChangeRequestByNumberParams{
Slug: projectSlug,
Number: int32(number),
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "change request not found")
}
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "get change request by number")
}
return rowToCR(row), nil
}
func (r *pgRepo) ListByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) {
rows, err := r.q.ListChangeRequestsByWorkspace(ctx, toPgUUID(wsID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list change requests by workspace")
}
out := make([]*ChangeRequest, 0, len(rows))
for _, row := range rows {
out = append(out, rowToCR(row))
}
return out, nil
}
func (r *pgRepo) ListByProject(ctx context.Context, projectID uuid.UUID) ([]*ChangeRequest, error) {
rows, err := r.q.ListChangeRequestsByProject(ctx, toPgUUID(projectID))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list change requests by project")
}
out := make([]*ChangeRequest, 0, len(rows))
for _, row := range rows {
out = append(out, rowToCR(row))
}
return out, nil
}
func (r *pgRepo) UpdateReview(ctx context.Context, id uuid.UUID, verdict Verdict, reviewedBy uuid.UUID) (*ChangeRequest, error) {
row, err := r.q.UpdateChangeRequestReview(ctx, changerequestsqlc.UpdateChangeRequestReviewParams{
ID: toPgUUID(id),
ReviewVerdict: string(verdict),
ReviewedBy: toPgUUID(reviewedBy),
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "change request not found")
}
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "update change request review")
}
return rowToCR(row), nil
}
func (r *pgRepo) UpdateState(ctx context.Context, id uuid.UUID, st State, mergeSHA string, mergedAt *time.Time) (*ChangeRequest, error) {
row, err := r.q.UpdateChangeRequestState(ctx, changerequestsqlc.UpdateChangeRequestStateParams{
ID: toPgUUID(id),
State: string(st),
MergeCommitSha: mergeSHA,
MergedAt: toPgTimePtr(mergedAt),
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "change request not found")
}
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "update change request state")
}
return rowToCR(row), nil
}
func (r *pgRepo) UpdateCI(ctx context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) {
row, err := r.q.UpdateChangeRequestCI(ctx, changerequestsqlc.UpdateChangeRequestCIParams{
ID: toPgUUID(id),
CiState: string(ci),
State: string(st),
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "change request not found")
}
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "update change request ci")
}
return rowToCR(row), nil
}
func (r *pgRepo) InTx(ctx context.Context, fn func(Tx) error) error {
return pgx.BeginFunc(ctx, r.pool, func(tx pgx.Tx) error {
return fn(&pgTx{q: r.q.WithTx(tx)})
})
}
type pgTx struct{ q *changerequestsqlc.Queries }
func (t *pgTx) NextNumber(ctx context.Context, projectID uuid.UUID) (int, error) {
n, err := t.q.NextChangeRequestNumber(ctx, toPgUUID(projectID))
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "next change request number")
}
return int(n), nil
}
func (t *pgTx) Create(ctx context.Context, cr *ChangeRequest) (*ChangeRequest, error) {
row, err := t.q.CreateChangeRequest(ctx, changerequestsqlc.CreateChangeRequestParams{
ID: toPgUUID(cr.ID),
ProjectID: toPgUUID(cr.ProjectID),
WorkspaceID: toPgUUID(cr.WorkspaceID),
RequirementID: toPgUUIDPtr(cr.RequirementID),
IssueID: toPgUUIDPtr(cr.IssueID),
Number: int32(cr.Number),
Title: cr.Title,
Description: cr.Description,
SourceBranch: cr.SourceBranch,
TargetBranch: cr.TargetBranch,
CiState: string(cr.CIState),
Provider: cr.Provider,
ExternalID: cr.ExternalID,
ExternalUrl: cr.ExternalURL,
CreatedBy: toPgUUID(cr.CreatedBy),
})
if err != nil {
if IsUniqueViolation(err) {
return nil, errs.Wrap(err, errs.CodeConflict, "change request number already used")
}
return nil, errs.Wrap(err, errs.CodeInternal, "create change request")
}
return rowToCR(row), nil
}
+338
View File
@@ -0,0 +1,338 @@
// service.go implements the change-request application service, including the
// merge gate (Service.Merge). Auth delegates to ProjectAccess (owner+admin
// write), mirroring workspace's narrow-interface approach so this package does
// not depend on internal/project wholesale.
package changerequest
import (
"context"
"time"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/vcs"
)
// WorkspaceLookup resolves a workspace's project + remote URL + default branch
// for RepoRef derivation and target-branch defaulting. Narrow interface over
// workspace.Repository so we avoid importing the full workspace service.
type WorkspaceLookup interface {
GetWorkspaceMeta(ctx context.Context, wsID uuid.UUID) (WorkspaceMeta, error)
}
// WorkspaceMeta is the workspace projection the change-request service needs.
type WorkspaceMeta struct {
ProjectID uuid.UUID
GitRemoteURL string
DefaultBranch string
}
// ProjectAccess is the auth interface (same shape as workspace.ProjectAccess):
// ResolveByID returns (canRead, canWrite, err) for a project.
type ProjectAccess interface {
ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error)
}
// Config controls the merge gate policy.
type Config struct {
RequireCIPass bool // when true, merge also requires ci_state=='success'
MergeMethod string // default host merge method ("merge"|"squash"|"rebase"); "merge" when empty
Host string // bare host the provider serves, for RepoRef host check; empty => skip
}
type service struct {
repo Repository
ws WorkspaceLookup
rec audit.Recorder
provider vcs.Provider
pa ProjectAccess
cfg Config
}
// NewService constructs the change-request Service.
func NewService(repo Repository, ws WorkspaceLookup, rec audit.Recorder, provider vcs.Provider, pa ProjectAccess, cfg Config) Service {
if cfg.MergeMethod == "" {
cfg.MergeMethod = "merge"
}
return &service{repo: repo, ws: ws, rec: rec, provider: provider, pa: pa, cfg: cfg}
}
// Create opens a host PR and persists the change_requests row.
func (s *service) Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateInput) (*ChangeRequest, error) {
meta, err := s.ws.GetWorkspaceMeta(ctx, wsID)
if err != nil {
return nil, err
}
if err := s.guardWrite(ctx, c, meta.ProjectID); err != nil {
return nil, err
}
if in.SourceBranch == "" {
return nil, errs.New(errs.CodeInvalidInput, "source_branch required")
}
if in.Title == "" {
return nil, errs.New(errs.CodeInvalidInput, "title required")
}
target := in.TargetBranch
if target == "" {
target = meta.DefaultBranch
}
if target == "" {
target = "main"
}
if in.SourceBranch == target {
return nil, errs.New(errs.CodeInvalidInput, "source_branch and target_branch must differ")
}
repoRef, err := vcs.ParseRepoRef(meta.GitRemoteURL, s.cfg.Host)
if err != nil {
return nil, err
}
pr, err := s.provider.CreatePR(ctx, vcs.CreatePRInput{
Repo: repoRef, Head: in.SourceBranch, Base: target,
Title: in.Title, Body: in.Description,
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeUpstream, "open pull request")
}
cr := &ChangeRequest{
ID: uuid.New(),
ProjectID: meta.ProjectID,
WorkspaceID: wsID,
RequirementID: in.RequirementID,
IssueID: in.IssueID,
Title: in.Title,
Description: in.Description,
SourceBranch: in.SourceBranch,
TargetBranch: target,
CIState: CIState(pr.CIState),
Provider: "gitea",
ExternalURL: pr.HTMLURL,
CreatedBy: c.UserID,
}
if cr.CIState == "" {
cr.CIState = CIUnknown
}
extID := pr.Number
cr.ExternalID = &extID
var created *ChangeRequest
err = s.repo.InTx(ctx, func(tx Tx) error {
n, nerr := tx.NextNumber(ctx, meta.ProjectID)
if nerr != nil {
return nerr
}
cr.Number = n
c2, cerr := tx.Create(ctx, cr)
if cerr != nil {
return cerr
}
created = c2
return nil
})
if err != nil {
return nil, err
}
s.audit(ctx, &c.UserID, "change_request.created", created.ID.String(), map[string]any{
"number": created.Number, "external_id": extID, "source": in.SourceBranch, "target": target,
})
return created, nil
}
func (s *service) Get(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) {
cr, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := s.guardRead(ctx, c, cr.ProjectID); err != nil {
return nil, err
}
return cr, nil
}
func (s *service) GetByNumber(ctx context.Context, c Caller, projectSlug string, number int) (*ChangeRequest, error) {
cr, err := s.repo.GetByNumber(ctx, projectSlug, number)
if err != nil {
return nil, err
}
if err := s.guardRead(ctx, c, cr.ProjectID); err != nil {
return nil, err
}
return cr, nil
}
func (s *service) ListByWorkspace(ctx context.Context, c Caller, wsID uuid.UUID) ([]*ChangeRequest, error) {
meta, err := s.ws.GetWorkspaceMeta(ctx, wsID)
if err != nil {
return nil, err
}
if err := s.guardRead(ctx, c, meta.ProjectID); err != nil {
return nil, err
}
return s.repo.ListByWorkspace(ctx, wsID)
}
// Review records a human verdict. Owner/admin only. Not an MCP tool.
func (s *service) Review(ctx context.Context, c Caller, id uuid.UUID, verdict Verdict, note string) (*ChangeRequest, error) {
if !verdict.IsValid() || verdict == VerdictPending {
return nil, errs.New(errs.CodeInvalidInput, "invalid review verdict")
}
cr, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := s.guardWrite(ctx, c, cr.ProjectID); err != nil {
return nil, err
}
if cr.State != StateOpen {
return nil, errs.New(errs.CodeFailedPrecondition, "cannot review a non-open change request")
}
updated, err := s.repo.UpdateReview(ctx, id, verdict, c.UserID)
if err != nil {
return nil, err
}
if note != "" && updated.ExternalID != nil {
if repoRef, perr := s.repoRefFor(ctx, updated.WorkspaceID); perr == nil {
_ = s.provider.Comment(ctx, repoRef, *updated.ExternalID, "Review ("+string(verdict)+"): "+note)
}
}
s.audit(ctx, &c.UserID, "change_request.reviewed", id.String(), map[string]any{
"verdict": string(verdict),
})
return updated, nil
}
// Merge is the gate. It requires review_verdict=='approved' (and ci success
// when RequireCIPass and not admin), then merges the host PR.
func (s *service) Merge(ctx context.Context, c Caller, id uuid.UUID, method string) (*ChangeRequest, error) {
cr, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := s.guardWrite(ctx, c, cr.ProjectID); err != nil {
return nil, err
}
if cr.State != StateOpen {
return nil, errs.New(errs.CodeFailedPrecondition, "change request is not open")
}
// THE GATE: review must be approved.
if cr.ReviewVerdict != VerdictApproved {
return nil, errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved")
}
// Optional CI gate; admins may override.
if s.cfg.RequireCIPass && !c.IsAdmin && cr.CIState != CISuccess {
return nil, errs.New(errs.CodeFailedPrecondition, "merge blocked: CI not passing")
}
if cr.ExternalID == nil {
return nil, errs.New(errs.CodeFailedPrecondition, "change request has no external PR")
}
repoRef, err := s.repoRefFor(ctx, cr.WorkspaceID)
if err != nil {
return nil, err
}
mm := method
if mm == "" {
mm = s.cfg.MergeMethod
}
mergeSHA, err := s.provider.Merge(ctx, repoRef, *cr.ExternalID, vcs.MergeInput{Method: mm})
if err != nil {
return nil, errs.Wrap(err, errs.CodeFailedPrecondition, "host merge failed")
}
now := time.Now()
updated, err := s.repo.UpdateState(ctx, id, StateMerged, mergeSHA, &now)
if err != nil {
return nil, err
}
s.audit(ctx, &c.UserID, "change_request.merged", id.String(), map[string]any{
"method": mm, "merge_sha": mergeSHA,
})
return updated, nil
}
// SyncStatus refreshes ci_state/state from the host PR.
func (s *service) SyncStatus(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) {
cr, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if err := s.guardRead(ctx, c, cr.ProjectID); err != nil {
return nil, err
}
if cr.ExternalID == nil {
return cr, nil
}
repoRef, err := s.repoRefFor(ctx, cr.WorkspaceID)
if err != nil {
return nil, err
}
pr, err := s.provider.GetPR(ctx, repoRef, *cr.ExternalID)
if err != nil {
return nil, errs.Wrap(err, errs.CodeUpstream, "fetch pull request status")
}
newState := cr.State
switch {
case pr.Merged:
newState = StateMerged
case pr.State == "closed":
newState = StateClosed
default:
newState = StateOpen
}
ci := CIState(pr.CIState)
if !validCI(ci) {
ci = CIUnknown
}
return s.repo.UpdateCI(ctx, id, ci, newState)
}
// ===== helpers =====
func (s *service) repoRefFor(ctx context.Context, wsID uuid.UUID) (vcs.RepoRef, error) {
meta, err := s.ws.GetWorkspaceMeta(ctx, wsID)
if err != nil {
return vcs.RepoRef{}, err
}
return vcs.ParseRepoRef(meta.GitRemoteURL, s.cfg.Host)
}
func (s *service) guardRead(ctx context.Context, c Caller, projectID uuid.UUID) error {
canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, projectID)
if err != nil {
return err
}
if !canRead {
return errs.New(errs.CodeForbidden, "no read access")
}
return nil
}
func (s *service) guardWrite(ctx context.Context, c Caller, projectID uuid.UUID) error {
_, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, projectID)
if err != nil {
return err
}
if !canWrite {
return errs.New(errs.CodeForbidden, "no write access")
}
return nil
}
func (s *service) audit(ctx context.Context, uid *uuid.UUID, action, id string, meta map[string]any) {
_ = s.rec.Record(ctx, audit.Entry{
UserID: uid, Action: action, TargetType: "change_request", TargetID: id, Metadata: meta,
})
}
func validCI(ci CIState) bool {
switch ci {
case CIUnknown, CIPending, CISuccess, CIFailure:
return true
}
return false
}
+312
View File
@@ -0,0 +1,312 @@
package changerequest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
"github.com/yan1h/agent-coding-workflow/internal/infra/vcs"
)
// ===== fakes =====
type fakeRepo struct {
items map[uuid.UUID]*ChangeRequest
nextNum int
}
func newFakeRepo() *fakeRepo { return &fakeRepo{items: map[uuid.UUID]*ChangeRequest{}, nextNum: 1} }
func (f *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (*ChangeRequest, error) {
cr, ok := f.items[id]
if !ok {
return nil, errs.New(errs.CodeNotFound, "not found")
}
cp := *cr
return &cp, nil
}
func (f *fakeRepo) GetByNumber(_ context.Context, _ string, number int) (*ChangeRequest, error) {
for _, cr := range f.items {
if cr.Number == number {
cp := *cr
return &cp, nil
}
}
return nil, errs.New(errs.CodeNotFound, "not found")
}
func (f *fakeRepo) ListByWorkspace(_ context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) {
var out []*ChangeRequest
for _, cr := range f.items {
if cr.WorkspaceID == wsID {
cp := *cr
out = append(out, &cp)
}
}
return out, nil
}
func (f *fakeRepo) ListByProject(_ context.Context, pid uuid.UUID) ([]*ChangeRequest, error) {
var out []*ChangeRequest
for _, cr := range f.items {
if cr.ProjectID == pid {
cp := *cr
out = append(out, &cp)
}
}
return out, nil
}
func (f *fakeRepo) UpdateReview(_ context.Context, id uuid.UUID, v Verdict, by uuid.UUID) (*ChangeRequest, error) {
cr := f.items[id]
cr.ReviewVerdict = v
cr.ReviewedBy = &by
now := time.Now()
cr.ReviewedAt = &now
cp := *cr
return &cp, nil
}
func (f *fakeRepo) UpdateState(_ context.Context, id uuid.UUID, st State, sha string, at *time.Time) (*ChangeRequest, error) {
cr := f.items[id]
cr.State = st
cr.MergeCommitSHA = sha
cr.MergedAt = at
cp := *cr
return &cp, nil
}
func (f *fakeRepo) UpdateCI(_ context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) {
cr := f.items[id]
cr.CIState = ci
cr.State = st
cp := *cr
return &cp, nil
}
func (f *fakeRepo) InTx(ctx context.Context, fn func(Tx) error) error {
return fn(&fakeTx{r: f})
}
type fakeTx struct{ r *fakeRepo }
func (t *fakeTx) NextNumber(_ context.Context, _ uuid.UUID) (int, error) {
n := t.r.nextNum
t.r.nextNum++
return n, nil
}
func (t *fakeTx) Create(_ context.Context, cr *ChangeRequest) (*ChangeRequest, error) {
cp := *cr
// mirror the DB INSERT defaults baked into the query (state/verdict).
cp.State = StateOpen
cp.ReviewVerdict = VerdictPending
if cp.Provider == "" {
cp.Provider = "gitea"
}
t.r.items[cr.ID] = &cp
out := cp
return &out, nil
}
type fakeProvider struct {
createPR *vcs.PullRequest
getPR *vcs.PullRequest
mergeSHA string
mergeErr error
mergeCalled bool
}
func (p *fakeProvider) CreatePR(_ context.Context, _ vcs.CreatePRInput) (*vcs.PullRequest, error) {
if p.createPR != nil {
return p.createPR, nil
}
return &vcs.PullRequest{Number: 1, State: "open", HTMLURL: "http://h/pr/1", CIState: "unknown"}, nil
}
func (p *fakeProvider) GetPR(_ context.Context, _ vcs.RepoRef, _ int64) (*vcs.PullRequest, error) {
if p.getPR != nil {
return p.getPR, nil
}
return &vcs.PullRequest{Number: 1, State: "open", CIState: "success"}, nil
}
func (p *fakeProvider) Comment(_ context.Context, _ vcs.RepoRef, _ int64, _ string) error { return nil }
func (p *fakeProvider) Merge(_ context.Context, _ vcs.RepoRef, _ int64, _ vcs.MergeInput) (string, error) {
p.mergeCalled = true
if p.mergeErr != nil {
return "", p.mergeErr
}
if p.mergeSHA == "" {
return "merged-sha", nil
}
return p.mergeSHA, nil
}
func (p *fakeProvider) ListIssues(_ context.Context, _ vcs.RepoRef, _ vcs.IssueListOptions) ([]vcs.Issue, error) {
return nil, nil
}
type fakeWS struct {
meta WorkspaceMeta
err error
}
func (f *fakeWS) GetWorkspaceMeta(_ context.Context, _ uuid.UUID) (WorkspaceMeta, error) {
return f.meta, f.err
}
type fakePA struct {
canRead, canWrite bool
}
func (f *fakePA) ResolveByID(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, bool, error) {
return f.canRead, f.canWrite, nil
}
type nopRec struct{}
func (nopRec) Record(_ context.Context, _ audit.Entry) error { return nil }
var (
tProj = uuid.New()
tWS = uuid.New()
tUser = uuid.New()
)
func newSvc(t *testing.T, prov *fakeProvider, pa *fakePA, cfg Config) (*service, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
ws := &fakeWS{meta: WorkspaceMeta{ProjectID: tProj, GitRemoteURL: "https://git.jerryyan.net/owner/repo.git", DefaultBranch: "main"}}
svc := NewService(repo, ws, nopRec{}, prov, pa, cfg).(*service)
return svc, repo
}
func caller() Caller { return Caller{UserID: tUser} }
// ===== tests =====
func TestCreate_PersistsPending(t *testing.T) {
prov := &fakeProvider{createPR: &vcs.PullRequest{Number: 7, State: "open", HTMLURL: "http://h/7", CIState: "pending"}}
svc, _ := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
cr, err := svc.Create(context.Background(), caller(), tWS, CreateInput{
SourceBranch: "feature", Title: "T",
})
require.NoError(t, err)
assert.Equal(t, VerdictPending, cr.ReviewVerdict)
assert.Equal(t, StateOpen, cr.State)
assert.Equal(t, "main", cr.TargetBranch)
require.NotNil(t, cr.ExternalID)
assert.Equal(t, int64(7), *cr.ExternalID)
assert.Equal(t, 1, cr.Number)
assert.Equal(t, CIPending, cr.CIState)
}
func TestCreate_RequiresWrite(t *testing.T) {
svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"})
_, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "f", Title: "T"})
require.Error(t, err)
ae, _ := errs.As(err)
assert.Equal(t, errs.CodeForbidden, ae.Code)
}
func TestCreate_SameBranchRejected(t *testing.T) {
svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
_, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "main", Title: "T"})
require.Error(t, err)
ae, _ := errs.As(err)
assert.Equal(t, errs.CodeInvalidInput, ae.Code)
}
// seed inserts an open, approved-able CR into the repo for merge tests.
func seedCR(repo *fakeRepo, verdict Verdict, ci CIState) *ChangeRequest {
id := uuid.New()
ext := int64(7)
cr := &ChangeRequest{
ID: id, ProjectID: tProj, WorkspaceID: tWS, Number: 1,
Title: "T", SourceBranch: "feature", TargetBranch: "main",
State: StateOpen, ReviewVerdict: verdict, CIState: ci,
Provider: "gitea", ExternalID: &ext, CreatedBy: tUser,
}
repo.items[id] = cr
return cr
}
func TestMerge_DeniedWhenNotApproved(t *testing.T) {
for _, v := range []Verdict{VerdictPending, VerdictChangesRequested, VerdictRejected} {
prov := &fakeProvider{}
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
cr := seedCR(repo, v, CISuccess)
_, err := svc.Merge(context.Background(), caller(), cr.ID, "")
require.Error(t, err, v)
ae, _ := errs.As(err)
assert.Equal(t, errs.CodeFailedPrecondition, ae.Code, v)
assert.False(t, prov.mergeCalled, "provider.Merge must NOT be called when verdict=%s", v)
}
}
func TestMerge_DeniedWhenCINotPassing(t *testing.T) {
prov := &fakeProvider{}
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
cr := seedCR(repo, VerdictApproved, CIFailure)
_, err := svc.Merge(context.Background(), caller(), cr.ID, "")
require.Error(t, err)
ae, _ := errs.As(err)
assert.Equal(t, errs.CodeFailedPrecondition, ae.Code)
assert.False(t, prov.mergeCalled)
}
func TestMerge_AdminBypassesCI(t *testing.T) {
prov := &fakeProvider{mergeSHA: "sha-x"}
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
cr := seedCR(repo, VerdictApproved, CIFailure)
out, err := svc.Merge(context.Background(), Caller{UserID: tUser, IsAdmin: true}, cr.ID, "")
require.NoError(t, err)
assert.True(t, prov.mergeCalled)
assert.Equal(t, StateMerged, out.State)
assert.Equal(t, "sha-x", out.MergeCommitSHA)
}
func TestMerge_AllowedWhenApproved(t *testing.T) {
prov := &fakeProvider{mergeSHA: "sha-y"}
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true})
cr := seedCR(repo, VerdictApproved, CISuccess)
out, err := svc.Merge(context.Background(), caller(), cr.ID, "squash")
require.NoError(t, err)
assert.True(t, prov.mergeCalled)
assert.Equal(t, StateMerged, out.State)
assert.NotNil(t, out.MergedAt)
assert.Equal(t, "sha-y", out.MergeCommitSHA)
}
func TestReview_SetsReviewer(t *testing.T) {
svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
cr := seedCR(repo, VerdictPending, CIUnknown)
out, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "lgtm")
require.NoError(t, err)
assert.Equal(t, VerdictApproved, out.ReviewVerdict)
require.NotNil(t, out.ReviewedBy)
assert.Equal(t, tUser, *out.ReviewedBy)
}
func TestReview_RequiresWrite(t *testing.T) {
svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"})
cr := seedCR(repo, VerdictPending, CIUnknown)
_, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "")
require.Error(t, err)
ae, _ := errs.As(err)
assert.Equal(t, errs.CodeForbidden, ae.Code)
}
func TestSyncStatus_RefreshesCIAndState(t *testing.T) {
prov := &fakeProvider{getPR: &vcs.PullRequest{Number: 7, State: "closed", Merged: true, CIState: "success", MergeCommitSHA: "z"}}
svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"})
cr := seedCR(repo, VerdictApproved, CIUnknown)
out, err := svc.SyncStatus(context.Background(), caller(), cr.ID)
require.NoError(t, err)
assert.Equal(t, CISuccess, out.CIState)
assert.Equal(t, StateMerged, out.State)
}
@@ -0,0 +1,429 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: change_requests.sql
package changerequestsqlc
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createChangeRequest = `-- name: CreateChangeRequest :one
INSERT INTO change_requests (
id, project_id, workspace_id, requirement_id, issue_id, number,
title, description, source_branch, target_branch,
state, review_verdict, ci_state, provider, external_id, external_url,
merge_commit_sha, created_by
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10,
'open', 'pending', $11, $12, $13, $14,
'', $15
)
RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at
`
type CreateChangeRequestParams struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
IssueID pgtype.UUID `json:"issue_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
CiState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalUrl string `json:"external_url"`
CreatedBy pgtype.UUID `json:"created_by"`
}
func (q *Queries) CreateChangeRequest(ctx context.Context, arg CreateChangeRequestParams) (ChangeRequest, error) {
row := q.db.QueryRow(ctx, createChangeRequest,
arg.ID,
arg.ProjectID,
arg.WorkspaceID,
arg.RequirementID,
arg.IssueID,
arg.Number,
arg.Title,
arg.Description,
arg.SourceBranch,
arg.TargetBranch,
arg.CiState,
arg.Provider,
arg.ExternalID,
arg.ExternalUrl,
arg.CreatedBy,
)
var i ChangeRequest
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getChangeRequestByID = `-- name: GetChangeRequestByID :one
SELECT id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at FROM change_requests WHERE id = $1
`
func (q *Queries) GetChangeRequestByID(ctx context.Context, id pgtype.UUID) (ChangeRequest, error) {
row := q.db.QueryRow(ctx, getChangeRequestByID, id)
var i ChangeRequest
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getChangeRequestByNumber = `-- name: GetChangeRequestByNumber :one
SELECT cr.id, cr.project_id, cr.workspace_id, cr.requirement_id, cr.issue_id, cr.number, cr.title, cr.description, cr.source_branch, cr.target_branch, cr.state, cr.review_verdict, cr.ci_state, cr.provider, cr.external_id, cr.external_url, cr.merge_commit_sha, cr.reviewed_by, cr.reviewed_at, cr.created_by, cr.merged_at, cr.created_at, cr.updated_at FROM change_requests cr
JOIN projects p ON p.id = cr.project_id
WHERE p.slug = $1 AND cr.number = $2
`
type GetChangeRequestByNumberParams struct {
Slug string `json:"slug"`
Number int32 `json:"number"`
}
func (q *Queries) GetChangeRequestByNumber(ctx context.Context, arg GetChangeRequestByNumberParams) (ChangeRequest, error) {
row := q.db.QueryRow(ctx, getChangeRequestByNumber, arg.Slug, arg.Number)
var i ChangeRequest
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listChangeRequestsByProject = `-- name: ListChangeRequestsByProject :many
SELECT id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at FROM change_requests
WHERE project_id = $1
ORDER BY number DESC
`
func (q *Queries) ListChangeRequestsByProject(ctx context.Context, projectID pgtype.UUID) ([]ChangeRequest, error) {
rows, err := q.db.Query(ctx, listChangeRequestsByProject, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChangeRequest
for rows.Next() {
var i ChangeRequest
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listChangeRequestsByWorkspace = `-- name: ListChangeRequestsByWorkspace :many
SELECT id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at FROM change_requests
WHERE workspace_id = $1
ORDER BY number DESC
`
func (q *Queries) ListChangeRequestsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]ChangeRequest, error) {
rows, err := q.db.Query(ctx, listChangeRequestsByWorkspace, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChangeRequest
for rows.Next() {
var i ChangeRequest
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const nextChangeRequestNumber = `-- name: NextChangeRequestNumber :one
SELECT COALESCE(MAX(number), 0) + 1 FROM change_requests WHERE project_id = $1 FOR UPDATE
`
// Per-project monotonic number; FOR UPDATE not needed because the
// max(number)+1 INSERT runs inside the same tx, but we lock existing project
// rows to serialize concurrent creates within a project.
func (q *Queries) NextChangeRequestNumber(ctx context.Context, projectID pgtype.UUID) (int32, error) {
row := q.db.QueryRow(ctx, nextChangeRequestNumber, projectID)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const updateChangeRequestCI = `-- name: UpdateChangeRequestCI :one
UPDATE change_requests
SET ci_state = $2,
state = $3,
updated_at = now()
WHERE id = $1
RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at
`
type UpdateChangeRequestCIParams struct {
ID pgtype.UUID `json:"id"`
CiState string `json:"ci_state"`
State string `json:"state"`
}
func (q *Queries) UpdateChangeRequestCI(ctx context.Context, arg UpdateChangeRequestCIParams) (ChangeRequest, error) {
row := q.db.QueryRow(ctx, updateChangeRequestCI, arg.ID, arg.CiState, arg.State)
var i ChangeRequest
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateChangeRequestReview = `-- name: UpdateChangeRequestReview :one
UPDATE change_requests
SET review_verdict = $2,
reviewed_by = $3,
reviewed_at = now(),
updated_at = now()
WHERE id = $1
RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at
`
type UpdateChangeRequestReviewParams struct {
ID pgtype.UUID `json:"id"`
ReviewVerdict string `json:"review_verdict"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
}
func (q *Queries) UpdateChangeRequestReview(ctx context.Context, arg UpdateChangeRequestReviewParams) (ChangeRequest, error) {
row := q.db.QueryRow(ctx, updateChangeRequestReview, arg.ID, arg.ReviewVerdict, arg.ReviewedBy)
var i ChangeRequest
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updateChangeRequestState = `-- name: UpdateChangeRequestState :one
UPDATE change_requests
SET state = $2,
merge_commit_sha = $3,
merged_at = $4,
updated_at = now()
WHERE id = $1
RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at
`
type UpdateChangeRequestStateParams struct {
ID pgtype.UUID `json:"id"`
State string `json:"state"`
MergeCommitSha string `json:"merge_commit_sha"`
MergedAt pgtype.Timestamptz `json:"merged_at"`
}
func (q *Queries) UpdateChangeRequestState(ctx context.Context, arg UpdateChangeRequestStateParams) (ChangeRequest, error) {
row := q.db.QueryRow(ctx, updateChangeRequestState,
arg.ID,
arg.State,
arg.MergeCommitSha,
arg.MergedAt,
)
var i ChangeRequest
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.WorkspaceID,
&i.RequirementID,
&i.IssueID,
&i.Number,
&i.Title,
&i.Description,
&i.SourceBranch,
&i.TargetBranch,
&i.State,
&i.ReviewVerdict,
&i.CiState,
&i.Provider,
&i.ExternalID,
&i.ExternalUrl,
&i.MergeCommitSha,
&i.ReviewedBy,
&i.ReviewedAt,
&i.CreatedBy,
&i.MergedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package changerequestsqlc
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+593
View File
@@ -0,0 +1,593 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package changerequestsqlc
import (
"net/netip"
"github.com/jackc/pgx/v5/pgtype"
"github.com/pgvector/pgvector-go"
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
KeyVersion int16 `json:"key_version"`
}
type AcpAgentKindConfigFile struct {
ID pgtype.UUID `json:"id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
RelPath string `json:"rel_path"`
EncryptedContent []byte `json:"encrypted_content"`
UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type AcpEvent struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
Direction string `json:"direction"`
RpcKind string `json:"rpc_kind"`
Method *string `json:"method"`
Payload []byte `json:"payload"`
PayloadSize int32 `json:"payload_size"`
Truncated bool `json:"truncated"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpPermissionRequest struct {
ID pgtype.UUID `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
AgentRequestID string `json:"agent_request_id"`
ToolName string `json:"tool_name"`
ToolCall []byte `json:"tool_call"`
Options []byte `json:"options"`
Status string `json:"status"`
ChosenOptionID *string `json:"chosen_option_id"`
DecidedBy pgtype.UUID `json:"decided_by"`
DecidedAt pgtype.Timestamptz `json:"decided_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpSession struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
UserID pgtype.UUID `json:"user_id"`
IssueID pgtype.UUID `json:"issue_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
AgentSessionID *string `json:"agent_session_id"`
Branch string `json:"branch"`
CwdPath string `json:"cwd_path"`
IsMainWorktree bool `json:"is_main_worktree"`
Status string `json:"status"`
Pid *int32 `json:"pid"`
ExitCode *int32 `json:"exit_code"`
LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
LastStopReason *string `json:"last_stop_reason"`
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
TerminatedReason *string `json:"terminated_reason"`
SandboxMode *string `json:"sandbox_mode"`
CostUsd pgtype.Numeric `json:"cost_usd"`
TokensTotal int64 `json:"tokens_total"`
}
type AcpSessionUsage struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
SourceEventID *int64 `json:"source_event_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpTurn struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
PromptRequestID *string `json:"prompt_request_id"`
Status string `json:"status"`
StopReason *string `json:"stop_reason"`
UpdateCount int32 `json:"update_count"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
}
type AuditLog struct {
ID int64 `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Action string `json:"action"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
Ip *netip.Addr `json:"ip"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type ChangeRequest struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
IssueID pgtype.UUID `json:"issue_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CiState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalUrl string `json:"external_url"`
MergeCommitSha string `json:"merge_commit_sha"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
CreatedBy pgtype.UUID `json:"created_by"`
MergedAt pgtype.Timestamptz `json:"merged_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CodeChunk struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CommitSha string `json:"commit_sha"`
FilePath string `json:"file_path"`
Lang *string `json:"lang"`
StartLine int32 `json:"start_line"`
EndLine int32 `json:"end_line"`
Content string `json:"content"`
ContentSha []byte `json:"content_sha"`
TokenCount *int32 `json:"token_count"`
Embedding *pgvector.Vector `json:"embedding"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CodeIndexRun struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Status string `json:"status"`
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
EmbeddingModel string `json:"embedding_model"`
Dims int32 `json:"dims"`
FilesIndexed int32 `json:"files_indexed"`
ChunksIndexed int32 `json:"chunks_indexed"`
Error *string `json:"error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CommitSummary struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Kind string `json:"kind"`
Title *string `json:"title"`
BodyMd string `json:"body_md"`
Model *string `json:"model"`
Status string `json:"status"`
Error *string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Conversation struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
ModelID pgtype.UUID `json:"model_id"`
SystemPrompt string `json:"system_prompt"`
Title *string `json:"title"`
TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
RequirementID pgtype.UUID `json:"requirement_id"`
}
type CryptoKey struct {
Version int32 `json:"version"`
Provider string `json:"provider"`
KeyRef *string `json:"key_ref"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type GitCredential struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Kind string `json:"kind"`
Username *string `json:"username"`
EncryptedSecret []byte `json:"encrypted_secret"`
Fingerprint *string `json:"fingerprint"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type Issue struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
AssigneeID pgtype.UUID `json:"assignee_id"`
CreatedBy pgtype.UUID `json:"created_by"`
ClosedAt pgtype.Timestamptz `json:"closed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentID pgtype.UUID `json:"parent_id"`
Priority int32 `json:"priority"`
}
type IssueDependency struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
BlockedID pgtype.UUID `json:"blocked_id"`
BlockerID pgtype.UUID `json:"blocker_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Job struct {
ID pgtype.UUID `json:"id"`
Type string `json:"type"`
Payload []byte `json:"payload"`
Status string `json:"status"`
ScheduledAt pgtype.Timestamptz `json:"scheduled_at"`
Attempts int32 `json:"attempts"`
MaxAttempts int32 `json:"max_attempts"`
LeasedAt pgtype.Timestamptz `json:"leased_at"`
LeasedBy *string `json:"leased_by"`
LastError *string `json:"last_error"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type LlmEndpoint struct {
ID pgtype.UUID `json:"id"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
BaseUrl string `json:"base_url"`
ApiKeyEncrypted []byte `json:"api_key_encrypted"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type LlmModel struct {
ID pgtype.UUID `json:"id"`
EndpointID pgtype.UUID `json:"endpoint_id"`
ModelID string `json:"model_id"`
DisplayName string `json:"display_name"`
Capabilities []byte `json:"capabilities"`
ContextWindow int32 `json:"context_window"`
MaxOutputTokens int32 `json:"max_output_tokens"`
PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"`
CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"`
ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"`
IsTitleGenerator bool `json:"is_title_generator"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sort_order"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type LlmUsage struct {
ID int64 `json:"id"`
ConversationID pgtype.UUID `json:"conversation_id"`
MessageID int64 `json:"message_id"`
UserID pgtype.UUID `json:"user_id"`
EndpointID pgtype.UUID `json:"endpoint_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int32 `json:"prompt_tokens"`
CompletionTokens int32 `json:"completion_tokens"`
ThinkingTokens int32 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type McpToken struct {
ID pgtype.UUID `json:"id"`
TokenHash []byte `json:"token_hash"`
UserID pgtype.UUID `json:"user_id"`
Name string `json:"name"`
Issuer string `json:"issuer"`
Scope []byte `json:"scope"`
AcpSessionID pgtype.UUID `json:"acp_session_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Message struct {
ID int64 `json:"id"`
ConversationID pgtype.UUID `json:"conversation_id"`
Role string `json:"role"`
Content string `json:"content"`
Thinking *string `json:"thinking"`
ToolCalls []byte `json:"tool_calls"`
Status string `json:"status"`
ErrorMessage *string `json:"error_message"`
PromptTokens *int32 `json:"prompt_tokens"`
CompletionTokens *int32 `json:"completion_tokens"`
ThinkingTokens *int32 `json:"thinking_tokens"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type MessageAttachment struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
MessageID *int64 `json:"message_id"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
Sha256 string `json:"sha256"`
StoragePath string `json:"storage_path"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Notification struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
Topic string `json:"topic"`
Severity string `json:"severity"`
Title string `json:"title"`
Body string `json:"body"`
Link *string `json:"link"`
Metadata []byte `json:"metadata"`
ReadAt pgtype.Timestamptz `json:"read_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type NotificationPref struct {
UserID pgtype.UUID `json:"user_id"`
EmailEnabled bool `json:"email_enabled"`
ImEnabled bool `json:"im_enabled"`
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
MinSeverity string `json:"min_severity"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type OrchestratorRun struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OwnerID pgtype.UUID `json:"owner_id"`
Status string `json:"status"`
CurrentPhase string `json:"current_phase"`
Config []byte `json:"config"`
LastError *string `json:"last_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type OrchestratorStep struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
Phase string `json:"phase"`
Attempt int32 `json:"attempt"`
ParentStepID pgtype.UUID `json:"parent_step_id"`
Depth int32 `json:"depth"`
Status string `json:"status"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
AcpSessionID pgtype.UUID `json:"acp_session_id"`
Prompt string `json:"prompt"`
StopReason *string `json:"stop_reason"`
GateResult []byte `json:"gate_result"`
LastError *string `json:"last_error"`
JobID pgtype.UUID `json:"job_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type Project struct {
ID pgtype.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
OwnerID pgtype.UUID `json:"owner_id"`
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type ProjectMember struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
AddedBy pgtype.UUID `json:"added_by"`
}
type ProjectMemory struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Source string `json:"source"`
Embedding *pgvector.Vector `json:"embedding"`
EmbeddingModel *string `json:"embedding_model"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptTemplate struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
Scope string `json:"scope"`
OwnerID pgtype.UUID `json:"owner_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type Requirement struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
Phase string `json:"phase"`
Status string `json:"status"`
OwnerID pgtype.UUID `json:"owner_id"`
ClosedAt pgtype.Timestamptz `json:"closed_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type RequirementArtifact struct {
ID pgtype.UUID `json:"id"`
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
Version int32 `json:"version"`
Content string `json:"content"`
Note string `json:"note"`
SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Verdict string `json:"verdict"`
}
type RequirementPhasePointer struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
ID pgtype.UUID `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
DisplayName string `json:"display_name"`
IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Enabled bool `json:"enabled"`
}
type UserSession struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
TokenHash []byte `json:"token_hash"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Workspace struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
GitRemoteUrl string `json:"git_remote_url"`
DefaultBranch string `json:"default_branch"`
MainPath string `json:"main_path"`
SyncStatus string `json:"sync_status"`
LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"`
LastSyncError *string `json:"last_sync_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"`
}
type WorkspaceRunProfile struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Command string `json:"command"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
LastStartedAt pgtype.Timestamptz `json:"last_started_at"`
LastExitCode *int32 `json:"last_exit_code"`
LastError string `json:"last_error"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
}
type WorkspaceWorktree struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Branch string `json:"branch"`
Path string `json:"path"`
Status string `json:"status"`
ActiveHolder *string `json:"active_holder"`
AcquiredAt pgtype.Timestamptz `json:"acquired_at"`
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"`
}
+15
View File
@@ -112,6 +112,13 @@ type Conversation struct {
DeletedAt *time.Time DeletedAt *time.Time
} }
// HasFKMount 报告会话是否挂载了任一 PM/工作区 FK(project/workspace/issue/requirement)。
// composeHistory 据此决定是否调用 ContextReader 组装 FK 上下文简报。
func (c *Conversation) HasFKMount() bool {
return c.ProjectID != nil || c.WorkspaceID != nil ||
c.IssueID != nil || c.RequirementID != nil
}
// Attachment 是 message 的附件(user_id 必填,message_id 上传后未关联时为 nil)。 // Attachment 是 message 的附件(user_id 必填,message_id 上传后未关联时为 nil)。
type Attachment struct { type Attachment struct {
ID uuid.UUID ID uuid.UUID
@@ -169,6 +176,14 @@ type ConversationReader interface {
ListConversationsByUser(ctx context.Context, userID uuid.UUID, limit int) ([]Conversation, error) ListConversationsByUser(ctx context.Context, userID uuid.UUID, limit int) ([]Conversation, error)
} }
// ContextReader 是 chat 模块对 FK 上下文组装器的窄接口(adapter 在 app.go 实现,
// 内部委托 internal/brief.Composer)。给定一个挂载了 project/workspace/issue/requirement
// 的会话,返回组装好的 FK 上下文简报;无任何挂载时返回空串。messageService 在
// composeHistory 注入它的输出到有效 system prompt。chat 不直接依赖 project/brief。
type ContextReader interface {
BriefForConversation(ctx context.Context, conv *Conversation) (string, error)
}
// ===== Service 接口(在 service_*.go 实现) ===== // ===== Service 接口(在 service_*.go 实现) =====
// ConversationService — 会话 CRUD + 标题生成 hook。 // ConversationService — 会话 CRUD + 标题生成 hook。
+11 -3
View File
@@ -2,14 +2,22 @@ package chat
import "strconv" import "strconv"
// computeCost returns the cost in USD for a single LLM call. // ComputeCost returns the cost in USD for a single LLM call.
// Prices on LLMModel are expressed in USD per million tokens. // Prices on LLMModel are expressed in USD per million tokens. Exported so other
func computeCost(m LLMModel, ptok, ctok, ttok int) float64 { // modules (e.g. internal/acp session cost accounting) reuse the single pricing
// formula instead of replicating it.
func ComputeCost(m LLMModel, ptok, ctok, ttok int) float64 {
return (float64(ptok)*m.PromptPricePerMillionUSD + return (float64(ptok)*m.PromptPricePerMillionUSD +
float64(ctok)*m.CompletionPricePerMillionUSD + float64(ctok)*m.CompletionPricePerMillionUSD +
float64(ttok)*m.ThinkingPricePerMillionUSD) / 1_000_000 float64(ttok)*m.ThinkingPricePerMillionUSD) / 1_000_000
} }
// computeCost is a thin alias kept for the existing internal caller (streamer.go)
// so that change stays untouched while ComputeCost is the public entry point.
func computeCost(m LLMModel, ptok, ctok, ttok int) float64 {
return ComputeCost(m, ptok, ctok, ttok)
}
// int64ToString converts an int64 to its decimal string representation. // int64ToString converts an int64 to its decimal string representation.
// Uses strconv.FormatInt rather than fmt.Sprintf for efficiency. // Uses strconv.FormatInt rather than fmt.Sprintf for efficiency.
func int64ToString(v int64) string { return strconv.FormatInt(v, 10) } func int64ToString(v int64) string { return strconv.FormatInt(v, 10) }
+13
View File
@@ -30,6 +30,19 @@ func TestComputeCost_BasicMath(t *testing.T) {
require.InDelta(t, 0.021, got, 1e-9) require.InDelta(t, 0.021, got, 1e-9)
} }
// TestExportedComputeCost_EqualsUnexported guards that the exported ComputeCost
// (reused by internal/acp) returns identical results to the unexported alias.
func TestExportedComputeCost_EqualsUnexported(t *testing.T) {
m := LLMModel{
PromptPricePerMillionUSD: 3.0,
CompletionPricePerMillionUSD: 15.0,
ThinkingPricePerMillionUSD: 3.0,
}
for _, c := range [][3]int{{1000, 0, 0}, {0, 1000, 0}, {0, 0, 1000}, {1234, 5678, 90}} {
require.Equal(t, computeCost(m, c[0], c[1], c[2]), ComputeCost(m, c[0], c[1], c[2]))
}
}
func TestComputeCost_ZeroTokens(t *testing.T) { func TestComputeCost_ZeroTokens(t *testing.T) {
m := LLMModel{ m := LLMModel{
PromptPricePerMillionUSD: 3.0, PromptPricePerMillionUSD: 3.0,
+39 -3
View File
@@ -35,9 +35,11 @@ type messageService struct {
maxFile int64 maxFile int64
maxMsg int64 maxMsg int64
safetyPct float64 safetyPct float64
briefReader ContextReader // 可为 nil;非 nil 时 composeHistory 注入 FK 上下文
} }
// NewMessageService constructs a MessageService. // NewMessageService constructs a MessageService. briefReader 可为 nil:nil 时
// composeHistory 完全保留旧行为(仅用 conv.SystemPrompt),不做 FK 上下文注入。
func NewMessageService( func NewMessageService(
repo Repository, repo Repository,
st storage.Storage, st storage.Storage,
@@ -45,6 +47,7 @@ func NewMessageService(
auditor audit.Recorder, auditor audit.Recorder,
log *slog.Logger, log *slog.Logger,
cfg MessageServiceConfig, cfg MessageServiceConfig,
briefReader ContextReader,
) MessageService { ) MessageService {
mimeWhite := make(map[string]bool, len(cfg.MimeWhitelist)) mimeWhite := make(map[string]bool, len(cfg.MimeWhitelist))
for _, m := range cfg.MimeWhitelist { for _, m := range cfg.MimeWhitelist {
@@ -60,6 +63,7 @@ func NewMessageService(
maxFile: cfg.MaxFileBytes, maxFile: cfg.MaxFileBytes,
maxMsg: cfg.MaxMsgBytes, maxMsg: cfg.MaxMsgBytes,
safetyPct: cfg.SafetyPct, safetyPct: cfg.SafetyPct,
briefReader: briefReader,
} }
} }
@@ -483,20 +487,52 @@ func (s *messageService) composeHistory(ctx context.Context, conv *Conversation,
} }
} }
// Resolve the effective system prompt by merging conv.SystemPrompt with the
// FK-derived context brief (requirement/issue/project mounts). The brief is
// pre-bounded by the composer, so it cannot starve message history to zero
// (truncateByTokens still reserves the remaining budget for messages).
effectiveSystemPrompt := s.effectiveSystemPrompt(ctx, conv)
// Compute token budget: context window minus max output tokens, then apply safety margin. // Compute token budget: context window minus max output tokens, then apply safety margin.
budget := model.ContextWindow - model.MaxOutputTokens budget := model.ContextWindow - model.MaxOutputTokens
budget = int(float64(budget) * (1 - s.safetyPct)) budget = int(float64(budget) * (1 - s.safetyPct))
msgs = truncateByTokens(msgs, conv.SystemPrompt, budget) msgs = truncateByTokens(msgs, effectiveSystemPrompt, budget)
return llm.Request{ return llm.Request{
SystemPrompt: conv.SystemPrompt, SystemPrompt: effectiveSystemPrompt,
Messages: msgs, Messages: msgs,
MaxOutputTokens: model.MaxOutputTokens, MaxOutputTokens: model.MaxOutputTokens,
Reasoning: model.Capabilities.Reasoning, Reasoning: model.Capabilities.Reasoning,
}, nil }, nil
} }
// effectiveSystemPrompt 把会话静态 system prompt 与 FK 派生上下文简报合并。
// briefReader 为 nil、会话无任何 FK 挂载、或简报为空时,直接返回 conv.SystemPrompt
// (与旧行为完全一致)。合并是追加式的(FK 简报置于静态 prompt 之后),便于
// 让旧会话(曾把角色 prompt 烘焙进 SystemPrompt)与新会话都能正常工作。
func (s *messageService) effectiveSystemPrompt(ctx context.Context, conv *Conversation) string {
if s.briefReader == nil || !conv.HasFKMount() {
return conv.SystemPrompt
}
fkBrief, err := s.briefReader.BriefForConversation(ctx, conv)
if err != nil {
if s.log != nil {
s.log.Warn("chat.compose_history.brief_failed",
"conversation_id", conv.ID, "err", err.Error())
}
return conv.SystemPrompt
}
fkBrief = strings.TrimSpace(fkBrief)
if fkBrief == "" {
return conv.SystemPrompt
}
if strings.TrimSpace(conv.SystemPrompt) == "" {
return fkBrief
}
return conv.SystemPrompt + "\n\n" + fkBrief
}
// truncateByTokens walks messages backwards and drops oldest messages until the // truncateByTokens walks messages backwards and drops oldest messages until the
// estimated token count (system prompt + messages) fits within budget. // estimated token count (system prompt + messages) fits within budget.
func truncateByTokens(msgs []llm.Message, systemPrompt string, budget int) []llm.Message { func truncateByTokens(msgs []llm.Message, systemPrompt string, budget int) []llm.Message {
+127 -2
View File
@@ -9,6 +9,7 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"strings"
"sync" "sync"
"testing" "testing"
@@ -282,7 +283,7 @@ func (f *fakeStorage) Delete(_ context.Context, _ string) error {
// ===== test builder ===== // ===== test builder =====
func buildMessageService(repo *msgFakeRepo, hub *fakeHub, cfg MessageServiceConfig) MessageService { func buildMessageService(repo *msgFakeRepo, hub *fakeHub, cfg MessageServiceConfig) MessageService {
return NewMessageService(repo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), cfg) return NewMessageService(repo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), cfg, nil)
} }
func defaultMsgConfig() MessageServiceConfig { func defaultMsgConfig() MessageServiceConfig {
@@ -457,7 +458,7 @@ func TestMessageService_Send_CapabilityMismatch(t *testing.T) {
a := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 100} a := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 100}
specialRepo.addAttachment(a) specialRepo.addAttachment(a)
svc := NewMessageService(specialRepo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), defaultMsgConfig()) svc := NewMessageService(specialRepo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), defaultMsgConfig(), nil)
_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a.ID}) _, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a.ID})
require.Error(t, err) require.Error(t, err)
@@ -990,6 +991,130 @@ func TestTruncateByTokens_PreservesNewest(t *testing.T) {
require.Equal(t, "hi", result[0].Blocks[0].Text) require.Equal(t, "hi", result[0].Blocks[0].Text)
} }
// ===== composeHistory FK context injection tests =====
// fakeContextReader 是 ContextReader 的测试替身。
type fakeContextReader struct {
brief string
err error
called bool
}
func (f *fakeContextReader) BriefForConversation(_ context.Context, _ *Conversation) (string, error) {
f.called = true
return f.brief, f.err
}
func newMsgSvcWithReader(repo *msgFakeRepo, reader ContextReader) *messageService {
return &messageService{
repo: repo,
storage: newFakeStorage(),
auditor: noopAuditor{},
log: discardLogger(),
mimeWhite: map[string]bool{},
safetyPct: 0.0,
briefReader: reader,
}
}
func TestComposeHistory_FKBriefMergedIntoSystemPrompt(t *testing.T) {
repo := newMsgFakeRepo()
reqID := uuid.New()
conv := newConv(uuid.New(), uuid.New())
conv.SystemPrompt = "STATIC PROMPT"
conv.RequirementID = &reqID
repo.addConversation(conv)
repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})
reader := &fakeContextReader{brief: "## 当前需求\n需求 #7:导出报表"}
svc := newMsgSvcWithReader(repo, reader)
model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
req, err := svc.composeHistory(context.Background(), conv, model)
require.NoError(t, err)
require.True(t, reader.called)
require.Contains(t, req.SystemPrompt, "STATIC PROMPT")
require.Contains(t, req.SystemPrompt, "需求 #7:导出报表")
}
func TestComposeHistory_FKBriefCountsAgainstTokenBudget(t *testing.T) {
repo := newMsgFakeRepo()
reqID := uuid.New()
conv := newConv(uuid.New(), uuid.New())
conv.RequirementID = &reqID
repo.addConversation(conv)
// 多条历史消息,预算很小,FK 简报应挤占消息预算导致历史被截断。
for i := int64(1); i <= 5; i++ {
repo.addMessage(&Message{
ID: i, ConversationID: conv.ID, Role: RoleUser,
Content: "message content that consumes tokens in the budget", Status: StatusOK,
})
}
hugeBrief := strings.Repeat("需求上下文 ", 300)
reader := &fakeContextReader{brief: hugeBrief}
svc := newMsgSvcWithReader(repo, reader)
// ContextWindow 紧到只够装下简报 + 少量消息:
// budget = (500-100)*(1-0) = 400 tokens;简报约 450 tokens(已超 budget),
// 剩余预算 < 5 条消息所需 → 消息被截断。
model := &LLMModel{ContextWindow: 500, MaxOutputTokens: 100}
req, err := svc.composeHistory(context.Background(), conv, model)
require.NoError(t, err)
require.Contains(t, req.SystemPrompt, "需求上下文")
// 简报占了预算 → 消息被截断(少于 5 条)。
require.Less(t, len(req.Messages), 5)
}
func TestComposeHistory_NilReaderPreservesBehavior(t *testing.T) {
repo := newMsgFakeRepo()
reqID := uuid.New()
conv := newConv(uuid.New(), uuid.New())
conv.SystemPrompt = "ONLY STATIC"
conv.RequirementID = &reqID
repo.addConversation(conv)
repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})
svc := newMsgSvcWithReader(repo, nil) // 无 reader
model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
req, err := svc.composeHistory(context.Background(), conv, model)
require.NoError(t, err)
require.Equal(t, "ONLY STATIC", req.SystemPrompt)
}
func TestComposeHistory_NoFKMountSkipsReader(t *testing.T) {
repo := newMsgFakeRepo()
conv := newConv(uuid.New(), uuid.New()) // 无任何 FK 挂载
conv.SystemPrompt = "STATIC"
repo.addConversation(conv)
repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})
reader := &fakeContextReader{brief: "SHOULD NOT APPEAR"}
svc := newMsgSvcWithReader(repo, reader)
model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
req, err := svc.composeHistory(context.Background(), conv, model)
require.NoError(t, err)
require.False(t, reader.called, "reader must not be called without FK mount")
require.Equal(t, "STATIC", req.SystemPrompt)
}
func TestComposeHistory_ReaderErrorFallsBackToStatic(t *testing.T) {
repo := newMsgFakeRepo()
reqID := uuid.New()
conv := newConv(uuid.New(), uuid.New())
conv.SystemPrompt = "STATIC FALLBACK"
conv.RequirementID = &reqID
repo.addConversation(conv)
repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})
reader := &fakeContextReader{err: errs.New(errs.CodeInternal, "boom")}
svc := newMsgSvcWithReader(repo, reader)
model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
req, err := svc.composeHistory(context.Background(), conv, model)
require.NoError(t, err)
require.Equal(t, "STATIC FALLBACK", req.SystemPrompt)
}
// ===== mimeAllowed / capabilityAllows helpers ===== // ===== mimeAllowed / capabilityAllows helpers =====
func TestMimeAllowed(t *testing.T) { func TestMimeAllowed(t *testing.T) {
+6 -3
View File
@@ -21,7 +21,7 @@ func (q *Queries) DeleteLLMEndpoint(ctx context.Context, id pgtype.UUID) error {
} }
const getLLMEndpoint = `-- name: GetLLMEndpoint :one const getLLMEndpoint = `-- name: GetLLMEndpoint :one
SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at FROM llm_endpoints WHERE id = $1 SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at, key_version FROM llm_endpoints WHERE id = $1
` `
func (q *Queries) GetLLMEndpoint(ctx context.Context, id pgtype.UUID) (LlmEndpoint, error) { func (q *Queries) GetLLMEndpoint(ctx context.Context, id pgtype.UUID) (LlmEndpoint, error) {
@@ -36,6 +36,7 @@ func (q *Queries) GetLLMEndpoint(ctx context.Context, id pgtype.UUID) (LlmEndpoi
&i.CreatedBy, &i.CreatedBy,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.KeyVersion,
) )
return i, err return i, err
} }
@@ -43,7 +44,7 @@ func (q *Queries) GetLLMEndpoint(ctx context.Context, id pgtype.UUID) (LlmEndpoi
const insertLLMEndpoint = `-- name: InsertLLMEndpoint :one const insertLLMEndpoint = `-- name: InsertLLMEndpoint :one
INSERT INTO llm_endpoints (id, provider, display_name, base_url, api_key_encrypted, created_by) INSERT INTO llm_endpoints (id, provider, display_name, base_url, api_key_encrypted, created_by)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at RETURNING id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at, key_version
` `
type InsertLLMEndpointParams struct { type InsertLLMEndpointParams struct {
@@ -74,12 +75,13 @@ func (q *Queries) InsertLLMEndpoint(ctx context.Context, arg InsertLLMEndpointPa
&i.CreatedBy, &i.CreatedBy,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.KeyVersion,
) )
return i, err return i, err
} }
const listLLMEndpoints = `-- name: ListLLMEndpoints :many const listLLMEndpoints = `-- name: ListLLMEndpoints :many
SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at FROM llm_endpoints ORDER BY created_at DESC SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at, key_version FROM llm_endpoints ORDER BY created_at DESC
` `
func (q *Queries) ListLLMEndpoints(ctx context.Context) ([]LlmEndpoint, error) { func (q *Queries) ListLLMEndpoints(ctx context.Context) ([]LlmEndpoint, error) {
@@ -100,6 +102,7 @@ func (q *Queries) ListLLMEndpoints(ctx context.Context) ([]LlmEndpoint, error) {
&i.CreatedBy, &i.CreatedBy,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.KeyVersion,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+223
View File
@@ -8,6 +8,7 @@ import (
"net/netip" "net/netip"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/pgvector/pgvector-go"
) )
type AcpAgentKind struct { type AcpAgentKind struct {
@@ -25,6 +26,11 @@ type AcpAgentKind struct {
ToolAllowlist []string `json:"tool_allowlist"` ToolAllowlist []string `json:"tool_allowlist"`
ClientType string `json:"client_type"` ClientType string `json:"client_type"`
EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"`
ModelID pgtype.UUID `json:"model_id"`
MaxCostUsd pgtype.Numeric `json:"max_cost_usd"`
MaxTokens *int64 `json:"max_tokens"`
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
KeyVersion int16 `json:"key_version"`
} }
type AcpAgentKindConfigFile struct { type AcpAgentKindConfigFile struct {
@@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct {
UpdatedBy pgtype.UUID `json:"updated_by"` UpdatedBy pgtype.UUID `json:"updated_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type AcpEvent struct { type AcpEvent struct {
@@ -81,6 +88,47 @@ type AcpSession struct {
LastError *string `json:"last_error"` LastError *string `json:"last_error"`
StartedAt pgtype.Timestamptz `json:"started_at"` StartedAt pgtype.Timestamptz `json:"started_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"` EndedAt pgtype.Timestamptz `json:"ended_at"`
LastStopReason *string `json:"last_stop_reason"`
OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
TotalCostUsd pgtype.Numeric `json:"total_cost_usd"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"`
BudgetMaxTokens *int64 `json:"budget_max_tokens"`
BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"`
TerminatedReason *string `json:"terminated_reason"`
SandboxMode *string `json:"sandbox_mode"`
CostUsd pgtype.Numeric `json:"cost_usd"`
TokensTotal int64 `json:"tokens_total"`
}
type AcpSessionUsage struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
UserID pgtype.UUID `json:"user_id"`
ProjectID pgtype.UUID `json:"project_id"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
ModelID pgtype.UUID `json:"model_id"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
ThinkingTokens int64 `json:"thinking_tokens"`
CostUsd pgtype.Numeric `json:"cost_usd"`
SourceEventID *int64 `json:"source_event_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpTurn struct {
ID int64 `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
TurnIndex int32 `json:"turn_index"`
PromptRequestID *string `json:"prompt_request_id"`
Status string `json:"status"`
StopReason *string `json:"stop_reason"`
UpdateCount int32 `json:"update_count"`
StartedAt pgtype.Timestamptz `json:"started_at"`
CompletedAt pgtype.Timestamptz `json:"completed_at"`
} }
type AuditLog struct { type AuditLog struct {
@@ -94,6 +142,81 @@ type AuditLog struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type ChangeRequest struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
IssueID pgtype.UUID `json:"issue_id"`
Number int32 `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
State string `json:"state"`
ReviewVerdict string `json:"review_verdict"`
CiState string `json:"ci_state"`
Provider string `json:"provider"`
ExternalID *int64 `json:"external_id"`
ExternalUrl string `json:"external_url"`
MergeCommitSha string `json:"merge_commit_sha"`
ReviewedBy pgtype.UUID `json:"reviewed_by"`
ReviewedAt pgtype.Timestamptz `json:"reviewed_at"`
CreatedBy pgtype.UUID `json:"created_by"`
MergedAt pgtype.Timestamptz `json:"merged_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CodeChunk struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CommitSha string `json:"commit_sha"`
FilePath string `json:"file_path"`
Lang *string `json:"lang"`
StartLine int32 `json:"start_line"`
EndLine int32 `json:"end_line"`
Content string `json:"content"`
ContentSha []byte `json:"content_sha"`
TokenCount *int32 `json:"token_count"`
Embedding *pgvector.Vector `json:"embedding"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CodeIndexRun struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Status string `json:"status"`
EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"`
EmbeddingModel string `json:"embedding_model"`
Dims int32 `json:"dims"`
FilesIndexed int32 `json:"files_indexed"`
ChunksIndexed int32 `json:"chunks_indexed"`
Error *string `json:"error"`
StartedAt pgtype.Timestamptz `json:"started_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CommitSummary struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
WorktreeID pgtype.UUID `json:"worktree_id"`
Branch string `json:"branch"`
CommitSha string `json:"commit_sha"`
Kind string `json:"kind"`
Title *string `json:"title"`
BodyMd string `json:"body_md"`
Model *string `json:"model"`
Status string `json:"status"`
Error *string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Conversation struct { type Conversation struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
@@ -110,6 +233,14 @@ type Conversation struct {
RequirementID pgtype.UUID `json:"requirement_id"` RequirementID pgtype.UUID `json:"requirement_id"`
} }
type CryptoKey struct {
Version int32 `json:"version"`
Provider string `json:"provider"`
KeyRef *string `json:"key_ref"`
Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type GitCredential struct { type GitCredential struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"` WorkspaceID pgtype.UUID `json:"workspace_id"`
@@ -119,6 +250,7 @@ type GitCredential struct {
Fingerprint *string `json:"fingerprint"` Fingerprint *string `json:"fingerprint"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type Issue struct { type Issue struct {
@@ -135,6 +267,17 @@ type Issue struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
WorkspaceID pgtype.UUID `json:"workspace_id"` WorkspaceID pgtype.UUID `json:"workspace_id"`
ParentID pgtype.UUID `json:"parent_id"`
Priority int32 `json:"priority"`
}
type IssueDependency struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
BlockedID pgtype.UUID `json:"blocked_id"`
BlockerID pgtype.UUID `json:"blocker_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type Job struct { type Job struct {
@@ -162,6 +305,7 @@ type LlmEndpoint struct {
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type LlmModel struct { type LlmModel struct {
@@ -252,6 +396,50 @@ type Notification struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type NotificationPref struct {
UserID pgtype.UUID `json:"user_id"`
EmailEnabled bool `json:"email_enabled"`
ImEnabled bool `json:"im_enabled"`
ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"`
MinSeverity string `json:"min_severity"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type OrchestratorRun struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
RequirementID pgtype.UUID `json:"requirement_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
OwnerID pgtype.UUID `json:"owner_id"`
Status string `json:"status"`
CurrentPhase string `json:"current_phase"`
Config []byte `json:"config"`
LastError *string `json:"last_error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type OrchestratorStep struct {
ID pgtype.UUID `json:"id"`
RunID pgtype.UUID `json:"run_id"`
Phase string `json:"phase"`
Attempt int32 `json:"attempt"`
ParentStepID pgtype.UUID `json:"parent_step_id"`
Depth int32 `json:"depth"`
Status string `json:"status"`
AgentKindID pgtype.UUID `json:"agent_kind_id"`
AcpSessionID pgtype.UUID `json:"acp_session_id"`
Prompt string `json:"prompt"`
StopReason *string `json:"stop_reason"`
GateResult []byte `json:"gate_result"`
LastError *string `json:"last_error"`
JobID pgtype.UUID `json:"job_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EndedAt pgtype.Timestamptz `json:"ended_at"`
}
type Project struct { type Project struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
@@ -264,6 +452,30 @@ type Project struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type ProjectMember struct {
ProjectID pgtype.UUID `json:"project_id"`
UserID pgtype.UUID `json:"user_id"`
Role string `json:"role"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
AddedBy pgtype.UUID `json:"added_by"`
}
type ProjectMemory struct {
ID pgtype.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Source string `json:"source"`
Embedding *pgvector.Vector `json:"embedding"`
EmbeddingModel *string `json:"embedding_model"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type PromptTemplate struct { type PromptTemplate struct {
ID pgtype.UUID `json:"id"` ID pgtype.UUID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -299,6 +511,16 @@ type RequirementArtifact struct {
SourceMessageID *int64 `json:"source_message_id"` SourceMessageID *int64 `json:"source_message_id"`
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
Verdict string `json:"verdict"`
}
type RequirementPhasePointer struct {
RequirementID pgtype.UUID `json:"requirement_id"`
Phase string `json:"phase"`
ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type User struct { type User struct {
@@ -354,6 +576,7 @@ type WorkspaceRunProfile struct {
CreatedBy pgtype.UUID `json:"created_by"` CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
KeyVersion int16 `json:"key_version"`
} }
type WorkspaceWorktree struct { type WorkspaceWorktree struct {
+33 -9
View File
@@ -70,26 +70,50 @@ type subscription struct {
// the stream closes, or ctx is cancelled. // the stream closes, or ctx is cancelled.
// It returns all events from nextEventID to the current tail. // It returns all events from nextEventID to the current tail.
func (sub *subscription) Next(ctx context.Context, nextEventID int64) ([]SSEEvent, bool, error) { func (sub *subscription) Next(ctx context.Context, nextEventID int64) ([]SSEEvent, bool, error) {
sub.st.mu.Lock() st := sub.st
defer sub.st.mu.Unlock()
for int64(len(sub.st.events)) <= nextEventID && !sub.st.closed { // Launch a single watcher that observes ctx cancellation and wakes any
// waiter. It broadcasts under the lock so the signal cannot be missed by a
// waiter that has not yet parked on cond.Wait(); the predicate loop below
// re-checks ctx.Err() after every wake, so a broadcast delivered before a
// waiter parks is still observed on the next predicate check.
//
// done is closed (under the lock) when Next returns, which makes the watcher
// exit even if ctx never cancels — so neither goroutine can leak.
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
sub.st.cond.Broadcast() st.mu.Lock()
st.cond.Broadcast()
st.mu.Unlock()
case <-done: case <-done:
} }
}() }()
sub.st.cond.Wait()
st.mu.Lock()
defer func() {
// Signal the watcher to exit, then release the lock. Closing done under
// the lock pairs with the watcher's Broadcast-under-lock so there is no
// window in which the watcher broadcasts to an unlocked, already-returned
// Next.
close(done) close(done)
if ctx.Err() != nil { st.mu.Unlock()
}()
// Wait until there is data at/after nextEventID, the stream closes, or ctx
// is cancelled. Each predicate component is re-checked after every wake so a
// missed/early broadcast cannot wedge the wait.
for int64(len(st.events)) <= nextEventID && !st.closed && ctx.Err() == nil {
st.cond.Wait()
}
if int64(len(st.events)) <= nextEventID && !st.closed && ctx.Err() != nil {
return nil, false, ctx.Err() return nil, false, ctx.Err()
} }
}
out := append([]SSEEvent(nil), sub.st.events[nextEventID:]...) out := append([]SSEEvent(nil), st.events[nextEventID:]...)
return out, sub.st.closed, nil return out, st.closed, nil
} }
// streamerHub manages all in-flight streamers. // streamerHub manages all in-flight streamers.
+71
View File
@@ -667,6 +667,77 @@ func TestStreamer_TitleGenerationTriggered(t *testing.T) {
}, 1*time.Second, 10*time.Millisecond, "GenerateTitle should have been triggered") }, 1*time.Second, 10*time.Millisecond, "GenerateTitle should have been triggered")
} }
// TestStreamer_Next_EarlyCancel_NoLeak exercises the early-cancel race in
// subscription.Next: when ctx is already cancelled (or cancels in the window
// before the waiter parks on cond.Wait) and there are no new events, Next must
// return ctx.Err() promptly instead of blocking forever, and must not leak the
// Next goroutine or its ctx watcher.
func TestStreamer_Next_EarlyCancel_NoLeak(t *testing.T) {
t.Parallel()
st := newStreamer()
sub := &subscription{st: st}
// Case 1: ctx already cancelled before Next is called. With no events and an
// open stream, the predicate is unsatisfied except for ctx, so Next must
// observe cancellation immediately rather than parking forever.
ctx, cancel := context.WithCancel(context.Background())
cancel()
done := make(chan struct{})
go func() {
defer close(done)
evs, closed, err := sub.Next(ctx, 0)
require.ErrorIs(t, err, context.Canceled)
require.Nil(t, evs)
require.False(t, closed)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Next did not return on an already-cancelled ctx; goroutine leaked")
}
// Case 2: cancel concurrently while a waiter is (about to be) parked. Repeat
// to widen the window between launching the watcher and parking on Wait.
for i := 0; i < 200; i++ {
ctx, cancel := context.WithCancel(context.Background())
ret := make(chan error, 1)
go func() {
_, _, err := sub.Next(ctx, 0)
ret <- err
}()
cancel()
select {
case err := <-ret:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(2 * time.Second):
t.Fatalf("iteration %d: Next wedged on concurrent cancel", i)
}
}
}
// TestStreamer_Next_DataDeliveredDespiteCancel verifies that when events are
// already buffered, Next returns them even if ctx is cancelled (data wins over
// a late cancel, preserving the original behavior).
func TestStreamer_Next_DataDeliveredDespiteCancel(t *testing.T) {
t.Parallel()
st := newStreamer()
sub := &subscription{st: st}
st.append(SSEEvent{Event: "text_delta", Data: map[string]string{"text": "hi"}})
ctx, cancel := context.WithCancel(context.Background())
cancel()
evs, closed, err := sub.Next(ctx, 0)
require.NoError(t, err)
require.False(t, closed)
require.Len(t, evs, 1)
require.Equal(t, "text_delta", evs[0].Event)
}
// ===== helpers for title generation test ===== // ===== helpers for title generation test =====
// spyConvSvc is a minimal conversationService-shaped struct that calls a hook // spyConvSvc is a minimal conversationService-shaped struct that calls a hook
+191
View File
@@ -0,0 +1,191 @@
package codeindex
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
)
// EmbedBatchSize bounds how many chunks are sent to the embedder per request.
const EmbedBatchSize = 64
// FailureNotifier is an optional best-effort callback invoked when a build run
// fails, so app.go can broadcast to admins without the runner importing user/notify.
type FailureNotifier func(ctx context.Context, run *Run, cause error)
// BuildRunner is the jobs.Handler that materializes a code_index_runs row into
// embedded code_chunks. It is event-driven (enqueued by EnqueueBuild), never a
// periodic ticker. Idempotent: re-running for the same run replaces its chunks.
type BuildRunner struct {
repo Repository
locator WorkspaceLocator
gitr git.Runner
embedSel EmbedderSelector
chunker *Chunker
notify FailureNotifier
log *slog.Logger
}
// NewBuildRunner constructs a BuildRunner. notify and log may be nil.
func NewBuildRunner(repo Repository, locator WorkspaceLocator, gitr git.Runner, embedSel EmbedderSelector, chunker *Chunker, notify FailureNotifier, log *slog.Logger) *BuildRunner {
if chunker == nil {
chunker = NewChunker()
}
if log == nil {
log = slog.Default()
}
return &BuildRunner{repo: repo, locator: locator, gitr: gitr, embedSel: embedSel, chunker: chunker, notify: notify, log: log}
}
var _ jobs.Handler = (*BuildRunner)(nil)
// Type returns the job type this handler processes.
func (h *BuildRunner) Type() jobs.JobType { return JobTypeBuild }
// Handle builds the index for the run referenced in the payload. On any error it
// marks the run failed and notifies admins; the returned error drives jobs retry.
func (h *BuildRunner) Handle(ctx context.Context, job jobs.Job) error {
var p buildPayload
if err := json.Unmarshal(job.Payload, &p); err != nil {
return jobs.Permanent(fmt.Errorf("codeindex.build: bad payload: %w", err))
}
run, err := h.repo.GetRun(ctx, p.RunID)
if err != nil {
return jobs.Permanent(fmt.Errorf("codeindex.build: load run %s: %w", p.RunID, err))
}
// Skip runs superseded by a newer one (stale) — no-op success.
if run.Status == StatusStale || run.Status == StatusCompleted {
return nil
}
if err := h.build(ctx, run); err != nil {
if markErr := h.repo.MarkFailed(ctx, run.ID, truncErr(err)); markErr != nil {
h.log.Error("codeindex.build.mark_failed", "run_id", run.ID, "err", markErr.Error())
}
if h.notify != nil {
h.notify(ctx, run, err)
}
return err // retryable unless wrapped Permanent inside build
}
return nil
}
func (h *BuildRunner) build(ctx context.Context, run *Run) error {
emb, _, model, dims, err := h.embedSel.Embedder(ctx)
if err != nil {
return jobs.Permanent(fmt.Errorf("codeindex.build: embedder unavailable: %w", err))
}
// Validate dims match the fixed pgvector column to avoid corrupt inserts.
if d := emb.EmbedDims(model); d != 0 && d != llm.PlatformEmbeddingDims {
return jobs.Permanent(fmt.Errorf("codeindex.build: embedder dims %d != platform %d", d, llm.PlatformEmbeddingDims))
}
if dims != llm.PlatformEmbeddingDims {
return jobs.Permanent(fmt.Errorf("codeindex.build: run dims %d != platform %d", dims, llm.PlatformEmbeddingDims))
}
// Resolve the worktree/main directory holding the commit's files.
dir := ""
if run.WorktreeID != nil {
path, _, _, lerr := h.locator.LocateWorktree(ctx, *run.WorktreeID)
if lerr != nil {
return fmt.Errorf("locate worktree: %w", lerr)
}
dir = path
} else {
info, lerr := h.locator.LocateWorkspace(ctx, run.WorkspaceID)
if lerr != nil {
return fmt.Errorf("locate workspace: %w", lerr)
}
dir = info.MainPath
}
if err := h.repo.MarkRunning(ctx, run.ID); err != nil {
return fmt.Errorf("mark running: %w", err)
}
files, err := h.gitr.ListTrackedFiles(ctx, dir)
if err != nil {
return fmt.Errorf("git ls-files: %w", err)
}
var rows []ChunkRow
filesIndexed := 0
for _, rel := range files {
abs := filepath.Join(dir, filepath.FromSlash(rel))
fi, serr := os.Stat(abs)
if serr != nil || fi.IsDir() {
continue
}
if int(fi.Size()) > h.chunker.maxFileBytes() || isVendored(rel) {
continue
}
content, rerr := os.ReadFile(abs)
if rerr != nil {
continue
}
chunks := h.chunker.Chunk(rel, content)
if len(chunks) == 0 {
continue
}
filesIndexed++
for _, c := range chunks {
rows = append(rows, ChunkRow{
FilePath: rel,
Lang: c.Lang,
StartLine: c.StartLine,
EndLine: c.EndLine,
Content: c.Content,
ContentSHA: c.ContentSHA,
TokenCount: llm.EstimateTokens(c.Content),
})
}
}
// Batch-embed chunk contents (input order preserved per batch).
for start := 0; start < len(rows); start += EmbedBatchSize {
end := start + EmbedBatchSize
if end > len(rows) {
end = len(rows)
}
inputs := make([]string, 0, end-start)
for i := start; i < end; i++ {
inputs = append(inputs, rows[i].Content)
}
vecs, eerr := emb.Embed(ctx, model, inputs)
if eerr != nil {
return fmt.Errorf("embed batch [%d:%d]: %w", start, end, eerr)
}
if len(vecs) != end-start {
return fmt.Errorf("embed batch [%d:%d]: got %d vectors", start, end, len(vecs))
}
for i := start; i < end; i++ {
rows[i].Embedding = vecs[i-start]
}
}
if err := h.repo.ReplaceChunks(ctx, run.ID, run.WorkspaceID, run.CommitSHA, rows); err != nil {
return fmt.Errorf("insert chunks: %w", err)
}
if err := h.repo.MarkCompleted(ctx, run.ID, filesIndexed, len(rows)); err != nil {
return fmt.Errorf("mark completed: %w", err)
}
h.log.Info("codeindex.build.completed",
"run_id", run.ID, "workspace_id", run.WorkspaceID, "commit", run.CommitSHA,
"files", filesIndexed, "chunks", len(rows))
return nil
}
func truncErr(err error) string {
s := err.Error()
if len(s) > 1000 {
return s[:1000]
}
return s
}

Some files were not shown because too many files have changed in this diff Show More