diff --git a/README.md b/README.md index 4c44d66..9e80586 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ process: opencodeCommand: "opencode" file: maxWriteBytes: 1048576 +gin: + mode: "release" log: level: "info" format: "json" @@ -59,6 +61,7 @@ log: | `CODESPACE_WORKSPACE_ROOT` | Workspace storage root | `./workspaces` | | `CODESPACE_OPENCODE_COMMAND` | OpenCode binary path | `opencode` | | `CODESPACE_FILE_MAX_WRITE_BYTES` | Max bytes per file write | `1048576` | +| `CODESPACE_GIN_MODE` | Gin mode (`debug`, `release`, `test`) | `release` | | `CODESPACE_READ_TIMEOUT` | HTTP read timeout | `15s` | | `CODESPACE_WRITE_TIMEOUT` | HTTP write timeout | `15s` | | `CODESPACE_IDLE_TIMEOUT` | HTTP idle timeout | `60s` | diff --git a/cmd/server/main.go b/cmd/server/main.go index 1162cd3..3d21b2b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -39,7 +39,7 @@ func main() { fileSvc := service.NewFileService(workspaces, cfg.File.MaxWriteBytes) processSvc := service.NewProcessService(workspaces, processes, lg) - router := api.NewRouter(workspaceSvc, fileSvc, processSvc, lg) + router := api.NewRouter(workspaceSvc, fileSvc, processSvc, lg, cfg.Gin.Mode) server := &http.Server{ Addr: cfg.Server.Addr, diff --git a/configs/config.yaml b/configs/config.yaml index b23f6e0..d2ed34c 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -10,6 +10,8 @@ process: opencodeCommand: "opencode" file: maxWriteBytes: 1048576 +gin: + mode: "release" log: level: "info" format: "json" diff --git a/docs/superpowers/plans/2026-07-02-gin-mode-config.md b/docs/superpowers/plans/2026-07-02-gin-mode-config.md new file mode 100644 index 0000000..f12db40 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-gin-mode-config.md @@ -0,0 +1,366 @@ +# Gin Mode Config Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move Gin mode selection out of `internal/api.NewRouter` hardcoding and into project configuration. + +**Architecture:** Add a dedicated `GinConfig` to `pkg/config`, validate Gin mode during `config.Load`, pass `cfg.Gin.Mode` from `cmd/server/main.go` into `api.NewRouter`, and use that value in `gin.SetMode`. Router tests should pass `gin.TestMode` explicitly. + +**Tech Stack:** Go 1.25, Gin, existing YAML config via `gopkg.in/yaml.v3`, existing Gin + slog backend. + +## Global Constraints + +- Do not hardcode `gin.SetMode(gin.ReleaseMode)` in router code. +- Add a dedicated `gin.mode` config block; do not put Gin mode under `server`. +- Supported Gin modes are exactly `debug`, `release`, and `test`. +- Default Gin mode is `release`. +- `CODESPACE_GIN_MODE` must override YAML/default config. +- Invalid Gin mode must fail during `config.Load`. +- Do not change API paths, methods, response JSON, middleware behavior, service interfaces, or logging policy except for passing Gin mode into router construction. +- All application logs must continue to use `log/slog`; do not add `fmt.Print*`, `log.Printf`, `gin.Default`, or `gin.Logger`. +- Do not add dependencies. +- Code generation/refactoring must be delegated to Codex MCP using exactly `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`. +- Do not commit unless the user explicitly asks. + +--- + +## File Structure + +### Modify + +- `configs/config.yaml` — add `gin.mode: "release"`. +- `pkg/config/config.go` — add `GinConfig`, default, YAML/env load, validation. +- `pkg/config/config_test.go` — add default/YAML/env/invalid mode tests. +- `internal/api/router.go` — change `NewRouter` signature to accept `ginMode string` and call `gin.SetMode(ginMode)`. +- `internal/api/router_test.go` — pass `gin.TestMode` into `NewRouter`. +- `cmd/server/main.go` — pass `cfg.Gin.Mode` into `api.NewRouter`. +- `README.md` — document `gin.mode` and `CODESPACE_GIN_MODE`. + +--- + +## Task 1: Add Gin mode config and validation + +**Files:** +- Modify: `pkg/config/config.go` +- Modify: `pkg/config/config_test.go` +- Modify: `configs/config.yaml` + +**Interfaces:** +- Produces: `type GinConfig struct { Mode string `yaml:"mode"` }`. +- Produces: `Config.Gin GinConfig`. +- Produces: `CODESPACE_GIN_MODE` env override. +- Produces: config-load validation for `debug|release|test`. + +- [ ] **Step 1: Add failing config tests** + +Add tests to `pkg/config/config_test.go`: + +```go +func TestDefaultIncludesGinConfig(t *testing.T) { + cfg := Default() + if cfg.Gin.Mode != "release" { + t.Fatalf("Gin.Mode = %q, want release", cfg.Gin.Mode) + } +} + +func TestLoadParsesGinConfigFromYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + data := []byte(`gin: + mode: "debug" +`) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.Gin.Mode != "debug" { + t.Fatalf("Gin.Mode = %q, want debug", cfg.Gin.Mode) + } +} + +func TestLoadAppliesGinModeEnvironmentOverride(t *testing.T) { + t.Setenv("CODESPACE_GIN_MODE", "test") + cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.Gin.Mode != "test" { + t.Fatalf("Gin.Mode = %q, want test", cfg.Gin.Mode) + } +} + +func TestLoadRejectsInvalidGinMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + data := []byte(`gin: + mode: "production" +`) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected invalid gin mode error") + } +} +``` + +- [ ] **Step 2: Implement config fields and validation** + +Update `pkg/config/config.go`: + +```go +type Config struct { + Server ServerConfig `yaml:"server"` + Workspace WorkspaceConfig `yaml:"workspace"` + Process ProcessConfig `yaml:"process"` + File FileConfig `yaml:"file"` + Log LogConfig `yaml:"log"` + Gin GinConfig `yaml:"gin"` +} + +type GinConfig struct { + Mode string `yaml:"mode"` +} +``` + +Update `rawConfig` with `Gin GinConfig`. + +Default: + +```go +Gin: GinConfig{Mode: "release"}, +``` + +Raw/env handling: + +```go +if raw.Gin.Mode != "" { cfg.Gin.Mode = raw.Gin.Mode } +if v := os.Getenv("CODESPACE_GIN_MODE"); v != "" { cfg.Gin.Mode = v } +``` + +Add validation after env overrides, before returning from `Load`: + +```go +func validateGinMode(mode string) error { + switch mode { + case "debug", "release", "test": + return nil + default: + return fmt.Errorf("invalid gin mode %q", mode) + } +} +``` + +Call it from `Load`: + +```go +if err := validateGinMode(cfg.Gin.Mode); err != nil { return Config{}, err } +``` + +- [ ] **Step 3: Update config YAML** + +Add to `configs/config.yaml`: + +```yaml +gin: + mode: "release" +``` + +- [ ] **Step 4: Verify config package** + +Run: + +```sh +gofmt -w pkg/config +go test ./pkg/config +``` + +Expected: config tests pass. + +--- + +## Task 2: Pass Gin mode into router + +**Files:** +- Modify: `internal/api/router.go` +- Modify: `internal/api/router_test.go` +- Modify: `cmd/server/main.go` + +**Interfaces:** +- Changes: `api.NewRouter(workspaces, files, processes, lg, ginMode string) *gin.Engine`. +- Consumes: `cfg.Gin.Mode` from main. +- Tests consume: `gin.TestMode`. + +- [ ] **Step 1: Update router signature** + +Modify `internal/api/router.go`: + +```go +func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, lg *slog.Logger, ginMode string) *gin.Engine { + gin.SetMode(ginMode) + r := gin.New() + ... +} +``` + +Remove `gin.SetMode(gin.ReleaseMode)`. + +- [ ] **Step 2: Update main wiring** + +Modify `cmd/server/main.go`: + +```go +router := api.NewRouter(workspaceSvc, fileSvc, processSvc, lg, cfg.Gin.Mode) +``` + +- [ ] **Step 3: Update router tests** + +Modify `internal/api/router_test.go`: + +```go +return NewRouter(wsSvc, fileSvc, procSvc, nil, gin.TestMode) +``` + +Add Gin import if needed: + +```go +"github.com/gin-gonic/gin" +``` + +- [ ] **Step 4: Verify API and server compile** + +Run: + +```sh +gofmt -w cmd/server internal/api +go test ./internal/api ./cmd/server +``` + +Expected: tests/compile pass. + +--- + +## Task 3: Documentation and final validation + +**Files:** +- Modify: `README.md` + +**Interfaces:** +- Documents `gin.mode` and `CODESPACE_GIN_MODE`. + +- [ ] **Step 1: Update README** + +Add a short config note: + +```markdown +### Gin mode + +Gin mode is configured through `config.yaml`: + +```yaml +gin: + mode: "release" +``` + +Supported values: `debug`, `release`, `test`. + +Environment override: + +- `CODESPACE_GIN_MODE` +``` + +- [ ] **Step 2: Run final validation** + +Run: + +```sh +gofmt -w cmd internal pkg +go mod tidy +go test ./... +grep -R "gin.SetMode(gin.ReleaseMode)" cmd internal pkg || true +grep -R "gin\.Default\|gin\.Logger" cmd internal pkg || true +grep -R "log\.Printf\|log\.Println\|log\.Fatal\|fmt\.Print" cmd internal pkg || true +``` + +Expected: + +- Tests pass. +- No hardcoded `gin.SetMode(gin.ReleaseMode)`. +- No `gin.Default` / `gin.Logger`. +- No disallowed runtime logging. + +- [ ] **Step 3: Server smoke test** + +Run: + +```sh +(go run ./cmd/server > /tmp/codespace-server.log 2>&1 & pid=$!; \ + for i in $(seq 1 30); do \ + if curl -fsS http://localhost:8080/healthz; then \ + kill $pid; wait $pid 2>/dev/null || true; exit 0; \ + fi; \ + sleep 0.2; \ + done; \ + kill $pid 2>/dev/null || true; \ + wait $pid 2>/dev/null || true; \ + cat /tmp/codespace-server.log; exit 1) +``` + +Expected: + +```json +{"status":"ok"} +``` + +--- + +## Codex Execution Prompt + +Use this exact structure when dispatching Codex: + +```text +## Context +- Project root: /Users/taochen/llm/codespace +- Current branch: feat/gin-mode-config +- Go baseline: 1.25 +- Existing backend uses Gin, explicit http.Server, slog logging, config.yaml. +- Design spec: docs/superpowers/specs/2026-07-02-gin-mode-config-design.md +- Implementation plan: docs/superpowers/plans/2026-07-02-gin-mode-config.md + +## Task +Move Gin mode from hardcoded `gin.SetMode(gin.ReleaseMode)` into config. +Steps: +1. Add `gin.mode` to config.yaml. +2. Add `config.GinConfig` with default `release`. +3. Support `CODESPACE_GIN_MODE` override. +4. Validate allowed modes: `debug`, `release`, `test` during `config.Load`. +5. Change `api.NewRouter` to accept `ginMode string` and call `gin.SetMode(ginMode)`. +6. Pass `cfg.Gin.Mode` from main and `gin.TestMode` from tests. +7. Update README and run validation. + +## Constraints +- Do not change API paths, methods, responses, services, middleware behavior, or logging strategy. +- Do not add dependencies. +- Do not use gin.Default or gin.Logger. +- Do not add disallowed runtime logging. +- Do not commit. + +## Acceptance +- gofmt -w cmd internal pkg succeeds. +- go mod tidy succeeds. +- go test ./... passes. +- grep for `gin.SetMode(gin.ReleaseMode)` returns no output. +- go run ./cmd/server starts and /healthz returns {"status":"ok"}. +``` + +--- + +## Self-Review + +- Spec coverage: Plan covers config field, default, YAML/env load, validation, router signature, main wiring, tests, docs, and validation. +- Placeholder scan: No TBD/TODO/ambiguous implementation steps remain. +- Type consistency: `GinConfig`, `Config.Gin`, `cfg.Gin.Mode`, and `api.NewRouter(..., ginMode string)` are consistent. +- Scope check: Plan is limited to Gin mode configuration. It does not change APIs, middleware behavior, services, logging strategy, or dependencies. diff --git a/docs/superpowers/specs/2026-07-02-gin-mode-config-design.md b/docs/superpowers/specs/2026-07-02-gin-mode-config-design.md new file mode 100644 index 0000000..71c6b04 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-gin-mode-config-design.md @@ -0,0 +1,156 @@ +# Gin Mode Config Design + +日期:2026-07-02 + +## 目标 + +移除 `internal/api.NewRouter` 中硬编码的: + +```go +gin.SetMode(gin.ReleaseMode) +``` + +改为通过项目配置控制 Gin mode。 + +## 当前问题 + +当前 `NewRouter` 写死 release mode。这个做法有两个问题: + +1. 开发、测试、生产不能用同一套配置机制控制 Gin mode。 +2. Gin mode 是运行配置,不应该藏在 router 代码里。 + +## 非目标 + +本次不做: + +- 不改变 API 路径、method、响应结构。 +- 不改变 Gin middleware。 +- 不改变 slog 日志策略。 +- 不引入新依赖。 +- 不实现 auth/CORS/WebSocket/Git/LSP/Agent。 + +## 配置设计 + +`configs/config.yaml` 增加: + +```yaml +gin: + mode: "release" +``` + +`pkg/config` 增加: + +```go +type GinConfig struct { + Mode string `yaml:"mode"` +} +``` + +`Config` 增加: + +```go +Gin GinConfig `yaml:"gin"` +``` + +默认值: + +```text +release +``` + +环境变量覆盖: + +```text +CODESPACE_GIN_MODE +``` + +合法值: + +- `debug` +- `release` +- `test` + +非法值在 `config.Load` 阶段返回错误。不要等到 router 初始化才失败。 + +## Router 设计 + +`api.NewRouter` 签名改为: + +```go +func NewRouter( + workspaces *service.WorkspaceService, + files *service.FileService, + processes *service.ProcessService, + lg *slog.Logger, + ginMode string, +) *gin.Engine +``` + +内部: + +```go +gin.SetMode(ginMode) +r := gin.New() +``` + +`NewRouter` 不负责校验 mode。校验属于 config 层。 + +## main.go 设计 + +```go +router := api.NewRouter(workspaceSvc, fileSvc, processSvc, lg, cfg.Gin.Mode) +``` + +## 测试设计 + +### config tests + +覆盖: + +- 默认 mode 是 `release` +- YAML 可设置 `debug` +- env `CODESPACE_GIN_MODE` 可覆盖为 `test` +- 非法 mode 返回错误 + +### router tests + +所有测试调用: + +```go +NewRouter(wsSvc, fileSvc, procSvc, nil, gin.TestMode) +``` + +这样测试明确使用 Gin test mode。 + +### validation + +必须通过: + +```sh +gofmt -w cmd internal pkg +go test ./... +grep -R "gin.SetMode(gin.ReleaseMode)" cmd internal pkg || true +grep -R "gin\.Default\|gin\.Logger" cmd internal pkg || true +``` + +预期: + +- 不再出现 `gin.SetMode(gin.ReleaseMode)` +- 不出现 `gin.Default` / `gin.Logger` + +## 风险与处理 + +- 风险:Gin mode 是全局状态,测试互相污染。处理:router tests 统一传 `gin.TestMode`。 +- 风险:非法 mode 到运行时才失败。处理:config.Load 阶段校验。 +- 风险:把 Gin mode 塞进 `server` 配置造成语义混乱。处理:独立 `gin` 配置块。 + +## 完成标准 + +- `configs/config.yaml` 有 `gin.mode`。 +- `config.Default()` 返回 `Gin.Mode == "release"`。 +- `CODESPACE_GIN_MODE` 可覆盖 mode。 +- 非法 mode 让 `config.Load` 返回错误。 +- `NewRouter` 不再硬编码 `gin.ReleaseMode`。 +- `main.go` 从 config 传入 Gin mode。 +- `go test ./...` 通过。 +- server smoke test 通过。 diff --git a/internal/api/router.go b/internal/api/router.go index 6ab780f..e5ad6d2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -10,8 +10,8 @@ import ( ) // NewRouter builds a Gin engine with all API routes registered. -func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, lg *slog.Logger) *gin.Engine { - gin.SetMode(gin.ReleaseMode) +func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService, lg *slog.Logger, ginMode string) *gin.Engine { + gin.SetMode(ginMode) r := gin.New() r.Use(gin.Recovery()) r.Use(requestLogger(lg)) diff --git a/internal/api/router_test.go b/internal/api/router_test.go index 1486640..a5fce53 100644 --- a/internal/api/router_test.go +++ b/internal/api/router_test.go @@ -10,6 +10,8 @@ import ( "codespace/internal/process" "codespace/internal/service" "codespace/internal/workspace" + + "github.com/gin-gonic/gin" ) func setupTestRouter(t *testing.T) http.Handler { @@ -25,7 +27,7 @@ func setupTestRouterWithMaxWriteBytes(t *testing.T, maxWriteBytes int64) http.Ha wsSvc := service.NewWorkspaceService(wsMgr, procMgr, nil) fileSvc := service.NewFileService(wsMgr, maxWriteBytes) procSvc := service.NewProcessService(wsMgr, procMgr, nil) - return NewRouter(wsSvc, fileSvc, procSvc, nil) + return NewRouter(wsSvc, fileSvc, procSvc, nil, gin.TestMode) } func createWorkspaceForTest(t *testing.T, router http.Handler, id string) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 838b5f2..6465767 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -15,6 +15,7 @@ type Config struct { Workspace WorkspaceConfig `yaml:"workspace"` Process ProcessConfig `yaml:"process"` File FileConfig `yaml:"file"` + Gin GinConfig `yaml:"gin"` Log LogConfig `yaml:"log"` } @@ -42,6 +43,11 @@ type FileConfig struct { MaxWriteBytes int64 `yaml:"maxWriteBytes"` } +// GinConfig holds Gin framework runtime settings. +type GinConfig struct { + Mode string `yaml:"mode"` +} + // LogConfig holds structured logging settings. type LogConfig struct { Level string `yaml:"level"` @@ -55,6 +61,7 @@ type rawConfig struct { Workspace WorkspaceConfig `yaml:"workspace"` Process ProcessConfig `yaml:"process"` File FileConfig `yaml:"file"` + Gin GinConfig `yaml:"gin"` Log LogConfig `yaml:"log"` } @@ -79,6 +86,7 @@ func Default() Config { Workspace: WorkspaceConfig{Root: "./workspaces"}, Process: ProcessConfig{OpenCodeCommand: "opencode"}, File: FileConfig{MaxWriteBytes: 1 << 20}, + Gin: GinConfig{Mode: "release"}, Log: LogConfig{Level: "info", Format: "json"}, } } @@ -104,6 +112,9 @@ func Load(path string) (Config, error) { if err := applyEnv(&cfg); err != nil { return Config{}, err } + if err := validateGinMode(cfg.Gin.Mode); err != nil { + return Config{}, err + } return cfg, nil } @@ -132,6 +143,9 @@ func applyRaw(cfg *Config, raw rawConfig) error { if raw.File.MaxWriteBytes != 0 { cfg.File.MaxWriteBytes = raw.File.MaxWriteBytes } + if raw.Gin.Mode != "" { + cfg.Gin.Mode = raw.Gin.Mode + } if raw.Log.Level != "" { cfg.Log.Level = raw.Log.Level } @@ -192,6 +206,9 @@ func applyEnv(cfg *Config) error { } cfg.File.MaxWriteBytes = n } + if v := os.Getenv("CODESPACE_GIN_MODE"); v != "" { + cfg.Gin.Mode = v + } if v := os.Getenv("CODESPACE_LOG_LEVEL"); v != "" { cfg.Log.Level = v } @@ -200,3 +217,12 @@ func applyEnv(cfg *Config) error { } return nil } + +func validateGinMode(mode string) error { + switch mode { + case "debug", "release", "test": + return nil + default: + return fmt.Errorf("invalid gin mode %q: must be one of debug, release, test", mode) + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 31bdae8..fc4a251 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -179,3 +179,54 @@ func TestLoadAppliesFileEnvironmentOverride(t *testing.T) { t.Fatalf("got %d", cfg.File.MaxWriteBytes) } } + +func TestDefaultIncludesGinConfig(t *testing.T) { + cfg := Default() + if cfg.Gin.Mode != "release" { + t.Fatalf("Gin.Mode = %q, want release", cfg.Gin.Mode) + } +} + +func TestLoadParsesGinConfigFromYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + data := []byte(`gin: + mode: "debug" +`) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.Gin.Mode != "debug" { + t.Fatalf("Gin.Mode = %q, want debug", cfg.Gin.Mode) + } +} + +func TestLoadAppliesGinModeEnvironmentOverride(t *testing.T) { + t.Setenv("CODESPACE_GIN_MODE", "test") + cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if cfg.Gin.Mode != "test" { + t.Fatalf("Gin.Mode = %q, want test", cfg.Gin.Mode) + } +} + +func TestLoadRejectsInvalidGinMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + data := []byte(`gin: + mode: "verbose" +`) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected invalid gin mode error") + } +}