Merge pull request 'chore: configure gin mode' (#3) from feat/gin-mode-config into main

Reviewed-on: https://gitea-production-a772.up.railway.app/taochen/codespace/pulls/3
This commit is contained in:
2026-07-02 09:47:58 +00:00
9 changed files with 610 additions and 4 deletions
+3
View File
@@ -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` |
+1 -1
View File
@@ -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,
+2
View File
@@ -10,6 +10,8 @@ process:
opencodeCommand: "opencode"
file:
maxWriteBytes: 1048576
gin:
mode: "release"
log:
level: "info"
format: "json"
@@ -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.
@@ -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 通过。
+2 -2
View File
@@ -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))
+3 -1
View File
@@ -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) {
+26
View File
@@ -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)
}
}
+51
View File
@@ -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")
}
}