You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
+9
-2
@@ -27,9 +27,16 @@ FROM registry.jerryyan.top/library/alpine:3.24
|
|||||||
# 均为 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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,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
|
||||||
@@ -33,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
|
||||||
@@ -76,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
|
||||||
@@ -105,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
|
||||||
@@ -113,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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type agentKindService struct {
|
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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 枚举。
|
||||||
|
|||||||
+171
-3
@@ -21,9 +21,14 @@ 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"`
|
||||||
CreatedBy uuid.UUID `json:"created_by"`
|
// 成本核算:model 价格来源 + 默认预算上限(均可空)。
|
||||||
CreatedAt string `json:"created_at"`
|
ModelID *string `json:"model_id"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
MaxCostUSD *float64 `json:"max_cost_usd"`
|
||||||
|
MaxTokens *int64 `json:"max_tokens"`
|
||||||
|
MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"`
|
||||||
|
CreatedBy uuid.UUID `json:"created_by"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。
|
// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+329
-25
@@ -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,17 +240,26 @@ 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,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
BinaryPath: strings.TrimSpace(req.BinaryPath),
|
BinaryPath: strings.TrimSpace(req.BinaryPath),
|
||||||
Args: req.Args,
|
Args: req.Args,
|
||||||
Env: req.Env,
|
Env: req.Env,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
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) {
|
||||||
@@ -675,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",
|
||||||
@@ -702,22 +977,51 @@ 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
|
||||||
}
|
}
|
||||||
return AgentKindAdminDTO{
|
var modelID *string
|
||||||
ID: k.ID,
|
if k.ModelID != nil {
|
||||||
Name: k.Name,
|
v := k.ModelID.String()
|
||||||
DisplayName: k.DisplayName,
|
modelID = &v
|
||||||
Description: k.Description,
|
|
||||||
BinaryPath: k.BinaryPath,
|
|
||||||
Args: append([]string(nil), k.Args...),
|
|
||||||
EnvKeys: envKeys,
|
|
||||||
Enabled: k.Enabled,
|
|
||||||
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
|
|
||||||
ClientType: string(k.ClientType),
|
|
||||||
MCPServers: mcpServers,
|
|
||||||
CreatedBy: k.CreatedBy,
|
|
||||||
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
||||||
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
||||||
}
|
}
|
||||||
|
return AgentKindAdminDTO{
|
||||||
|
ID: k.ID,
|
||||||
|
Name: k.Name,
|
||||||
|
DisplayName: k.DisplayName,
|
||||||
|
Description: k.Description,
|
||||||
|
BinaryPath: k.BinaryPath,
|
||||||
|
Args: append([]string(nil), k.Args...),
|
||||||
|
EnvKeys: envKeys,
|
||||||
|
Enabled: k.Enabled,
|
||||||
|
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
|
||||||
|
ClientType: string(k.ClientType),
|
||||||
|
MCPServers: mcpServers,
|
||||||
|
ModelID: modelID,
|
||||||
|
MaxCostUSD: k.MaxCostUSD,
|
||||||
|
MaxTokens: k.MaxTokens,
|
||||||
|
MaxWallClockSeconds: k.MaxWallClockSeconds,
|
||||||
|
CreatedBy: k.CreatedBy,
|
||||||
|
CreatedAt: k.CreatedAt.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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+104
-15
@@ -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,
|
||||||
@@ -122,18 +204,25 @@ func agentKindToMCP(k *AgentKind) mcp.ACPAgentKind {
|
|||||||
|
|
||||||
func sessionToMCP(s *Session) mcp.ACPSession {
|
func sessionToMCP(s *Session) mcp.ACPSession {
|
||||||
return mcp.ACPSession{
|
return mcp.ACPSession{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
AgentKindID: s.AgentKindID,
|
AgentKindID: s.AgentKindID,
|
||||||
UserID: s.UserID,
|
UserID: s.UserID,
|
||||||
IssueID: s.IssueID,
|
IssueID: s.IssueID,
|
||||||
RequirementID: s.RequirementID,
|
RequirementID: s.RequirementID,
|
||||||
Branch: s.Branch,
|
Branch: s.Branch,
|
||||||
IsMainWorktree: s.IsMainWorktree,
|
IsMainWorktree: s.IsMainWorktree,
|
||||||
Status: string(s.Status),
|
Status: string(s.Status),
|
||||||
LastError: s.LastError,
|
LastError: s.LastError,
|
||||||
StartedAt: s.StartedAt,
|
LastStopReason: s.LastStopReason,
|
||||||
EndedAt: s.EndedAt,
|
StartedAt: s.StartedAt,
|
||||||
|
EndedAt: s.EndedAt,
|
||||||
|
PromptTokens: s.PromptTokens,
|
||||||
|
CompletionTokens: s.CompletionTokens,
|
||||||
|
ThinkingTokens: s.ThinkingTokens,
|
||||||
|
TotalCostUSD: s.TotalCostUSD,
|
||||||
|
BudgetMaxCostUSD: s.BudgetMaxCostUSD,
|
||||||
|
BudgetMaxTokens: s.BudgetMaxTokens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 owner 或 admin。
|
// 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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,50 +1,61 @@
|
|||||||
-- 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;
|
||||||
|
|
||||||
-- name: UpdateAgentKind :one
|
-- name: UpdateAgentKind :one
|
||||||
UPDATE acp_agent_kinds
|
UPDATE acp_agent_kinds
|
||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
description = $3,
|
description = $3,
|
||||||
binary_path = $4,
|
binary_path = $4,
|
||||||
args = $5,
|
args = $5,
|
||||||
encrypted_env = $6,
|
encrypted_env = $6,
|
||||||
enabled = $7,
|
enabled = $7,
|
||||||
tool_allowlist = $8,
|
tool_allowlist = $8,
|
||||||
client_type = $9,
|
client_type = $9,
|
||||||
encrypted_mcp_servers = $10,
|
encrypted_mcp_servers = $10,
|
||||||
updated_at = now()
|
model_id = $11,
|
||||||
|
max_cost_usd = $12,
|
||||||
|
max_tokens = $13,
|
||||||
|
max_wall_clock_seconds = $14,
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
+46
-2
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -272,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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+230
-28
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,17 +374,21 @@ func normalizeStrSlice(s []string) []string {
|
|||||||
|
|
||||||
func (r *pgRepo) InsertSession(ctx context.Context, s *Session) (*Session, error) {
|
func (r *pgRepo) InsertSession(ctx context.Context, s *Session) (*Session, error) {
|
||||||
row, err := r.q.InsertSession(ctx, acpsqlc.InsertSessionParams{
|
row, err := r.q.InsertSession(ctx, acpsqlc.InsertSessionParams{
|
||||||
ID: toPgUUID(s.ID),
|
ID: toPgUUID(s.ID),
|
||||||
WorkspaceID: toPgUUID(s.WorkspaceID),
|
WorkspaceID: toPgUUID(s.WorkspaceID),
|
||||||
ProjectID: toPgUUID(s.ProjectID),
|
ProjectID: toPgUUID(s.ProjectID),
|
||||||
AgentKindID: toPgUUID(s.AgentKindID),
|
AgentKindID: toPgUUID(s.AgentKindID),
|
||||||
UserID: toPgUUID(s.UserID),
|
UserID: toPgUUID(s.UserID),
|
||||||
IssueID: toPgUUIDPtr(s.IssueID),
|
IssueID: toPgUUIDPtr(s.IssueID),
|
||||||
RequirementID: toPgUUIDPtr(s.RequirementID),
|
RequirementID: toPgUUIDPtr(s.RequirementID),
|
||||||
Branch: s.Branch,
|
Branch: s.Branch,
|
||||||
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 {
|
||||||
@@ -480,23 +564,34 @@ func rowToSession(row acpsqlc.AcpSession) *Session {
|
|||||||
endedAt = &t
|
endedAt = &t
|
||||||
}
|
}
|
||||||
return &Session{
|
return &Session{
|
||||||
ID: fromPgUUID(row.ID),
|
ID: fromPgUUID(row.ID),
|
||||||
WorkspaceID: fromPgUUID(row.WorkspaceID),
|
WorkspaceID: fromPgUUID(row.WorkspaceID),
|
||||||
ProjectID: fromPgUUID(row.ProjectID),
|
ProjectID: fromPgUUID(row.ProjectID),
|
||||||
AgentKindID: fromPgUUID(row.AgentKindID),
|
AgentKindID: fromPgUUID(row.AgentKindID),
|
||||||
UserID: fromPgUUID(row.UserID),
|
UserID: fromPgUUID(row.UserID),
|
||||||
IssueID: fromPgUUIDPtr(row.IssueID),
|
IssueID: fromPgUUIDPtr(row.IssueID),
|
||||||
RequirementID: fromPgUUIDPtr(row.RequirementID),
|
RequirementID: fromPgUUIDPtr(row.RequirementID),
|
||||||
AgentSessionID: row.AgentSessionID,
|
AgentSessionID: row.AgentSessionID,
|
||||||
Branch: row.Branch,
|
Branch: row.Branch,
|
||||||
CwdPath: row.CwdPath,
|
CwdPath: row.CwdPath,
|
||||||
IsMainWorktree: row.IsMainWorktree,
|
IsMainWorktree: row.IsMainWorktree,
|
||||||
Status: SessionStatus(row.Status),
|
Status: SessionStatus(row.Status),
|
||||||
PID: row.Pid,
|
PID: row.Pid,
|
||||||
ExitCode: row.ExitCode,
|
ExitCode: row.ExitCode,
|
||||||
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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,25 +24,31 @@ 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 {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -234,32 +273,41 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
|
|||||||
|
|
||||||
const updateAgentKind = `-- name: UpdateAgentKind :one
|
const updateAgentKind = `-- name: UpdateAgentKind :one
|
||||||
UPDATE acp_agent_kinds
|
UPDATE acp_agent_kinds
|
||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
description = $3,
|
description = $3,
|
||||||
binary_path = $4,
|
binary_path = $4,
|
||||||
args = $5,
|
args = $5,
|
||||||
encrypted_env = $6,
|
encrypted_env = $6,
|
||||||
enabled = $7,
|
enabled = $7,
|
||||||
tool_allowlist = $8,
|
tool_allowlist = $8,
|
||||||
client_type = $9,
|
client_type = $9,
|
||||||
encrypted_mcp_servers = $10,
|
encrypted_mcp_servers = $10,
|
||||||
updated_at = now()
|
model_id = $11,
|
||||||
|
max_cost_usd = $12,
|
||||||
|
max_tokens = $13,
|
||||||
|
max_wall_clock_seconds = $14,
|
||||||
|
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 {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+240
-17
@@ -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 {
|
||||||
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AcpSession struct {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
ProjectID pgtype.UUID `json:"project_id"`
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||||
UserID pgtype.UUID `json:"user_id"`
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
IssueID pgtype.UUID `json:"issue_id"`
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
AgentSessionID *string `json:"agent_session_id"`
|
AgentSessionID *string `json:"agent_session_id"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
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"`
|
||||||
Pid *int32 `json:"pid"`
|
Pid *int32 `json:"pid"`
|
||||||
ExitCode *int32 `json:"exit_code"`
|
ExitCode *int32 `json:"exit_code"`
|
||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,26 +270,37 @@ 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 {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
ProjectID pgtype.UUID `json:"project_id"`
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||||
UserID pgtype.UUID `json:"user_id"`
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
IssueID pgtype.UUID `json:"issue_id"`
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+283
-33
@@ -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 子进程总管。
|
||||||
@@ -51,22 +72,87 @@ type Supervisor struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
procs map[uuid.UUID]*Process
|
procs map[uuid.UUID]*Process
|
||||||
|
|
||||||
repo Repository
|
repo Repository
|
||||||
audit audit.Recorder
|
audit audit.Recorder
|
||||||
notify *notify.Dispatcher
|
notify *notify.Dispatcher
|
||||||
wtSvc workspace.WorktreeService
|
wtSvc workspace.WorktreeService
|
||||||
wsRepo workspace.Repository
|
wsRepo workspace.Repository
|
||||||
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(部分测试)
|
||||||
cfg SupervisorConfig
|
turnBus TurnBus // 回合完成事件总线;可为 nil(部分测试)
|
||||||
log *slog.Logger
|
orchRedrive OrchestratorRedrive // 崩溃的编排器 session 再驱动回调;可为 nil
|
||||||
|
prices ModelPriceLookup // model 价格查询;可为 nil(无 model 价格时成本=0/agent 自报)
|
||||||
|
agentsMD AgentsMDRenderer // AGENTS.md 渲染回调(project memory);可为 nil
|
||||||
|
metrics SessionExitRecorder // 会话退出计数;可为 nil(metrics 关闭)
|
||||||
|
cfg SupervisorConfig
|
||||||
|
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,
|
||||||
@@ -114,6 +200,10 @@ type Process struct {
|
|||||||
// 返回后立即取消),故 relay 的 reader/writer/persist/permission Decide 在整个
|
// 返回后立即取消),故 relay 的 reader/writer/persist/permission Decide 在整个
|
||||||
// 会话生命周期内都使用未取消的 ctx。onExit 调用 relayCancel 完成 relay teardown。
|
// 会话生命周期内都使用未取消的 ctx。onExit 调用 relayCancel 完成 relay teardown。
|
||||||
relayCancel context.CancelFunc
|
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。
|
||||||
@@ -163,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,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()
|
||||||
@@ -243,6 +361,50 @@ 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 返回后
|
// relay 的会话级 context:独立于调用方的 spawn ctx(生产调用方在 Spawn 返回后
|
||||||
// 立即 cancel spawn ctx)。relay 的 reader/writer/persist/permission Decide 必须
|
// 立即 cancel spawn ctx)。relay 的 reader/writer/persist/permission Decide 必须
|
||||||
// 在整个会话生命周期内使用未取消的 ctx,否则 prompt 被丢、事件停止持久化、权限
|
// 在整个会话生命周期内使用未取消的 ctx,否则 prompt 被丢、事件停止持久化、权限
|
||||||
@@ -250,20 +412,21 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
|||||||
relayCtx, relayCancel := context.WithCancel(context.Background())
|
relayCtx, relayCancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
proc := &Process{
|
proc := &Process{
|
||||||
SessionID: sess.ID,
|
SessionID: sess.ID,
|
||||||
WorkspaceID: sess.WorkspaceID,
|
WorkspaceID: sess.WorkspaceID,
|
||||||
UserID: sess.UserID,
|
UserID: sess.UserID,
|
||||||
IsMain: sess.IsMainWorktree,
|
IsMain: sess.IsMainWorktree,
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
group: group,
|
group: group,
|
||||||
Stdin: stdin,
|
Stdin: stdin,
|
||||||
Stdout: stdout,
|
Stdout: stdout,
|
||||||
Stderr: stderr,
|
Stderr: stderr,
|
||||||
Relay: relay,
|
Relay: relay,
|
||||||
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,
|
relayCancel: relayCancel,
|
||||||
|
sandboxCleanup: sandboxCleanup,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -272,14 +435,7 @@ 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(relayCtx, 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 截止语义。
|
// 握手仍走调用方的 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 {
|
||||||
@@ -354,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
|
||||||
@@ -440,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
|
||||||
@@ -451,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 != "" {
|
||||||
@@ -460,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。
|
||||||
@@ -530,6 +727,17 @@ 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 / 已派生
|
// 取消 relay 的会话级 context:停止 reader/writer,并让仍阻塞在 Decide / 已派生
|
||||||
// 的 handleAgentRequest goroutine 收到 ctx.Done 后退出。在 Relay.Close 之前调,
|
// 的 handleAgentRequest goroutine 收到 ctx.Done 后退出。在 Relay.Close 之前调,
|
||||||
// 二者共同完成 teardown(relayCancel 停 ctx 派生的 goroutine,Close 关 r.done +
|
// 二者共同完成 teardown(relayCancel 停 ctx 派生的 goroutine,Close 关 r.done +
|
||||||
@@ -540,4 +748,46 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
|
|||||||
|
|
||||||
// 最后关 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+914
-41
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+240
-17
@@ -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 {
|
||||||
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AcpSession struct {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
ProjectID pgtype.UUID `json:"project_id"`
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||||
UserID pgtype.UUID `json:"user_id"`
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
IssueID pgtype.UUID `json:"issue_id"`
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
AgentSessionID *string `json:"agent_session_id"`
|
AgentSessionID *string `json:"agent_session_id"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
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"`
|
||||||
Pid *int32 `json:"pid"`
|
Pid *int32 `json:"pid"`
|
||||||
ExitCode *int32 `json:"exit_code"`
|
ExitCode *int32 `json:"exit_code"`
|
||||||
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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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 背景、阶段产物与仓库现状,谨慎地推进实现:先理解上下文,必要时澄清,再动手修改;保持改动最小且可验证。`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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 *;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -26,18 +26,20 @@ type MessageServiceConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type messageService struct {
|
type messageService struct {
|
||||||
repo Repository
|
repo Repository
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
hub StreamerHub
|
hub StreamerHub
|
||||||
auditor audit.Recorder
|
auditor audit.Recorder
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
mimeWhite map[string]bool
|
mimeWhite map[string]bool
|
||||||
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,21 +47,23 @@ 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 {
|
||||||
mimeWhite[m] = true
|
mimeWhite[m] = true
|
||||||
}
|
}
|
||||||
return &messageService{
|
return &messageService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
storage: st,
|
storage: st,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
auditor: auditor,
|
auditor: auditor,
|
||||||
log: log,
|
log: log,
|
||||||
mimeWhite: mimeWhite,
|
mimeWhite: mimeWhite,
|
||||||
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+240
-17
@@ -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 {
|
||||||
@@ -64,23 +71,64 @@ type AcpPermissionRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AcpSession struct {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
ProjectID pgtype.UUID `json:"project_id"`
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
AgentKindID pgtype.UUID `json:"agent_kind_id"`
|
||||||
UserID pgtype.UUID `json:"user_id"`
|
UserID pgtype.UUID `json:"user_id"`
|
||||||
IssueID pgtype.UUID `json:"issue_id"`
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
RequirementID pgtype.UUID `json:"requirement_id"`
|
RequirementID pgtype.UUID `json:"requirement_id"`
|
||||||
AgentSessionID *string `json:"agent_session_id"`
|
AgentSessionID *string `json:"agent_session_id"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
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"`
|
||||||
Pid *int32 `json:"pid"`
|
Pid *int32 `json:"pid"`
|
||||||
ExitCode *int32 `json:"exit_code"`
|
ExitCode *int32 `json:"exit_code"`
|
||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package codeindex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== fakes =====
|
||||||
|
|
||||||
|
type fakeRepo struct {
|
||||||
|
runs map[uuid.UUID]*Run
|
||||||
|
replaced map[uuid.UUID][]ChunkRow
|
||||||
|
completed map[uuid.UUID]bool
|
||||||
|
failed map[uuid.UUID]string
|
||||||
|
running map[uuid.UUID]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeRepo() *fakeRepo {
|
||||||
|
return &fakeRepo{
|
||||||
|
runs: map[uuid.UUID]*Run{}, replaced: map[uuid.UUID][]ChunkRow{},
|
||||||
|
completed: map[uuid.UUID]bool{}, failed: map[uuid.UUID]string{}, running: map[uuid.UUID]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) InsertRun(_ context.Context, run *Run) (*Run, error) {
|
||||||
|
cp := *run
|
||||||
|
r.runs[run.ID] = &cp
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) GetRun(_ context.Context, id uuid.UUID) (*Run, error) {
|
||||||
|
run, ok := r.runs[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
cp := *run
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) GetRunByCommit(_ context.Context, wsID uuid.UUID, commit, model string) (*Run, error) {
|
||||||
|
for _, run := range r.runs {
|
||||||
|
if run.WorkspaceID == wsID && run.CommitSHA == commit && run.EmbeddingModel == model {
|
||||||
|
cp := *run
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) GetLatestCompletedRun(_ context.Context, wsID uuid.UUID) (*Run, error) {
|
||||||
|
for _, run := range r.runs {
|
||||||
|
if run.WorkspaceID == wsID && run.Status == StatusCompleted {
|
||||||
|
cp := *run
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) MarkRunning(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.running[id] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) MarkCompleted(_ context.Context, id uuid.UUID, files, chunks int) error {
|
||||||
|
r.completed[id] = true
|
||||||
|
if run := r.runs[id]; run != nil {
|
||||||
|
run.Status = StatusCompleted
|
||||||
|
run.FilesIndexed = files
|
||||||
|
run.ChunksIndexed = chunks
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) MarkFailed(_ context.Context, id uuid.UUID, msg string) error {
|
||||||
|
r.failed[id] = msg
|
||||||
|
if run := r.runs[id]; run != nil {
|
||||||
|
run.Status = StatusFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) MarkStale(_ context.Context, _ uuid.UUID) error { return nil }
|
||||||
|
func (r *fakeRepo) MarkStuckStale(_ context.Context, _ time.Time) ([]uuid.UUID, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) ReplaceChunks(_ context.Context, runID, _ uuid.UUID, _ string, rows []ChunkRow) error {
|
||||||
|
r.replaced[runID] = rows
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeRepo) SearchKNN(context.Context, uuid.UUID, []float32, int, string, string) ([]Snippet, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeLocator struct {
|
||||||
|
dir string
|
||||||
|
wsID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l fakeLocator) LocateWorkspace(context.Context, uuid.UUID) (WorkspaceInfo, error) {
|
||||||
|
return WorkspaceInfo{ID: l.wsID, MainPath: l.dir, DefaultBranch: "main"}, nil
|
||||||
|
}
|
||||||
|
func (l fakeLocator) LocateWorktree(context.Context, uuid.UUID) (string, string, uuid.UUID, error) {
|
||||||
|
return l.dir, "main", l.wsID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeEmbedSel struct {
|
||||||
|
emb llm.Embedder
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s fakeEmbedSel) Embedder(context.Context) (llm.Embedder, uuid.UUID, string, int, error) {
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, uuid.Nil, "", 0, s.err
|
||||||
|
}
|
||||||
|
return s.emb, uuid.New(), "fake-model", llm.PlatformEmbeddingDims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tempGitRepo(t *testing.T) (dir, sha string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("git not in PATH")
|
||||||
|
}
|
||||||
|
dir = t.TempDir()
|
||||||
|
mustGit(t, dir, "init", "-b", "main")
|
||||||
|
mustGit(t, dir, "config", "user.email", "t@e.com")
|
||||||
|
mustGit(t, dir, "config", "user.name", "T")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "util.go"), []byte("package main\n\nfunc add(a, b int) int { return a + b }\n"), 0o644))
|
||||||
|
mustGit(t, dir, "add", ".")
|
||||||
|
mustGit(t, dir, "commit", "-m", "init")
|
||||||
|
out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sha = string(out[:len(out)-1])
|
||||||
|
return dir, sha
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRunner_Success(t *testing.T) {
|
||||||
|
dir, sha := tempGitRepo(t)
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
run := &Run{
|
||||||
|
ID: uuid.New(), WorkspaceID: wsID, Branch: "main", CommitSHA: sha,
|
||||||
|
Status: StatusPending, EmbeddingModel: "fake-model", Dims: llm.PlatformEmbeddingDims,
|
||||||
|
}
|
||||||
|
repo.runs[run.ID] = run
|
||||||
|
|
||||||
|
gitr := git.NewDefaultRunner(git.DefaultConfig())
|
||||||
|
br := NewBuildRunner(repo, fakeLocator{dir: dir, wsID: wsID}, gitr,
|
||||||
|
fakeEmbedSel{emb: llm.NewFakeEmbedder()}, NewChunker(), nil, nil)
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(buildPayload{RunID: run.ID})
|
||||||
|
err := br.Handle(context.Background(), jobs.Job{Payload: payload})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, repo.completed[run.ID])
|
||||||
|
require.True(t, repo.running[run.ID])
|
||||||
|
require.NotEmpty(t, repo.replaced[run.ID], "chunks must be inserted")
|
||||||
|
// Each inserted row must carry an embedding of platform dims.
|
||||||
|
for _, row := range repo.replaced[run.ID] {
|
||||||
|
require.Len(t, row.Embedding, llm.PlatformEmbeddingDims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRunner_FailureMarksFailedAndNotifies(t *testing.T) {
|
||||||
|
dir, sha := tempGitRepo(t)
|
||||||
|
wsID := uuid.New()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
run := &Run{
|
||||||
|
ID: uuid.New(), WorkspaceID: wsID, Branch: "main", CommitSHA: sha,
|
||||||
|
Status: StatusPending, EmbeddingModel: "fake-model", Dims: llm.PlatformEmbeddingDims,
|
||||||
|
}
|
||||||
|
repo.runs[run.ID] = run
|
||||||
|
|
||||||
|
embErr := errors.New("embed boom")
|
||||||
|
notified := false
|
||||||
|
gitr := git.NewDefaultRunner(git.DefaultConfig())
|
||||||
|
br := NewBuildRunner(repo, fakeLocator{dir: dir, wsID: wsID}, gitr,
|
||||||
|
fakeEmbedSel{emb: &llm.FakeEmbedder{Dims: llm.PlatformEmbeddingDims, Err: embErr}},
|
||||||
|
NewChunker(),
|
||||||
|
func(context.Context, *Run, error) { notified = true }, nil)
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(buildPayload{RunID: run.ID})
|
||||||
|
err := br.Handle(context.Background(), jobs.Job{Payload: payload})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, repo.failed[run.ID], "embed boom")
|
||||||
|
require.True(t, notified)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRunner_SkipsAlreadyCompleted(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
run := &Run{ID: uuid.New(), Status: StatusCompleted, EmbeddingModel: "fake-model", Dims: llm.PlatformEmbeddingDims}
|
||||||
|
repo.runs[run.ID] = run
|
||||||
|
br := NewBuildRunner(repo, fakeLocator{}, nil, fakeEmbedSel{emb: llm.NewFakeEmbedder()}, NewChunker(), nil, nil)
|
||||||
|
payload, _ := json.Marshal(buildPayload{RunID: run.ID})
|
||||||
|
require.NoError(t, br.Handle(context.Background(), jobs.Job{Payload: payload}))
|
||||||
|
require.Empty(t, repo.replaced[run.ID])
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Repository = (*fakeRepo)(nil)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package codeindex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunker config defaults. Windows are line-based; the token cap (estimated via
|
||||||
|
// llm.EstimateTokens) hard-bounds embedding cost on pathological long lines.
|
||||||
|
const (
|
||||||
|
DefaultWindowLines = 60
|
||||||
|
DefaultOverlapLines = 10
|
||||||
|
DefaultMaxFileBytes = 512 * 1024 // skip files larger than this
|
||||||
|
DefaultMaxChunkTokens = 1024 // estimated; oversize windows are truncated
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunker splits file content into overlapping line windows.
|
||||||
|
type Chunker struct {
|
||||||
|
WindowLines int
|
||||||
|
OverlapLines int
|
||||||
|
MaxFileBytes int
|
||||||
|
MaxChunkTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChunker returns a Chunker with platform defaults.
|
||||||
|
func NewChunker() *Chunker {
|
||||||
|
return &Chunker{
|
||||||
|
WindowLines: DefaultWindowLines,
|
||||||
|
OverlapLines: DefaultOverlapLines,
|
||||||
|
MaxFileBytes: DefaultMaxFileBytes,
|
||||||
|
MaxChunkTokens: DefaultMaxChunkTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunker) window() int {
|
||||||
|
if c.WindowLines > 0 {
|
||||||
|
return c.WindowLines
|
||||||
|
}
|
||||||
|
return DefaultWindowLines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunker) overlap() int {
|
||||||
|
if c.OverlapLines >= 0 && c.OverlapLines < c.window() {
|
||||||
|
return c.OverlapLines
|
||||||
|
}
|
||||||
|
return DefaultOverlapLines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunker) maxFileBytes() int {
|
||||||
|
if c.MaxFileBytes > 0 {
|
||||||
|
return c.MaxFileBytes
|
||||||
|
}
|
||||||
|
return DefaultMaxFileBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunker) maxChunkTokens() int {
|
||||||
|
if c.MaxChunkTokens > 0 {
|
||||||
|
return c.MaxChunkTokens
|
||||||
|
}
|
||||||
|
return DefaultMaxChunkTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skippable reports whether a file should not be indexed (binary, oversize, or
|
||||||
|
// vendored). Callers should check this before reading large files.
|
||||||
|
func (c *Chunker) Skippable(filePath string, size int, content []byte) bool {
|
||||||
|
if size > c.maxFileBytes() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if isVendored(filePath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if isBinary(content) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk splits content into overlapping line windows. Returns nil for skippable
|
||||||
|
// or empty input. ContentSHA is the sha256 of the chunk's exact content so the
|
||||||
|
// build runner can reuse already-embedded chunks across commits.
|
||||||
|
func (c *Chunker) Chunk(filePath string, content []byte) []Chunk {
|
||||||
|
if len(content) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.Skippable(filePath, len(content), content) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
// Drop a trailing empty element from a final newline so end_line is accurate.
|
||||||
|
if n := len(lines); n > 0 && lines[n-1] == "" {
|
||||||
|
lines = lines[:n-1]
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lang := langForPath(filePath)
|
||||||
|
window := c.window()
|
||||||
|
step := window - c.overlap()
|
||||||
|
if step <= 0 {
|
||||||
|
step = window
|
||||||
|
}
|
||||||
|
maxTok := c.maxChunkTokens()
|
||||||
|
|
||||||
|
var out []Chunk
|
||||||
|
for start := 0; start < len(lines); start += step {
|
||||||
|
end := start + window
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
body := strings.Join(lines[start:end], "\n")
|
||||||
|
// Hard token cap: truncate runaway windows (minified/one-line files).
|
||||||
|
if llm.EstimateTokens(body) > maxTok {
|
||||||
|
body = truncateToTokens(body, maxTok)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
out = append(out, Chunk{
|
||||||
|
StartLine: start + 1, // 1-based, inclusive
|
||||||
|
EndLine: end, // 1-based, inclusive
|
||||||
|
Lang: lang,
|
||||||
|
Content: body,
|
||||||
|
ContentSHA: sum[:],
|
||||||
|
})
|
||||||
|
if end == len(lines) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateToTokens cuts s down to roughly maxTok estimated tokens (4 chars/token).
|
||||||
|
func truncateToTokens(s string, maxTok int) string {
|
||||||
|
maxBytes := maxTok * 4
|
||||||
|
if len(s) <= maxBytes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
// Cut on a UTF-8 boundary.
|
||||||
|
for maxBytes > 0 && (s[maxBytes]&0xC0) == 0x80 {
|
||||||
|
maxBytes--
|
||||||
|
}
|
||||||
|
return s[:maxBytes]
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBinary reports whether content looks binary (NUL byte in the first 8KB).
|
||||||
|
func isBinary(content []byte) bool {
|
||||||
|
n := len(content)
|
||||||
|
if n > 8192 {
|
||||||
|
n = 8192
|
||||||
|
}
|
||||||
|
return bytes.IndexByte(content[:n], 0) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// vendoredDirs are path segments whose contents are excluded from the index.
|
||||||
|
var vendoredDirs = []string{
|
||||||
|
"vendor/", "node_modules/", ".git/", "dist/", "build/", "third_party/",
|
||||||
|
"testdata/", ".venv/", "__pycache__/",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVendored(p string) bool {
|
||||||
|
norm := filepath.ToSlash(p)
|
||||||
|
for _, d := range vendoredDirs {
|
||||||
|
if strings.HasPrefix(norm, d) || strings.Contains(norm, "/"+d) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// langForPath maps a file extension to a coarse language label (best-effort).
|
||||||
|
func langForPath(p string) string {
|
||||||
|
switch strings.ToLower(filepath.Ext(p)) {
|
||||||
|
case ".go":
|
||||||
|
return "go"
|
||||||
|
case ".ts", ".tsx":
|
||||||
|
return "typescript"
|
||||||
|
case ".js", ".jsx", ".mjs", ".cjs":
|
||||||
|
return "javascript"
|
||||||
|
case ".vue":
|
||||||
|
return "vue"
|
||||||
|
case ".py":
|
||||||
|
return "python"
|
||||||
|
case ".rs":
|
||||||
|
return "rust"
|
||||||
|
case ".java":
|
||||||
|
return "java"
|
||||||
|
case ".c", ".h":
|
||||||
|
return "c"
|
||||||
|
case ".cpp", ".cc", ".hpp":
|
||||||
|
return "cpp"
|
||||||
|
case ".rb":
|
||||||
|
return "ruby"
|
||||||
|
case ".php":
|
||||||
|
return "php"
|
||||||
|
case ".sql":
|
||||||
|
return "sql"
|
||||||
|
case ".sh", ".bash":
|
||||||
|
return "shell"
|
||||||
|
case ".md", ".markdown":
|
||||||
|
return "markdown"
|
||||||
|
case ".json":
|
||||||
|
return "json"
|
||||||
|
case ".yaml", ".yml":
|
||||||
|
return "yaml"
|
||||||
|
case ".toml":
|
||||||
|
return "toml"
|
||||||
|
case ".html", ".htm":
|
||||||
|
return "html"
|
||||||
|
case ".css", ".scss":
|
||||||
|
return "css"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package codeindex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChunker_WindowsAndLineNumbers(t *testing.T) {
|
||||||
|
c := &Chunker{WindowLines: 10, OverlapLines: 2, MaxFileBytes: 1 << 20, MaxChunkTokens: 10000}
|
||||||
|
// 25 numbered lines.
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := 1; i <= 25; i++ {
|
||||||
|
sb.WriteString("line")
|
||||||
|
sb.WriteByte(byte('0' + i%10))
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
chunks := c.Chunk("a.go", []byte(sb.String()))
|
||||||
|
require.NotEmpty(t, chunks)
|
||||||
|
|
||||||
|
// First window: lines 1..10.
|
||||||
|
require.Equal(t, 1, chunks[0].StartLine)
|
||||||
|
require.Equal(t, 10, chunks[0].EndLine)
|
||||||
|
require.Equal(t, "go", chunks[0].Lang)
|
||||||
|
|
||||||
|
// step = window - overlap = 8 → next window starts at line 9.
|
||||||
|
require.Equal(t, 9, chunks[1].StartLine)
|
||||||
|
require.Equal(t, 18, chunks[1].EndLine)
|
||||||
|
|
||||||
|
// Last chunk must end exactly at line 25.
|
||||||
|
last := chunks[len(chunks)-1]
|
||||||
|
require.Equal(t, 25, last.EndLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunker_ContentSHAStable(t *testing.T) {
|
||||||
|
c := NewChunker()
|
||||||
|
content := []byte("package x\n\nfunc Y() {}\n")
|
||||||
|
a := c.Chunk("x.go", content)
|
||||||
|
b := c.Chunk("x.go", content)
|
||||||
|
require.Equal(t, len(a), len(b))
|
||||||
|
require.Equal(t, a[0].ContentSHA, b[0].ContentSHA)
|
||||||
|
// SHA must be sha256 of the chunk content.
|
||||||
|
sum := sha256.Sum256([]byte(a[0].Content))
|
||||||
|
require.Equal(t, sum[:], a[0].ContentSHA)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunker_SkipsBinary(t *testing.T) {
|
||||||
|
c := NewChunker()
|
||||||
|
bin := []byte("text\x00more\x00binary")
|
||||||
|
require.Nil(t, c.Chunk("data.bin", bin))
|
||||||
|
require.True(t, c.Skippable("data.bin", len(bin), bin))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunker_SkipsOversize(t *testing.T) {
|
||||||
|
c := &Chunker{WindowLines: 10, MaxFileBytes: 8}
|
||||||
|
big := []byte("aaaaaaaaaaaaaaaaaaaa\n") // > 8 bytes
|
||||||
|
require.Nil(t, c.Chunk("big.txt", big))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunker_SkipsVendored(t *testing.T) {
|
||||||
|
c := NewChunker()
|
||||||
|
content := []byte("package vendored\n")
|
||||||
|
require.Nil(t, c.Chunk("vendor/foo/bar.go", content))
|
||||||
|
require.Nil(t, c.Chunk("node_modules/pkg/index.js", content))
|
||||||
|
require.True(t, isVendored("a/b/node_modules/x.js"))
|
||||||
|
require.False(t, isVendored("internal/codeindex/chunker.go"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunker_EmptyInput(t *testing.T) {
|
||||||
|
c := NewChunker()
|
||||||
|
require.Nil(t, c.Chunk("e.go", nil))
|
||||||
|
require.Nil(t, c.Chunk("e.go", []byte("")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunker_TokenCapTruncates(t *testing.T) {
|
||||||
|
// One very long line that exceeds the token cap is truncated.
|
||||||
|
c := &Chunker{WindowLines: 60, OverlapLines: 10, MaxFileBytes: 1 << 20, MaxChunkTokens: 5}
|
||||||
|
long := strings.Repeat("x", 1000)
|
||||||
|
chunks := c.Chunk("min.js", []byte(long+"\n"))
|
||||||
|
require.Len(t, chunks, 1)
|
||||||
|
require.LessOrEqual(t, len(chunks[0].Content), 5*4) // ~maxTok*4 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLangForPath(t *testing.T) {
|
||||||
|
require.Equal(t, "go", langForPath("a/b.go"))
|
||||||
|
require.Equal(t, "typescript", langForPath("c.tsx"))
|
||||||
|
require.Equal(t, "python", langForPath("x.py"))
|
||||||
|
require.Equal(t, "", langForPath("Makefile"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Package codeindex builds and queries a pgvector-backed code index keyed by
|
||||||
|
// (workspace, commit). A background job (BuildRunner) embeds git-tracked file
|
||||||
|
// chunks; the Search path runs cosine-KNN scoped to the latest completed run.
|
||||||
|
// When no embedding endpoint is configured the whole feature is disabled with a
|
||||||
|
// typed error (callers degrade to keyword discovery via fs/grep).
|
||||||
|
package codeindex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobTypeBuild is the jobs.JobType for an event-driven index build.
|
||||||
|
const JobTypeBuild jobs.JobType = "codeindex.build"
|
||||||
|
|
||||||
|
// Run status values mirror the code_index_runs.status CHECK constraint.
|
||||||
|
const (
|
||||||
|
StatusPending = "pending"
|
||||||
|
StatusRunning = "running"
|
||||||
|
StatusCompleted = "completed"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
StatusStale = "stale"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run mirrors a code_index_runs row.
|
||||||
|
type Run struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
WorkspaceID uuid.UUID
|
||||||
|
WorktreeID *uuid.UUID
|
||||||
|
Branch string
|
||||||
|
CommitSHA string
|
||||||
|
Status string
|
||||||
|
EmbeddingEndpointID *uuid.UUID
|
||||||
|
EmbeddingModel string
|
||||||
|
Dims int
|
||||||
|
FilesIndexed int
|
||||||
|
ChunksIndexed int
|
||||||
|
Error *string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk is one embeddable line window produced by the Chunker.
|
||||||
|
type Chunk struct {
|
||||||
|
StartLine int
|
||||||
|
EndLine int
|
||||||
|
Lang string
|
||||||
|
Content string
|
||||||
|
ContentSHA []byte // sha256 of Content; enables incremental re-embed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet is a ranked search result returned to callers (MCP search_code).
|
||||||
|
type Snippet struct {
|
||||||
|
FilePath string
|
||||||
|
StartLine int
|
||||||
|
EndLine int
|
||||||
|
Content string
|
||||||
|
Score float32 // cosine similarity in [0,1]; higher is closer
|
||||||
|
CommitSHA string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchQuery parameterizes a code search.
|
||||||
|
type SearchQuery struct {
|
||||||
|
Text string
|
||||||
|
TopK int
|
||||||
|
PathPrefix string
|
||||||
|
Lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPayload is the jobs payload for JobTypeBuild.
|
||||||
|
type buildPayload struct {
|
||||||
|
RunID uuid.UUID `json:"run_id"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- The embedding (vector) column is intentionally excluded from every sqlc query
|
||||||
|
-- here: sqlc has no native pgvector type. Vector INSERT (bulk) and cosine-KNN
|
||||||
|
-- SELECT are hand-written in repository.go using the pgvector text literal format.
|
||||||
|
|
||||||
|
-- name: DeleteChunksByRun :exec
|
||||||
|
DELETE FROM code_chunks WHERE run_id = $1;
|
||||||
|
|
||||||
|
-- name: CountChunksByRun :one
|
||||||
|
SELECT count(*) FROM code_chunks WHERE run_id = $1;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- name: InsertRun :one
|
||||||
|
INSERT INTO code_index_runs (
|
||||||
|
id, workspace_id, worktree_id, branch, commit_sha, status,
|
||||||
|
embedding_endpoint_id, embedding_model, dims
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id, workspace_id, worktree_id, branch, commit_sha, status,
|
||||||
|
embedding_endpoint_id, embedding_model, dims,
|
||||||
|
files_indexed, chunks_indexed, error, started_at, finished_at, created_at;
|
||||||
|
|
||||||
|
-- name: GetRun :one
|
||||||
|
SELECT id, workspace_id, worktree_id, branch, commit_sha, status,
|
||||||
|
embedding_endpoint_id, embedding_model, dims,
|
||||||
|
files_indexed, chunks_indexed, error, started_at, finished_at, created_at
|
||||||
|
FROM code_index_runs
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetRunByCommit :one
|
||||||
|
SELECT id, workspace_id, worktree_id, branch, commit_sha, status,
|
||||||
|
embedding_endpoint_id, embedding_model, dims,
|
||||||
|
files_indexed, chunks_indexed, error, started_at, finished_at, created_at
|
||||||
|
FROM code_index_runs
|
||||||
|
WHERE workspace_id = $1 AND commit_sha = $2 AND embedding_model = $3;
|
||||||
|
|
||||||
|
-- name: GetLatestCompletedRun :one
|
||||||
|
SELECT id, workspace_id, worktree_id, branch, commit_sha, status,
|
||||||
|
embedding_endpoint_id, embedding_model, dims,
|
||||||
|
files_indexed, chunks_indexed, error, started_at, finished_at, created_at
|
||||||
|
FROM code_index_runs
|
||||||
|
WHERE workspace_id = $1 AND status = 'completed'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: MarkRunRunning :exec
|
||||||
|
UPDATE code_index_runs
|
||||||
|
SET status = 'running', started_at = now()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: MarkRunCompleted :exec
|
||||||
|
UPDATE code_index_runs
|
||||||
|
SET status = 'completed', files_indexed = $2, chunks_indexed = $3,
|
||||||
|
error = NULL, finished_at = now()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: MarkRunFailed :exec
|
||||||
|
UPDATE code_index_runs
|
||||||
|
SET status = 'failed', error = $2, finished_at = now()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: MarkRunsStale :exec
|
||||||
|
UPDATE code_index_runs
|
||||||
|
SET status = 'stale'
|
||||||
|
WHERE workspace_id = $1 AND status IN ('pending','running','completed');
|
||||||
|
|
||||||
|
-- name: MarkStuckRunsStale :many
|
||||||
|
UPDATE code_index_runs
|
||||||
|
SET status = 'stale'
|
||||||
|
WHERE status = 'running' AND started_at < $1
|
||||||
|
RETURNING id;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user