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:
Pluckypan 2026-03-04 18:20:44 +08:00 committed by plucky
parent 2ff84f5bed
commit 538e7a521a
3 changed files with 329 additions and 2 deletions

View file

@ -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
}

View file

@ -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)
}

View 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
}