This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/docs/superpowers/plans/2026-07-02-gin-server-composition.md
T
2026-07-02 15:07:54 +08:00

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() 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:

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 ServerConfig lacks timeout/header fields.

  • Step 3: Implement config parsing

Modify pkg/config/config.go:

  • Import fmt, strconv, and time.
  • Change public ServerConfig to:
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() 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:

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.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:

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 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:

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:

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:

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.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:

(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.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.