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:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+90
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 对话请求
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Executable
+33
@@ -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
@@ -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)"
|
||||
|
||||
|
||||
Executable
+102
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user