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
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -11,6 +13,10 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
Aliases: []string{"g"},
|
Aliases: []string{"g"},
|
||||||
Short: "Start picoclaw gateway",
|
Short: "Start picoclaw gateway",
|
||||||
|
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,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return gatewayCmd(debug)
|
return gatewayCmd(debug)
|
||||||
|
|
@ -19,5 +25,107 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,46 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
assert.Nil(t, cmd.PersistentPreRun)
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
assert.Nil(t, cmd.PersistentPostRun)
|
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.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
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