diff --git a/README.md b/README.md index 2b770f215..cdf058416 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,91 @@ That's it! You have a working AI assistant in 2 minutes. --- +## šŸš€ Running the Gateway + +PicoClaw provides two modes for running the gateway: + +### Foreground Mode (Development) + +Run the gateway in the foreground for development and testing: + +```bash +picoclaw gateway +``` + +Press `Ctrl+C` to stop the gateway. + +### Daemon Mode (Production) + +Run the gateway as a background daemon for production deployments: + +```bash +# Start the gateway in the background +picoclaw gateway start + +# Check gateway status +picoclaw gateway status + +# Stop the gateway +picoclaw gateway stop + +# Restart the gateway +picoclaw gateway restart +``` + +**Daemon Features:** +- **Auto-restart**: If the gateway crashes, it automatically restarts up to 3 times with exponential backoff +- **Logging**: All logs are written to `~/.picoclaw/gateway.log` with automatic rotation (100MB per file, 3 backups) +- **Status tracking**: View PID, uptime, and restart count with `picoclaw gateway status` +- **No terminal needed**: The gateway runs in the background, freeing up your terminal + +**Log File Location:** +``` +~/.picoclaw/gateway.log # Current log +~/.picoclaw/gateway.log.1 # First backup +~/.picoclaw/gateway.log.2 # Second backup +~/.picoclaw/gateway.log.3 # Third backup +``` + +**Viewing Logs:** +```bash +# Follow the log file +tail -f ~/.picoclaw/gateway.log + +# View last 100 lines +tail -n 100 ~/.picoclaw/gateway.log +``` + +**systemd Integration (Linux):** + +For automatic startup on boot, create a systemd service: + +```ini +# /etc/systemd/system/picoclaw.service +[Unit] +Description=PicoClaw Gateway +After=network.target + +[Service] +Type=forking +User=pi +ExecStart=/home/pi/picoclaw gateway start +ExecStop=/home/pi/picoclaw gateway stop +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: +```bash +sudo systemctl enable picoclaw +sudo systemctl start picoclaw +``` + +--- + ## šŸ’¬ Chat Apps Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom @@ -1107,15 +1192,19 @@ picoclaw agent -m "Hello" ## CLI Reference -| Command | Description | -| ------------------------- | ----------------------------- | -| `picoclaw onboard` | Initialize config & workspace | -| `picoclaw agent -m "..."` | Chat with the agent | -| `picoclaw agent` | Interactive chat mode | -| `picoclaw gateway` | Start the gateway | -| `picoclaw status` | Show status | -| `picoclaw cron list` | List all scheduled jobs | -| `picoclaw cron add ...` | Add a scheduled job | +| Command | Description | +| ------------------------------- | ------------------------------------------ | +| `picoclaw onboard` | Initialize config & workspace | +| `picoclaw agent -m "..."` | Chat with the agent | +| `picoclaw agent` | Interactive chat mode | +| `picoclaw gateway` | Start gateway in foreground | +| `picoclaw gateway start` | Start gateway as daemon (background) | +| `picoclaw gateway stop` | Stop the gateway daemon | +| `picoclaw gateway restart` | Restart the gateway daemon | +| `picoclaw gateway status` | Show gateway daemon status (PID, uptime) | +| `picoclaw status` | Show picoclaw status | +| `picoclaw cron list` | List all scheduled jobs | +| `picoclaw cron add ...` | Add a scheduled job | ### Scheduled Tasks / Reminders diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 3010c1451..8e57bcf9f 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -11,6 +11,7 @@ import ( "os/signal" "path/filepath" "strings" + "syscall" "time" "github.com/sipeed/picoclaw/pkg/agent" @@ -28,27 +29,35 @@ import ( "github.com/sipeed/picoclaw/pkg/voice" ) -func gatewayCmd() { - // Check for --debug flag - args := os.Args[2:] - for _, arg := range args { - if arg == "--debug" || arg == "-d" { - logger.SetLevel(logger.DEBUG) - fmt.Println("šŸ” Debug mode enabled") - break - } - } +// gatewayRunner holds the initialized gateway components. +// This allows the gateway lifecycle to be managed externally (e.g., by daemon service). +type gatewayRunner struct { + cfg *config.Config + provider providers.LLMProvider + msgBus *bus.MessageBus + agentLoop *agent.AgentLoop + cronService *cron.CronService + heartbeatService *heartbeat.HeartbeatService + channelManager *channels.Manager + deviceService *devices.Service + healthServer *health.Server + stateManager *state.Manager + ctx context.Context + cancel context.CancelFunc +} +// createGatewayRunner initializes all gateway components and returns a runner. +// This function does NOT start any services - it only initializes them. +// The caller is responsible for calling the returned start function. +func createGatewayRunner(isDaemon bool) (*gatewayRunner, error) { cfg, err := loadConfig() if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("error loading config: %w", err) } provider, modelID, err := providers.CreateProvider(cfg) if err != nil { - fmt.Printf("Error creating provider: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("error creating provider: %w", err) } // Use the resolved model ID from provider creation if modelID != "" { @@ -58,24 +67,6 @@ func gatewayCmd() { msgBus := bus.NewMessageBus() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - // Print agent startup info - fmt.Println("\nšŸ“¦ Agent Status:") - startupInfo := agentLoop.GetStartupInfo() - toolsInfo := startupInfo["tools"].(map[string]any) - skillsInfo := startupInfo["skills"].(map[string]any) - fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) - fmt.Printf(" • Skills: %d/%d available\n", - skillsInfo["available"], - skillsInfo["total"]) - - // Log to file as well - logger.InfoCF("agent", "Agent initialized", - map[string]any{ - "tools_count": toolsInfo["count"], - "skills_total": skillsInfo["total"], - "skills_available": skillsInfo["available"], - }) - // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute cronService := setupCronTool( @@ -114,8 +105,7 @@ func gatewayCmd() { channelManager, err := channels.NewManager(cfg, msgBus) if err != nil { - fmt.Printf("Error creating channel manager: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("error creating channel manager: %w", err) } // Inject channel manager into agent loop for command handling @@ -157,28 +147,7 @@ func gatewayCmd() { } } - enabledChannels := channelManager.GetEnabledChannels() - if len(enabledChannels) > 0 { - fmt.Printf("āœ“ Channels enabled: %s\n", enabledChannels) - } else { - fmt.Println("⚠ Warning: No channels enabled") - } - - fmt.Printf("āœ“ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) - fmt.Println("Press Ctrl+C to stop") - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := cronService.Start(); err != nil { - fmt.Printf("Error starting cron service: %v\n", err) - } - fmt.Println("āœ“ Cron service started") - - if err := heartbeatService.Start(); err != nil { - fmt.Printf("Error starting heartbeat service: %v\n", err) - } - fmt.Println("āœ“ Heartbeat service started") stateManager := state.NewManager(cfg.WorkspacePath()) deviceService := devices.NewService(devices.Config{ @@ -186,42 +155,183 @@ func gatewayCmd() { MonitorUSB: cfg.Devices.MonitorUSB, }, stateManager) deviceService.SetBus(msgBus) - if err := deviceService.Start(ctx); err != nil { - fmt.Printf("Error starting device service: %v\n", err) - } else if cfg.Devices.Enabled { + + healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + + return &gatewayRunner{ + cfg: cfg, + provider: provider, + msgBus: msgBus, + agentLoop: agentLoop, + cronService: cronService, + heartbeatService: heartbeatService, + channelManager: channelManager, + deviceService: deviceService, + healthServer: healthServer, + stateManager: stateManager, + ctx: ctx, + cancel: cancel, + }, nil +} + +// run starts all gateway services and waits for context cancellation. +// This method blocks until the gateway is stopped. +func (r *gatewayRunner) run(isDaemon bool) error { + // Print startup info only in foreground mode + if !isDaemon { + // Print agent startup info + fmt.Println("\nšŸ“¦ Agent Status:") + startupInfo := r.agentLoop.GetStartupInfo() + toolsInfo := startupInfo["tools"].(map[string]any) + skillsInfo := startupInfo["skills"].(map[string]any) + fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"]) + fmt.Printf(" • Skills: %d/%d available\n", + skillsInfo["available"], + skillsInfo["total"]) + + // Log to file as well + logger.InfoCF("agent", "Agent initialized", + map[string]any{ + "tools_count": toolsInfo["count"], + "skills_total": skillsInfo["total"], + "skills_available": skillsInfo["available"], + }) + + enabledChannels := r.channelManager.GetEnabledChannels() + if len(enabledChannels) > 0 { + fmt.Printf("āœ“ Channels enabled: %s\n", enabledChannels) + } else { + fmt.Println("⚠ Warning: No channels enabled") + } + + fmt.Printf("āœ“ Gateway started on %s:%d\n", r.cfg.Gateway.Host, r.cfg.Gateway.Port) + fmt.Println("Press Ctrl+C to stop") + } + + // Start cron service + if err := r.cronService.Start(); err != nil { + return fmt.Errorf("error starting cron service: %w", err) + } + if !isDaemon { + fmt.Println("āœ“ Cron service started") + } + + // Start heartbeat service + if err := r.heartbeatService.Start(); err != nil { + return fmt.Errorf("error starting heartbeat service: %w", err) + } + if !isDaemon { + fmt.Println("āœ“ Heartbeat service started") + } + + // Start device service + if err := r.deviceService.Start(r.ctx); err != nil { + logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()}) + } else if r.cfg.Devices.Enabled && !isDaemon { fmt.Println("āœ“ Device event service started") } - if err := channelManager.StartAll(ctx); err != nil { - fmt.Printf("Error starting channels: %v\n", err) + // Start channels + if err := r.channelManager.StartAll(r.ctx); err != nil { + return fmt.Errorf("error starting channels: %w", err) } - healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + // Start health server go func() { - if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { + if err := r.healthServer.Start(); err != nil && err != http.ErrServerClosed { logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) } }() - fmt.Printf("āœ“ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) + if !isDaemon { + fmt.Printf("āœ“ Health endpoints available at http://%s:%d/health and /ready\n", r.cfg.Gateway.Host, r.cfg.Gateway.Port) + } - go agentLoop.Run(ctx) + // Start agent loop + go r.agentLoop.Run(r.ctx) - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt) - <-sigChan + // Wait for context cancellation + <-r.ctx.Done() - fmt.Println("\nShutting down...") - if cp, ok := provider.(providers.StatefulProvider); ok { + return nil +} + +// stop gracefully stops all gateway services. +func (r *gatewayRunner) stop() { + logger.InfoC("gateway", "Shutting down...") + + if !isDaemonMode() { + fmt.Println("\nShutting down...") + } + + if cp, ok := r.provider.(providers.StatefulProvider); ok { cp.Close() } - cancel() - healthServer.Stop(context.Background()) - deviceService.Stop() - heartbeatService.Stop() - cronService.Stop() - agentLoop.Stop() - channelManager.StopAll(ctx) - fmt.Println("āœ“ Gateway stopped") + + r.cancel() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + r.healthServer.Stop(ctx) + r.deviceService.Stop() + r.heartbeatService.Stop() + r.cronService.Stop() + r.agentLoop.Stop() + r.channelManager.StopAll(ctx) + + if !isDaemonMode() { + fmt.Println("āœ“ Gateway stopped") + } + + logger.InfoC("gateway", "Shutdown complete") +} + +// isDaemonMode returns true if the process is running in daemon mode. +func isDaemonMode() bool { + return os.Getenv("PICOCLAW_DAEMON") == "1" +} + +// gatewayCmd runs the gateway in the foreground. +func gatewayCmd() { + // Check for --debug flag + args := os.Args[2:] + for _, arg := range args { + if arg == "--debug" || arg == "-d" { + logger.SetLevel(logger.DEBUG) + if !isDaemonMode() { + fmt.Println("šŸ” Debug mode enabled") + } + break + } + } + + // Create gateway runner + runner, err := createGatewayRunner(isDaemonMode()) + if err != nil { + if isDaemonMode() { + logger.ErrorCF("gateway", "Failed to initialize gateway", map[string]any{"error": err.Error()}) + } else { + fmt.Printf("Error: %v\n", err) + } + os.Exit(1) + } + + // Set up signal handling for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Start the gateway + go func() { + if err := runner.run(isDaemonMode()); err != nil { + logger.ErrorCF("gateway", "Gateway error", map[string]any{"error": err.Error()}) + runner.stop() + os.Exit(1) + } + }() + + // Wait for shutdown signal + <-sigChan + runner.stop() } func setupCronTool( diff --git a/cmd/picoclaw/cmd_service.go b/cmd/picoclaw/cmd_service.go new file mode 100644 index 000000000..805261c08 --- /dev/null +++ b/cmd/picoclaw/cmd_service.go @@ -0,0 +1,197 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package main + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/sipeed/picoclaw/pkg/daemon" +) + +// gatewayServiceCmd handles gateway service management subcommands. +// Usage: picoclaw gateway +func gatewayServiceCmd(subcommand string) { + // Get the picoclaw config directory + configDir := getConfigDir() + + // Get the path to the current binary + binaryPath, err := os.Executable() + if err != nil { + fmt.Printf("Error: Unable to determine binary path: %v\n", err) + os.Exit(1) + } + + // Create daemon service configuration + serviceConfig := &daemon.ServiceConfig{ + ConfigDir: configDir, + BinaryPath: binaryPath, + Version: formatVersion(), + // Use default restart policy: 3 attempts, exponential backoff + RestartPolicy: daemon.DefaultRestartPolicy(), + // Log files will be stored in config directory + } + + // Create the daemon service + service, err := daemon.NewService(serviceConfig) + if err != nil { + fmt.Printf("Error: Failed to create daemon service: %v\n", err) + os.Exit(1) + } + + // Execute the requested subcommand + switch subcommand { + case "start": + handleStart(service) + + case "stop": + handleStop(service) + + case "restart": + handleRestart(service) + + case "status": + handleStatus(service) + + default: + fmt.Printf("Error: Unknown gateway subcommand: %s\n", subcommand) + fmt.Println("Valid subcommands are: start, stop, restart, status") + os.Exit(1) + } +} + +// handleStart starts the gateway daemon. +func handleStart(service *daemon.Service) { + fmt.Printf("%s Starting PicoClaw Gateway...\n", logo) + + if err := service.Start(); err != nil { + switch e := err.(type) { + case *daemon.AlreadyRunningError: + fmt.Printf("āœ— Gateway is already running with PID %d\n", e.GetPID()) + fmt.Println("\nUse 'picoclaw gateway status' for more information") + default: + fmt.Printf("āœ— Failed to start gateway: %v\n", err) + } + os.Exit(1) + } + + fmt.Println("āœ“ Gateway started successfully") + fmt.Printf(" PID: %d\n", service.Status().PID) + fmt.Printf(" Log: %s\n", filepath.Join(getConfigDir(), daemon.DefaultLogFileName)) + fmt.Println("\nUse 'picoclaw gateway status' to check the gateway status") + fmt.Println("Use 'picoclaw gateway stop' to stop the gateway") +} + +// handleStop stops the gateway daemon. +func handleStop(service *daemon.Service) { + fmt.Printf("%s Stopping PicoClaw Gateway...\n", logo) + + if err := service.Stop(); err != nil { + switch err.(type) { + case *daemon.NotRunningError: + fmt.Println("āœ— Gateway is not running") + fmt.Println("\nUse 'picoclaw gateway start' to start the gateway") + default: + fmt.Printf("āœ— Failed to stop gateway: %v\n", err) + } + os.Exit(1) + } + + fmt.Println("āœ“ Gateway stopped successfully") +} + +// handleRestart restarts the gateway daemon with automatic crash recovery. +func handleRestart(service *daemon.Service) { + fmt.Printf("%s Restarting PicoClaw Gateway...\n", logo) + + if err := service.Restart(); err != nil { + switch e := err.(type) { + case *daemon.AlreadyRunningError: + fmt.Printf("āœ— Gateway is already running with PID %d\n", e.GetPID()) + fmt.Println("\nUse 'picoclaw gateway stop' first, then 'picoclaw gateway start'") + case *daemon.MaxRestartsExceededError: + fmt.Printf("āœ— Maximum restart attempts exceeded (%d attempts in %v)\n", + e.GetAttempts(), e.Window) + fmt.Println("\nThe gateway is crashing repeatedly. Check the log file for errors:") + fmt.Printf(" Log: %s\n", filepath.Join(getConfigDir(), daemon.DefaultLogFileName)) + default: + fmt.Printf("āœ— Failed to restart gateway: %v\n", err) + } + os.Exit(1) + } + + status := service.Status() + fmt.Println("āœ“ Gateway restarted successfully") + fmt.Printf(" PID: %d\n", status.PID) + fmt.Printf(" Restart count: %d\n", status.RestartCount) + fmt.Printf(" Log: %s\n", filepath.Join(getConfigDir(), daemon.DefaultLogFileName)) +} + +// handleStatus displays the current status of the gateway daemon. +func handleStatus(service *daemon.Service) { + status := service.Status() + + fmt.Printf("%s PicoClaw Gateway Status\n", logo) + fmt.Printf("Version: %s\n\n", formatVersion()) + + if !status.IsRunning { + fmt.Println("Status: Stopped") + fmt.Println("\nUse 'picoclaw gateway start' to start the gateway") + return + } + + fmt.Println("Status: Running") + fmt.Printf(" PID: %d\n", status.PID) + + if !status.StartTime.IsZero() { + fmt.Printf(" Started: %s\n", status.StartTime.Format("2006-01-02 15:04:05")) + + if status.Uptime > 0 { + fmt.Printf(" Uptime: %s\n", formatUptime(status.Uptime)) + } + } + + if status.RestartCount > 0 { + fmt.Printf(" Restarts: %d\n", status.RestartCount) + } + + if status.Version != "" { + fmt.Printf(" Version: %s\n", status.Version) + } + + // Log file location + logPath := filepath.Join(getConfigDir(), daemon.DefaultLogFileName) + fmt.Printf("\nLog file: %s\n", logPath) +} + +// getConfigDir returns the picoclaw configuration directory. +func getConfigDir() string { + home, err := os.UserHomeDir() + if err != nil { + return ".picoclaw" + } + return filepath.Join(home, ".picoclaw") +} + +// formatUptime formats a duration in a human-readable way. +func formatUptime(d time.Duration) string { + if d < time.Minute { + return d.String() + } + if d < time.Hour { + return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) + } + if d < 24*time.Hour { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh %dm", hours, minutes) + } + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%dd %dh", days, hours) +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 25ad701ca..622b30e8e 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -106,6 +106,16 @@ func main() { case "agent": agentCmd() case "gateway": + // Check for subcommands: start, stop, restart, status + if len(os.Args) >= 3 { + subcommand := os.Args[2] + switch subcommand { + case "start", "stop", "restart", "status": + gatewayServiceCmd(subcommand) + return + } + } + // No subcommand or unrecognized subcommand - run gateway in foreground gatewayCmd() case "status": statusCmd() @@ -178,15 +188,19 @@ func printHelp() { fmt.Println("Usage: picoclaw ") fmt.Println() fmt.Println("Commands:") - fmt.Println(" onboard Initialize picoclaw configuration and workspace") - fmt.Println(" agent Interact with the agent directly") - fmt.Println(" auth Manage authentication (login, logout, status)") - fmt.Println(" gateway Start picoclaw gateway") - fmt.Println(" status Show picoclaw status") - fmt.Println(" cron Manage scheduled tasks") - fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") - fmt.Println(" skills Manage skills (install, list, remove)") - fmt.Println(" version Show version information") + fmt.Println(" onboard Initialize picoclaw configuration and workspace") + fmt.Println(" agent Interact with the agent directly") + fmt.Println(" auth Manage authentication (login, logout, status)") + fmt.Println(" gateway Start picoclaw gateway (foreground mode)") + fmt.Println(" gateway start Start gateway as daemon (background)") + fmt.Println(" gateway stop Stop the gateway daemon") + fmt.Println(" gateway restart Restart the gateway daemon") + fmt.Println(" gateway status Show gateway daemon status") + fmt.Println(" status Show picoclaw status") + fmt.Println(" cron Manage scheduled tasks") + fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") + fmt.Println(" skills Manage skills (install, list, remove)") + fmt.Println(" version Show version information") } func getConfigPath() string { diff --git a/pkg/daemon/logger.go b/pkg/daemon/logger.go new file mode 100644 index 000000000..a57c2b494 --- /dev/null +++ b/pkg/daemon/logger.go @@ -0,0 +1,243 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package daemon + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// LogConfig defines the configuration for daemon logging. +type LogConfig struct { + // Path is the log file path + Path string + + // MaxSize is the maximum size in bytes before rotation + MaxSize int64 + + // MaxBackups is the maximum number of backup files to keep + MaxBackups int + + // MaxAge is the maximum age to keep a log file before deletion + MaxAge time.Duration +} + +// DefaultLogConfig returns a log configuration with sensible defaults. +func DefaultLogConfig(path string) *LogConfig { + return &LogConfig{ + Path: path, + MaxSize: 100 * 1024 * 1024, // 100 MB + MaxBackups: 3, // Keep 3 backups + MaxAge: 30 * 24 * time.Hour, // 30 days + } +} + +// Logger manages daemon logging with automatic file rotation. +// +// Design rationale: +// - Long-running daemons need log rotation to prevent disk space exhaustion +// - Atomic rotation prevents log loss during rotation +// - Size-based rotation is more predictable than time-based rotation +// - Automatic cleanup of old logs prevents accumulation +type Logger struct { + config *LogConfig + file *os.File + size int64 + mu sync.Mutex +} + +// NewLogger creates a new daemon logger with the given configuration. +func NewLogger(config *LogConfig) (*Logger, error) { + if config == nil { + return nil, fmt.Errorf("log config cannot be nil") + } + + l := &Logger{ + config: config, + } + + // Ensure log directory exists + dir := filepath.Dir(config.Path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create log directory: %w", err) + } + + // Open log file + if err := l.openLogFile(); err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + + // Get current file size + info, err := l.file.Stat() + if err != nil { + l.file.Close() + return nil, fmt.Errorf("failed to stat log file: %w", err) + } + l.size = info.Size() + + return l, nil +} + +// openLogFile opens the log file for appending. +// Must be called with the lock held. +func (l *Logger) openLogFile() error { + var err error + l.file, err = os.OpenFile(l.config.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + return err +} + +// Write writes a message to the log file. +// If the file size exceeds MaxSize, it rotates the log file first. +func (l *Logger) Write(message string) error { + l.mu.Lock() + defer l.mu.Unlock() + + // Check if rotation is needed + if l.size+l.fileSizeFor(message) > l.config.MaxSize { + if err := l.rotate(); err != nil { + // Log rotation failed, try writing to current file anyway + // but include error message + message = fmt.Sprintf("[ERROR] Failed to rotate log: %v\n%s", err, message) + } + } + + // Write message + n, err := l.file.WriteString(message) + if err != nil { + return fmt.Errorf("failed to write to log file: %w", err) + } + + l.size += int64(n) + + // Sync to ensure data is written to disk + if err := l.file.Sync(); err != nil { + return fmt.Errorf("failed to sync log file: %w", err) + } + + return nil +} + +// fileSizeFor estimates the size of the message in bytes. +func (l *Logger) fileSizeFor(message string) int64 { + return int64(len(message)) +} + +// rotate performs log file rotation. +// Must be called with the lock held. +func (l *Logger) rotate() error { + // Close current log file + if l.file != nil { + if err := l.file.Close(); err != nil { + return fmt.Errorf("failed to close current log file: %w", err) + } + } + + // Find the next available backup number + backupNum := 1 + for { + backupPath := l.backupPath(backupNum) + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + break + } + backupNum++ + } + + // Rotate existing backups + for i := backupNum - 1; i >= 1; i-- { + oldPath := l.backupPath(i) + newPath := l.backupPath(i + 1) + + if i >= l.config.MaxBackups { + // Delete old backup if we have too many + os.Remove(oldPath) + } else { + // Rename backup + os.Rename(oldPath, newPath) + } + } + + // Move current log to backup + if err := os.Rename(l.config.Path, l.backupPath(1)); err != nil { + // Reopen current file if rename failed + l.openLogFile() + return fmt.Errorf("failed to rotate log file: %w", err) + } + + // Clean up old log files based on age + l.cleanupOldLogs() + + // Open new log file + if err := l.openLogFile(); err != nil { + return fmt.Errorf("failed to open new log file after rotation: %w", err) + } + + // Reset size + l.size = 0 + + return nil +} + +// backupPath returns the path for a backup file with the given number. +func (l *Logger) backupPath(num int) string { + return fmt.Sprintf("%s.%d", l.config.Path, num) +} + +// cleanupOldLogs removes log files older than MaxAge. +// Must be called with the lock held. +func (l *Logger) cleanupOldLogs() { + if l.config.MaxAge <= 0 { + return + } + + cutoff := time.Now().Add(-l.config.MaxAge) + + // Check all backup files + for i := 1; i <= l.config.MaxBackups; i++ { + backupPath := l.backupPath(i) + + info, err := os.Stat(backupPath) + if err != nil { + continue + } + + // Delete if older than cutoff + if info.ModTime().Before(cutoff) { + os.Remove(backupPath) + } + } +} + +// Close closes the log file. +func (l *Logger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + + if l.file != nil { + return l.file.Close() + } + + return nil +} + +// Rotate forces an immediate log rotation. +// Useful for testing or manual log management. +func (l *Logger) Rotate() error { + l.mu.Lock() + defer l.mu.Unlock() + + return l.rotate() +} + +// GetSize returns the current size of the log file in bytes. +func (l *Logger) GetSize() int64 { + l.mu.Lock() + defer l.mu.Unlock() + + return l.size +} diff --git a/pkg/daemon/pidfile.go b/pkg/daemon/pidfile.go new file mode 100644 index 000000000..0ee026c59 --- /dev/null +++ b/pkg/daemon/pidfile.go @@ -0,0 +1,230 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package daemon + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + "syscall" + "time" +) + +// PIDFile manages process ID file with atomic operations. +// It provides thread-safe operations for creating, reading, and removing PID files. +// +// Design rationale: +// - PID files are the canonical Unix way to track daemon processes +// - Atomic operations prevent race conditions during concurrent access +// - Proper cleanup prevents stale PID files from accumulating +type PIDFile struct { + path string + mu sync.Mutex +} + +// NewPIDFile creates a new PID file manager for the given path. +func NewPIDFile(path string) *PIDFile { + // Ensure directory exists + dir := filepath.Dir(path) + os.MkdirAll(dir, 0o755) + + return &PIDFile{ + path: path, + } +} + +// Write atomically writes the current process ID to the PID file. +// Returns an error if a PID file already exists with a running process. +// +// This prevents multiple instances of the gateway from running simultaneously, +// which could cause resource conflicts and undefined behavior. +func (p *PIDFile) Write() error { + p.mu.Lock() + defer p.mu.Unlock() + + // Check for existing PID file + if _, err := os.Stat(p.path); err == nil { + // PID file exists, check if process is running + pid, err := p.read() + if err == nil && p.isProcessRunning(pid) { + return &ProcessRunningError{ + pid: pid, + Path: p.path, + } + } + // Process is not running, stale PID file, continue + } + + // Write PID to temp file in same directory (atomic preparation) + pid := os.Getpid() + pidStr := strconv.Itoa(pid) + + tempFile := p.path + ".tmp" + if err := os.WriteFile(tempFile, []byte(pidStr), 0o644); err != nil { + return fmt.Errorf("failed to write temp PID file: %w", err) + } + + // Atomic rename from temp to target + if err := os.Rename(tempFile, p.path); err != nil { + os.Remove(tempFile) // Cleanup temp file + return fmt.Errorf("failed to atomically create PID file: %w", err) + } + + return nil +} + +// Remove deletes the PID file. +// Safe to call multiple times (idempotent operation). +func (p *PIDFile) Remove() { + p.mu.Lock() + defer p.mu.Unlock() + + _ = os.Remove(p.path) +} + +// Read returns the process ID stored in the PID file. +// Returns 0 if the file doesn't exist or cannot be read. +func (p *PIDFile) Read() int { + p.mu.Lock() + defer p.mu.Unlock() + + pid, err := p.read() + if err != nil { + return 0 + } + return pid +} + +// read must be called with the lock held. +func (p *PIDFile) read() (int, error) { + data, err := os.ReadFile(p.path) + if err != nil { + return 0, err + } + + pid, err := strconv.Atoi(string(data)) + if err != nil { + return 0, fmt.Errorf("invalid PID in file: %w", err) + } + + return pid, nil +} + +// IsProcessRunning checks if the process with the given PID is still running. +func (p *PIDFile) IsProcessRunning() bool { + p.mu.Lock() + defer p.mu.Unlock() + + pid, err := p.read() + if err != nil { + return false + } + + return p.isProcessRunning(pid) +} + +// isProcessRunning must be called with the lock held. +func (p *PIDFile) isProcessRunning(pid int) bool { + if pid <= 0 { + return false + } + + // Send signal 0 to check if process exists + // On Unix, this doesn't actually send a signal but checks if the process exists + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // Try to send signal 0 (no signal, just check existence) + err = process.Signal(syscall.Signal(0)) + if err != nil { + // Process doesn't exist or we don't have permission to signal it + return false + } + + return true +} + +// GetUptime returns the uptime of the process if it's running. +// Returns zero duration if the process is not running or uptime cannot be determined. +func (p *PIDFile) GetUptime() time.Duration { + p.mu.Lock() + defer p.mu.Unlock() + + pid, err := p.read() + if err != nil { + return 0 + } + + return p.getProcessUptime(pid) +} + +// getProcessUptime must be called with the lock held. +func (p *PIDFile) getProcessUptime(pid int) time.Duration { + if pid <= 0 { + return 0 + } + + // Read /proc//stat to get process start time + // Format: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt ... + // The 22nd field (index 21) is the start time in jiffies + statPath := fmt.Sprintf("/proc/%d/stat", pid) + data, err := os.ReadFile(statPath) + if err != nil { + // Process doesn't exist or /proc not available + return 0 + } + + // Parse the stat file + // Find the last ')' to handle command names with spaces + fields := string(data) + lastParen := -1 + for i, c := range fields { + if c == ')' { + lastParen = i + } + } + + if lastParen == -1 { + return 0 + } + + // Extract fields after the command name + rest := fields[lastParen+2:] // Skip ") " + var startTimeJiffies uint64 + _, err = fmt.Sscanf(rest, "%*c %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %d", + &startTimeJiffies) + if err != nil { + return 0 + } + + // Convert jiffies to nanoseconds + // Assumes USER_HZ=100 (common on Linux) + // This is a simplification; for production code, use sysconf(_SC_CLK_TCK) + clockTick := int64(100) + startTime := time.Unix(int64(startTimeJiffies)/clockTick, 0) + + return time.Since(startTime) +} + +// ProcessRunningError is returned when attempting to write a PID file +// but a process is already running. +type ProcessRunningError struct { + pid int + Path string +} + +func (e *ProcessRunningError) Error() string { + return fmt.Sprintf("process already running with PID %d (PID file: %s)", e.pid, e.Path) +} + +// GetPID returns the PID of the running process. +func (e *ProcessRunningError) GetPID() int { + return e.pid +} diff --git a/pkg/daemon/restart.go b/pkg/daemon/restart.go new file mode 100644 index 000000000..1ea25a6f7 --- /dev/null +++ b/pkg/daemon/restart.go @@ -0,0 +1,190 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package daemon + +import ( + "sync" + "time" +) + +// RestartPolicy defines the strategy for process restart after crashes. +// +// Design rationale: +// - Automatic restart improves reliability for long-running services +// - Exponential backoff prevents rapid restart loops that could indicate systemic issues +// - Maximum attempt limits prevent infinite restart cycles +// - Time window ensures crashes are counted within a meaningful period +type RestartPolicy struct { + // MaxAttempts is the maximum number of restart attempts before giving up. + MaxAttempts int + + // WindowDuration is the time window in which restart attempts are counted. + // Crashes outside this window don't count toward MaxAttempts. + WindowDuration time.Duration + + // BackoffBase is the initial backoff duration before first restart. + BackoffBase time.Duration + + // BackoffMultiplier is the factor by which backoff increases after each attempt. + // A value of 2.0 means each backoff is double the previous one. + BackoffMultiplier float64 + + // BackoffMax is the maximum backoff duration. + BackoffMax time.Duration +} + +// DefaultRestartPolicy returns a restart policy with sensible defaults. +// +// Defaults: +// - Max 3 attempts within 5 minutes +// - Exponential backoff starting at 1 second, capped at 30 seconds +func DefaultRestartPolicy() *RestartPolicy { + return &RestartPolicy{ + MaxAttempts: 3, + WindowDuration: 5 * time.Minute, + BackoffBase: 1 * time.Second, + BackoffMultiplier: 2.0, + BackoffMax: 30 * time.Second, + } +} + +// RestartTracker tracks restart attempts and determines when restart should occur. +type RestartTracker struct { + policy *RestartPolicy + attempts []time.Time + mu sync.Mutex +} + +// NewRestartTracker creates a new restart tracker with the given policy. +func NewRestartTracker(policy *RestartPolicy) *RestartTracker { + if policy == nil { + policy = DefaultRestartPolicy() + } + + return &RestartTracker{ + policy: policy, + attempts: make([]time.Time, 0, policy.MaxAttempts), + } +} + +// RecordAttempt records a restart attempt at the current time. +// Returns the duration to wait before the next restart, or an error if +// the maximum number of attempts has been exceeded. +func (rt *RestartTracker) RecordAttempt() (time.Duration, error) { + rt.mu.Lock() + defer rt.mu.Unlock() + + now := time.Now() + + // Remove attempts outside the time window + rt.cleanupOldAttempts(now) + + // Check if we've exceeded max attempts + if len(rt.attempts) >= rt.policy.MaxAttempts { + return 0, &MaxRestartsExceededError{ + attempts: len(rt.attempts), + maxAttempts: rt.policy.MaxAttempts, + Window: rt.policy.WindowDuration, + LastAttemptAt: rt.attempts[len(rt.attempts)-1], + } + } + + // Record this attempt + rt.attempts = append(rt.attempts, now) + + // Calculate backoff duration + backoff := rt.calculateBackoff(len(rt.attempts)) + + return backoff, nil +} + +// cleanupOldAttempts removes attempts that are outside the time window. +// Must be called with the lock held. +func (rt *RestartTracker) cleanupOldAttempts(now time.Time) { + cutoff := now.Add(-rt.policy.WindowDuration) + + // Find the first attempt within the window + firstValid := 0 + for i, attempt := range rt.attempts { + if attempt.After(cutoff) { + firstValid = i + break + } + } + + // Remove old attempts + if firstValid > 0 { + rt.attempts = rt.attempts[firstValid:] + } +} + +// calculateBackoff computes the backoff duration using exponential backoff. +// Must be called with the lock held. +func (rt *RestartTracker) calculateBackoff(attemptNum int) time.Duration { + // Exponential backoff: base * multiplier^(attemptNum-1) + backoff := rt.policy.BackoffBase + + for i := 1; i < attemptNum; i++ { + backoff = time.Duration(float64(backoff) * rt.policy.BackoffMultiplier) + if backoff > rt.policy.BackoffMax { + backoff = rt.policy.BackoffMax + break + } + } + + return backoff +} + +// Reset clears all recorded attempts. +// Use this when the process has been running successfully for a while +// and you want to reset the crash counter. +func (rt *RestartTracker) Reset() { + rt.mu.Lock() + defer rt.mu.Unlock() + + rt.attempts = make([]time.Time, 0, rt.policy.MaxAttempts) +} + +// ShouldRestart returns true if another restart attempt should be made. +func (rt *RestartTracker) ShouldRestart() bool { + rt.mu.Lock() + defer rt.mu.Unlock() + + rt.cleanupOldAttempts(time.Now()) + return len(rt.attempts) < rt.policy.MaxAttempts +} + +// GetAttemptCount returns the number of restart attempts within the current window. +func (rt *RestartTracker) GetAttemptCount() int { + rt.mu.Lock() + defer rt.mu.Unlock() + + rt.cleanupOldAttempts(time.Now()) + return len(rt.attempts) +} + +// MaxRestartsExceededError is returned when the maximum number of +// restart attempts has been exceeded within the time window. +type MaxRestartsExceededError struct { + attempts int + maxAttempts int + Window time.Duration + LastAttemptAt time.Time +} + +func (e *MaxRestartsExceededError) Error() string { + return "maximum restart attempts exceeded" +} + +// GetAttempts returns the number of restart attempts made. +func (e *MaxRestartsExceededError) GetAttempts() int { + return e.attempts +} + +// GetMaxAttempts returns the maximum allowed attempts. +func (e *MaxRestartsExceededError) GetMaxAttempts() int { + return e.maxAttempts +} diff --git a/pkg/daemon/service.go b/pkg/daemon/service.go new file mode 100644 index 000000000..1dd95ec28 --- /dev/null +++ b/pkg/daemon/service.go @@ -0,0 +1,465 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package daemon + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +const ( + // DefaultPIDFileName is the default PID file name + DefaultPIDFileName = "gateway.pid" + // DefaultStateFileName is the default state file name + DefaultStateFileName = "gateway-state.json" + // DefaultLogFileName is the default log file name + DefaultLogFileName = "gateway.log" +) + +// Service manages a daemon process with lifecycle control. +// +// Design rationale: +// - Provides a high-level API for daemon operations (start, stop, restart, status) +// - Encapsulates PID file, state management, and logging concerns +// - Follows the same Start/Stop/IsRunning pattern as other services (heartbeat, cron) +// - Thread-safe operations with proper mutex protection +type Service struct { + // configDir is the directory containing configuration and runtime files + configDir string + + // binaryPath is the path to the picoclaw binary + binaryPath string + + // args are the arguments to pass to the daemon process + args []string + + // version is the picoclaw version + version string + + pidFile *PIDFile + state *StateManager + logConfig *LogConfig + restartPolicy *RestartPolicy + + mu sync.Mutex + quitChan chan struct{} +} + +// ServiceConfig is the configuration for creating a new Service. +type ServiceConfig struct { + // ConfigDir is the directory containing config and runtime files (~/.picoclaw) + ConfigDir string + + // BinaryPath is the path to the picoclaw binary + BinaryPath string + + // Args are additional arguments to pass to the gateway process + Args []string + + // Version is the picoclaw version + Version string + + // RestartPolicy is the restart policy (nil for default) + RestartPolicy *RestartPolicy + + // LogConfig is the logging configuration (nil for default) + LogConfig *LogConfig +} + +// NewService creates a new daemon service with the given configuration. +func NewService(config *ServiceConfig) (*Service, error) { + if config == nil { + return nil, fmt.Errorf("service config cannot be nil") + } + + pidFilePath := filepath.Join(config.ConfigDir, DefaultPIDFileName) + stateFilePath := filepath.Join(config.ConfigDir, DefaultStateFileName) + logFilePath := filepath.Join(config.ConfigDir, DefaultLogFileName) + + logConfig := config.LogConfig + if logConfig == nil { + logConfig = DefaultLogConfig(logFilePath) + } + + return &Service{ + configDir: config.ConfigDir, + binaryPath: config.BinaryPath, + args: config.Args, + version: config.Version, + pidFile: NewPIDFile(pidFilePath), + state: NewStateManager(stateFilePath), + logConfig: logConfig, + restartPolicy: config.RestartPolicy, + quitChan: make(chan struct{}), + }, nil +} + +// Start starts the gateway daemon. +// +// Returns an error if: +// - The daemon is already running +// - The binary cannot be executed +// - PID file creation fails +func (s *Service) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + logger.InfoCF("daemon", "Starting gateway daemon", map[string]any{ + "version": s.version, + "binary": s.binaryPath, + }) + + // Check if already running + if s.pidFile.IsProcessRunning() { + pid := s.pidFile.Read() + return &AlreadyRunningError{pid: pid} + } + + // Prepare command + cmd := exec.Command(s.binaryPath, s.buildArgs()...) + + // Set up environment for daemon mode + // This tells the gateway process it's running as a daemon + cmd.Env = append(os.Environ(), "PICOCLAW_DAEMON=1") + + // Redirect stdout and stderr to log file + logFile, err := os.OpenFile(s.logConfig.Path, 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 + + // Start the process + if err := cmd.Start(); err != nil { + logFile.Close() + return fmt.Errorf("failed to start gateway process: %w", err) + } + + pid := cmd.Process.Pid + + // Write PID file atomically + if err := s.pidFile.Write(); err != nil { + // Failed to write PID file, kill the process + cmd.Process.Kill() + logFile.Close() + return fmt.Errorf("failed to write PID file: %w", err) + } + + // Update state + s.state.SetPID(pid) + s.state.SetStartTime(time.Now()) + s.state.SetVersion(s.version) + + logger.InfoCF("daemon", "Gateway daemon started", map[string]any{ + "pid": pid, + "version": s.version, + }) + + return nil +} + +// Stop stops the running gateway daemon gracefully. +// +// Returns an error if: +// - The daemon is not running +// - The PID file cannot be read +// - Signal delivery fails +func (s *Service) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + logger.InfoC("daemon", "Stopping gateway daemon") + + pid := s.pidFile.Read() + if pid == 0 { + return &NotRunningError{} + } + + // Send SIGTERM for graceful shutdown + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process: %w", err) + } + + if err := process.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("failed to send SIGTERM: %w", err) + } + + // Wait for process to exit (with timeout) + done := make(chan error, 1) + go func() { + _, err := process.Wait() + done <- err + }() + + select { + case <-done: + // Process exited + logger.InfoCF("daemon", "Gateway daemon stopped", map[string]any{ + "pid": pid, + }) + case <-time.After(30 * time.Second): + // Timeout, force kill + logger.WarnCF("daemon", "Gateway did not stop gracefully, forcing", map[string]any{ + "pid": pid, + }) + process.Kill() + } + + // Clean up + s.pidFile.Remove() + s.state.Clear() + + return nil +} + +// Restart restarts the gateway daemon with automatic crash recovery. +// +// This method implements the auto-restart loop: +// 1. If daemon is running, stop it first +// 2. Start the daemon +// 3. If it crashes, wait (exponential backoff) and restart +// 4. Repeat up to MaxAttempts within WindowDuration +// 5. Give up if max attempts exceeded +// +// Returns an error if: +// - Stop fails +// - Max restart attempts exceeded +func (s *Service) Restart() error { + s.mu.Lock() + defer s.mu.Unlock() + + logger.InfoC("daemon", "Restarting gateway daemon") + + // Stop if running + if s.pidFile.IsProcessRunning() { + if err := s.Stop(); err != nil { + return fmt.Errorf("failed to stop daemon: %w", err) + } + } + + // Create restart tracker + tracker := NewRestartTracker(s.restartPolicy) + + // Restart loop + for tracker.ShouldRestart() { + // Attempt to start + err := s.startInternal() + if err == nil { + // Success! Reset restart counter and return + tracker.Reset() + logger.InfoC("daemon", "Gateway daemon restarted successfully") + return nil + } + + // Check if this is an "already running" error + if _, ok := err.(*AlreadyRunningError); ok { + return err + } + + // Record the failed attempt and get backoff duration + backoff, backoffErr := tracker.RecordAttempt() + if backoffErr != nil { + // Max attempts exceeded + logger.ErrorCF("daemon", "Maximum restart attempts exceeded", map[string]any{ + "attempts": tracker.GetAttemptCount(), + "max": s.restartPolicy.MaxAttempts, + }) + return backoffErr + } + + // Update state with restart count + s.state.IncrementRestartCount() + + logger.WarnCF("daemon", "Gateway daemon crashed, will restart", map[string]any{ + "attempt": tracker.GetAttemptCount(), + "backoff": backoff.String(), + "error": err.Error(), + }) + + // Wait before next restart attempt + select { + case <-time.After(backoff): + // Continue to next attempt + case <-s.quitChan: + // Abort restart loop + return fmt.Errorf("restart aborted") + } + } + + return fmt.Errorf("maximum restart attempts exceeded") +} + +// startInternal starts the daemon without locking. +// Must be called with the lock held. +func (s *Service) startInternal() error { + // Prepare command + cmd := exec.Command(s.binaryPath, s.buildArgs()...) + cmd.Env = append(os.Environ(), "PICOCLAW_DAEMON=1") + + // Redirect output to log file + logFile, err := os.OpenFile(s.logConfig.Path, 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 + + // Start the process + if err := cmd.Start(); err != nil { + logFile.Close() + return fmt.Errorf("failed to start gateway process: %w", err) + } + + pid := cmd.Process.Pid + + // Write PID file + if err := s.pidFile.Write(); err != nil { + cmd.Process.Kill() + logFile.Close() + return fmt.Errorf("failed to write PID file: %w", err) + } + + // Update state + s.state.SetPID(pid) + s.state.SetStartTime(time.Now()) + s.state.SetVersion(s.version) + + // Wait for process to exit + return cmd.Wait() +} + +// Status returns the current status of the gateway daemon. +func (s *Service) Status() *StatusInfo { + s.mu.Lock() + defer s.mu.Unlock() + + pid := s.pidFile.Read() + isRunning := s.pidFile.IsProcessRunning() + + state := s.state.GetState() + + return &StatusInfo{ + IsRunning: isRunning, + PID: pid, + StartTime: state.StartTime, + Uptime: s.state.GetUptime(), + RestartCount: state.RestartCount, + Version: state.Version, + } +} + +// IsRunning returns true if the daemon is currently running. +func (s *Service) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + + return s.pidFile.IsProcessRunning() +} + +// buildArgs constructs the command line arguments for the gateway process. +func (s *Service) buildArgs() []string { + args := append([]string{"gateway"}, s.args...) + return args +} + +// Abort aborts any ongoing restart operation. +func (s *Service) Abort() { + close(s.quitChan) +} + +// StatusInfo contains status information about the daemon. +type StatusInfo struct { + IsRunning bool + PID int + StartTime time.Time + Uptime time.Duration + RestartCount int + Version string +} + +// AlreadyRunningError is returned when attempting to start a daemon +// that is already running. +type AlreadyRunningError struct { + pid int +} + +func (e *AlreadyRunningError) Error() string { + return fmt.Sprintf("gateway already running with PID %d", e.pid) +} + +// GetPID returns the PID of the running process. +func (e *AlreadyRunningError) GetPID() int { + return e.pid +} + +// NotRunningError is returned when attempting to stop a daemon +// that is not running. +type NotRunningError struct{} + +func (e *NotRunningError) Error() string { + return "gateway is not running" +} + +// Run runs the gateway in the foreground (not as a daemon). +// This is the existing behavior when running `picoclaw gateway` without subcommands. +// +// This method blocks until the gateway is stopped via Ctrl+C. +// It sets up signal handlers for graceful shutdown and initializes +// file logging for daemon mode. +func (s *Service) Run(gatewayFunc func(context.Context) error) error { + // Check if running as daemon + isDaemon := os.Getenv("PICOCLAW_DAEMON") == "1" + + if isDaemon { + // Set up file logging + daemonLogger, err := NewLogger(s.logConfig) + if err != nil { + return fmt.Errorf("failed to initialize daemon logging: %w", err) + } + defer daemonLogger.Close() + + // Redirect logger output to file + if err := logger.EnableFileLogging(s.logConfig.Path); err != nil { + return fmt.Errorf("failed to enable file logging: %w", err) + } + defer logger.DisableFileLogging() + + logger.InfoCF("daemon", "Gateway started in daemon mode", map[string]any{ + "pid": os.Getpid(), + "version": s.version, + }) + } + + // Create context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Run the gateway function in a goroutine + errChan := make(chan error, 1) + go func() { + errChan <- gatewayFunc(ctx) + }() + + // Wait for context cancellation or error + select { + case <-ctx.Done(): + return nil + case err := <-errChan: + return err + } +} diff --git a/pkg/daemon/state.go b/pkg/daemon/state.go new file mode 100644 index 000000000..e0657c14a --- /dev/null +++ b/pkg/daemon/state.go @@ -0,0 +1,236 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package daemon + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// State represents the persistent state of a daemon process. +// +// Design rationale: +// - Persistent state allows status reporting across process restarts +// - Atomic saves prevent corruption from crashes or power loss +// - Tracking restart history helps identify systemic issues +type State struct { + // PID is the process ID of the running daemon + PID int `json:"pid"` + + // StartTime is when the daemon was started + StartTime time.Time `json:"start_time"` + + // RestartCount is the number of times the daemon has been restarted + // This is cumulative and is reset when the daemon runs successfully + // for longer than the restart window duration. + RestartCount int `json:"restart_count"` + + // LastRestartTime is the timestamp of the most recent restart + LastRestartTime *time.Time `json:"last_restart_time,omitempty"` + + // Version is the picoclaw version that started this daemon + Version string `json:"version,omitempty"` +} + +// StateManager manages daemon state with atomic saves. +// Follows the same atomic save pattern as pkg/state/state.go +type StateManager struct { + stateFile string + state *State + mu sync.RWMutex +} + +// NewStateManager creates a new state manager for the given state file path. +func NewStateManager(stateFile string) *StateManager { + // Ensure directory exists + dir := filepath.Dir(stateFile) + os.MkdirAll(dir, 0o755) + + sm := &StateManager{ + stateFile: stateFile, + state: &State{}, + } + + // Load existing state if present + sm.load() + + return sm +} + +// Save atomically saves the current state to disk. +// Uses the temp file + rename pattern for atomic writes. +func (sm *StateManager) Save() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + return sm.saveAtomic() +} + +// saveAtomic performs an atomic save using temp file + rename. +// Must be called with the lock held. +func (sm *StateManager) saveAtomic() error { + // Create temp file in the same directory as the target + tempFile := sm.stateFile + ".tmp" + + // Marshal state to JSON + data, err := json.MarshalIndent(sm.state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + // Write to temp file + if err := os.WriteFile(tempFile, data, 0o644); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // Atomic rename from temp to target + if err := os.Rename(tempFile, sm.stateFile); err != nil { + // Cleanup temp file if rename fails + os.Remove(tempFile) + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil +} + +// load loads the state from disk. +// Must be called with the lock held. +func (sm *StateManager) load() error { + data, err := os.ReadFile(sm.stateFile) + if err != nil { + // File doesn't exist yet, that's OK + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read state file: %w", err) + } + + if err := json.Unmarshal(data, sm.state); err != nil { + return fmt.Errorf("failed to unmarshal state: %w", err) + } + + return nil +} + +// SetPID updates the PID in the state and saves atomically. +func (sm *StateManager) SetPID(pid int) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.PID = pid + return sm.saveAtomic() +} + +// GetPID returns the PID from the state. +func (sm *StateManager) GetPID() int { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.PID +} + +// SetStartTime sets the start time in the state and saves atomically. +func (sm *StateManager) SetStartTime(startTime time.Time) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.StartTime = startTime + return sm.saveAtomic() +} + +// GetStartTime returns the start time from the state. +func (sm *StateManager) GetStartTime() time.Time { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.StartTime +} + +// IncrementRestartCount increments the restart counter and saves atomically. +func (sm *StateManager) IncrementRestartCount() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.RestartCount++ + now := time.Now() + sm.state.LastRestartTime = &now + + return sm.saveAtomic() +} + +// GetRestartCount returns the restart count from the state. +func (sm *StateManager) GetRestartCount() int { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.RestartCount +} + +// ResetRestartCount resets the restart counter to zero and saves atomically. +// Call this when the daemon has been running successfully for a while +// to indicate that crashes are no longer a concern. +func (sm *StateManager) ResetRestartCount() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.RestartCount = 0 + sm.state.LastRestartTime = nil + + return sm.saveAtomic() +} + +// SetVersion sets the picoclaw version in the state and saves atomically. +func (sm *StateManager) SetVersion(version string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.Version = version + return sm.saveAtomic() +} + +// GetVersion returns the version from the state. +func (sm *StateManager) GetVersion() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.Version +} + +// GetUptime returns the duration since the daemon started. +// Returns zero if start time is not set. +func (sm *StateManager) GetUptime() time.Duration { + sm.mu.RLock() + defer sm.mu.RUnlock() + + if sm.state.StartTime.IsZero() { + return 0 + } + + return time.Since(sm.state.StartTime) +} + +// GetState returns a copy of the current state. +func (sm *StateManager) GetState() State { + sm.mu.RLock() + defer sm.mu.RUnlock() + + return *sm.state +} + +// Clear removes the state file entirely. +// Use this when stopping the daemon gracefully. +func (sm *StateManager) Clear() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state = &State{} + + if err := os.Remove(sm.stateFile); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove state file: %w", err) + } + + return nil +}