feat: scaffold codespace backend

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-02 15:07:54 +08:00
co-authored by Claude Fable 5
commit 411ed1f8ba
43 changed files with 4428 additions and 0 deletions
@@ -0,0 +1,390 @@
# codespace 可运行后端骨架设计
日期:2026-07-02
## 目标
在当前目录 `/Users/taochen/llm/codespace` 生成一个 Go 后端项目骨架。第一版目标是可运行、可测试、边界清晰的 Workspace Service,而不是完整 Web IDE。
核心能力:
- 每个 workspace 对应本地文件系统中的一个目录。
- 文件读写统一通过 `FileSystem` 接口,第一版实现 `LocalFS`
- OpenCode 以本机进程方式启动,工作目录 `cwd` 指向 workspace root。
- HTTP handler 不直接操作文件系统或进程,必须经过 service 层。
- 预留 watcher、Git、LSP、AI Agent 的后续扩展点。
- 不依赖 Docker。
## 非目标
第一版不实现:
- 用户认证和权限系统。
- Docker / 容器隔离。
- Git 集成。
- LSP。
- AI Agent 编排。
- Web 前端。
- 完整 watcher / WebSocket 文件事件推送。
这些能力以后接入,但不能污染第一版的核心边界。
## 目录结构
```text
.
├── cmd/
│ └── server/
│ └── main.go
├── configs/
│ └── config.yaml
├── internal/
│ ├── api/
│ │ ├── router.go
│ │ ├── workspace_handler.go
│ │ ├── file_handler.go
│ │ └── process_handler.go
│ ├── workspace/
│ │ ├── manager.go
│ │ ├── workspace.go
│ │ └── path.go
│ ├── fs/
│ │ ├── filesystem.go
│ │ ├── localfs.go
│ │ └── fileinfo.go
│ ├── process/
│ │ ├── manager.go
│ │ ├── session.go
│ │ └── opencode.go
│ ├── watcher/
│ │ └── watcher.go
│ ├── service/
│ │ ├── workspace_service.go
│ │ ├── file_service.go
│ │ └── process_service.go
│ ├── model/
│ │ ├── workspace.go
│ │ ├── file.go
│ │ └── process.go
│ └── util/
│ ├── path.go
│ └── errors.go
├── pkg/
│ ├── config/
│ ├── logger/
│ └── response/
├── web/
├── docs/
├── Makefile
├── go.mod
└── README.md
```
## 架构
```text
HTTP request
internal/api handler
internal/service
workspace.Manager / fs.FileSystem / process.Manager
local workspace directory + opencode process
```
边界规则:
1. `api` 只负责 HTTP 编解码、路由参数、状态码。
2. `service` 负责业务编排和校验。
3. `workspace` 负责 workspace 元数据和 root 目录管理。
4. `fs` 负责 workspace 内文件操作。
5. `process` 负责进程生命周期。
6. `watcher` 第一版只保留接口/占位,不接入运行链路。
## 核心数据结构
### Workspace
```go
type Workspace struct {
ID string `json:"id"`
Root string `json:"root"`
}
```
含义:
- `ID` 是外部 API 使用的 workspace 标识。
- `Root` 是服务端本地绝对路径。
- 第一版不引入数据库,workspace 是否存在由目录和 manager 判断。
### FileInfo
```go
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
}
```
`Path` 始终是 workspace 内相对路径,不向 API 暴露宿主机绝对路径。
### ProcessStatus
```go
type ProcessStatus struct {
WorkspaceID string `json:"workspaceId"`
Running bool `json:"running"`
PID int `json:"pid,omitempty"`
}
```
## Workspace Manager
职责:
- 创建 workspace 目录。
- 查询 workspace。
- 删除 workspace 目录。
- 生成 workspace root 路径。
接口:
```go
type Manager interface {
Create(id string) (*Workspace, error)
Get(id string) (*Workspace, error)
Delete(id string) error
}
```
路径策略:
- 配置项 `workspaceRoot` 默认使用 `./workspaces`
- workspace root 为 `<workspaceRoot>/<workspaceID>`
- workspace ID 第一版限制为安全路径片段,只允许字母、数字、`-``_``.`
## FileSystem
所有文件操作必须走接口:
```go
type FileSystem interface {
List(path string) ([]FileInfo, error)
Read(path string) ([]byte, error)
Write(path string, data []byte) error
Mkdir(path string) error
Remove(path string) error
Rename(oldPath, newPath string) error
Stat(path string) (*FileInfo, error)
}
```
第一版实现 `LocalFS`
- `LocalFS` 绑定一个 workspace root。
- 所有传入路径都是 workspace 内相对路径。
- 解析路径时必须阻止 `../` 逃逸。
- API 不返回宿主机绝对路径。
## Process Manager
职责:按 workspace 管理 OpenCode 进程。
接口:
```go
type Manager interface {
Start(workspaceID string, workspaceRoot string) error
Stop(workspaceID string) error
Restart(workspaceID string, workspaceRoot string) error
Status(workspaceID string) Status
}
```
第一版行为:
- 命令默认是 `opencode`,通过配置项 `opencodeCommand` 可改。
- `cmd.Dir = workspace.Root`
- `HOME` 默认设置为 workspace root,避免不同 workspace 共享 CLI 状态。
- 同一个 workspace 已有运行进程时,再次 start 返回已运行错误,不重复启动。
- stop 使用进程 kill;优雅退出可以后续补。
## Service 层
### WorkspaceService
- `CreateWorkspace(id string)`
- `GetWorkspace(id string)`
- `DeleteWorkspace(id string)`
删除 workspace 前,先停止对应进程,避免目录被运行进程占用。
### FileService
- `List(workspaceID, path string)`
- `Read(workspaceID, path string)`
- `Write(workspaceID, path string, data []byte)`
- `Mkdir(workspaceID, path string)`
- `Remove(workspaceID, path string)`
- `Rename(workspaceID, oldPath, newPath string)`
- `Stat(workspaceID, path string)`
每次通过 workspace manager 取 workspace,再构造绑定该 root 的 `LocalFS`
### ProcessService
- `Start(workspaceID string)`
- `Stop(workspaceID string)`
- `Restart(workspaceID string)`
- `Status(workspaceID string)`
ProcessService 不自己拼路径,只通过 workspace manager 取 root。
## HTTP API
基础路径:`/api`
### 健康检查
```text
GET /healthz
```
返回:
```json
{"status":"ok"}
```
### Workspace
```text
POST /api/workspaces
GET /api/workspaces/{id}
DELETE /api/workspaces/{id}
```
创建请求:
```json
{"id":"user1"}
```
### Files
```text
GET /api/workspaces/{id}/files?path=.
GET /api/workspaces/{id}/files/read?path=main.go
PUT /api/workspaces/{id}/files/write
POST /api/workspaces/{id}/files/mkdir
DELETE /api/workspaces/{id}/files?path=main.go
POST /api/workspaces/{id}/files/rename
```
写文件请求:
```json
{"path":"main.go","content":"package main\n"}
```
重命名请求:
```json
{"oldPath":"old.go","newPath":"new.go"}
```
### Process
```text
POST /api/workspaces/{id}/process/start
POST /api/workspaces/{id}/process/stop
POST /api/workspaces/{id}/process/restart
GET /api/workspaces/{id}/process/status
```
## 配置
`configs/config.yaml`
```yaml
server:
addr: ":8080"
workspace:
root: "./workspaces"
process:
opencodeCommand: "opencode"
```
配置加载规则:
1. 先加载默认值。
2. 如果配置文件存在,覆盖默认值。
3. 环境变量可覆盖:
- `CODESPACE_ADDR`
- `CODESPACE_WORKSPACE_ROOT`
- `CODESPACE_OPENCODE_COMMAND`
## 错误处理
统一 JSON 响应:
```json
{
"error": {
"code": "bad_request",
"message": "invalid workspace id"
}
}
```
常见映射:
- 参数错误:400
- workspace 不存在:404
- 文件不存在:404
- 进程已运行:409
- 内部错误:500
第一版只做简单错误类型,不引入复杂错误框架。
## 测试策略
最小测试:
- workspace ID 校验和路径生成。
- LocalFS 路径逃逸防护。
- LocalFS 基础读写/列表。
- Process manager 状态逻辑使用可配置命令测试,不依赖真实 OpenCode。
- HTTP handler 可做轻量测试,至少覆盖 health 和 workspace 创建。
验收命令:
```sh
go test ./...
go run ./cmd/server
```
## 兼容性与风险
- 当前目录是项目根目录,不再额外创建 `codespace/` 子目录。
- 初始化会创建标准 Go 项目文件;如果目标文件已存在,实施阶段必须先检查,避免覆盖用户已有内容。
- 不改变公共 API,因为这是初始版本。
- 最大安全风险是路径逃逸,必须在 `LocalFS` 和 workspace ID 校验中处理。
- 最大复杂度风险是过早引入 watcher/Git/LSP/Agent;第一版明确不实现。
## 骨架完成标准
完成后应满足:
- 当前目录包含完整 Go module。
- `go test ./...` 通过。
- `go run ./cmd/server` 能启动 HTTP 服务。
- 可以创建 workspace、读写 workspace 内文件、查询/启动/停止进程。
- Handler 不直接调用 `os.*``exec.*`
- 文件路径不能逃逸 workspace root。
@@ -0,0 +1,287 @@
# Gin Router and Explicit HTTP Server Composition Design
日期:2026-07-02
## 目标
将 codespace 后端的原生 Go 1.22 `http.ServeMux` 路由替换为 Gin,同时继续显式构造 `http.Server`。Gin 只负责路由和 handler 上下文,网络层仍由 `http.Server` 控制。
本次改动的核心要求:
- 使用 Gin 注册 API 路由。
- 不使用 `router.Run()``engine.Run()`
-`*gin.Engine` 显式设置为 `http.Server.Handler`
- 将服务器网络参数配置化,包括 timeout 和最大请求头大小。
- 保持现有 API 路径、HTTP method、响应 JSON 结构不变。
## 当前状态
当前代码:
- `cmd/server/main.go` 已显式构造 `http.Server`
- `internal/api/router.go` 使用 `http.NewServeMux()`
- handler 使用 `http.ResponseWriter``*http.Request``r.PathValue("id")``json.NewDecoder(r.Body)`
- `pkg/config` 只配置 `server.addr`timeout 仍硬编码在 `main.go`
## 非目标
本次不做:
- 不引入认证、CORS、rate limit、request logger middleware。
- 不改变 workspace/file/process service 接口。
- 不改变 API 路径、HTTP method、响应结构。
- 不实现 WebSocket、watcher 推送、Git、LSP、AI Agent。
- 不把网络层控制权交给 Gin 的 `Run()`
## 目标架构
```text
cmd/server/main.go
config.Load()
api.NewRouter(...)
*gin.Engine
http.Server{
Addr,
Handler: ginEngine,
ReadTimeout,
WriteTimeout,
IdleTimeout,
MaxHeaderBytes,
}
ListenAndServe()
```
关键规则:
1. `internal/api.NewRouter` 返回 `*gin.Engine`
2. `cmd/server/main.go` 显式创建 `http.Server`
3. `http.Server.Handler` 设置为 Gin engine。
4. 代码中不得出现 `.Run(` 用于启动 Gin server。
5. `ReadTimeout``WriteTimeout``IdleTimeout``MaxHeaderBytes` 来自配置。
## 配置设计
`configs/config.yaml` 扩展为:
```yaml
server:
addr: ":8080"
readTimeout: "15s"
writeTimeout: "15s"
idleTimeout: "60s"
maxHeaderBytes: 1048576
workspace:
root: "./workspaces"
process:
opencodeCommand: "opencode"
```
`pkg/config.ServerConfig` 对外使用强类型:
```go
type ServerConfig struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
}
```
YAML 加载不要依赖 `time.Duration` 的隐式解析。使用 raw config 字符串字段承接 YAML,然后通过 `time.ParseDuration` 显式解析。这样 `"15s"``"1m"``"500ms"` 都清晰可控。
环境变量增加:
- `CODESPACE_READ_TIMEOUT`
- `CODESPACE_WRITE_TIMEOUT`
- `CODESPACE_IDLE_TIMEOUT`
- `CODESPACE_MAX_HEADER_BYTES`
已有环境变量保持不变:
- `CODESPACE_ADDR`
- `CODESPACE_WORKSPACE_ROOT`
- `CODESPACE_OPENCODE_COMMAND`
默认值:
- `addr`: `:8080`
- `readTimeout`: `15s`
- `writeTimeout`: `15s`
- `idleTimeout`: `60s`
- `maxHeaderBytes`: `1048576`
## 路由设计
原路由保持不变:
```text
GET /healthz
POST /api/workspaces
GET /api/workspaces/:id
DELETE /api/workspaces/:id
GET /api/workspaces/:id/files
GET /api/workspaces/:id/files/read
PUT /api/workspaces/:id/files/write
POST /api/workspaces/:id/files/mkdir
DELETE /api/workspaces/:id/files
POST /api/workspaces/:id/files/rename
POST /api/workspaces/:id/process/start
POST /api/workspaces/:id/process/stop
POST /api/workspaces/:id/process/restart
GET /api/workspaces/:id/process/status
```
Gin 注册方式:
```go
r := gin.New()
r.GET("/healthz", healthHandler)
api := r.Group("/api")
api.POST("/workspaces", workspaceHandler.create)
api.GET("/workspaces/:id", workspaceHandler.get)
```
第一版使用 `gin.New()` 而不是 `gin.Default()`,避免隐式加入 logger middleware。只添加 `gin.Recovery()`,防止 panic 打爆进程。请求日志以后有真实需求再加。
## Handler 迁移
签名从:
```go
func (h *workspaceHandler) get(w http.ResponseWriter, r *http.Request)
```
改为:
```go
func (h *workspaceHandler) get(c *gin.Context)
```
路径参数:
```go
id := c.Param("id")
```
查询参数:
```go
path := c.Query("path")
```
JSON body
```go
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, util.New(util.CodeBadRequest, "invalid json"))
return
}
```
响应:
```go
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
c.Status(http.StatusNoContent)
```
## 错误处理
保持现有错误 JSON
```json
{
"error": {
"code": "bad_request",
"message": "invalid json"
}
}
```
`internal/api/errors_helper.go` 改为接收 `*gin.Context`
```go
func writeError(c *gin.Context, err error)
```
继续使用 `util.CodeOf(err)` 映射:
- `bad_request` -> 400
- `not_found` -> 404
- `conflict` -> 409
- default -> 500
可以复用 `pkg/response.ErrorBody``pkg/response.ErrorDetail`,避免重复定义响应结构。
## Server 组合
`cmd/server/main.go` 最终保持显式组合:
```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,
}
```
启动仍然使用:
```go
server.ListenAndServe()
```
## 测试策略
现有 `httptest` 测试继续有效,因为 `*gin.Engine` 实现 `http.Handler`
需要验证:
- `GET /healthz` 行为不变。
- workspace 创建、文件写读、process status 行为不变。
- Gin path param `:id` 正常传入 service。
- 配置默认值正确。
- YAML timeout 字符串能解析。
- 环境变量能覆盖 timeout 和 `maxHeaderBytes`
- `go test ./...` 通过。
- `grep -R "\.Run(" cmd internal || true` 无输出。
- `go run ./cmd/server``/healthz` 返回 `{"status":"ok"}`
## 依赖
新增唯一依赖:
```text
github.com/gin-gonic/gin
```
不新增其他框架或中间件依赖。
## 风险与处理
- 风险:Gin 默认模式可能输出 debug 信息。处理:启动时可设置 `gin.SetMode(gin.ReleaseMode)`,或保留 Gin 默认但不影响功能。第一版建议在 `NewRouter` 中设置 `gin.ReleaseMode`,减少噪音。
- 风险:`time.Duration` YAML 解析行为不符合预期。处理:使用 raw string + `time.ParseDuration`
- 风险:错误响应格式变化。处理:复用现有响应结构并保留 HTTP 测试。
- 风险:误用 `router.Run()`。处理:增加 grep 验证。
## 完成标准
完成后必须满足:
- `internal/api` 使用 Gin 注册路由。
- `cmd/server/main.go` 使用 `http.Server{Handler: router}`
- 不出现 Gin `Run()` 启动服务器。
- server 网络参数来自配置。
- API 行为和响应结构保持兼容。
- `go test ./...` 通过。
- server smoke test 通过。