From b812d1d30f6bf47320038ccf9ebba8305724e7ee Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 24 Sep 2026 15:53:50 +0000 Subject: [PATCH] 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. --- ...026-09-24-single-binary-dev-test-report.md | 135 ++++++++ .env.example | 7 +- cmd/events/main.go | 54 +-- cmd/server/main.go | 41 ++- configs/.env.dev.example | 6 + configs/.env.main.example | 4 + configs/.env.test.example | 4 + deploy/pi-agent-sidecar/start.sh | 90 +++++ deploy/pi-agent/entrypoint.sh | 2 +- ...026-09-24-notification-digest-severity.sql | 2 + docker-compose.main.yml | 6 +- docker-compose.test.yml | 6 +- internal/app/eventsconsumer/eventsconsumer.go | 121 +++++++ internal/localpg/ensure_smoke_test.go | 41 +++ internal/localpg/localpg.go | 319 ++++++++++++++++++ internal/localpg/localpg_test.go | 32 ++ internal/server/handlers.go | 1 + internal/server/handlers_health.go | 10 + internal/server/server.go | 9 + internal/server/static_assets.go | 144 +++++++- scripts/build-single-binary.sh | 33 ++ scripts/launch.sh | 193 +++++++++-- scripts/migrate-dev-db.sh | 102 ++++++ web/embed.go | 11 + 24 files changed, 1283 insertions(+), 90 deletions(-) create mode 100644 .doc/test-reports/2026-09-24-single-binary-dev-test-report.md create mode 100755 deploy/pi-agent-sidecar/start.sh create mode 100644 internal/app/eventsconsumer/eventsconsumer.go create mode 100644 internal/localpg/ensure_smoke_test.go create mode 100644 internal/localpg/localpg.go create mode 100644 internal/localpg/localpg_test.go create mode 100755 scripts/build-single-binary.sh create mode 100755 scripts/migrate-dev-db.sh create mode 100644 web/embed.go diff --git a/.doc/test-reports/2026-09-24-single-binary-dev-test-report.md b/.doc/test-reports/2026-09-24-single-binary-dev-test-report.md new file mode 100644 index 00000000..d8152577 --- /dev/null +++ b/.doc/test-reports/2026-09-24-single-binary-dev-test-report.md @@ -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 本地,未入库) diff --git a/.env.example b/.env.example index b8006b72..c301d9df 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/cmd/events/main.go b/cmd/events/main.go index 39c9c00c..c514d9ef 100644 --- a/cmd/events/main.go +++ b/cmd/events/main.go @@ -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) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 8411ca50..c1076712 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/configs/.env.dev.example b/configs/.env.dev.example index 91916fba..a4e9de14 100644 --- a/configs/.env.dev.example +++ b/configs/.env.dev.example @@ -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= diff --git a/configs/.env.main.example b/configs/.env.main.example index 0e6fac84..ae1736bf 100644 --- a/configs/.env.main.example +++ b/configs/.env.main.example @@ -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 diff --git a/configs/.env.test.example b/configs/.env.test.example index 1e732ce0..73f96e38 100644 --- a/configs/.env.test.example +++ b/configs/.env.test.example @@ -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 diff --git a/deploy/pi-agent-sidecar/start.sh b/deploy/pi-agent-sidecar/start.sh new file mode 100755 index 00000000..f5ebdd66 --- /dev/null +++ b/deploy/pi-agent-sidecar/start.sh @@ -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" < "${PI_DIR}/settings.json" <). +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 +} diff --git a/internal/localpg/localpg_test.go b/internal/localpg/localpg_test.go new file mode 100644 index 00000000..124b812c --- /dev/null +++ b/internal/localpg/localpg_test.go @@ -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) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index e54f2717..b80af643 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -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 对话请求 diff --git a/internal/server/handlers_health.go b/internal/server/handlers_health.go index c3123d8d..885beab4 100644 --- a/internal/server/handlers_health.go +++ b/internal/server/handlers_health.go @@ -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} diff --git a/internal/server/server.go b/internal/server/server.go index 60cdf7f9..4aa94277 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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. diff --git a/internal/server/static_assets.go b/internal/server/static_assets.go index 67e67c9f..b89745a0 100644 --- a/internal/server/static_assets.go +++ b/internal/server/static_assets.go @@ -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) diff --git a/scripts/build-single-binary.sh b/scripts/build-single-binary.sh new file mode 100755 index 00000000..e7c0e7cf --- /dev/null +++ b/scripts/build-single-binary.sh @@ -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" diff --git a/scripts/launch.sh b/scripts/launch.sh index 0d611885..df5b7405 100755 --- a/scripts/launch.sh +++ b/scripts/launch.sh @@ -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 " 与旧版 "svc 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)" - diff --git a/scripts/migrate-dev-db.sh b/scripts/migrate-dev-db.sh new file mode 100755 index 00000000..6ec5805b --- /dev/null +++ b/scripts/migrate-dev-db.sh @@ -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 < 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" diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 00000000..aac79d94 --- /dev/null +++ b/web/embed.go @@ -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