18 KiB
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()orengine.Run(). - Set
*gin.Engineashttp.Server.Handlerexplicitly. - Keep all existing API paths, HTTP methods, and JSON response shapes compatible.
- Add configurable
readTimeout,writeTimeout,idleTimeout, andmaxHeaderBytesserver 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", andapproval-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 fromgo mod tidy.configs/config.yaml— add server network settings.pkg/config/config.go— parse duration/header config and environment overrides.internal/api/router.go— replacehttp.ServeMuxwithgin.Engineroute 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 explicithttp.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.ServerConfigwith fieldsAddr 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:
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:
go test ./pkg/config
Expected:
-
Fails because
ServerConfiglacks timeout/header fields. -
Step 3: Implement config parsing
Modify pkg/config/config.go:
- Import
fmt,strconv, andtime. - Change public
ServerConfigto:
type ServerConfig struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
}
- Introduce raw YAML structs:
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()returns15*time.Second,15*time.Second,60*time.Second, and1048576.Load(path)starts fromDefault(), unmarshals YAML intorawConfig, applies only non-zero/non-empty raw fields, parses durations usingtime.ParseDuration, then applies env.applyEnvparses:CODESPACE_READ_TIMEOUTCODESPACE_WRITE_TIMEOUTCODESPACE_IDLE_TIMEOUTCODESPACE_MAX_HEADER_BYTES
- Invalid duration or integer env values return an error from
Load, not silent fallback.
A clean implementation shape:
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:
server:
addr: ":8080"
readTimeout: "15s"
writeTimeout: "15s"
idleTimeout: "60s"
maxHeaderBytes: 1048576
workspace:
root: "./workspaces"
process:
opencodeCommand: "opencode"
- Step 5: Verify config package
Run:
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:
go get github.com/gin-gonic/gin
Expected:
-
go.modcontainsgithub.com/gin-gonic/gin. -
go.sumis 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:
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:
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:
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 behttp.StatusText(status). -
Step 5: Update workspace handler
Modify internal/api/workspace_handler.go:
-
Remove
encoding/jsonimport. -
Handler signatures use
*gin.Context. -
createusesc.ShouldBindJSON(&req). -
get/deleteusec.Param("id"). -
Success responses use
c.JSON(...)orc.Status(http.StatusNoContent). -
Step 6: Update file handler
Modify internal/api/file_handler.go:
-
Remove
encoding/jsonimport. -
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)orc.JSON(...). -
Step 8: Verify API package
Run:
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 GinRun(). -
Step 1: Update server wiring
Modify cmd/server/main.go server construction to:
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, notgin.Engine.Run(). - Config supports
server.readTimeout,server.writeTimeout,server.idleTimeout, andserver.maxHeaderBytes.
Do not add promises for auth/CORS/Git/LSP/frontend.
- Step 3: Verify server package
Run:
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:
gofmt -w cmd internal pkg
go mod tidy
go test ./...
Expected:
-
All tests pass.
-
Step 2: Check Gin Run is not used
Run:
grep -R "\.Run(" cmd internal || true
Expected:
-
No output.
-
Step 3: Check explicit http.Server composition exists
Run:
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.gocontainshttp.Server,Handler: router, andMaxHeaderBytes: cfg.Server.MaxHeaderBytes. -
pkg/config/config.goandconfigs/config.yamlcontainMaxHeaderBytes/maxHeaderBytes. -
Step 4: Run server smoke test
Run:
(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:
{"status":"ok"}
- Step 5: Check git diff for scope creep
Run:
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:
## 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.NewRouterconsistently returns*gin.Engine;cmd/server/main.goconsumes it ashttp.Server.Handler;ServerConfigfields 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.