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
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"net/http"
"codespace/internal/util"
"codespace/pkg/response"
"github.com/gin-gonic/gin"
)
// writeError maps a service-layer error to an HTTP JSON error response.
func writeError(c *gin.Context, err error) {
code := util.CodeOf(err)
var status int
switch code {
case util.CodeBadRequest:
status = http.StatusBadRequest
case util.CodeNotFound:
status = http.StatusNotFound
case util.CodeConflict:
status = http.StatusConflict
default:
status = http.StatusInternalServerError
}
msg := http.StatusText(status)
if err != nil {
msg = err.Error()
}
c.JSON(status, response.ErrorBody{Error: response.ErrorDetail{
Code: string(code),
Message: msg,
}})
}
// writeBadRequest is a shorthand for 400 responses without a service error.
func writeBadRequest(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, response.ErrorBody{Error: response.ErrorDetail{
Code: string(util.CodeBadRequest),
Message: message,
}})
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"net/http"
"codespace/internal/model"
"codespace/internal/service"
"github.com/gin-gonic/gin"
)
type fileHandler struct {
svc *service.FileService
}
func (h *fileHandler) list(c *gin.Context) {
id := c.Param("id")
path := c.Query("path")
if path == "" {
path = "."
}
infos, err := h.svc.List(id, path)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, infos)
}
func (h *fileHandler) read(c *gin.Context) {
id := c.Param("id")
path := c.Query("path")
if path == "" {
writeBadRequest(c, "path is required")
return
}
data, err := h.svc.Read(id, path)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, model.ReadFileResponse{
Path: path,
Content: string(data),
})
}
func (h *fileHandler) write(c *gin.Context) {
id := c.Param("id")
var req model.WriteFileRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeBadRequest(c, "invalid json")
return
}
if req.Path == "" {
writeBadRequest(c, "path is required")
return
}
if err := h.svc.Write(id, req.Path, []byte(req.Content)); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *fileHandler) mkdir(c *gin.Context) {
id := c.Param("id")
var req model.MkdirRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeBadRequest(c, "invalid json")
return
}
if req.Path == "" {
writeBadRequest(c, "path is required")
return
}
if err := h.svc.Mkdir(id, req.Path); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *fileHandler) remove(c *gin.Context) {
id := c.Param("id")
path := c.Query("path")
if path == "" {
writeBadRequest(c, "path is required")
return
}
if err := h.svc.Remove(id, path); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *fileHandler) rename(c *gin.Context) {
id := c.Param("id")
var req model.RenameRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeBadRequest(c, "invalid json")
return
}
if req.OldPath == "" || req.NewPath == "" {
writeBadRequest(c, "oldPath and newPath are required")
return
}
if err := h.svc.Rename(id, req.OldPath, req.NewPath); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"net/http"
"codespace/internal/model"
"codespace/internal/service"
"github.com/gin-gonic/gin"
)
type processHandler struct {
svc *service.ProcessService
}
func (h *processHandler) start(c *gin.Context) {
id := c.Param("id")
if err := h.svc.Start(id); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *processHandler) stop(c *gin.Context) {
id := c.Param("id")
if err := h.svc.Stop(id); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *processHandler) restart(c *gin.Context) {
id := c.Param("id")
if err := h.svc.Restart(id); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *processHandler) status(c *gin.Context) {
id := c.Param("id")
status, err := h.svc.Status(id)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, model.ProcessStatusResponse{
WorkspaceID: status.WorkspaceID,
Running: status.Running,
PID: status.PID,
})
}
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"net/http"
"codespace/internal/service"
"github.com/gin-gonic/gin"
)
// NewRouter builds a Gin engine with all API routes registered.
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"})
}
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codespace/internal/process"
"codespace/internal/service"
"codespace/internal/workspace"
)
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)
}
func TestHealthz(t *testing.T) {
router := setupTestRouter(t)
req := httptest.NewRequest("GET", "/healthz", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status = %q, want ok", body["status"])
}
}
func TestCreateWorkspace(t *testing.T) {
router := setupTestRouter(t)
payload, _ := json.Marshal(map[string]string{"id": "user1"})
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["id"] != "user1" {
t.Errorf("id = %q, want user1", resp["id"])
}
}
func TestWriteReadFile(t *testing.T) {
router := setupTestRouter(t)
// Create workspace first.
payload, _ := json.Marshal(map[string]string{"id": "user1"})
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create workspace: status=%d body=%s", rec.Code, rec.Body.String())
}
// Write file.
writePayload, _ := json.Marshal(map[string]string{"path": "main.go", "content": "package main\n"})
req = httptest.NewRequest("PUT", "/api/workspaces/user1/files/write", bytes.NewReader(writePayload))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("write file: status=%d want=%d body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
// Read file.
req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=main.go", nil)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("read file: status=%d want=%d body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["content"] != "package main\n" {
t.Errorf("content = %q, want 'package main\\n'", resp["content"])
}
}
func TestProcessStatus(t *testing.T) {
router := setupTestRouter(t)
// Create workspace.
payload, _ := json.Marshal(map[string]string{"id": "user1"})
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create workspace: status=%d", rec.Code)
}
// Get process status.
req = httptest.NewRequest("GET", "/api/workspaces/user1/process/status", nil)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
WorkspaceID string `json:"workspaceId"`
Running bool `json:"running"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.WorkspaceID != "user1" {
t.Errorf("workspaceId = %q, want user1", resp.WorkspaceID)
}
if resp.Running {
t.Error("expected Running=false")
}
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"net/http"
"codespace/internal/model"
"codespace/internal/service"
"github.com/gin-gonic/gin"
)
type workspaceHandler struct {
svc *service.WorkspaceService
}
func (h *workspaceHandler) create(c *gin.Context) {
var req model.CreateWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeBadRequest(c, "invalid json")
return
}
if req.ID == "" {
writeBadRequest(c, "id is required")
return
}
ws, err := h.svc.Create(req.ID)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
}
func (h *workspaceHandler) get(c *gin.Context) {
id := c.Param("id")
ws, err := h.svc.Get(id)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, model.WorkspaceResponse{ID: ws.ID})
}
func (h *workspaceHandler) delete(c *gin.Context) {
id := c.Param("id")
if err := h.svc.Delete(id); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+13
View File
@@ -0,0 +1,13 @@
package fs
import "time"
// FileInfo describes a file or directory within a workspace.
// Path is always workspace-relative; host absolute paths are never exposed.
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
ModTime time.Time `json:"modTime"`
}
+13
View File
@@ -0,0 +1,13 @@
package fs
// FileSystem abstracts file operations within a single workspace root.
// All path arguments are workspace-relative.
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)
}
+170
View File
@@ -0,0 +1,170 @@
package fs
import (
"os"
"path/filepath"
"sort"
"strings"
"codespace/internal/util"
)
// LocalFS implements FileSystem bound to a single workspace root directory.
type LocalFS struct {
root string
}
// NewLocal creates a LocalFS whose root is absolute and cleaned if possible.
func NewLocal(root string) *LocalFS {
abs, err := filepath.Abs(root)
if err != nil {
abs = filepath.Clean(root)
}
return &LocalFS{root: abs}
}
// resolve validates path, cleans it, and returns the absolute path on disk
// plus the workspace-relative form. It rejects paths that escape the root.
func (l *LocalFS) resolve(path string) (abs string, rel string, err error) {
rel, err = util.CleanRelative(path)
if err != nil {
return "", "", err
}
abs = filepath.Join(l.root, rel)
// Final containment check: ensure abs is within root.
if abs != l.root && !strings.HasPrefix(abs, l.root+string(filepath.Separator)) {
return "", "", util.New(util.CodeBadRequest, "path escapes workspace root")
}
return abs, rel, nil
}
// List returns sorted directory entries for path.
func (l *LocalFS) List(path string) ([]FileInfo, error) {
abs, rel, err := l.resolve(path)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, util.New(util.CodeNotFound, "path not found")
}
return nil, util.Wrap(util.CodeInternal, "failed to list directory", err)
}
result := make([]FileInfo, 0, len(entries))
for _, e := range entries {
info, err := e.Info()
if err != nil {
return nil, util.Wrap(util.CodeInternal, "failed to get file info", err)
}
result = append(result, FileInfo{
Name: e.Name(),
Path: filepath.Join(rel, e.Name()),
IsDir: e.IsDir(),
Size: info.Size(),
ModTime: info.ModTime(),
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result, nil
}
// Read returns the contents of path.
func (l *LocalFS) Read(path string) ([]byte, error) {
abs, _, err := l.resolve(path)
if err != nil {
return nil, err
}
data, err := os.ReadFile(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, util.New(util.CodeNotFound, "file not found")
}
return nil, util.Wrap(util.CodeInternal, "failed to read file", err)
}
return data, nil
}
// Write writes data to path, creating parent directories as needed.
func (l *LocalFS) Write(path string, data []byte) error {
abs, _, err := l.resolve(path)
if err != nil {
return err
}
parent := filepath.Dir(abs)
if err := os.MkdirAll(parent, 0o755); err != nil {
return util.Wrap(util.CodeInternal, "failed to create parent directories", err)
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
return util.Wrap(util.CodeInternal, "failed to write file", err)
}
return nil
}
// Mkdir creates a directory at path.
func (l *LocalFS) Mkdir(path string) error {
abs, _, err := l.resolve(path)
if err != nil {
return err
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return util.Wrap(util.CodeInternal, "failed to create directory", err)
}
return nil
}
// Remove removes path (file or directory recursively).
func (l *LocalFS) Remove(path string) error {
abs, _, err := l.resolve(path)
if err != nil {
return err
}
if err := os.RemoveAll(abs); err != nil {
return util.Wrap(util.CodeInternal, "failed to remove path", err)
}
return nil
}
// Rename moves oldPath to newPath, creating destination parent dirs.
func (l *LocalFS) Rename(oldPath, newPath string) error {
oldAbs, _, err := l.resolve(oldPath)
if err != nil {
return err
}
newAbs, _, err := l.resolve(newPath)
if err != nil {
return err
}
parent := filepath.Dir(newAbs)
if err := os.MkdirAll(parent, 0o755); err != nil {
return util.Wrap(util.CodeInternal, "failed to create parent directories", err)
}
if err := os.Rename(oldAbs, newAbs); err != nil {
return util.Wrap(util.CodeInternal, "failed to rename", err)
}
return nil
}
// Stat returns metadata for path.
func (l *LocalFS) Stat(path string) (*FileInfo, error) {
abs, rel, err := l.resolve(path)
if err != nil {
return nil, err
}
info, err := os.Stat(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, util.New(util.CodeNotFound, "path not found")
}
return nil, util.Wrap(util.CodeInternal, "failed to stat path", err)
}
return &FileInfo{
Name: filepath.Base(rel),
Path: rel,
IsDir: info.IsDir(),
Size: info.Size(),
ModTime: info.ModTime(),
}, nil
}
+137
View File
@@ -0,0 +1,137 @@
package fs
import (
"os"
"path/filepath"
"sort"
"testing"
)
func TestLocalFSWriteReadListStat(t *testing.T) {
root := t.TempDir()
fsys := NewLocal(root)
content := []byte("package main\n")
if err := fsys.Write("dir/main.go", content); err != nil {
t.Fatalf("Write: %v", err)
}
got, err := fsys.Read("dir/main.go")
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(got) != string(content) {
t.Errorf("Read = %q, want %q", got, content)
}
entries, err := fsys.List("dir")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(entries) != 1 {
t.Fatalf("List returned %d entries, want 1", len(entries))
}
if entries[0].Name != "main.go" {
t.Errorf("entry name = %q, want main.go", entries[0].Name)
}
if entries[0].Path != "dir/main.go" {
t.Errorf("entry path = %q, want dir/main.go", entries[0].Path)
}
if entries[0].IsDir {
t.Error("expected IsDir=false")
}
info, err := fsys.Stat("dir/main.go")
if err != nil {
t.Fatalf("Stat: %v", err)
}
if info.Size != int64(len(content)) {
t.Errorf("Stat size = %d, want %d", info.Size, len(content))
}
}
func TestLocalFSRejectsPathEscape(t *testing.T) {
root := t.TempDir()
fsys := NewLocal(root)
// Create an outside file to verify writes don't reach it.
outside := filepath.Join(root, "..", "outside_escape_test")
defer os.Remove(outside)
ops := []struct {
name string
fn func() error
}{
{"Read", func() error { _, err := fsys.Read("../outside_escape_test"); return err }},
{"Write", func() error { return fsys.Write("../outside_escape_test", []byte("x")) }},
{"Mkdir", func() error { return fsys.Mkdir("../outside_escape_dir") }},
{"Remove", func() error { return fsys.Remove("../outside_escape_test") }},
{"Rename", func() error { return fsys.Rename("file", "../outside_escape_test") }},
}
for _, op := range ops {
if err := op.fn(); err == nil {
t.Errorf("%s with ../ path should fail", op.name)
}
}
if _, err := os.Stat(outside); err == nil {
t.Error("outside file should not have been created")
}
}
func TestLocalFSRenameAndRemove(t *testing.T) {
root := t.TempDir()
fsys := NewLocal(root)
if err := fsys.Write("old.txt", []byte("hello")); err != nil {
t.Fatalf("Write: %v", err)
}
if err := fsys.Rename("old.txt", "new.txt"); err != nil {
t.Fatalf("Rename: %v", err)
}
if _, err := fsys.Stat("old.txt"); err == nil {
t.Error("old.txt should not exist after rename")
}
data, err := fsys.Read("new.txt")
if err != nil {
t.Fatalf("Read new.txt: %v", err)
}
if string(data) != "hello" {
t.Errorf("Read new.txt = %q, want hello", data)
}
if err := fsys.Remove("new.txt"); err != nil {
t.Fatalf("Remove: %v", err)
}
if _, err := fsys.Stat("new.txt"); err == nil {
t.Error("new.txt should not exist after remove")
}
}
func TestLocalFSListSorted(t *testing.T) {
root := t.TempDir()
fsys := NewLocal(root)
for _, name := range []string{"c.txt", "a.txt", "b.txt"} {
if err := fsys.Write(name, []byte("x")); err != nil {
t.Fatalf("Write %s: %v", name, err)
}
}
entries, err := fsys.List(".")
if err != nil {
t.Fatalf("List: %v", err)
}
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name
}
if !sort.StringsAreSorted(names) {
t.Errorf("List not sorted: %v", names)
}
}
+24
View File
@@ -0,0 +1,24 @@
package model
// WriteFileRequest is the request body for writing a file.
type WriteFileRequest struct {
Path string `json:"path"`
Content string `json:"content"`
}
// MkdirRequest is the request body for creating a directory.
type MkdirRequest struct {
Path string `json:"path"`
}
// RenameRequest is the request body for renaming a file or directory.
type RenameRequest struct {
OldPath string `json:"oldPath"`
NewPath string `json:"newPath"`
}
// ReadFileResponse is the API response for reading a file.
type ReadFileResponse struct {
Path string `json:"path"`
Content string `json:"content"`
}
+8
View File
@@ -0,0 +1,8 @@
package model
// ProcessStatusResponse is the API response for process status.
type ProcessStatusResponse struct {
WorkspaceID string `json:"workspaceId"`
Running bool `json:"running"`
PID int `json:"pid,omitempty"`
}
+11
View File
@@ -0,0 +1,11 @@
package model
// CreateWorkspaceRequest is the request body for creating a workspace.
type CreateWorkspaceRequest struct {
ID string `json:"id"`
}
// WorkspaceResponse is the API response for a workspace.
type WorkspaceResponse struct {
ID string `json:"id"`
}
+119
View File
@@ -0,0 +1,119 @@
package process
import (
"fmt"
"os"
"os/exec"
"sync"
"codespace/internal/util"
)
// Manager manages OpenCode processes per workspace.
type Manager interface {
Start(workspaceID string, workspaceRoot string) error
Stop(workspaceID string) error
Restart(workspaceID string, workspaceRoot string) error
Status(workspaceID string) Status
}
// LocalManager implements Manager using local OS processes.
type LocalManager struct {
command string
args []string
mu sync.Mutex
sessions map[string]*Session
}
// NewManager creates a LocalManager with the given command.
// If command is empty, it defaults to "opencode".
func NewManager(command string) *LocalManager {
return &LocalManager{
command: normalizeCommand(command),
args: []string{},
sessions: make(map[string]*Session),
}
}
// Start launches a process for the given workspace.
// Returns CodeConflict if a session is already running.
func (m *LocalManager) Start(workspaceID string, workspaceRoot string) error {
m.mu.Lock()
defer m.mu.Unlock()
if sess, ok := m.sessions[workspaceID]; ok && sess.Cmd.Process != nil {
if sess.Cmd.ProcessState == nil {
return util.New(util.CodeConflict, "process already running")
}
}
cmd := exec.Command(m.command, m.args...)
cmd.Dir = workspaceRoot
cmd.Env = append(os.Environ(), fmt.Sprintf("HOME=%s", workspaceRoot))
if err := cmd.Start(); err != nil {
return util.Wrap(util.CodeInternal, "failed to start process", err)
}
m.sessions[workspaceID] = &Session{
WorkspaceID: workspaceID,
Root: workspaceRoot,
Cmd: cmd,
}
return nil
}
// Stop kills the process for the given workspace.
// Returns CodeNotFound if no session exists.
func (m *LocalManager) Stop(workspaceID string) error {
m.mu.Lock()
defer m.mu.Unlock()
sess, ok := m.sessions[workspaceID]
if !ok || sess.Cmd.Process == nil {
return util.New(util.CodeNotFound, "no running process for workspace")
}
if err := sess.Cmd.Process.Kill(); err != nil {
return util.Wrap(util.CodeInternal, "failed to stop process", err)
}
delete(m.sessions, workspaceID)
return nil
}
// Restart stops (if running) then starts the process.
func (m *LocalManager) Restart(workspaceID string, workspaceRoot string) error {
m.mu.Lock()
if sess, ok := m.sessions[workspaceID]; ok && sess.Cmd.Process != nil {
if sess.Cmd.ProcessState == nil {
_ = sess.Cmd.Process.Kill()
delete(m.sessions, workspaceID)
}
}
m.mu.Unlock()
return m.Start(workspaceID, workspaceRoot)
}
// Status returns the current process status for the workspace.
func (m *LocalManager) Status(workspaceID string) Status {
m.mu.Lock()
defer m.mu.Unlock()
sess, ok := m.sessions[workspaceID]
if !ok || sess.Cmd.Process == nil {
return Status{WorkspaceID: workspaceID, Running: false}
}
if sess.Cmd.ProcessState != nil {
return Status{WorkspaceID: workspaceID, Running: false}
}
return Status{
WorkspaceID: workspaceID,
Running: true,
PID: sess.Cmd.Process.Pid,
}
}
+61
View File
@@ -0,0 +1,61 @@
package process
import (
"os"
"testing"
"codespace/internal/util"
)
func TestStatusBeforeStartIsNotRunning(t *testing.T) {
m := NewManager("opencode")
s := m.Status("ws1")
if s.Running {
t.Error("expected Running=false before start")
}
if s.PID != 0 {
t.Errorf("expected PID=0, got %d", s.PID)
}
}
func TestStartRejectsDuplicateRunningSession(t *testing.T) {
m := NewManager("")
// Override command for testing using the test binary itself.
m.command = os.Args[0]
m.args = []string{"-test.run=TestHelperProcess", "--"}
// Set env so the helper process enters its blocking select loop.
t.Setenv("CODESPACE_HELPER_PROCESS", "1")
root := t.TempDir()
if err := m.Start("ws1", root); err != nil {
t.Fatalf("first Start: %v", err)
}
defer m.Stop("ws1")
err := m.Start("ws1", root)
if err == nil {
t.Fatal("expected error on duplicate start")
}
if code := util.CodeOf(err); code != util.CodeConflict {
t.Errorf("expected conflict error, got %v", err)
}
}
func TestStopMissingSessionIsNotFound(t *testing.T) {
m := NewManager("opencode")
err := m.Stop("nonexistent")
if err == nil {
t.Fatal("expected error stopping missing session")
}
if code := util.CodeOf(err); code != util.CodeNotFound {
t.Errorf("expected not_found error, got %v", err)
}
}
func TestHelperProcess(t *testing.T) {
if os.Getenv("CODESPACE_HELPER_PROCESS") != "1" {
return
}
select {}
}
+12
View File
@@ -0,0 +1,12 @@
package process
// DefaultOpenCodeCommand is the default command used to launch OpenCode.
const DefaultOpenCodeCommand = "opencode"
// normalizeCommand returns command if non-empty, otherwise the default.
func normalizeCommand(command string) string {
if command == "" {
return DefaultOpenCodeCommand
}
return command
}
+17
View File
@@ -0,0 +1,17 @@
package process
import "os/exec"
// Status represents the status of a process for a workspace.
type Status struct {
WorkspaceID string `json:"workspaceId"`
Running bool `json:"running"`
PID int `json:"pid,omitempty"`
}
// Session holds the runtime state of a started OpenCode process.
type Session struct {
WorkspaceID string
Root string
Cmd *exec.Cmd
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"codespace/internal/fs"
"codespace/internal/workspace"
)
// FileService handles file operations within workspaces.
type FileService struct {
workspaces workspace.Manager
}
// NewFileService creates a FileService.
func NewFileService(workspaces workspace.Manager) *FileService {
return &FileService{workspaces: workspaces}
}
// List lists files at the given workspace-relative path.
func (s *FileService) List(workspaceID, path string) ([]fs.FileInfo, error) {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return nil, err
}
return fs.NewLocal(ws.Root).List(path)
}
// Read reads a file at the given workspace-relative path.
func (s *FileService) Read(workspaceID, path string) ([]byte, error) {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return nil, err
}
return fs.NewLocal(ws.Root).Read(path)
}
// Write writes data to a file at the given workspace-relative path.
func (s *FileService) Write(workspaceID, path string, data []byte) error {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return err
}
return fs.NewLocal(ws.Root).Write(path, data)
}
// Mkdir creates a directory at the given workspace-relative path.
func (s *FileService) Mkdir(workspaceID, path string) error {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return err
}
return fs.NewLocal(ws.Root).Mkdir(path)
}
// Remove removes a file or directory at the given workspace-relative path.
func (s *FileService) Remove(workspaceID, path string) error {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return err
}
return fs.NewLocal(ws.Root).Remove(path)
}
// Rename renames a file or directory from oldPath to newPath.
func (s *FileService) Rename(workspaceID, oldPath, newPath string) error {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return err
}
return fs.NewLocal(ws.Root).Rename(oldPath, newPath)
}
// Stat returns file info for the given workspace-relative path.
func (s *FileService) Stat(workspaceID, path string) (*fs.FileInfo, error) {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return nil, err
}
return fs.NewLocal(ws.Root).Stat(path)
}
+48
View File
@@ -0,0 +1,48 @@
package service
import (
"codespace/internal/process"
"codespace/internal/workspace"
)
// ProcessService manages OpenCode processes for workspaces.
type ProcessService struct {
workspaces workspace.Manager
processes process.Manager
}
// NewProcessService creates a ProcessService.
func NewProcessService(workspaces workspace.Manager, processes process.Manager) *ProcessService {
return &ProcessService{workspaces: workspaces, processes: processes}
}
// Start starts an OpenCode process for the given workspace.
func (s *ProcessService) Start(workspaceID string) error {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return err
}
return s.processes.Start(workspaceID, ws.Root)
}
// Stop stops the OpenCode process for the given workspace.
func (s *ProcessService) Stop(workspaceID string) error {
return s.processes.Stop(workspaceID)
}
// Restart restarts the OpenCode process for the given workspace.
func (s *ProcessService) Restart(workspaceID string) error {
ws, err := s.workspaces.Get(workspaceID)
if err != nil {
return err
}
return s.processes.Restart(workspaceID, ws.Root)
}
// Status returns the process status for the given workspace.
func (s *ProcessService) Status(workspaceID string) (process.Status, error) {
if _, err := s.workspaces.Get(workspaceID); err != nil {
return process.Status{}, err
}
return s.processes.Status(workspaceID), nil
}
+36
View File
@@ -0,0 +1,36 @@
package service
import (
"codespace/internal/process"
"codespace/internal/workspace"
)
// WorkspaceService orchestrates workspace lifecycle operations.
type WorkspaceService struct {
workspaces workspace.Manager
processes process.Manager
}
// NewWorkspaceService creates a WorkspaceService.
func NewWorkspaceService(workspaces workspace.Manager, processes process.Manager) *WorkspaceService {
return &WorkspaceService{workspaces: workspaces, processes: processes}
}
// Create creates a new workspace.
func (s *WorkspaceService) Create(id string) (*workspace.Workspace, error) {
return s.workspaces.Create(id)
}
// Get retrieves a workspace by id.
func (s *WorkspaceService) Get(id string) (*workspace.Workspace, error) {
return s.workspaces.Get(id)
}
// Delete stops any running process for the workspace, then removes it.
func (s *WorkspaceService) Delete(id string) error {
status := s.processes.Status(id)
if status.Running {
_ = s.processes.Stop(id)
}
return s.workspaces.Delete(id)
}
+57
View File
@@ -0,0 +1,57 @@
package util
import (
"errors"
"fmt"
)
// Code is a string error category used across the application.
type Code string
const (
CodeBadRequest Code = "bad_request"
CodeNotFound Code = "not_found"
CodeConflict Code = "conflict"
CodeInternal Code = "internal"
)
// AppError is a typed error carrying a Code and optional wrapped error.
type AppError struct {
Code Code
Message string
Err error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error {
return e.Err
}
// New returns a new AppError with the given code and message.
func New(code Code, message string) error {
return &AppError{Code: code, Message: message}
}
// Wrap returns a new AppError wrapping err with the given code and message.
func Wrap(code Code, message string, err error) error {
return &AppError{Code: code, Message: message, Err: err}
}
// CodeOf returns the Code embedded in err. nil returns CodeInternal;
// non-AppError errors also default to CodeInternal.
func CodeOf(err error) Code {
if err == nil {
return CodeInternal
}
var ae *AppError
if errors.As(err, &ae) {
return ae.Code
}
return CodeInternal
}
+34
View File
@@ -0,0 +1,34 @@
package util
import (
"path/filepath"
"strings"
)
// CleanRelative validates and cleans a workspace-relative path.
// It rejects absolute paths and paths that escape the workspace root via "..".
// Empty path and "." return ".".
func CleanRelative(path string) (string, error) {
if path == "" || path == "." {
return ".", nil
}
// Reject absolute paths.
if filepath.IsAbs(path) {
return "", New(CodeBadRequest, "absolute paths are not allowed")
}
cleaned := filepath.Clean(path)
// Reject paths that escape the workspace root.
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", New(CodeBadRequest, "path escapes workspace root")
}
return cleaned, nil
}
// JoinClean joins base and elem after cleaning, returning a cleaned path.
func JoinClean(base, elem string) string {
return filepath.Clean(filepath.Join(base, elem))
}
+15
View File
@@ -0,0 +1,15 @@
package watcher
// Event represents a filesystem change event.
type Event struct {
Type string `json:"type"`
Path string `json:"path"`
}
// Watcher is a placeholder interface for future filesystem watching.
// Not wired into the API in v1.
type Watcher interface {
Start(root string) error
Stop() error
Events() <-chan Event
}
+73
View File
@@ -0,0 +1,73 @@
package workspace
import (
"os"
"path/filepath"
"codespace/internal/util"
)
// Manager manages workspace lifecycle.
type Manager interface {
Create(id string) (*Workspace, error)
Get(id string) (*Workspace, error)
Delete(id string) error
}
// LocalManager implements Manager using the local filesystem.
type LocalManager struct {
root string
}
// NewLocalManager creates a LocalManager rooted at root. The root is made
// absolute and cleaned if possible.
func NewLocalManager(root string) *LocalManager {
abs, err := filepath.Abs(root)
if err != nil {
abs = filepath.Clean(root)
}
return &LocalManager{root: abs}
}
// Create creates a workspace directory and returns the workspace.
func (m *LocalManager) Create(id string) (*Workspace, error) {
if !ValidID(id) {
return nil, util.New(util.CodeBadRequest, "invalid workspace id")
}
root := RootFor(m.root, id)
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, util.Wrap(util.CodeInternal, "failed to create workspace", err)
}
return &Workspace{ID: id, Root: root}, nil
}
// Get returns the workspace if its directory exists.
func (m *LocalManager) Get(id string) (*Workspace, error) {
if !ValidID(id) {
return nil, util.New(util.CodeBadRequest, "invalid workspace id")
}
root := RootFor(m.root, id)
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return nil, util.New(util.CodeNotFound, "workspace not found")
}
return nil, util.Wrap(util.CodeInternal, "failed to stat workspace", err)
}
if !info.IsDir() {
return nil, util.New(util.CodeNotFound, "workspace not found")
}
return &Workspace{ID: id, Root: root}, nil
}
// Delete removes the workspace directory recursively.
func (m *LocalManager) Delete(id string) error {
if !ValidID(id) {
return util.New(util.CodeBadRequest, "invalid workspace id")
}
root := RootFor(m.root, id)
if err := os.RemoveAll(root); err != nil {
return util.Wrap(util.CodeInternal, "failed to delete workspace", err)
}
return nil
}
+25
View File
@@ -0,0 +1,25 @@
package workspace
import (
"regexp"
"codespace/internal/util"
)
var idPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
// ValidID reports whether id is a safe workspace identifier: only letters,
// digits, '-', '_', '.' and non-empty. The bare values "." and ".." are
// rejected because they would resolve to the parent or current directory.
func ValidID(id string) bool {
if id == "" || id == "." || id == ".." {
return false
}
return idPattern.MatchString(id)
}
// RootFor returns the absolute workspace root path for the given base root and
// workspace id. The result is filepath-cleaned.
func RootFor(baseRoot, id string) string {
return util.JoinClean(baseRoot, id)
}
+31
View File
@@ -0,0 +1,31 @@
package workspace
import (
"testing"
)
func TestValidIDAcceptsSafeSegments(t *testing.T) {
valid := []string{"user1", "project-A", "project_A", "project.1"}
for _, id := range valid {
if !ValidID(id) {
t.Errorf("expected ValidID(%q) to be true", id)
}
}
}
func TestValidIDRejectsUnsafeSegments(t *testing.T) {
invalid := []string{"", ".", "..", "/tmp/x", "../x", "a/b", "a b", "中文", "*"}
for _, id := range invalid {
if ValidID(id) {
t.Errorf("expected ValidID(%q) to be false", id)
}
}
}
func TestRootForUsesConfiguredRoot(t *testing.T) {
got := RootFor("/tmp/workspaces", "user1")
want := "/tmp/workspaces/user1"
if got != want {
t.Errorf("RootFor = %q, want %q", got, want)
}
}
+7
View File
@@ -0,0 +1,7 @@
package workspace
// Workspace represents a single workspace backed by a local directory.
type Workspace struct {
ID string `json:"id"`
Root string `json:"-"`
}