feat(dev): single-binary no-docker runtime with managed localpg

Embed web/dist, run events in-process, manage brew PG18+pgvector, add PI host sidecar and launch --no-docker; unify MiniMax-M2.7-highspeed pins and record dev acceptance.
This commit is contained in:
weichen
2026-09-24 15:53:50 +00:00
parent edbe592478
commit b812d1d30f
24 changed files with 1283 additions and 90 deletions
@@ -0,0 +1,135 @@
# [功能/模块名称] 单二进制化(dev)测试报告
## 一、 测试背景 (Background)
将 dev 环境从「app/events/pi-agent + system-db 容器」切换为:`agent-server` 单二进制(嵌入 `web/dist`、进程内 events)、brew `postgresql@18`+pgvector 自管(`LOCALPG_MODE=managed`)、本机 PI sidecar;三环境模型统一为 MiniMax-M2.7-highspeed。test/main 容器形态本轮不动。
## 二、 测试目标 (Test Objectives)
- `./scripts/build-single-binary.sh` / `go build` 产出 `bin/agent-server`
- `./scripts/launch.sh --no-docker` 拉起 localpg + PI sidecar + agent-server + Vite
- 容器 `shuimu-context-pg-dev` 数据经 `migrate-dev-db.sh` 迁入本机 `:5433`(volume 保留可回退)
- `/api/v1/health` `/api/v1/ready`、PI sidecar `/health`、前端 `:3100`、真实 Runner 聊天
- 共享 KB `shuimu-kb-shared` 保持运行
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|---|---|
| OS | Linux 7.0.0-31-generic |
| Worktree | `.worktrees/single-binary-dev-impl-2026-09-24` |
| Task | `single-binary-dev-impl-b-2026-09-24` |
| PG | Homebrew postgresql@18.6 + pgvector;`DATA_DIR=…/data/dev`;socket=`$XDG_RUNTIME_DIR/as-pg-5433` |
| PI | 本机 `pi` CLI **0.87.1**(容器曾 pin 0.85.1;差异已记录) |
| 模型 | MiniMax-M2.7-highspeed(`.env` / sidecar health / compose defaults) |
| 层级 | 工程 smoke(`go test` localpg/server)+ dev 集成全链路 |
## 四、 预期结果 (Expected Results)
- health/ready:`database_ready`、`events_ready`、`kb_ready` 为 true
- 迁移后 goose ≥ 容器 dump 时版本(67);启动后可继续 Up 到仓库最新
- PI health:`model_id=MiniMax-M2.7-highspeed`,`has_token=true`
- 聊天可触发 sidecar `[runner] Starting pi run … model: MiniMax-M2.7-highspeed`
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 容器 → localpg 迁移
- **输入**:容器 goose=67、public tables=111;`pg_dump -Fc` → restore
- **实际输出**:restore 后 goose=67、tables=111;扩展 COMMENT 权限告警 3 条(可忽略)
- **判定**:PASS
- **备注**:随后 agent-server 启动继续 goose Up → **70**(00068–00070,含 00068 `StatementBegin` 修复)
### 5.2 `launch.sh --no-docker`
- **输入**:`LOCALPG_MODE=managed ./scripts/launch.sh --no-docker`
- **实际输出**(摘要):
```
模式: --no-docker(localpg + PI sidecar + agent-server + Vite)
✅ PI sidecar 就绪
✅ 后端已启动并通过健康检查
⏭️ Vite 已运行于 :3100
```
- **判定**:PASS
- **耗时**:约 15s(含停容器 app/events/pi-agent)
### 5.3 Health / Ready
- **输入**:`curl http://127.0.0.1:8080/api/v1/health` / `ready`
- **实际输出**(关键字段):
```json
{
"status": "ok",
"env": "dev",
"agent_backend": "pi-agent",
"kb_ready": true,
"database_ready": true,
"schema_version": 70,
"schema_ready": true,
"events_ready": true
}
```
- **判定**:PASS(`vault_*` false 为本机无 vault,与本轮范围无关)
### 5.4 PI sidecar
- **输入**:`curl http://127.0.0.1:18865/health`
- **实际输出**:
```json
{
"ok": true,
"service": "shuimu-pi-agent-runner",
"model_id": "MiniMax-M2.7-highspeed",
"has_token": true
}
```
- **判定**:PASS
### 5.5 Vite
- **输入**:`curl -o /dev/null -w '%{http_code}' http://127.0.0.1:3100/`
- **实际输出**:`200`
- **判定**:PASS
### 5.6 真实 Runner 聊天
- **输入**:admin 登录后 `POST /api/v1/chat`(非寒暄短句)
- **实际输出**:HTTP 200,约 2.8s;sidecar 日志:
```
[runner] Starting pi run in workspace: …/dialogue/… model: MiniMax-M2.7-highspeed
```
- **判定**:PASS(真实 PI runner 已调用)
### 5.7 共享 KB / 回退面
- `shuimu-kb-shared`:仍 Up(`:5432`)
- `shuimu-context-pg-dev`:Exited(volume 未删);回退:`docker compose -f docker-compose.dev.yml -p agent-dev up -d system-db`
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 检查 | 结果 |
|---|---|
| `go vet`(localpg/eventsconsumer/server/cmd) | PASS(无输出) |
| `go test -short ./internal/localpg/ ./internal/server/ -run TestResolve\|TestMode\|TestEnsure\|TestStatic\|TestEmbed` | PASS |
| arch-check block | NOT REQUIRED(本轮未改 arch 注册表;依据验证契约工程工具补充) |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
**结论**:dev 单二进制非容器路径可测可用——localpg、进程内 events、embed 静态回退、PI sidecar、模型统一均已验证;容器 system-db 已停并保留 volume。
**后续**:
1. 将 `launch --no-docker` 作为日常默认入口;docker 模式保留回退
2. configs/agents 模板 embed(backlog)
3. PI CLI 0.87.1 vs 容器 0.85.1:差异可接受;必要时 `npx @earendil-works/pi-coding-agent@0.85.1`
4. test/main 仅配置 pin 已对齐;运行形态下轮再议
**证据路径**:`.tmp/launch/{server,pi-sidecar,launch-no-docker,migrate-dev-db}.log`(worktree 本地,未入库)
+6 -1
View File
@@ -47,11 +47,16 @@ TAVILY_API_KEYS=
# --- 6. Pi-Agent Runner 远程调用(Go 宿主连容器时使用)---
PI_AGENT_RUNNER_PORT=18865
# 宿主 bin/server 直连本机 pi-agent 容器时启用;容器内 app 服务由 compose 注入 http://pi-agent:18865
# 宿主 bin/server 直连本机 pi-agent(容器或 sidecar)时启用
# PI_AGENT_RUNNER_URL=http://127.0.0.1:18865
# Runner 服务间认证(main 必填;dev 可留空)
RUNNER_AUTH_TOKEN=
# --- 6b. 非容器化 localpg(brew postgresql@18;dev --no-docker 默认 managed)---
# LOCALPG_MODE=off|managed|attach
# LOCALPG_MODE=managed
# LOCALPG_BIN=/home/linuxbrew/.linuxbrew/opt/postgresql@18/bin
# --- 7. 本机 Pi CLI 回退(不使用容器 runner 时)---
PI_AGENT_BIN=/opt/homebrew/bin/pi
PI_AGENT_BRIDGE_BIN=tools/pi-bridge/index.mjs
+13 -41
View File
@@ -11,19 +11,10 @@ import (
"syscall"
"time"
"chat-agent-system/internal/app/docadvance"
"chat-agent-system/internal/app/docnotify"
"chat-agent-system/internal/app/docproject"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/eventsconsumer"
"chat-agent-system/internal/config"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/db"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/events"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/workflowdef"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -65,31 +56,16 @@ func main() {
os.Exit(1)
}
owner := fmt.Sprintf("events-%d", os.Getpid())
disp := events.NewDispatcher(pool, cfg.Env, owner)
notify := notification.NewService(pool)
gov := docgov.NewGovernanceEngine(pool)
wfDef := workflowdef.NewService(pool, nil)
pwSvc := projectworkflow.NewService(projectworkflow.NewStore(pool), nil)
instSvc := projectinstance.NewService(
projectinstance.NewStore(pool), wfDef.Store(), pwSvc.Store(), projectinstance.NewDocGovChecker(gov),
)
_ = disp.Register(events.ConsumerNotify, (&docnotify.EventConsumer{
Notify: notify, Docs: gov, Env: cfg.Env,
Chat: ctxmgr.NewContextManager(pool, nil, ctxmgr.TimeDecayConfig{}),
}).HandleLease)
_ = disp.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{
Inst: instSvc, Docs: gov, PW: pwSvc, Notify: notify, Env: cfg.Env,
}).HandleLease)
_ = disp.Register(events.ConsumerProjection, intentflow.MuxProjection(
(&docproject.EventConsumer{Docs: gov, Env: cfg.Env}).HandleLease,
(&intentflow.ProjectionConsumer{Env: cfg.Env}).HandleLease,
))
// Intentionally NOT registering events.ConsumerFAV / internal/app/fav.
// Registry.Register("fav") fails closed; mode stays off until separate approval.
h, err := eventsconsumer.Start(ctx, pool, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "events: start: %v\n", err)
os.Exit(1)
}
defer h.Stop()
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "env": cfg.Env, "owner": owner})
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "env": cfg.Env, "owner": h.Owner})
})
mux.HandleFunc("GET /ready", func(w http.ResponseWriter, r *http.Request) {
if err := events.PingPool(r.Context(), pool); err != nil {
@@ -108,14 +84,14 @@ func main() {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
})
mux.HandleFunc("GET /live", func(w http.ResponseWriter, _ *http.Request) {
if !disp.Alive(30 * time.Second) {
if !h.Alive(30 * time.Second) {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "stale"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
})
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) {
m := disp.Metrics
m := h.Disp.Metrics
writeJSON(w, http.StatusOK, map[string]any{
"claimed": m.Claimed.Load(), "acked": m.Acked.Load(), "failed": m.Failed.Load(),
"fenced": m.Fenced.Load(), "dead": m.Dead.Load(), "quarantine": m.Quarantine.Load(),
@@ -124,18 +100,14 @@ func main() {
})
srv := &http.Server{Addr: fmt.Sprintf("%s:%d", cfg.HTTPHost, port), Handler: mux}
go func() {
consumers := []string{events.ConsumerNotify, events.ConsumerProjection, events.ConsumerWorkflowAdvance}
_ = disp.Serve(ctx, consumers)
}()
go func() {
<-ctx.Done()
shut, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
disp.StopClaim()
h.Stop()
_ = srv.Shutdown(shut)
}()
fmt.Printf("events worker env=%s listen=%s:%d owner=%s mode=cluster-table\n", cfg.Env, cfg.HTTPHost, port, owner)
fmt.Printf("events worker env=%s listen=%s:%d owner=%s mode=cluster-table\n", cfg.Env, cfg.HTTPHost, port, h.Owner)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
os.Exit(1)
}
+35 -6
View File
@@ -11,7 +11,9 @@ import (
"time"
"chat-agent-system/internal/app"
"chat-agent-system/internal/app/eventsconsumer"
"chat-agent-system/internal/config"
"chat-agent-system/internal/localpg"
"chat-agent-system/internal/server"
"chat-agent-system/internal/workflow"
)
@@ -39,6 +41,19 @@ func main() {
cfg.AgentBackend = *backend
}
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pg, err := localpg.Ensure(rootCtx, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "localpg: %v\n", err)
os.Exit(1)
}
if pg != nil {
defer func() { _ = pg.Stop() }()
fmt.Printf("localpg managed PGDATA=%s port=%d\n", pg.PGData, pg.Port)
}
rt, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "业务服务初始化失败: %v\n", err)
@@ -48,11 +63,25 @@ func main() {
if rt.WFDef != nil {
rt.WFDef.SetRegistryValidators(workflow.ValidateExecutorKey, workflow.ValidateQualityGateKey)
}
if err := rt.Start(context.Background()); err != nil {
if err := rt.Start(rootCtx); err != nil {
fmt.Fprintf(os.Stderr, "后台 worker 启动失败: %v\n", err)
os.Exit(1)
}
var eventsH *eventsconsumer.Handle
if eventsconsumer.Enabled() && rt.Pool != nil {
eventsH, err = eventsconsumer.Start(rootCtx, rt.Pool, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "eventsconsumer: %v\n", err)
os.Exit(1)
}
fmt.Printf("events in-process owner=%s mode=cluster-table\n", eventsH.Owner)
}
srv := server.NewFromRuntime(rt)
if eventsH != nil {
srv.SetEventsAlive(func() bool { return eventsH.Alive(30 * time.Second) })
}
addr := fmt.Sprintf("%s:%d", cfg.HTTPHost, cfg.HTTPPort)
fmt.Println("==================================================================")
@@ -84,9 +113,6 @@ func main() {
fmt.Println(" • [GET] /api/v1/docgov/audit-logs - 全生命周期审计轨迹流")
fmt.Println("==================================================================")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
go func() {
if err := srv.Start(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "服务启动失败: %v\n", err)
@@ -94,9 +120,12 @@ func main() {
}
}()
<-stop
<-rootCtx.Done()
fmt.Println("\n正在优雅停止服务...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if eventsH != nil {
eventsH.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Fprintf(os.Stderr, "服务停止异常: %v\n", err)
+6
View File
@@ -33,6 +33,12 @@ PI_AGENT_RUNNER_PORT=18865
AGENT_BACKEND=pi-native
# pi-native 失败时进程内回退到容器 pi-agent,无需改此值。
AGENT_TIMEOUT=60m
# 5b. 非容器化(./scripts/launch.sh --no-docker)
# LOCALPG_MODE=managed
# PI_AGENT_RUNNER_URL=http://127.0.0.1:18865
MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed
PI_AGENT_MODEL=MiniMax-M2.7-highspeed
# AgentX / workspace registry (optional; compose maps MINIMAX_* by default)
# AGENTX_API_BASE=
# AGENTX_API_KEY=
+4
View File
@@ -36,3 +36,7 @@ AGENT_TIMEOUT=60m
# 生产必填(不可使用 shared_kb_secret_pass / admin-secret-password-123 等默认值)
RUNNER_AUTH_TOKEN=
ADMIN_PASSWORD=
# Unified model pin
MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed
PI_AGENT_MODEL=MiniMax-M2.7-highspeed
+4
View File
@@ -35,3 +35,7 @@ AGENT_TIMEOUT=60m
# 6. Phase 5 soak 探测示例
# scripts/test-soak.sh http://127.0.0.1:8081 300 10
# Unified model pin
MINIMAX_MODEL_ID=MiniMax-M2.7-highspeed
PI_AGENT_MODEL=MiniMax-M2.7-highspeed
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Host-side PI Agent Runner (non-container). Port defaults to 18865 (dev).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
umask 000
PORT="${PORT:-${PI_AGENT_RUNNER_PORT:-18865}}"
DATA_DIR="${DATA_DIR:-$ROOT/data/dev/workspaces}"
PI_DIR="${PI_CODING_AGENT_DIR:-$ROOT/data/dev/pi-agent}"
ENDPOINT_URL="${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}"
API_TOKEN="${MINIMAX_API_KEY:-${MINIMAX_API_TOKEN:-}}"
# Unified model pin across environments.
MODEL_ID="${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}"
RAW_API_TYPE="${MINIMAX_API_TYPE:-openai-compatible}"
if [ "$RAW_API_TYPE" = "openai-compatible" ] || [ "$RAW_API_TYPE" = "openai" ]; then
API_TYPE="openai-completions"
else
API_TYPE="$RAW_API_TYPE"
fi
mkdir -p "${PI_DIR}/extensions" "$DATA_DIR"
EXT_SRC="$ROOT/deploy/pi-agent/extensions/tavily-search.mjs"
if [ -f "$EXT_SRC" ]; then
cp -f "$EXT_SRC" "${PI_DIR}/extensions/tavily-search.mjs"
fi
cat > "${PI_DIR}/models.json" <<EOF
{
"providers": {
"minimax": {
"name": "MiniMax",
"baseUrl": "${ENDPOINT_URL}",
"api": "${API_TYPE}",
"apiKey": "${API_TOKEN}",
"models": [
{
"id": "${MODEL_ID}",
"name": "${MODEL_ID}",
"reasoning": true,
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 131072
},
{
"id": "MiniMax-M2.7-highspeed",
"name": "MiniMax-M2.7-highspeed",
"reasoning": true,
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 131072
},
{
"id": "MiniMax-M3",
"name": "MiniMax-M3",
"reasoning": true,
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 131072
}
]
}
}
}
EOF
cat > "${PI_DIR}/settings.json" <<EOF
{
"defaultProvider": "minimax",
"defaultModel": "${MODEL_ID}",
"extensions": [
"${PI_DIR}/extensions/tavily-search.mjs"
]
}
EOF
export PORT DATA_DIR PI_CODING_AGENT_DIR="$PI_DIR"
export PATH="${PATH}:/home/linuxbrew/.linuxbrew/bin"
echo "================================================================="
echo " PI Agent Sidecar (host)"
echo " - PORT : ${PORT}"
echo " - DATA_DIR : ${DATA_DIR}"
echo " - PI_DIR : ${PI_DIR}"
echo " - MODEL ID : ${MODEL_ID}"
echo "================================================================="
cd "$ROOT/deploy/pi-agent"
exec node runner.mjs
+1 -1
View File
@@ -17,7 +17,7 @@ ENDPOINT_URL="${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}"
API_TOKEN="${MINIMAX_API_KEY:-${MINIMAX_API_TOKEN:-}}"
# 3) MODEL ID (默认: MiniMax-M3)
MODEL_ID="${MINIMAX_MODEL_ID:-MiniMax-M3}"
MODEL_ID="${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}"
# 4) API 类型 (用户设定 openai-compatible,规整映射至 pi 的 openai-completions)
RAW_API_TYPE="${MINIMAX_API_TYPE:-openai-compatible}"
@@ -3,11 +3,13 @@ ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS digest_id BIGINT,
ADD COLUMN IF NOT EXISTS severity TEXT NOT NULL DEFAULT 'info';
-- +goose StatementBegin
DO $$ BEGIN
ALTER TABLE notifications
ADD CONSTRAINT chk_notification_severity CHECK (severity IN ('action_required', 'info'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- +goose StatementEnd
CREATE INDEX IF NOT EXISTS idx_notifications_digest_id ON notifications (digest_id) WHERE digest_id IS NOT NULL;
+3 -3
View File
@@ -41,8 +41,8 @@ services:
DATA_DIR: /app/data/workspaces
MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
# Release pin. dev uses MiniMax-M2.7-highspeed on purpose.
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3}
# Unified model across environments (was MiniMax-M3 release pin).
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}
MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible}
MINIMAX_PROVIDER: minimax
TAVILY_API_KEYS: ${TAVILY_API_KEYS:-}
@@ -93,7 +93,7 @@ services:
PI_AGENT_RUNNER_URL: http://pi-agent:18867
PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production}
PI_AGENT_PROVIDER: ${PI_AGENT_PROVIDER:-minimax}
PI_AGENT_MODEL: ${PI_AGENT_MODEL:-${MINIMAX_MODEL_ID:-MiniMax-M3}}
PI_AGENT_MODEL: ${PI_AGENT_MODEL:-${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}}
RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:?RUNNER_AUTH_TOKEN is required for main/production}
KB_DB_PASSWORD: ${KB_DB_PASSWORD:?KB_DB_PASSWORD is required for main/production}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
+3 -3
View File
@@ -41,8 +41,8 @@ services:
DATA_DIR: /app/data/workspaces
MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
# Release pin. dev uses MiniMax-M2.7-highspeed on purpose.
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3}
# Unified model across environments (was MiniMax-M3 release pin).
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}
MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible}
MINIMAX_PROVIDER: minimax
TAVILY_API_KEYS: ${TAVILY_API_KEYS:-}
@@ -91,7 +91,7 @@ services:
PI_AGENT_RUNNER_URL: http://pi-agent:18866
PI_AGENT_RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-}
PI_AGENT_PROVIDER: ${PI_AGENT_PROVIDER:-minimax}
PI_AGENT_MODEL: ${PI_AGENT_MODEL:-${MINIMAX_MODEL_ID:-MiniMax-M3}}
PI_AGENT_MODEL: ${PI_AGENT_MODEL:-${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}}
RUNNER_AUTH_TOKEN: ${RUNNER_AUTH_TOKEN:-}
EMBEDDING_ENDPOINT_URL: ${EMBEDDING_ENDPOINT_URL:-}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-bge-m3}
@@ -0,0 +1,121 @@
package eventsconsumer
import (
"context"
"fmt"
"os"
"time"
"chat-agent-system/internal/app/docadvance"
"chat-agent-system/internal/app/docnotify"
"chat-agent-system/internal/app/docproject"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/config"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/events"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/workflowdef"
"github.com/jackc/pgx/v5/pgxpool"
)
// Handle owns an in-process events dispatcher loop.
type Handle struct {
Disp *events.Dispatcher
Owner string
cancel context.CancelFunc
}
// DefaultConsumers is the production consumer set (FAV intentionally omitted).
func DefaultConsumers() []string {
return []string{
events.ConsumerNotify,
events.ConsumerProjection,
events.ConsumerWorkflowAdvance,
}
}
// Enabled reports whether in-process events should run inside cmd/server.
// Default true; set EVENTS_INPROCESS=0|false|off to disable (use standalone cmd/events).
func Enabled() bool {
v := os.Getenv("EVENTS_INPROCESS")
switch v {
case "0", "false", "off", "no":
return false
default:
return true
}
}
// Start registers consumers and runs Dispatcher.Serve in a goroutine until ctx cancels.
func Start(parent context.Context, pool *pgxpool.Pool, cfg *config.Config) (*Handle, error) {
if pool == nil {
return nil, fmt.Errorf("eventsconsumer: pool required")
}
if cfg == nil {
return nil, fmt.Errorf("eventsconsumer: config required")
}
owner := fmt.Sprintf("events-%d", os.Getpid())
disp := events.NewDispatcher(pool, cfg.Env, owner)
if err := registerConsumers(disp, pool, cfg); err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(parent)
go func() {
_ = disp.Serve(ctx, DefaultConsumers())
}()
return &Handle{Disp: disp, Owner: owner, cancel: cancel}, nil
}
func registerConsumers(disp *events.Dispatcher, pool *pgxpool.Pool, cfg *config.Config) error {
notify := notification.NewService(pool)
gov := docgov.NewGovernanceEngine(pool)
wfDef := workflowdef.NewService(pool, nil)
pwSvc := projectworkflow.NewService(projectworkflow.NewStore(pool), nil)
instSvc := projectinstance.NewService(
projectinstance.NewStore(pool), wfDef.Store(), pwSvc.Store(), projectinstance.NewDocGovChecker(gov),
)
if err := disp.Register(events.ConsumerNotify, (&docnotify.EventConsumer{
Notify: notify, Docs: gov, Env: cfg.Env,
Chat: ctxmgr.NewContextManager(pool, nil, ctxmgr.TimeDecayConfig{}),
}).HandleLease); err != nil {
return err
}
if err := disp.Register(events.ConsumerWorkflowAdvance, (&docadvance.EventConsumer{
Inst: instSvc, Docs: gov, PW: pwSvc, Notify: notify, Env: cfg.Env,
}).HandleLease); err != nil {
return err
}
if err := disp.Register(events.ConsumerProjection, intentflow.MuxProjection(
(&docproject.EventConsumer{Docs: gov, Env: cfg.Env}).HandleLease,
(&intentflow.ProjectionConsumer{Env: cfg.Env}).HandleLease,
)); err != nil {
return err
}
// Intentionally NOT registering events.ConsumerFAV / internal/app/fav.
return nil
}
// Stop requests claim stop and cancels the serve loop.
func (h *Handle) Stop() {
if h == nil {
return
}
if h.Disp != nil {
h.Disp.StopClaim()
}
if h.cancel != nil {
h.cancel()
}
}
// Alive reports whether the dispatcher heartbeat is fresh.
func (h *Handle) Alive(maxAge time.Duration) bool {
if h == nil || h.Disp == nil {
return false
}
return h.Disp.Alive(maxAge)
}
+41
View File
@@ -0,0 +1,41 @@
package localpg
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"chat-agent-system/internal/config"
)
func TestEnsureManagedSmoke(t *testing.T) {
if testing.Short() {
// still run — this is the acceptance smoke; skip only if no pg_ctl
}
if _, err := ResolveBinDir(); err != nil {
t.Skip(err)
}
t.Setenv("LOCALPG_MODE", "managed")
dir := t.TempDir()
cfg := &config.Config{
Env: "dev",
DataDir: dir,
ContextDbHost: "127.0.0.1",
ContextDbPort: 55433,
ContextDbUser: "app",
ContextDbPassword: "dev_secret_db_pass",
ContextDbName: "agent_system_dev",
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
h, err := Ensure(ctx, cfg)
if err != nil {
t.Fatalf("Ensure: %v", err)
}
defer func() { _ = h.Stop() }()
if _, err := os.Stat(filepath.Join(dir, "pg", "PG_VERSION")); err != nil {
t.Fatalf("PG_VERSION: %v", err)
}
}
+319
View File
@@ -0,0 +1,319 @@
package localpg
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"chat-agent-system/internal/config"
)
// Mode selects how PostgreSQL is managed by the server process.
type Mode string
const (
ModeOff Mode = "off" // external DSN only (compose / remote)
ModeManaged Mode = "managed" // initdb + pg_ctl child process
ModeAttach Mode = "attach" // connect only; do not start/stop
)
// Handle owns a managed postmaster child process.
type Handle struct {
BinDir string
PGData string
Port int
Socket string
started bool
}
// ModeFromEnv reads LOCALPG_MODE (default off).
func ModeFromEnv() Mode {
switch strings.ToLower(strings.TrimSpace(os.Getenv("LOCALPG_MODE"))) {
case "managed":
return ModeManaged
case "attach":
return ModeAttach
default:
return ModeOff
}
}
// ResolveBinDir finds postgresql binaries (LOCALPG_BIN overrides).
func ResolveBinDir() (string, error) {
if v := strings.TrimSpace(os.Getenv("LOCALPG_BIN")); v != "" {
if _, err := os.Stat(filepath.Join(v, "pg_ctl")); err != nil {
return "", fmt.Errorf("localpg: LOCALPG_BIN=%s missing pg_ctl: %w", v, err)
}
return v, nil
}
candidates := []string{
"/home/linuxbrew/.linuxbrew/opt/postgresql@18/bin",
"/opt/homebrew/opt/postgresql@18/bin",
"/usr/local/opt/postgresql@18/bin",
"/home/linuxbrew/.linuxbrew/opt/postgresql/bin",
}
for _, c := range candidates {
if _, err := os.Stat(filepath.Join(c, "pg_ctl")); err == nil {
return c, nil
}
}
if p, err := exec.LookPath("pg_ctl"); err == nil {
return filepath.Dir(p), nil
}
return "", fmt.Errorf("localpg: pg_ctl not found; install postgresql@18 or set LOCALPG_BIN")
}
// shortSocketDir returns a unix socket directory whose path stays under the
// Linux ~107-byte sun_path limit (including /.s.PGSQL.<port>).
func shortSocketDir(port int) string {
if v := strings.TrimSpace(os.Getenv("LOCALPG_SOCKET_DIR")); v != "" {
return v
}
base := os.Getenv("XDG_RUNTIME_DIR")
if base == "" || len(base) > 40 {
base = os.TempDir()
}
return filepath.Join(base, fmt.Sprintf("as-pg-%d", port))
}
// Ensure bootstraps a managed local PostgreSQL when LOCALPG_MODE=managed.
// Returns nil handle for off/attach (no process ownership).
func Ensure(ctx context.Context, cfg *config.Config) (*Handle, error) {
mode := ModeFromEnv()
switch mode {
case ModeOff, ModeAttach:
return nil, nil
case ModeManaged:
// continue
default:
return nil, fmt.Errorf("localpg: unknown LOCALPG_MODE %q", mode)
}
if cfg == nil {
return nil, fmt.Errorf("localpg: config required")
}
binDir, err := ResolveBinDir()
if err != nil {
return nil, err
}
dataDir := strings.TrimSpace(cfg.DataDir)
if dataDir == "" {
dataDir = config.DefaultDataDir()
}
pgData := filepath.Join(dataDir, "pg")
port := cfg.ContextDbPort
if port <= 0 {
port = 5433
}
// Socket dir must be short: Linux sun_path max is ~107 bytes; worktree
// paths under .worktrees/... easily exceed that. Prefer XDG_RUNTIME_DIR
// or /tmp with a stable short name keyed by port.
socketDir := shortSocketDir(port)
h := &Handle{BinDir: binDir, PGData: pgData, Port: port, Socket: socketDir}
if err := os.MkdirAll(socketDir, 0o700); err != nil {
return nil, fmt.Errorf("localpg: mkdir socket: %w", err)
}
if _, err := os.Stat(filepath.Join(pgData, "PG_VERSION")); err != nil {
if err := os.MkdirAll(pgData, 0o700); err != nil {
return nil, fmt.Errorf("localpg: mkdir PGDATA: %w", err)
}
if err := h.initdb(); err != nil {
return nil, err
}
}
if err := h.start(ctx); err != nil {
return nil, err
}
if err := h.waitReady(ctx, 30*time.Second); err != nil {
_ = h.Stop()
return nil, err
}
if err := h.bootstrapRoleAndDB(cfg); err != nil {
_ = h.Stop()
return nil, err
}
return h, nil
}
func (h *Handle) bin(name string) string {
return filepath.Join(h.BinDir, name)
}
func (h *Handle) initdb() error {
cmd := exec.Command(h.bin("initdb"),
"-D", h.PGData,
"--locale=C",
"--encoding=UTF8",
"--auth-local=trust",
"--auth-host=scram-sha-256",
"-U", "postgres",
)
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("localpg: initdb: %w\n%s", err, out)
}
// Allow TCP from localhost with password + unix trust for bootstrap.
conf := filepath.Join(h.PGData, "postgresql.conf")
appendConf := fmt.Sprintf("\n# agent-system localpg\nlisten_addresses = '127.0.0.1'\nport = %d\nunix_socket_directories = '%s'\n", h.Port, h.Socket)
f, err := os.OpenFile(conf, os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("localpg: open postgresql.conf: %w", err)
}
defer f.Close()
if _, err := f.WriteString(appendConf); err != nil {
return err
}
hba := filepath.Join(h.PGData, "pg_hba.conf")
hbaBody := []byte(`# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
`)
if err := os.WriteFile(hba, hbaBody, 0o600); err != nil {
return fmt.Errorf("localpg: write pg_hba.conf: %w", err)
}
return nil
}
func (h *Handle) start(ctx context.Context) error {
// Already running?
if h.isReady(ctx) {
h.started = false // do not stop foreign instance
return nil
}
logFile := filepath.Join(h.PGData, "pg.log")
cmd := exec.Command(h.bin("pg_ctl"),
"-D", h.PGData,
"-l", logFile,
"-o", fmt.Sprintf("-p %d -k %s", h.Port, h.Socket),
"start",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("localpg: pg_ctl start: %w\n%s", err, out)
}
h.started = true
return nil
}
func (h *Handle) isReady(ctx context.Context) bool {
args := []string{"-h", "127.0.0.1", "-p", strconv.Itoa(h.Port)}
cmd := exec.CommandContext(ctx, h.bin("pg_isready"), args...)
return cmd.Run() == nil
}
func (h *Handle) waitReady(ctx context.Context, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if h.isReady(ctx) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(200 * time.Millisecond):
}
}
return fmt.Errorf("localpg: pg_isready timeout on 127.0.0.1:%d", h.Port)
}
func (h *Handle) psql(args ...string) error {
cmdArgs := append([]string{"-h", h.Socket, "-p", strconv.Itoa(h.Port), "-U", "postgres"}, args...)
cmd := exec.Command(h.bin("psql"), cmdArgs...)
cmd.Env = append(os.Environ(), "PGHOST="+h.Socket)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("localpg: psql %v: %w\n%s", args, err, out)
}
return nil
}
func (h *Handle) bootstrapRoleAndDB(cfg *config.Config) error {
user := cfg.ContextDbUser
if user == "" {
user = "app"
}
pass := cfg.ContextDbPassword
if pass == "" {
pass = "dev_secret_db_pass"
}
dbName := cfg.ContextDbName
if dbName == "" {
dbName = "agent_system_dev"
}
// Escape single quotes for SQL literals.
esc := func(s string) string { return strings.ReplaceAll(s, "'", "''") }
sql := fmt.Sprintf(`
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
CREATE ROLE %s LOGIN PASSWORD '%s';
ELSE
ALTER ROLE %s WITH LOGIN PASSWORD '%s';
END IF;
END$$;
SELECT 'ok' WHERE EXISTS (SELECT FROM pg_database WHERE datname = '%s');
`, esc(user), quoteIdent(user), esc(pass), quoteIdent(user), esc(pass), esc(dbName))
if err := h.psql("-v", "ON_ERROR_STOP=1", "-c", sql); err != nil {
return err
}
// Create DB if missing (cannot run CREATE DATABASE inside DO easily on all versions).
check := exec.Command(h.bin("psql"), "-h", h.Socket, "-p", strconv.Itoa(h.Port), "-U", "postgres", "-tAc",
fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname='%s'", esc(dbName)))
check.Env = append(os.Environ(), "PGHOST="+h.Socket)
out, _ := check.Output()
if strings.TrimSpace(string(out)) != "1" {
create := exec.Command(h.bin("createdb"),
"-h", h.Socket, "-p", strconv.Itoa(h.Port), "-U", "postgres",
"-O", user, dbName,
)
create.Env = append(os.Environ(), "PGHOST="+h.Socket)
if o, err := create.CombinedOutput(); err != nil {
return fmt.Errorf("localpg: createdb: %w\n%s", err, o)
}
}
extSQL := fmt.Sprintf(`
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm;
GRANT ALL ON SCHEMA public TO %s;
`, quoteIdent(user))
cmd := exec.Command(h.bin("psql"),
"-h", h.Socket, "-p", strconv.Itoa(h.Port), "-U", "postgres",
"-d", dbName, "-v", "ON_ERROR_STOP=1", "-c", extSQL,
)
cmd.Env = append(os.Environ(), "PGHOST="+h.Socket)
if o, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("localpg: extensions: %w\n%s", err, o)
}
return nil
}
func quoteIdent(ident string) string {
return `"` + strings.ReplaceAll(ident, `"`, `""`) + `"`
}
// Stop stops a postmaster we started. No-op if we attached to an already-running instance.
func (h *Handle) Stop() error {
if h == nil || !h.started {
return nil
}
cmd := exec.Command(h.bin("pg_ctl"), "-D", h.PGData, "-m", "fast", "stop")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("localpg: pg_ctl stop: %w\n%s", err, out)
}
h.started = false
return nil
}
+32
View File
@@ -0,0 +1,32 @@
package localpg
import (
"os"
"path/filepath"
"testing"
)
func TestModeFromEnv(t *testing.T) {
t.Setenv("LOCALPG_MODE", "")
if ModeFromEnv() != ModeOff {
t.Fatalf("default want off")
}
t.Setenv("LOCALPG_MODE", "managed")
if ModeFromEnv() != ModeManaged {
t.Fatalf("want managed")
}
t.Setenv("LOCALPG_MODE", "ATTACH")
if ModeFromEnv() != ModeAttach {
t.Fatalf("want attach")
}
}
func TestResolveBinDir(t *testing.T) {
dir, err := ResolveBinDir()
if err != nil {
t.Skipf("no pg_ctl on this host: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "pg_ctl")); err != nil {
t.Fatalf("pg_ctl missing in %s: %v", dir, err)
}
}
+1
View File
@@ -21,6 +21,7 @@ type HealthResponse struct {
VaultIdentityOK bool `json:"vault_identity_ok"`
VaultConnected bool `json:"vault_connected"`
VaultLagOK bool `json:"vault_lag_ok"`
EventsReady bool `json:"events_ready,omitempty"`
}
// ChatRequest 对话请求
+10
View File
@@ -72,6 +72,15 @@ func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthRe
kbReady := s.kb != nil && s.kb.Online()
eventsReady := true
if s.eventsAlive != nil {
eventsReady = s.eventsAlive()
if strict && !eventsReady && s.cfg != nil && s.cfg.ContextDbHost != "" && !s.cfg.AllowInMemory && dbReady {
status = "not_ready"
code = http.StatusServiceUnavailable
}
}
resp := HealthResponse{
Status: status,
Env: s.cfg.Env,
@@ -86,6 +95,7 @@ func (s *Server) buildHealthResponse(ctx context.Context, strict bool) (HealthRe
VaultIdentityOK: vaultIdentityOK,
VaultConnected: vaultConnected,
VaultLagOK: vaultLagOK,
EventsReady: eventsReady,
}
s.healthMu.Lock()
s.healthCache = healthSnapshot{at: time.Now(), strict: strict, resp: resp, code: code, ok: true}
+9
View File
@@ -85,6 +85,15 @@ type Server struct {
loginLimiter *loginRateLimiter
healthMu sync.Mutex
healthCache healthSnapshot
eventsAlive func() bool
}
// SetEventsAlive wires an optional in-process events dispatcher liveness probe for /ready.
func (s *Server) SetEventsAlive(fn func() bool) {
if s == nil {
return
}
s.eventsAlive = fn
}
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
+139 -5
View File
@@ -1,16 +1,23 @@
package server
import (
"bytes"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"chat-agent-system/web"
)
// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退。
// 优先磁盘(dev / 热更新 / DATA_DIR 覆盖);无磁盘时回退到 go:embed。
// /assets 与 /fonts 下缺失文件返回 404(不回退 HTML),避免旧 chunk 拿到 HTML 当 JS。
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
@@ -18,19 +25,24 @@ func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
return
}
distDir := resolveDistDir(s.cfg.DataDir)
if distDir == "" {
http.NotFound(w, r)
dataDir := ""
if s.cfg != nil {
dataDir = s.cfg.DataDir
}
if distDir := resolveDistDir(dataDir); distDir != "" {
serveSPAFromDisk(w, r, distDir)
return
}
serveSPAFromEmbed(w, r)
}
func serveSPAFromDisk(w http.ResponseWriter, r *http.Request, distDir string) {
reqPath := filepath.Clean(r.URL.Path)
if reqPath == "/" || reqPath == "." {
serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true)
return
}
// Prevent path escape outside distDir.
targetFile := filepath.Join(distDir, strings.TrimPrefix(reqPath, string(filepath.Separator)))
if !isPathInside(distDir, targetFile) {
http.NotFound(w, r)
@@ -48,10 +60,41 @@ func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
return
}
// SPA client-route fallback
serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true)
}
func serveSPAFromEmbed(w http.ResponseWriter, r *http.Request) {
root, err := fs.Sub(web.DistFS, "dist")
if err != nil {
http.NotFound(w, r)
return
}
reqPath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
if reqPath == "/" || reqPath == "." {
serveStaticFS(w, r, root, "index.html", "no-cache", true)
return
}
rel := strings.TrimPrefix(reqPath, "/")
if strings.Contains(rel, "..") {
http.NotFound(w, r)
return
}
noFallback := strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/")
if fi, err := fs.Stat(root, rel); err == nil && !fi.IsDir() {
serveStaticFS(w, r, root, rel, cacheControlFor(reqPath), true)
return
}
if noFallback {
http.NotFound(w, r)
return
}
serveStaticFS(w, r, root, "index.html", "no-cache", true)
}
func resolveDistDir(dataDir string) string {
candidates := []string{
"web/dist",
@@ -69,6 +112,97 @@ func resolveDistDir(dataDir string) string {
return ""
}
func serveStaticFS(w http.ResponseWriter, r *http.Request, root fs.FS, name, cacheControl string, negotiateCompress bool) {
w.Header().Set("Cache-Control", cacheControl)
fi, err := fs.Stat(root, name)
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
useCompress := negotiateCompress && r.Header.Get("Range") == ""
encoding, openName := "", name
if useCompress {
encoding, openName = pickCompressedVariantFS(root, name, r.Header.Get("Accept-Encoding"))
}
if negotiateCompress && isCompressiblePath(name) {
w.Header().Set("Vary", "Accept-Encoding")
}
if encoding != "" {
w.Header().Set("Content-Encoding", encoding)
if ct := contentTypeFor(name); ct != "" {
w.Header().Set("Content-Type", ct)
}
}
f, err := root.Open(openName)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
http.NotFound(w, r)
return
}
http.ServeContent(w, r, path.Base(name), fi.ModTime(), bytes.NewReader(data))
}
// pickCompressedVariantFS mirrors pickCompressedVariant against an fs.FS.
func pickCompressedVariantFS(root fs.FS, name, acceptEncoding string) (encoding, variantName string) {
if !isCompressiblePath(name) {
return "", name
}
offers := parseAcceptEncoding(acceptEncoding)
if len(offers) == 0 {
return "", name
}
identityQ := 1.0
identityExplicit := false
preferred := make([]encodingOffer, 0, 2)
for _, o := range offers {
switch o.name {
case "identity":
identityQ = o.q
identityExplicit = true
case "br", "gzip":
if o.q > 0 {
preferred = append(preferred, o)
}
}
}
sort.SliceStable(preferred, func(i, j int) bool {
if preferred[i].q != preferred[j].q {
return preferred[i].q > preferred[j].q
}
return preferred[i].name == "br" && preferred[j].name != "br"
})
for _, o := range preferred {
var variant string
switch o.name {
case "br":
variant = name + ".br"
case "gzip":
variant = name + ".gz"
default:
continue
}
if fi, err := fs.Stat(root, variant); err == nil && !fi.IsDir() {
if o.q >= identityQ || !identityExplicit {
return o.name, variant
}
}
}
return "", name
}
func isPathInside(root, candidate string) bool {
absRoot, err1 := filepath.Abs(root)
absCand, err2 := filepath.Abs(candidate)
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# build-single-binary.sh — produce bin/agent-server with embedded web/dist
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
info() { printf '\033[1;34m[build-single-binary]\033[0m %s\n' "$*"; }
SHA="$(git rev-parse --short HEAD)"
OUT="${OUT_BIN:-$ROOT/bin/agent-server}"
info "building web/dist (demo seeds OFF)..."
(
cd "$ROOT/web"
if [[ ! -d node_modules ]]; then
npm ci
fi
env -u VITE_ENABLE_DEMO_SEEDS npm run build
)
if [[ ! -f "$ROOT/web/dist/index.html" ]]; then
echo "missing web/dist/index.html after build" >&2
exit 1
fi
info "go build → $OUT (embed web/dist @ $SHA)..."
mkdir -p "$(dirname "$OUT")"
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${SHA}" -o "$OUT" ./cmd/server
info "done: $OUT ($(du -h "$OUT" | awk '{print $1}'))"
info "next: LOCALPG_MODE=managed PI_AGENT_RUNNER_URL=http://127.0.0.1:18865 $OUT"
info " or ./scripts/launch.sh --no-docker"
+163 -30
View File
@@ -1,40 +1,47 @@
#!/usr/bin/env bash
# launch.sh — dev 环境一键检测与启动(容器组 + Go 后端 + Web 前端)
# launch.sh — dev 环境一键检测与启动
#
# 行为(检测 → 按需更新 → 启动;无变化则完全不动):
# 1) 容器组 agent-dev:compose 配置 hash / 运行状态有变化 → `up -d --build` 重建过期容器;全部一致 → 跳过
# 2) 后端 :8080:Go 源码比 bin/server 新(或服务未运行/不健康)→ 停止旧进程 → 重新构建 → 后台重启;否则跳过
# 3) 前端 :3100:Vite 未运行 → npm install(如需)→ 后台启动;运行中 → 跳过(Vite 自带热更新)
# 模式:
# ./scripts/launch.sh 容器组 (system-db + pi-agent) + 宿主 bin/server + Vite
# ./scripts/launch.sh --no-docker 非容器:localpg + PI sidecar + bin/agent-server + Vite
# (共享 KB 容器 shuimu-kb-shared 仍可保留)
#
# 说明:
# - 「更新到最新」= 按当前工作区源码重建,不做 git pull(避免影响未提交 WIP)
# - FORCE_BUILD=1 ./scripts/launch.sh 可跳过检测强制重建容器镜像与后端二进制
# - 日志归档:.tmp/launch/{server,web}.log
# FORCE_BUILD=1 强制重建;日志 .tmp/launch/{server,web,pi-sidecar}.log
set -uo pipefail
# ---------- 常量 ----------
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="docker-compose.dev.yml"
COMPOSE_PROJECT="agent-dev"
# 本地后端模式:bin/server 占用 8080,容器化后端服务 app(container_name: agent-app-dev)
# 默认不启用(避免端口冲突)。若要改用容器化后端,请从 EXCLUDED_SERVICES 移除并停用本地 bin/server。
EXCLUDED_SERVICES="app"
LOG_DIR="$ROOT/.tmp/launch"
BACKEND_BIN="$ROOT/bin/server"
BACKEND_BIN_LEGACY="$ROOT/bin/server"
BACKEND_BIN_SINGLE="$ROOT/bin/agent-server"
BACKEND_URL="http://127.0.0.1:8080/api/v1/health"
FRONTEND_PORT=3100
PI_AGENT_CONTAINER="shuimu-pi-agent-dev"
PI_SIDECAR_PORT="${PI_AGENT_RUNNER_PORT:-18865}"
NO_DOCKER=0
for arg in "$@"; do
case "$arg" in
--no-docker) NO_DOCKER=1 ;;
-h|--help)
echo "usage: $0 [--no-docker]"
exit 0
;;
esac
done
mkdir -p "$LOG_DIR"
# ---------- 输出与等待工具 ----------
info() { printf '\033[1;34m[launch]\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m ✅ %s\033[0m\n' "$*"; }
skip() { printf '\033[1;32m ⏭️ %s\033[0m\n' "$*"; }
act() { printf '\033[1;33m 🔄 %s\033[0m\n' "$*"; }
fail() { printf '\033[1;31m ❌ %s\033[0m\n' "$*"; }
wait_http() { # $1=url $2=retries(默认30)
wait_http() {
local url="$1" retries="${2:-30}" i
for ((i = 0; i < retries; i++)); do
curl -sf -m 2 "$url" >/dev/null 2>&1 && return 0
@@ -43,7 +50,7 @@ wait_http() { # $1=url $2=retries(默认30)
return 1
}
wait_port() { # $1=port $2=retries(默认30)
wait_port() {
local port="$1" retries="${2:-30}" i
for ((i = 0; i < retries; i++)); do
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 && return 0
@@ -52,12 +59,148 @@ wait_port() { # $1=port $2=retries(默认30)
return 1
}
# ---------- 1) 容器组 ----------
stop_pid_on_port() {
local port="$1" pid
pid="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | head -n1 || true)"
if [[ -n "$pid" ]]; then
act "停止占用 :$port 的进程 PID $pid"
kill "$pid" 2>/dev/null || true
for ((i = 0; i < 10; i++)); do
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 || return 0
sleep 1
done
lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | xargs -r kill -9 2>/dev/null || true
fi
}
# ---------- --no-docker path ----------
launch_no_docker() {
info "模式: --no-docker(localpg + PI sidecar + agent-server + Vite)"
cd "$ROOT"
# Stop conflicting docker app/events/pi-agent (keep shared KB; system-db stopped after migrate)
if docker info >/dev/null 2>&1; then
act "停止 dev 容器 app / events / pi-agent(保留 shared KB)..."
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" stop app events pi-agent 2>/dev/null || true
fi
# Ensure env flags for managed PG
export LOCALPG_MODE="${LOCALPG_MODE:-managed}"
export PI_AGENT_RUNNER_URL="${PI_AGENT_RUNNER_URL:-http://127.0.0.1:${PI_SIDECAR_PORT}}"
export MINIMAX_MODEL_ID="${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed}"
export PI_AGENT_MODEL="${PI_AGENT_MODEL:-MiniMax-M2.7-highspeed}"
export DATA_DIR="${DATA_DIR:-$ROOT/data/dev}"
export ENV="${ENV:-dev}"
# PI sidecar
info "检测 PI sidecar :$PI_SIDECAR_PORT ..."
if wait_http "http://127.0.0.1:${PI_SIDECAR_PORT}/health" 2; then
skip "PI sidecar 已健康"
else
stop_pid_on_port "$PI_SIDECAR_PORT"
act "启动 PI sidecar(日志 $LOG_DIR/pi-sidecar.log)..."
(
set -a
# shellcheck disable=SC1091
[[ -f "$ROOT/.env" ]] && . "$ROOT/.env"
set +a
export PORT="$PI_SIDECAR_PORT"
export DATA_DIR="$DATA_DIR/workspaces"
export PI_CODING_AGENT_DIR="$DATA_DIR/../pi-agent"
export MINIMAX_MODEL_ID PI_AGENT_MODEL
nohup bash "$ROOT/deploy/pi-agent-sidecar/start.sh" >"$LOG_DIR/pi-sidecar.log" 2>&1 &
)
if wait_http "http://127.0.0.1:${PI_SIDECAR_PORT}/health" 60; then
ok "PI sidecar 就绪"
else
fail "PI sidecar 健康检查超时,见 $LOG_DIR/pi-sidecar.log"
exit 1
fi
fi
# agent-server (embeds web; manages localpg when LOCALPG_MODE=managed)
info "检测后端 agent-server :8080 ..."
NEED_REBUILD=0
if [[ ! -f "$BACKEND_BIN_SINGLE" ]]; then
NEED_REBUILD=1
elif [[ -n "$(find "$ROOT/cmd" "$ROOT/internal" "$ROOT/web/embed.go" "$ROOT/go.mod" -newer "$BACKEND_BIN_SINGLE" -print -quit 2>/dev/null)" ]]; then
NEED_REBUILD=1
fi
[[ -n "${FORCE_BUILD:-}" ]] && NEED_REBUILD=1
if [[ "$NEED_REBUILD" -eq 1 ]]; then
act "构建单二进制(scripts/build-single-binary.sh)..."
# Prefer quick go build if dist already present
if [[ -f "$ROOT/web/dist/index.html" ]]; then
(cd "$ROOT" && CGO_ENABLED=0 go build -o "$BACKEND_BIN_SINGLE" ./cmd/server) || {
fail "go build 失败"
exit 1
}
else
bash "$ROOT/scripts/build-single-binary.sh" || { fail "build-single-binary 失败"; exit 1; }
fi
fi
stop_pid_on_port 8080
# Also stop legacy bin/server if different path
if [[ -f "$BACKEND_BIN_LEGACY" ]]; then
pgrep -f "$BACKEND_BIN_LEGACY" >/dev/null 2>&1 && pkill -f "$BACKEND_BIN_LEGACY" || true
fi
act "后台启动 agent-server(LOCALPG_MODE=$LOCALPG_MODE)..."
(
cd "$ROOT"
set -a
# shellcheck disable=SC1091
[[ -f "$ROOT/.env" ]] && . "$ROOT/.env"
set +a
export LOCALPG_MODE PI_AGENT_RUNNER_URL MINIMAX_MODEL_ID PI_AGENT_MODEL DATA_DIR ENV
nohup "$BACKEND_BIN_SINGLE" >"$LOG_DIR/server.log" 2>&1 &
)
if wait_http "$BACKEND_URL" 90; then
ok "后端已启动并通过健康检查"
else
fail "后端健康检查超时,见 $LOG_DIR/server.log"
exit 1
fi
# Vite
info "检测前端 :$FRONTEND_PORT ..."
if wait_port "$FRONTEND_PORT" 1; then
skip "Vite 已运行于 :$FRONTEND_PORT"
else
if [[ ! -d "$ROOT/web/node_modules" ]]; then
act "npm install..."
(cd "$ROOT/web" && npm install) || { fail "npm install 失败"; exit 1; }
fi
act "启动 Vite..."
(cd "$ROOT/web" && nohup npm run dev >"$LOG_DIR/web.log" 2>&1 &)
if wait_port "$FRONTEND_PORT" 30; then
ok "前端已启动"
else
fail "前端启动超时,见 $LOG_DIR/web.log"
exit 1
fi
fi
info "完成(--no-docker)。工作台: http://localhost:$FRONTEND_PORT"
info "API: http://127.0.0.1:8080 PI: http://127.0.0.1:$PI_SIDECAR_PORT"
info "若首次切换,请跑: ./scripts/migrate-dev-db.sh 然后 docker compose -p agent-dev stop system-db"
}
if [[ "$NO_DOCKER" -eq 1 ]]; then
launch_no_docker
exit 0
fi
# ---------- legacy docker-assisted path (unchanged below) ----------
BACKEND_BIN="$BACKEND_BIN_LEGACY"
info "检测容器组 ($COMPOSE_PROJECT)..."
cd "$ROOT"
if ! docker info >/dev/null 2>&1; then
fail "Docker 未运行,请先启动 Docker Desktop"
fail "Docker 未运行,请先启动 Docker Desktop(或改用 --no-docker)"
exit 1
fi
@@ -73,7 +216,6 @@ is_excluded() { case " $EXCLUDED_SERVICES " in *" $1 "*) return 0;; *) return 1;
CONTAINERS_STALE=""
for svc in $SERVICES; do
if is_excluded "$svc"; then
# 排除服务:清理遗留的非运行容器,避免状态噪音
excluded_cid="$(docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" ps -aq "$svc" 2>/dev/null | head -n1)"
if [[ -n "$excluded_cid" ]]; then
excluded_state="$(docker inspect --format '{{.State.Status}}' "$excluded_cid" 2>/dev/null || true)"
@@ -84,7 +226,6 @@ for svc in $SERVICES; do
continue
fi
ENABLED_SERVICES="$ENABLED_SERVICES $svc"
# 期望的配置 hash:兼容 "svc <hash>" 与旧版 "svc hash=<hash>" 两种输出格式;解析失败则只看运行状态
want_hash="$(docker compose -f "$COMPOSE_FILE" config --hash "$svc" 2>/dev/null || true)"
want_hash="${want_hash##*hash=}"
want_hash="${want_hash##* }"
@@ -101,7 +242,7 @@ for svc in $SERVICES; do
done
if [[ -n "${FORCE_BUILD:-}" ]]; then
act "FORCE_BUILD=1:跳过检测,强制重建容器组 (排除: $EXCLUDED_SERVICES)"
act "FORCE_BUILD=1:强制重建容器组 (排除: $EXCLUDED_SERVICES)"
docker compose -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT" up -d --build $ENABLED_SERVICES || { fail "容器组重建失败"; exit 1; }
elif [[ -n "$CONTAINERS_STALE" ]]; then
act "以下服务需要更新/启动:$CONTAINERS_STALE"
@@ -110,7 +251,6 @@ else
skip "容器组全部运行且配置一致,不处理"
fi
# 等待 pi-agent 健康(LLM 关键依赖;pg 类容器启动即用)
act "等待 $PI_AGENT_CONTAINER 健康..."
for ((i = 0; i < 60; i++)); do
hstate="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$PI_AGENT_CONTAINER" 2>/dev/null || true)"
@@ -123,7 +263,6 @@ else
fail "pi-agent 健康检查超时 (当前 $hstate),可稍后重跑本脚本"
fi
# ---------- 2) 后端 ----------
info "检测后端(Go server :8080)..."
NEED_REBUILD=0
@@ -137,9 +276,7 @@ fi
BACKEND_PID="$(lsof -nP -tiTCP:8080 -sTCP:LISTEN 2>/dev/null | head -n1)"
BACKEND_RUNNING=0
if [[ -n "$BACKEND_PID" ]]; then
BACKEND_RUNNING=1
fi
[[ -n "$BACKEND_PID" ]] && BACKEND_RUNNING=1
if [[ "$NEED_REBUILD" -eq 0 && "$BACKEND_RUNNING" -eq 1 ]] && curl -sf -m 3 "$BACKEND_URL" >/dev/null 2>&1; then
skip "源码无变化且服务健康,保持运行"
@@ -151,7 +288,6 @@ else
lsof -nP -iTCP:8080 -sTCP:LISTEN >/dev/null 2>&1 || break
sleep 1
done
# 优雅退出超时则强杀兜底(仅针对仍占用 8080 的进程,避免误伤)
if lsof -nP -iTCP:8080 -sTCP:LISTEN >/dev/null 2>&1; then
lsof -nP -tiTCP:8080 -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true
sleep 1
@@ -170,7 +306,6 @@ else
fi
fi
# ---------- 3) 前端 ----------
info "检测前端 (Vite dev server :$FRONTEND_PORT)..."
if wait_port "$FRONTEND_PORT" 1; then
@@ -189,6 +324,4 @@ else
fi
fi
# ---------- 汇总 ----------
info "完成。工作台入口:http://localhost:$FRONTEND_PORT (/api 代理 → 127.0.0.1:8080)"
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# migrate-dev-db.sh — dump container system-db → brew localpg (PG18) on :5433
#
# Prerequisites:
# - LOCALPG_MODE=managed server once started (or this script can start pg via pg_ctl)
# - container shuimu-context-pg-dev healthy
# - brew postgresql@18 + pgvector installed
#
# Does NOT delete the docker volume (rollback: compose up system-db).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PG_BIN="${LOCALPG_BIN:-/home/linuxbrew/.linuxbrew/opt/postgresql@18/bin}"
DATA_DIR="${DATA_DIR:-$ROOT/data/dev}"
PGDATA="${PGDATA:-$DATA_DIR/pg}"
PORT="${CONTEXT_DB_PORT:-5433}"
DB_USER="${CONTEXT_DB_USER:-app}"
DB_PASS="${CONTEXT_DB_PASSWORD:-dev_secret_db_pass}"
DB_NAME="${CONTEXT_DB_NAME:-agent_system_dev}"
CONTAINER="${DEV_PG_CONTAINER:-shuimu-context-pg-dev}"
DUMP="${DUMP_PATH:-$ROOT/.tmp/dev-pg-migrate.dump}"
info() { printf '\033[1;34m[migrate-dev-db]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[migrate-dev-db]\033[0m %s\n' "$*"; exit 1; }
[[ -x "$PG_BIN/pg_ctl" ]] || fail "pg_ctl not found at $PG_BIN (set LOCALPG_BIN)"
docker inspect "$CONTAINER" >/dev/null 2>&1 || fail "container $CONTAINER not found"
info "dumping $CONTAINER → $DUMP"
docker exec "$CONTAINER" pg_dump -U "${SYSTEM_DB_USER:-app}" -d "${SYSTEM_DB_NAME:-agent_system_dev}" -Fc -f /tmp/dev.dump
docker cp "$CONTAINER:/tmp/dev.dump" "$DUMP"
# Free :5433 for localpg (do not remove volume — rollback via compose up).
if docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null | grep -qi true; then
info "stopping container $CONTAINER to free port $PORT (volume kept)"
docker stop "$CONTAINER" >/dev/null
fi
SOCK="${PG_SOCKET_DIR:-${LOCALPG_SOCKET_DIR:-${XDG_RUNTIME_DIR:-/tmp}/as-pg-$PORT}}"
mkdir -p "$(dirname "$DUMP")" "$SOCK"
# Ensure local PG is up via pg_ctl (avoid agent-server migrations before restore).
if ! "$PG_BIN/pg_isready" -h 127.0.0.1 -p "$PORT" >/dev/null 2>&1; then
info "local PG not ready on :$PORT — initdb/pg_ctl bootstrap..."
mkdir -p "$ROOT/.tmp/launch"
if [[ ! -f "$PGDATA/PG_VERSION" ]]; then
rm -rf "$PGDATA"
mkdir -p "$PGDATA"
"$PG_BIN/initdb" -D "$PGDATA" --locale=C --encoding=UTF8 --auth-local=trust --auth-host=scram-sha-256 -U postgres
{
echo "listen_addresses = '127.0.0.1'"
echo "port = $PORT"
echo "unix_socket_directories = '$SOCK'"
} >>"$PGDATA/postgresql.conf"
cat >"$PGDATA/pg_hba.conf" <<'HBA'
# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 scram-sha-256
host all all ::1/128 scram-sha-256
HBA
fi
"$PG_BIN/pg_ctl" -D "$PGDATA" -l "$PGDATA/pg.log" -o "-p $PORT -k $SOCK" start
for ((i = 0; i < 60; i++)); do
"$PG_BIN/pg_isready" -h 127.0.0.1 -p "$PORT" >/dev/null 2>&1 && break
sleep 1
done
"$PG_BIN/pg_isready" -h 127.0.0.1 -p "$PORT" >/dev/null 2>&1 || fail "local PG still not listening after bootstrap"
# Ensure app role exists before CREATE DATABASE OWNER.
"$PG_BIN/psql" -h "$SOCK" -p "$PORT" -U postgres -v ON_ERROR_STOP=1 <<SQL
DO \$\$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$DB_USER') THEN
CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS';
ELSE
ALTER ROLE $DB_USER WITH LOGIN PASSWORD '$DB_PASS';
END IF;
END \$\$;
SQL
fi
info "dropping & recreating local database $DB_NAME (owner $DB_USER)"
"$PG_BIN/psql" -h "$SOCK" -p "$PORT" -U postgres -v ON_ERROR_STOP=1 <<SQL
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS $DB_NAME;
CREATE DATABASE $DB_NAME OWNER $DB_USER;
\\c $DB_NAME
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pg_trgm;
GRANT ALL ON SCHEMA public TO $DB_USER;
SQL
info "restoring dump into local $DB_NAME"
"$PG_BIN/pg_restore" -h "$SOCK" -p "$PORT" -U postgres -d "$DB_NAME" --no-owner --role="$DB_USER" --verbose "$DUMP" \
|| info "pg_restore exited non-zero (often OK with extension noise); verify goose version next"
info "goose version on local:"
export PGPASSWORD="$DB_PASS"
"$PG_BIN/psql" -h 127.0.0.1 -p "$PORT" -U "$DB_USER" -d "$DB_NAME" -c \
"SELECT COALESCE(MAX(version_id),0) AS goose_version FROM goose_db_version;" || true
info "done. Next: docker compose -f docker-compose.dev.yml -p agent-dev stop system-db"
info "Rollback: docker compose -f docker-compose.dev.yml -p agent-dev up -d system-db"
+11
View File
@@ -0,0 +1,11 @@
package web
import "embed"
// DistFS holds the production SPA build (npm run build + precompress).
// Build scripts must materialize web/dist before `go build`; the directory is
// gitignored. At runtime the server prefers an on-disk web/dist when present
// (dev Vite / hot reload) and falls back to this embed.
//
//go:embed all:dist
var DistFS embed.FS