# Gin Server Composition 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:** Replace the standard-library ServeMux API layer with Gin while keeping explicit `http.Server` composition and configurable network settings. **Architecture:** `internal/api.NewRouter` will build and return a `*gin.Engine`. `cmd/server/main.go` will pass that engine into `http.Server{Handler: router}` and continue using `ListenAndServe`, never `router.Run`. `pkg/config` will parse server timeout/header settings into strong types used by `http.Server`. **Tech Stack:** Go 1.22+, Gin (`github.com/gin-gonic/gin`), standard library `net/http`, YAML config via `gopkg.in/yaml.v3`. ## Global Constraints - Use Gin for API routing. - Do not use `router.Run()` or `engine.Run()`. - Set `*gin.Engine` as `http.Server.Handler` explicitly. - Keep all existing API paths, HTTP methods, and JSON response shapes compatible. - Add configurable `readTimeout`, `writeTimeout`, `idleTimeout`, and `maxHeaderBytes` server settings. - Do not add auth, CORS, rate limiting, request logger middleware, WebSocket, Git, LSP, watcher streaming, or AI Agent orchestration. - Use only one new dependency for this migration: `github.com/gin-gonic/gin`. - 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 - `go.mod` — add Gin dependency. - `go.sum` — update transitive dependency checksums from `go mod tidy`. - `configs/config.yaml` — add server network settings. - `pkg/config/config.go` — parse duration/header config and environment overrides. - `internal/api/router.go` — replace `http.ServeMux` with `gin.Engine` route registration. - `internal/api/errors_helper.go` — convert error writer to Gin context. - `internal/api/workspace_handler.go` — migrate handlers to `*gin.Context`. - `internal/api/file_handler.go` — migrate handlers to `*gin.Context`. - `internal/api/process_handler.go` — migrate handlers to `*gin.Context`. - `internal/api/router_test.go` — keep smoke tests and add Gin/server compatibility assertions if useful. - `cmd/server/main.go` — use configured server network parameters and keep explicit `http.Server`. - `README.md` — update dependency/framework and config examples if needed. ### Create - `pkg/config/config_test.go` — tests for default config, YAML duration parsing, environment overrides, and invalid config failures. --- ## Task 1: Add configurable server network settings **Files:** - Modify: `configs/config.yaml` - Modify: `pkg/config/config.go` - Create: `pkg/config/config_test.go` **Interfaces:** - Consumes: existing `config.Load(path string) (Config, error)`. - Produces: `config.ServerConfig` with fields `Addr string`, `ReadTimeout time.Duration`, `WriteTimeout time.Duration`, `IdleTimeout time.Duration`, `MaxHeaderBytes int`. - [ ] **Step 1: Write config tests first** Create `pkg/config/config_test.go` with tests for these behaviors: ```go package config import ( "os" "path/filepath" "testing" "time" ) func TestDefaultIncludesServerNetworkSettings(t *testing.T) { cfg := Default() if cfg.Server.Addr != ":8080" { t.Fatalf("Addr = %q, want :8080", cfg.Server.Addr) } if cfg.Server.ReadTimeout != 15*time.Second { t.Fatalf("ReadTimeout = %v, want 15s", cfg.Server.ReadTimeout) } if cfg.Server.WriteTimeout != 15*time.Second { t.Fatalf("WriteTimeout = %v, want 15s", cfg.Server.WriteTimeout) } if cfg.Server.IdleTimeout != 60*time.Second { t.Fatalf("IdleTimeout = %v, want 60s", cfg.Server.IdleTimeout) } if cfg.Server.MaxHeaderBytes != 1048576 { t.Fatalf("MaxHeaderBytes = %d, want 1048576", cfg.Server.MaxHeaderBytes) } } func TestLoadParsesServerNetworkSettingsFromYAML(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") data := []byte(`server: addr: ":9090" readTimeout: "2s" writeTimeout: "3s" idleTimeout: "4s" maxHeaderBytes: 2048 workspace: root: "./tmp-workspaces" process: opencodeCommand: "fake-opencode" `) 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.Server.Addr != ":9090" { t.Fatalf("Addr = %q, want :9090", cfg.Server.Addr) } if cfg.Server.ReadTimeout != 2*time.Second || cfg.Server.WriteTimeout != 3*time.Second || cfg.Server.IdleTimeout != 4*time.Second { t.Fatalf("timeouts = %v/%v/%v, want 2s/3s/4s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout) } if cfg.Server.MaxHeaderBytes != 2048 { t.Fatalf("MaxHeaderBytes = %d, want 2048", cfg.Server.MaxHeaderBytes) } } func TestLoadAppliesServerEnvironmentOverrides(t *testing.T) { t.Setenv("CODESPACE_ADDR", ":7070") t.Setenv("CODESPACE_READ_TIMEOUT", "5s") t.Setenv("CODESPACE_WRITE_TIMEOUT", "6s") t.Setenv("CODESPACE_IDLE_TIMEOUT", "7s") t.Setenv("CODESPACE_MAX_HEADER_BYTES", "4096") cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) if err != nil { t.Fatalf("Load returned error: %v", err) } if cfg.Server.Addr != ":7070" { t.Fatalf("Addr = %q, want :7070", cfg.Server.Addr) } if cfg.Server.ReadTimeout != 5*time.Second || cfg.Server.WriteTimeout != 6*time.Second || cfg.Server.IdleTimeout != 7*time.Second { t.Fatalf("timeouts = %v/%v/%v, want 5s/6s/7s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout) } if cfg.Server.MaxHeaderBytes != 4096 { t.Fatalf("MaxHeaderBytes = %d, want 4096", cfg.Server.MaxHeaderBytes) } } func TestLoadRejectsInvalidDuration(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") data := []byte(`server: readTimeout: "garbage" `) if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { t.Fatal("expected invalid duration error") } } ``` - [ ] **Step 2: Run tests to verify failure** Run: ```sh go test ./pkg/config ``` Expected: - Fails because `ServerConfig` lacks timeout/header fields. - [ ] **Step 3: Implement config parsing** Modify `pkg/config/config.go`: - Import `fmt`, `strconv`, and `time`. - Change public `ServerConfig` to: ```go type ServerConfig struct { Addr string ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration MaxHeaderBytes int } ``` - Introduce raw YAML structs: ```go type rawConfig struct { Server rawServerConfig `yaml:"server"` Workspace WorkspaceConfig `yaml:"workspace"` Process ProcessConfig `yaml:"process"` } type rawServerConfig struct { Addr string `yaml:"addr"` ReadTimeout string `yaml:"readTimeout"` WriteTimeout string `yaml:"writeTimeout"` IdleTimeout string `yaml:"idleTimeout"` MaxHeaderBytes int `yaml:"maxHeaderBytes"` } ``` - `Default()` returns `15*time.Second`, `15*time.Second`, `60*time.Second`, and `1048576`. - `Load(path)` starts from `Default()`, unmarshals YAML into `rawConfig`, applies only non-zero/non-empty raw fields, parses durations using `time.ParseDuration`, then applies env. - `applyEnv` parses: - `CODESPACE_READ_TIMEOUT` - `CODESPACE_WRITE_TIMEOUT` - `CODESPACE_IDLE_TIMEOUT` - `CODESPACE_MAX_HEADER_BYTES` - Invalid duration or integer env values return an error from `Load`, not silent fallback. A clean implementation shape: ```go func applyRaw(cfg *Config, raw rawConfig) error func applyDuration(target *time.Duration, value string, name string) error func applyEnv(cfg *Config) error ``` - [ ] **Step 4: Update YAML config** Modify `configs/config.yaml` to: ```yaml server: addr: ":8080" readTimeout: "15s" writeTimeout: "15s" idleTimeout: "60s" maxHeaderBytes: 1048576 workspace: root: "./workspaces" process: opencodeCommand: "opencode" ``` - [ ] **Step 5: Verify config package** Run: ```sh gofmt -w pkg/config go test ./pkg/config ``` Expected: - Tests pass. --- ## Task 2: Migrate API router and handlers to Gin **Files:** - Modify: `go.mod` - Modify: `go.sum` - Modify: `internal/api/router.go` - Modify: `internal/api/errors_helper.go` - Modify: `internal/api/workspace_handler.go` - Modify: `internal/api/file_handler.go` - Modify: `internal/api/process_handler.go` - Modify: `internal/api/router_test.go` **Interfaces:** - Consumes: service constructors and methods unchanged. - Produces: `api.NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine`. - [ ] **Step 1: Add Gin dependency** Run: ```sh go get github.com/gin-gonic/gin ``` Expected: - `go.mod` contains `github.com/gin-gonic/gin`. - `go.sum` is updated. - [ ] **Step 2: Keep HTTP tests as compatibility tests** `internal/api/router_test.go` can keep using `http.Handler` because `*gin.Engine` implements `ServeHTTP`. Update setup only if compile requires it: ```go func setupTestRouter(t *testing.T) http.Handler { t.Helper() root := t.TempDir() wsMgr := workspace.NewLocalManager(root) procMgr := process.NewManager("") wsSvc := service.NewWorkspaceService(wsMgr, procMgr) fileSvc := service.NewFileService(wsMgr) procSvc := service.NewProcessService(wsMgr, procMgr) return NewRouter(wsSvc, fileSvc, procSvc) } ``` Existing tests must continue to assert the same HTTP status codes and JSON response bodies. - [ ] **Step 3: Implement Gin router** Modify `internal/api/router.go` to: ```go package api import ( "net/http" "codespace/internal/service" "github.com/gin-gonic/gin" ) func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) wsHandler := &workspaceHandler{svc: workspaces} fileHandler := &fileHandler{svc: files} procHandler := &processHandler{svc: processes} r.GET("/healthz", healthHandler) api := r.Group("/api") api.POST("/workspaces", wsHandler.create) api.GET("/workspaces/:id", wsHandler.get) api.DELETE("/workspaces/:id", wsHandler.delete) api.GET("/workspaces/:id/files", fileHandler.list) api.GET("/workspaces/:id/files/read", fileHandler.read) api.PUT("/workspaces/:id/files/write", fileHandler.write) api.POST("/workspaces/:id/files/mkdir", fileHandler.mkdir) api.DELETE("/workspaces/:id/files", fileHandler.remove) api.POST("/workspaces/:id/files/rename", fileHandler.rename) api.POST("/workspaces/:id/process/start", procHandler.start) api.POST("/workspaces/:id/process/stop", procHandler.stop) api.POST("/workspaces/:id/process/restart", procHandler.restart) api.GET("/workspaces/:id/process/status", procHandler.status) return r } func healthHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } ``` - [ ] **Step 4: Update error helper for Gin** Modify `internal/api/errors_helper.go` to use: ```go func writeError(c *gin.Context, err error) ``` Required behavior: - Use `util.CodeOf(err)`. - Map bad request/not found/conflict/internal to 400/404/409/500. - Respond with `response.ErrorBody{Error: response.ErrorDetail{Code: string(code), Message: message}}`. - If `err == nil`, message should be `http.StatusText(status)`. - [ ] **Step 5: Update workspace handler** Modify `internal/api/workspace_handler.go`: - Remove `encoding/json` import. - Handler signatures use `*gin.Context`. - `create` uses `c.ShouldBindJSON(&req)`. - `get/delete` use `c.Param("id")`. - Success responses use `c.JSON(...)` or `c.Status(http.StatusNoContent)`. - [ ] **Step 6: Update file handler** Modify `internal/api/file_handler.go`: - Remove `encoding/json` import. - Handler signatures use `*gin.Context`. - Workspace ID uses `c.Param("id")`. - Query path uses `c.Query("path")`. - JSON body uses `c.ShouldBindJSON(&req)`. - No-content responses use `c.Status(http.StatusNoContent)`. - [ ] **Step 7: Update process handler** Modify `internal/api/process_handler.go`: - Handler signatures use `*gin.Context`. - Workspace ID uses `c.Param("id")`. - Success responses use `c.Status(http.StatusNoContent)` or `c.JSON(...)`. - [ ] **Step 8: Verify API package** Run: ```sh gofmt -w internal/api go test ./internal/api ``` Expected: - API tests pass with the same status/body assertions. --- ## Task 3: Wire Gin engine into explicit http.Server **Files:** - Modify: `cmd/server/main.go` - Modify: `README.md` **Interfaces:** - Consumes: `api.NewRouter(...) *gin.Engine`. - Consumes: `cfg.Server.ReadTimeout`, `cfg.Server.WriteTimeout`, `cfg.Server.IdleTimeout`, `cfg.Server.MaxHeaderBytes`. - Produces: explicit `http.Server{Handler: router}` startup without Gin `Run()`. - [ ] **Step 1: Update server wiring** Modify `cmd/server/main.go` server construction to: ```go router := api.NewRouter(workspaceSvc, fileSvc, processSvc) server := &http.Server{ Addr: cfg.Server.Addr, Handler: router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, MaxHeaderBytes: cfg.Server.MaxHeaderBytes, } ``` Remove direct `time` usage if it becomes unused except shutdown timeout. Keep `10*time.Second` for graceful shutdown context unless you add a separate config field; do not add that field in this task. - [ ] **Step 2: Update README** Update `README.md` so it says: - The API router uses Gin. - The server is still started through explicit `http.Server`, not `gin.Engine.Run()`. - Config supports `server.readTimeout`, `server.writeTimeout`, `server.idleTimeout`, and `server.maxHeaderBytes`. Do not add promises for auth/CORS/Git/LSP/frontend. - [ ] **Step 3: Verify server package** Run: ```sh gofmt -w cmd/server go test ./cmd/server ./pkg/config ./internal/api ``` Expected: - All listed packages pass. --- ## Task 4: Final validation and safety checks **Files:** - Modify only files from Tasks 1-3 if validation exposes issues. **Interfaces:** - Produces validated Gin migration. - [ ] **Step 1: Format, tidy, and test everything** Run: ```sh gofmt -w cmd internal pkg go mod tidy go test ./... ``` Expected: - All tests pass. - [ ] **Step 2: Check Gin Run is not used** Run: ```sh grep -R "\.Run(" cmd internal || true ``` Expected: - No output. - [ ] **Step 3: Check explicit http.Server composition exists** Run: ```sh grep -R "http.Server" -n cmd/server/main.go grep -R "Handler:" -n cmd/server/main.go grep -R "MaxHeaderBytes" -n cmd/server/main.go pkg/config configs/config.yaml ``` Expected: - `cmd/server/main.go` contains `http.Server`, `Handler: router`, and `MaxHeaderBytes: cfg.Server.MaxHeaderBytes`. - `pkg/config/config.go` and `configs/config.yaml` contain `MaxHeaderBytes`/`maxHeaderBytes`. - [ ] **Step 4: Run 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"} ``` - [ ] **Step 5: Check git diff for scope creep** Run: ```sh git diff --stat git status --short ``` Expected: - Changes are limited to Gin migration, config, docs/spec/plan, `.gitignore`, and previously generated skeleton files. - No auth/CORS/Git/LSP/frontend implementation appears. --- ## Codex Execution Prompt Use this exact structure when dispatching Codex: ```text ## Context - Project root: /Users/taochen/llm/codespace - Current branch: feat/codespace-backend-skeleton - Existing backend is a Go 1.22 codespace service. - Current router uses standard library http.ServeMux in internal/api/router.go. - Server already uses explicit http.Server in cmd/server/main.go. - Design spec: docs/superpowers/specs/2026-07-02-gin-server-composition-design.md - Implementation plan: docs/superpowers/plans/2026-07-02-gin-server-composition.md ## Task Migrate routing to Gin and configure explicit http.Server network settings. Steps: 1. Add Gin dependency. 2. Add configurable server read/write/idle timeout and maxHeaderBytes settings. 3. Replace internal/api ServeMux router and handlers with Gin equivalents. 4. Keep cmd/server/main.go using explicit http.Server{Handler: router}; do not use router.Run(). 5. Update tests and README. 6. Run final validation. ## Constraints - Do not change API paths, methods, or response JSON shapes. - Do not change service interfaces. - Do not add auth, CORS, logging middleware, rate limiting, WebSocket, Git, LSP, frontend, watcher streaming, or AI Agent features. - Use only one new dependency: github.com/gin-gonic/gin. - Do not commit. ## Acceptance - gofmt -w cmd internal pkg succeeds. - go mod tidy succeeds. - go test ./... passes. - grep -R "\.Run(" cmd internal || true returns no output. - cmd/server/main.go contains explicit http.Server with Handler: router and MaxHeaderBytes from config. - go run ./cmd/server starts and /healthz returns {"status":"ok"}. ``` --- ## Self-Review - Spec coverage: Plan covers Gin routing, handler migration, explicit server composition, configurable network settings, docs, tests, and validation against `Run()` misuse. - Placeholder scan: No TBD/TODO/ambiguous implementation steps remain. - Type consistency: `api.NewRouter` consistently returns `*gin.Engine`; `cmd/server/main.go` consumes it as `http.Server.Handler`; `ServerConfig` fields are consistent across plan tasks. - Scope check: Plan is limited to Gin migration and server config. It does not introduce auth, CORS, WebSocket, Git, LSP, frontend, watcher streaming, or Agent features.