feat(gateway): add start/stop/status subcommands for background service
- Add 'picoclaw gateway start' to run gateway in background - Add 'picoclaw gateway stop' to stop background service - Add 'picoclaw gateway status' to check service status - Original 'picoclaw gateway' still runs in foreground - PID file stored at ~/.picoclaw/gateway.pid - Logs written to ~/.picoclaw/logs/gateway.log
This commit is contained in:
parent
2ff84f5bed
commit
538e7a521a
3 changed files with 329 additions and 2 deletions
|
|
@ -1,6 +1,8 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -11,7 +13,11 @@ func NewGatewayCommand() *cobra.Command {
|
|||
Use: "gateway",
|
||||
Aliases: []string{"g"},
|
||||
Short: "Start picoclaw gateway",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `Start picoclaw gateway in foreground mode.
|
||||
|
||||
Use 'picoclaw gateway start' to run in background mode.
|
||||
Use 'picoclaw gateway stop' to stop the background service.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return gatewayCmd(debug)
|
||||
},
|
||||
|
|
@ -19,5 +25,107 @@ func NewGatewayCommand() *cobra.Command {
|
|||
|
||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(newStartCommand(&debug))
|
||||
cmd.AddCommand(newStopCommand())
|
||||
cmd.AddCommand(newStatusCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newStartCommand(debug *bool) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start picoclaw gateway in background",
|
||||
Long: `Start picoclaw gateway as a background service.
|
||||
|
||||
The gateway will run in the background and logs will be written to:
|
||||
~/.picoclaw/logs/gateway.log
|
||||
|
||||
Use 'picoclaw gateway stop' to stop the background service.
|
||||
Use 'picoclaw gateway status' to check if the gateway is running.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
// Check if already running
|
||||
if running, pid := isGatewayRunning(); running {
|
||||
fmt.Printf("⚠️ Gateway is already running (PID: %d)\n", pid)
|
||||
fmt.Println("Use 'picoclaw gateway stop' to stop it first.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("🚀 Starting picoclaw gateway in background...")
|
||||
|
||||
if err := startGatewayBackground(*debug); err != nil {
|
||||
return fmt.Errorf("failed to start gateway: %w", err)
|
||||
}
|
||||
|
||||
running, pid, logPath := getGatewayStatus()
|
||||
if running {
|
||||
fmt.Printf("✓ Gateway started successfully (PID: %d)\n", pid)
|
||||
fmt.Printf("📝 Logs: %s\n", logPath)
|
||||
fmt.Println("\nUse 'picoclaw gateway stop' to stop the service.")
|
||||
fmt.Println("Use 'picoclaw gateway status' to check the status.")
|
||||
} else {
|
||||
fmt.Println("⚠️ Gateway may have failed to start. Check the logs:")
|
||||
fmt.Printf(" %s\n", logPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newStopCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop picoclaw gateway background service",
|
||||
Long: `Stop the picoclaw gateway running in the background.
|
||||
|
||||
This command will gracefully stop the gateway service that was started
|
||||
with 'picoclaw gateway start'.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
running, pid := isGatewayRunning()
|
||||
if !running {
|
||||
fmt.Println("ℹ️ Gateway is not running.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("🛑 Stopping picoclaw gateway (PID: %d)...\n", pid)
|
||||
|
||||
if err := stopGatewayProcess(); err != nil {
|
||||
return fmt.Errorf("failed to stop gateway: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Gateway stopped successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newStatusCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show picoclaw gateway status",
|
||||
Long: `Show the current status of the picoclaw gateway background service.`,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
running, pid, logPath := getGatewayStatus()
|
||||
|
||||
if running {
|
||||
fmt.Printf("✓ Gateway is running (PID: %d)\n", pid)
|
||||
fmt.Printf("📝 Logs: %s\n", logPath)
|
||||
fmt.Println("\nUse 'picoclaw gateway stop' to stop the service.")
|
||||
} else {
|
||||
fmt.Println("○ Gateway is not running.")
|
||||
fmt.Println("\nUse 'picoclaw gateway start' to start the service.")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,46 @@ func TestNewGatewayCommand(t *testing.T) {
|
|||
assert.Nil(t, cmd.PersistentPreRun)
|
||||
assert.Nil(t, cmd.PersistentPostRun)
|
||||
|
||||
assert.False(t, cmd.HasSubCommands())
|
||||
// Gateway command now has subcommands: start, stop, status
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
assert.Len(t, cmd.Commands(), 3)
|
||||
|
||||
assert.True(t, cmd.HasFlags())
|
||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||
}
|
||||
|
||||
func TestGatewayStartCommand(t *testing.T) {
|
||||
cmd := NewGatewayCommand()
|
||||
|
||||
startCmd, _, err := cmd.Find([]string{"start"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, startCmd)
|
||||
|
||||
assert.Equal(t, "start", startCmd.Use)
|
||||
assert.Equal(t, "Start picoclaw gateway in background", startCmd.Short)
|
||||
assert.NotNil(t, startCmd.RunE)
|
||||
}
|
||||
|
||||
func TestGatewayStopCommand(t *testing.T) {
|
||||
cmd := NewGatewayCommand()
|
||||
|
||||
stopCmd, _, err := cmd.Find([]string{"stop"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stopCmd)
|
||||
|
||||
assert.Equal(t, "stop", stopCmd.Use)
|
||||
assert.Equal(t, "Stop picoclaw gateway background service", stopCmd.Short)
|
||||
assert.NotNil(t, stopCmd.RunE)
|
||||
}
|
||||
|
||||
func TestGatewayStatusCommand(t *testing.T) {
|
||||
cmd := NewGatewayCommand()
|
||||
|
||||
statusCmd, _, err := cmd.Find([]string{"status"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, statusCmd)
|
||||
|
||||
assert.Equal(t, "status", statusCmd.Use)
|
||||
assert.Equal(t, "Show picoclaw gateway status", statusCmd.Short)
|
||||
assert.NotNil(t, statusCmd.Run)
|
||||
}
|
||||
|
|
|
|||
181
cmd/picoclaw/internal/gateway/process.go
Normal file
181
cmd/picoclaw/internal/gateway/process.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// getPidFilePath returns the path to the gateway PID file
|
||||
func getPidFilePath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "gateway.pid")
|
||||
}
|
||||
|
||||
// savePID saves the process ID to the PID file
|
||||
func savePID(pid int) error {
|
||||
pidFile := getPidFilePath()
|
||||
if err := os.MkdirAll(filepath.Dir(pidFile), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
return os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0o644)
|
||||
}
|
||||
|
||||
// loadPID loads the process ID from the PID file
|
||||
func loadPID() (int, error) {
|
||||
data, err := os.ReadFile(getPidFilePath())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(string(data))
|
||||
}
|
||||
|
||||
// removePIDFile removes the PID file
|
||||
func removePIDFile() {
|
||||
_ = os.Remove(getPidFilePath())
|
||||
}
|
||||
|
||||
// isProcessRunning checks if a process with the given PID is running
|
||||
func isProcessRunning(pid int) bool {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// On Unix systems, FindProcess always succeeds, so we need to send signal 0 to check
|
||||
if runtime.GOOS != "windows" {
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// On Windows, we rely on FindProcess result
|
||||
return true
|
||||
}
|
||||
|
||||
// isGatewayRunning checks if the gateway is currently running
|
||||
func isGatewayRunning() (bool, int) {
|
||||
pid, err := loadPID()
|
||||
if err != nil {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
if isProcessRunning(pid) {
|
||||
return true, pid
|
||||
}
|
||||
|
||||
// PID file exists but process is not running, clean up
|
||||
removePIDFile()
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// startGatewayBackground starts the gateway in the background
|
||||
func startGatewayBackground(debug bool) error {
|
||||
// Check if already running
|
||||
if running, pid := isGatewayRunning(); running {
|
||||
return fmt.Errorf("gateway is already running (PID: %d)", pid)
|
||||
}
|
||||
|
||||
// Get the current executable path
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get executable path: %w", err)
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
args := []string{"gateway"}
|
||||
if debug {
|
||||
args = append(args, "--debug")
|
||||
}
|
||||
|
||||
cmd := exec.Command(execPath, args...)
|
||||
|
||||
// Redirect output to log file
|
||||
logPath := getLogFilePath()
|
||||
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Set process group for proper cleanup on Unix systems
|
||||
if runtime.GOOS != "windows" {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Start the process
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logFile.Close()
|
||||
return fmt.Errorf("failed to start gateway: %w", err)
|
||||
}
|
||||
|
||||
// Close log file handle (process inherits it)
|
||||
_ = logFile.Close()
|
||||
|
||||
// Save PID
|
||||
if err := savePID(cmd.Process.Pid); err != nil {
|
||||
// Try to kill the process if we can't save the PID
|
||||
_ = cmd.Process.Kill()
|
||||
return fmt.Errorf("failed to save PID: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopGatewayProcess stops the running gateway process
|
||||
func stopGatewayProcess() error {
|
||||
running, pid := isGatewayRunning()
|
||||
if !running {
|
||||
return fmt.Errorf("gateway is not running")
|
||||
}
|
||||
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
removePIDFile()
|
||||
return fmt.Errorf("failed to find process: %w", err)
|
||||
}
|
||||
|
||||
// Send interrupt signal first for graceful shutdown
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := process.Signal(os.Interrupt); err != nil {
|
||||
// If interrupt fails, try kill
|
||||
if killErr := process.Kill(); killErr != nil {
|
||||
removePIDFile()
|
||||
return fmt.Errorf("failed to stop gateway: %w", killErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Windows doesn't support Interrupt, use Kill directly
|
||||
if err := process.Kill(); err != nil {
|
||||
removePIDFile()
|
||||
return fmt.Errorf("failed to stop gateway: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
removePIDFile()
|
||||
return nil
|
||||
}
|
||||
|
||||
// getLogFilePath returns the path to the gateway log file
|
||||
func getLogFilePath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "logs", "gateway.log")
|
||||
}
|
||||
|
||||
// getGatewayStatus returns the status information of the gateway
|
||||
func getGatewayStatus() (bool, int, string) {
|
||||
running, pid := isGatewayRunning()
|
||||
logPath := getLogFilePath()
|
||||
return running, pid, logPath
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue