feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user