diff --git a/README.md b/README.md index 985ee50b..e63c5ea3 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,9 @@ For browser-based auth from source, make sure the local frontend is running at ` ### Authentication ```bash -multica auth login # Open browser to authenticate (one-click if already logged in) -multica auth login --token # Paste a personal access token manually +multica login # Authenticate and auto-watch your workspaces +multica auth login # Legacy auth-only flow +multica auth login --token # Legacy token-only auth flow multica auth status # Show current auth status multica auth logout # Remove stored token ``` @@ -143,6 +144,7 @@ Credentials are saved to `~/.multica/config.json`. ```bash multica workspace list # List all workspaces you belong to +multica workspace get # Show the current workspace details/context ``` ### Daemon Watch List @@ -163,13 +165,13 @@ The daemon polls watched workspaces for tasks and executes them using locally in ```bash # 1. Authenticate -multica auth login +multica login # 2. Add workspaces to watch multica workspace watch # 3. Start the daemon -multica daemon +multica daemon start ``` The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server. @@ -178,8 +180,9 @@ The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. W ```bash multica agent list # List agents in the current workspace -multica runtime list # List registered runtimes -multica config show # Show CLI configuration +multica daemon status # Show local daemon status +multica config # Show CLI configuration +multica config show # Compatibility alias for config display multica version # Show CLI version ``` diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index 07fe89b8..b89579b9 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -24,35 +24,10 @@ var agentListCmd = &cobra.Command{ RunE: runAgentList, } -var agentGetCmd = &cobra.Command{ - Use: "get ", - Short: "Get agent details", - Args: cobra.ExactArgs(1), - RunE: runAgentGet, -} - -var agentDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an agent", - Args: cobra.ExactArgs(1), - RunE: runAgentDelete, -} - -var agentStopCmd = &cobra.Command{ - Use: "stop ", - Short: "Stop an agent (set status to offline)", - Args: cobra.ExactArgs(1), - RunE: runAgentStop, -} - func init() { agentCmd.AddCommand(agentListCmd) - agentCmd.AddCommand(agentGetCmd) - agentCmd.AddCommand(agentDeleteCmd) - agentCmd.AddCommand(agentStopCmd) agentListCmd.Flags().String("output", "table", "Output format: table or json") - agentGetCmd.Flags().String("output", "json", "Output format: table or json") } func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) { @@ -136,72 +111,6 @@ func runAgentList(cmd *cobra.Command, _ []string) error { return nil } -func runAgentGet(cmd *cobra.Command, args []string) error { - client, err := newAPIClient(cmd) - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - var agent map[string]any - if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil { - return fmt.Errorf("get agent: %w", err) - } - - output, _ := cmd.Flags().GetString("output") - if output == "table" { - headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "DESCRIPTION"} - rows := [][]string{{ - strVal(agent, "id"), - strVal(agent, "name"), - strVal(agent, "status"), - strVal(agent, "runtime_mode"), - strVal(agent, "description"), - }} - cli.PrintTable(os.Stdout, headers, rows) - return nil - } - - return cli.PrintJSON(os.Stdout, agent) -} - -func runAgentDelete(cmd *cobra.Command, args []string) error { - client, err := newAPIClient(cmd) - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - if err := client.DeleteJSON(ctx, "/api/agents/"+args[0]); err != nil { - return fmt.Errorf("delete agent: %w", err) - } - - fmt.Fprintf(os.Stderr, "Agent %s deleted.\n", args[0]) - return nil -} - -func runAgentStop(cmd *cobra.Command, args []string) error { - client, err := newAPIClient(cmd) - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - body := map[string]any{"status": "offline"} - if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, nil); err != nil { - return fmt.Errorf("stop agent: %w", err) - } - - fmt.Fprintf(os.Stderr, "Agent %s stopped.\n", args[0]) - return nil -} - func strVal(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { diff --git a/server/cmd/multica/cmd_auth.go b/server/cmd/multica/cmd_auth.go index 10503484..271ca67d 100644 --- a/server/cmd/multica/cmd_auth.go +++ b/server/cmd/multica/cmd_auth.go @@ -26,9 +26,11 @@ var authCmd = &cobra.Command{ } var authLoginCmd = &cobra.Command{ - Use: "login", - Short: "Authenticate with Multica", - RunE: runAuthLogin, + Use: "login", + Short: "Authenticate with Multica", + Long: "Authenticate with Multica without auto-configuring workspaces. Use 'multica login' for the guided setup flow.", + Hidden: true, + RunE: runAuthLogin, } var authStatusCmd = &cobra.Command{ @@ -257,7 +259,7 @@ func runAuthStatus(cmd *cobra.Command, _ []string) error { serverURL := resolveServerURL(cmd) if token == "" { - fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica auth login' to authenticate.") + fmt.Fprintln(os.Stderr, "Not authenticated. Run 'multica login' to authenticate.") return nil } @@ -271,7 +273,7 @@ func runAuthStatus(cmd *cobra.Command, _ []string) error { Email string `json:"email"` } if err := client.GetJSON(ctx, "/api/me", &me); err != nil { - fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica auth login' to re-authenticate.\n", err) + fmt.Fprintf(os.Stderr, "Token is invalid or expired: %v\nRun 'multica login' to re-authenticate.\n", err) return nil } diff --git a/server/cmd/multica/cmd_compat_test.go b/server/cmd/multica/cmd_compat_test.go new file mode 100644 index 00000000..a3c20956 --- /dev/null +++ b/server/cmd/multica/cmd_compat_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "testing" + + "github.com/multica-ai/multica/server/internal/cli" +) + +func TestLegacyCompatibilityCommandsRemainAvailable(t *testing.T) { + t.Run("auth login remains available", func(t *testing.T) { + if _, _, err := authCmd.Find([]string{"login"}); err != nil { + t.Fatalf("expected auth login command to exist: %v", err) + } + }) + + t.Run("workspace get remains available", func(t *testing.T) { + if _, _, err := workspaceCmd.Find([]string{"get"}); err != nil { + t.Fatalf("expected workspace get command to exist: %v", err) + } + }) + + t.Run("workspace members remains available", func(t *testing.T) { + if _, _, err := workspaceCmd.Find([]string{"members"}); err != nil { + t.Fatalf("expected workspace members command to exist: %v", err) + } + }) + + t.Run("config show and set remain available", func(t *testing.T) { + if _, _, err := configCmd.Find([]string{"show"}); err != nil { + t.Fatalf("expected config show command to exist: %v", err) + } + if _, _, err := configCmd.Find([]string{"set"}); err != nil { + t.Fatalf("expected config set command to exist: %v", err) + } + }) +} + +func TestRunConfigSetPersistsValues(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + if err := runConfigSet(nil, []string{"server_url", "http://example.com"}); err != nil { + t.Fatalf("runConfigSet(server_url) error = %v", err) + } + if err := runConfigSet(nil, []string{"workspace_id", "ws-123"}); err != nil { + t.Fatalf("runConfigSet(workspace_id) error = %v", err) + } + + cfg, err := cli.LoadCLIConfig() + if err != nil { + t.Fatalf("LoadCLIConfig() error = %v", err) + } + if cfg.ServerURL != "http://example.com" { + t.Fatalf("ServerURL = %q, want %q", cfg.ServerURL, "http://example.com") + } + if cfg.WorkspaceID != "ws-123" { + t.Fatalf("WorkspaceID = %q, want %q", cfg.WorkspaceID, "ws-123") + } +} diff --git a/server/cmd/multica/cmd_config.go b/server/cmd/multica/cmd_config.go index a9b5d1f5..585a4ab1 100644 --- a/server/cmd/multica/cmd_config.go +++ b/server/cmd/multica/cmd_config.go @@ -11,7 +11,8 @@ import ( var configCmd = &cobra.Command{ Use: "config", - Short: "Manage CLI configuration", + Short: "Show CLI configuration", + RunE: runConfigShow, } var configShowCmd = &cobra.Command{ diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index 9e16a386..ca3939ab 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -2,9 +2,18 @@ package main import ( "context" + "encoding/json" "errors" + "fmt" + "net/http" + "os" + "os/exec" "os/signal" + "path/filepath" + "strconv" + "strings" "syscall" + "time" "github.com/spf13/cobra" @@ -15,22 +24,179 @@ import ( var daemonCmd = &cobra.Command{ Use: "daemon", - Short: "Run the local agent runtime daemon", - Long: "Start the daemon process that polls for tasks and executes them using local agent CLIs (Claude, Codex).", - RunE: runDaemon, + Short: "Manage the local agent runtime daemon", +} + +var daemonStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the local agent runtime daemon", + Long: "Start the daemon process that polls for tasks and executes them using local agent CLIs (Claude, Codex).\nRuns in the background by default. Use --foreground to run in the current terminal.", + RunE: runDaemonStart, +} + +var daemonStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the running daemon", + RunE: runDaemonStop, +} + +var daemonStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show daemon status", + RunE: runDaemonStatus, +} + +var daemonLogsCmd = &cobra.Command{ + Use: "logs", + Short: "Show daemon logs", + RunE: runDaemonLogs, } func init() { - f := daemonCmd.Flags() + f := daemonStartCmd.Flags() + f.Bool("foreground", false, "Run in the foreground instead of background") f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)") f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)") f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)") f.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)") f.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)") f.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)") + + daemonLogsCmd.Flags().BoolP("follow", "f", false, "Follow log output") + daemonLogsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") + + daemonStatusCmd.Flags().String("output", "table", "Output format: table or json") + + daemonCmd.AddCommand(daemonStartCmd) + daemonCmd.AddCommand(daemonStopCmd) + daemonCmd.AddCommand(daemonStatusCmd) + daemonCmd.AddCommand(daemonLogsCmd) } -func runDaemon(cmd *cobra.Command, _ []string) error { +// daemonDir returns the path to ~/.multica/. +func daemonDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".multica") +} + +func daemonPIDPath() string { + return filepath.Join(daemonDir(), "daemon.pid") +} + +func daemonLogPath() string { + return filepath.Join(daemonDir(), "daemon.log") +} + +// --- daemon start --- + +func runDaemonStart(cmd *cobra.Command, _ []string) error { + foreground, _ := cmd.Flags().GetBool("foreground") + if foreground { + return runDaemonForeground(cmd) + } + return runDaemonBackground(cmd) +} + +func runDaemonBackground(cmd *cobra.Command) error { + // Check if daemon is already running. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + health := checkDaemonHealth(ctx) + if health["status"] == "running" { + return fmt.Errorf("daemon is already running (pid %v)", health["pid"]) + } + + // Resolve current executable. + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable path: %w", err) + } + + // Build child args: daemon start --foreground + forwarded flags. + args := buildDaemonStartArgs(cmd) + + // Ensure daemon directory exists. + dir := daemonDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create daemon directory: %w", err) + } + + logPath := daemonLogPath() + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open log file %s: %w", logPath, err) + } + + child := exec.Command(exePath, args...) + child.Stdout = logFile + child.Stderr = logFile + child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + if err := child.Start(); err != nil { + logFile.Close() + return fmt.Errorf("start daemon: %w", err) + } + logFile.Close() + + // Detach: we don't Wait() on the child — it runs independently. + child.Process.Release() + + // Write PID file. + pidPath := daemonPIDPath() + if err := os.WriteFile(pidPath, []byte(strconv.Itoa(child.Process.Pid)), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not write PID file: %v\n", err) + } + + // Wait briefly and verify daemon started via health endpoint. + time.Sleep(2 * time.Second) + ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel2() + health = checkDaemonHealth(ctx2) + if health["status"] != "running" { + fmt.Fprintf(os.Stderr, "Daemon may not have started successfully. Check logs:\n %s\n", logPath) + return nil + } + + fmt.Fprintf(os.Stderr, "Daemon started (pid %d)\n", child.Process.Pid) + fmt.Fprintf(os.Stderr, "Logs: %s\n", logPath) + return nil +} + +// buildDaemonStartArgs constructs args for the background child process. +func buildDaemonStartArgs(cmd *cobra.Command) []string { + args := []string{"daemon", "start", "--foreground"} + + if v := flagString(cmd, "daemon-id"); v != "" { + args = append(args, "--daemon-id", v) + } + if v := flagString(cmd, "device-name"); v != "" { + args = append(args, "--device-name", v) + } + if v := flagString(cmd, "runtime-name"); v != "" { + args = append(args, "--runtime-name", v) + } + if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 { + args = append(args, "--poll-interval", d.String()) + } + if d, _ := cmd.Flags().GetDuration("heartbeat-interval"); d > 0 { + args = append(args, "--heartbeat-interval", d.String()) + } + if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 { + args = append(args, "--agent-timeout", d.String()) + } + + // Forward global persistent flags. + if v, _ := cmd.Flags().GetString("server-url"); v != "" { + args = append(args, "--server-url", v) + } + + return args +} + +func runDaemonForeground(cmd *cobra.Command) error { overrides := daemon.Overrides{ ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""), DaemonID: flagString(cmd, "daemon-id"), @@ -58,12 +224,142 @@ func runDaemon(cmd *cobra.Command, _ []string) error { logger := logger_pkg.NewLogger("daemon") d := daemon.New(cfg, logger) + // Write PID file so "daemon stop" can find us. + if dir := daemonDir(); dir != "" { + os.MkdirAll(dir, 0o755) + os.WriteFile(daemonPIDPath(), []byte(strconv.Itoa(os.Getpid())), 0o644) + } + defer os.Remove(daemonPIDPath()) + if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { return err } return nil } +// --- daemon stop --- + +func runDaemonStop(_ *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + health := checkDaemonHealth(ctx) + if health["status"] != "running" { + fmt.Fprintln(os.Stderr, "Daemon is not running.") + return nil + } + + pid, ok := health["pid"].(float64) + if !ok || pid == 0 { + return fmt.Errorf("could not determine daemon PID from health endpoint") + } + + process, err := os.FindProcess(int(pid)) + if err != nil { + return fmt.Errorf("find process %d: %w", int(pid), err) + } + + if err := process.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("stop daemon (pid %d): %w", int(pid), err) + } + + fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid)) + + // Poll health endpoint until daemon is gone. + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second) + h := checkDaemonHealth(ctx2) + cancel2() + if h["status"] != "running" { + os.Remove(daemonPIDPath()) + fmt.Fprintln(os.Stderr, "Daemon stopped.") + return nil + } + } + + fmt.Fprintln(os.Stderr, "Daemon is still stopping. It may be finishing a running task.") + return nil +} + +// --- daemon status --- + +func runDaemonStatus(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + health := checkDaemonHealth(ctx) + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, health) + } + + if health["status"] != "running" { + fmt.Fprintln(os.Stdout, "Daemon: stopped") + return nil + } + + fmt.Fprintf(os.Stdout, "Daemon: running (pid %v, uptime %v)\n", health["pid"], health["uptime"]) + if agents, ok := health["agents"].([]any); ok && len(agents) > 0 { + parts := make([]string, len(agents)) + for i, a := range agents { + parts[i] = fmt.Sprint(a) + } + fmt.Fprintf(os.Stdout, "Agents: %s\n", strings.Join(parts, ", ")) + } + if ws, ok := health["workspaces"].([]any); ok { + fmt.Fprintf(os.Stdout, "Workspaces: %d\n", len(ws)) + } + return nil +} + +// --- daemon logs --- + +func runDaemonLogs(cmd *cobra.Command, _ []string) error { + logPath := daemonLogPath() + if _, err := os.Stat(logPath); os.IsNotExist(err) { + return fmt.Errorf("no log file found at %s\nThe daemon may not have been started in background mode", logPath) + } + + follow, _ := cmd.Flags().GetBool("follow") + lines, _ := cmd.Flags().GetInt("lines") + + args := []string{"-n", strconv.Itoa(lines)} + if follow { + args = append(args, "-f") + } + args = append(args, logPath) + + tail := exec.Command("tail", args...) + tail.Stdout = os.Stdout + tail.Stderr = os.Stderr + return tail.Run() +} + +// checkDaemonHealth calls the daemon's local health endpoint. +func checkDaemonHealth(ctx context.Context) map[string]any { + addr := fmt.Sprintf("http://127.0.0.1:%d/health", daemon.DefaultHealthPort) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil) + if err != nil { + return map[string]any{"status": "stopped"} + } + + httpClient := &http.Client{Timeout: 2 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return map[string]any{"status": "stopped"} + } + defer resp.Body.Close() + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return map[string]any{"status": "stopped"} + } + return result +} + +// flagString returns a string flag value or empty string. func flagString(cmd *cobra.Command, name string) string { val, _ := cmd.Flags().GetString(name) return val diff --git a/server/cmd/multica/cmd_login.go b/server/cmd/multica/cmd_login.go new file mode 100644 index 00000000..0ab2691a --- /dev/null +++ b/server/cmd/multica/cmd_login.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate and set up workspaces", + Long: "Log in to Multica, then automatically discover and watch all your workspaces.", + RunE: runLogin, +} + +func init() { + loginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token") +} + +func runLogin(cmd *cobra.Command, args []string) error { + // Run the standard auth login flow. + if err := runAuthLogin(cmd, args); err != nil { + return err + } + + // Auto-discover and watch all workspaces. + if err := autoWatchWorkspaces(cmd); err != nil { + fmt.Fprintf(os.Stderr, "\nCould not auto-configure workspaces: %v\n", err) + fmt.Fprintf(os.Stderr, "Run 'multica workspace list' and 'multica workspace watch ' to set up manually.\n") + return nil + } + + fmt.Fprintf(os.Stderr, "\n→ Run 'multica daemon start' to start your local agent runtime.\n") + return nil +} + +func autoWatchWorkspaces(cmd *cobra.Command) error { + serverURL := resolveServerURL(cmd) + token := resolveToken() + if token == "" { + return fmt.Errorf("not authenticated") + } + + client := cli.NewAPIClient(serverURL, "", token) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var workspaces []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := client.GetJSON(ctx, "/api/workspaces", &workspaces); err != nil { + return fmt.Errorf("list workspaces: %w", err) + } + + if len(workspaces) == 0 { + fmt.Fprintln(os.Stderr, "\nNo workspaces found.") + return nil + } + + cfg, err := cli.LoadCLIConfig() + if err != nil { + return err + } + + var added int + for _, ws := range workspaces { + if cfg.AddWatchedWorkspace(ws.ID, ws.Name) { + added++ + } + } + + // Set default workspace if not set. + if cfg.WorkspaceID == "" { + cfg.WorkspaceID = workspaces[0].ID + } + + if err := cli.SaveCLIConfig(cfg); err != nil { + return err + } + + if added > 0 { + fmt.Fprintf(os.Stderr, "\nWatching %d workspace(s):\n", len(workspaces)) + for _, ws := range workspaces { + fmt.Fprintf(os.Stderr, " • %s (%s)\n", ws.Name, ws.ID) + } + } else { + fmt.Fprintf(os.Stderr, "\nAll %d workspace(s) already watched.\n", len(workspaces)) + } + + return nil +} diff --git a/server/cmd/multica/cmd_runtime.go b/server/cmd/multica/cmd_runtime.go deleted file mode 100644 index a507579f..00000000 --- a/server/cmd/multica/cmd_runtime.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/spf13/cobra" - - "github.com/multica-ai/multica/server/internal/cli" -) - -var runtimeCmd = &cobra.Command{ - Use: "runtime", - Short: "Manage agent runtimes", -} - -var runtimeListCmd = &cobra.Command{ - Use: "list", - Short: "List agent runtimes", - RunE: runRuntimeList, -} - -func init() { - runtimeCmd.AddCommand(runtimeListCmd) - - runtimeListCmd.Flags().String("output", "table", "Output format: table or json") -} - -func runRuntimeList(cmd *cobra.Command, _ []string) error { - client, err := newAPIClient(cmd) - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - var runtimes []map[string]any - if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil { - return fmt.Errorf("list runtimes: %w", err) - } - - output, _ := cmd.Flags().GetString("output") - if output == "json" { - return cli.PrintJSON(os.Stdout, runtimes) - } - - headers := []string{"ID", "NAME", "PROVIDER", "STATUS", "DEVICE"} - rows := make([][]string, 0, len(runtimes)) - for _, r := range runtimes { - rows = append(rows, []string{ - strVal(r, "id"), - strVal(r, "name"), - strVal(r, "provider"), - strVal(r, "status"), - strVal(r, "device_info"), - }) - } - cli.PrintTable(os.Stdout, headers, rows) - return nil -} diff --git a/server/cmd/multica/cmd_status.go b/server/cmd/multica/cmd_status.go deleted file mode 100644 index 656e851a..00000000 --- a/server/cmd/multica/cmd_status.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/multica-ai/multica/server/internal/cli" - "github.com/multica-ai/multica/server/internal/daemon" -) - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Check server and daemon status", - RunE: runStatus, -} - -func init() { - statusCmd.Flags().String("output", "table", "Output format: table or json") -} - -func runStatus(cmd *cobra.Command, _ []string) error { - client, err := newAPIClient(cmd) - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Check server health. - serverStatus := "unreachable" - body, err := client.HealthCheck(ctx) - if err == nil { - serverStatus = strings.TrimSpace(body) - } - - // Check local daemon via its health endpoint. - daemonHealth := checkDaemonHealth(ctx) - - output, _ := cmd.Flags().GetString("output") - if output == "json" { - result := map[string]any{ - "server": map[string]any{ - "url": client.BaseURL, - "status": serverStatus, - }, - "daemon": daemonHealth, - } - return cli.PrintJSON(os.Stdout, result) - } - - fmt.Fprintf(os.Stdout, "Server: %s (%s)\n", serverStatus, client.BaseURL) - if daemonHealth["status"] == "running" { - fmt.Fprintf(os.Stdout, "Daemon: running (pid %v, uptime %v)\n", daemonHealth["pid"], daemonHealth["uptime"]) - if agents, ok := daemonHealth["agents"].([]any); ok && len(agents) > 0 { - parts := make([]string, len(agents)) - for i, a := range agents { - parts[i] = fmt.Sprint(a) - } - fmt.Fprintf(os.Stdout, " Agents: %s\n", strings.Join(parts, ", ")) - } - if ws, ok := daemonHealth["workspaces"].([]any); ok { - fmt.Fprintf(os.Stdout, " Workspaces: %d\n", len(ws)) - } - } else { - fmt.Fprintf(os.Stdout, "Daemon: stopped\n") - } - return nil -} - -// checkDaemonHealth calls the daemon's local health endpoint. -func checkDaemonHealth(ctx context.Context) map[string]any { - addr := fmt.Sprintf("http://127.0.0.1:%d/health", daemon.DefaultHealthPort) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr, nil) - if err != nil { - return map[string]any{"status": "stopped"} - } - - httpClient := &http.Client{Timeout: 2 * time.Second} - resp, err := httpClient.Do(req) - if err != nil { - return map[string]any{"status": "stopped"} - } - defer resp.Body.Close() - - var result map[string]any - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return map[string]any{"status": "stopped"} - } - return result -} diff --git a/server/cmd/multica/cmd_workspace.go b/server/cmd/multica/cmd_workspace.go index 643a9c5c..da1ce556 100644 --- a/server/cmd/multica/cmd_workspace.go +++ b/server/cmd/multica/cmd_workspace.go @@ -67,7 +67,7 @@ func runWorkspaceList(cmd *cobra.Command, _ []string) error { serverURL := resolveServerURL(cmd) token := resolveToken() if token == "" { - return fmt.Errorf("not authenticated: run 'multica auth login' first") + return fmt.Errorf("not authenticated: run 'multica login' first") } client := cli.NewAPIClient(serverURL, "", token) @@ -203,7 +203,7 @@ func runWatch(cmd *cobra.Command, args []string) error { serverURL := resolveServerURL(cmd) token := resolveToken() if token == "" { - return fmt.Errorf("not authenticated: run 'multica auth login' first") + return fmt.Errorf("not authenticated: run 'multica login' first") } client := cli.NewAPIClient(serverURL, "", token) diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 2a2da222..6c76f774 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -24,14 +24,13 @@ func init() { rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)") rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)") + rootCmd.AddCommand(loginCmd) rootCmd.AddCommand(authCmd) rootCmd.AddCommand(daemonCmd) rootCmd.AddCommand(agentCmd) - rootCmd.AddCommand(runtimeCmd) rootCmd.AddCommand(workspaceCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(issueCmd) - rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(versionCmd) } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index d5401a9c..5cb3b0a4 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -86,6 +86,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession) r.Post("/register", h.DaemonRegister) + r.Post("/deregister", h.DaemonDeregister) r.Post("/heartbeat", h.DaemonHeartbeat) r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime) diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index a62b65b2..88df73ff 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -128,6 +128,12 @@ func (c *Client) ReportPingResult(ctx context.Context, runtimeID, pingID string, return c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/ping/%s/result", runtimeID, pingID), result, nil) } +func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error { + return c.postJSON(ctx, "/api/daemon/deregister", map[string]any{ + "runtime_ids": runtimeIDs, + }, nil) +} + func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) { var resp struct { Runtimes []Runtime `json:"runtimes"` diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index a58f19fb..752bab58 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -73,6 +73,9 @@ func (d *Daemon) Run(ctx context.Context) error { return fmt.Errorf("no runtimes registered") } + // Deregister runtimes on shutdown (uses a fresh context since ctx will be cancelled). + defer d.deregisterRuntimes() + // Start config watcher for hot-reload. go d.configWatchLoop(ctx) @@ -82,6 +85,23 @@ func (d *Daemon) Run(ctx context.Context) error { return d.pollLoop(ctx) } +// deregisterRuntimes notifies the server that all runtimes are going offline. +func (d *Daemon) deregisterRuntimes() { + runtimeIDs := d.allRuntimeIDs() + if len(runtimeIDs) == 0 { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := d.client.Deregister(ctx, runtimeIDs); err != nil { + d.logger.Warn("failed to deregister runtimes on shutdown", "error", err) + } else { + d.logger.Info("deregistered runtimes", "count", len(runtimeIDs)) + } +} + // resolveAuth loads the auth token from the CLI config. func (d *Daemon) resolveAuth() error { cfg, err := cli.LoadCLIConfig() @@ -89,8 +109,8 @@ func (d *Daemon) resolveAuth() error { return fmt.Errorf("load CLI config: %w", err) } if cfg.Token == "" { - d.logger.Warn("not authenticated — run 'multica auth login' to authenticate, then restart the daemon") - return fmt.Errorf("not authenticated: run 'multica auth login' first") + d.logger.Warn("not authenticated — run 'multica login' to authenticate, then restart the daemon") + return fmt.Errorf("not authenticated: run 'multica login' first") } d.client.SetToken(cfg.Token) d.logger.Info("authenticated") @@ -105,7 +125,7 @@ func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error { } if len(cfg.WatchedWorkspaces) == 0 { - return fmt.Errorf("no watched workspaces configured: run 'multica watch ' to add one") + return fmt.Errorf("no watched workspaces configured: run 'multica workspace watch ' to add one") } var registered int diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index fbe8b8f4..c040e6d2 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -109,6 +109,51 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp}) } +// DaemonDeregister marks runtimes as offline when the daemon shuts down. +func (h *Handler) DaemonDeregister(w http.ResponseWriter, r *http.Request) { + var req struct { + RuntimeIDs []string `json:"runtime_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if len(req.RuntimeIDs) == 0 { + writeError(w, http.StatusBadRequest, "runtime_ids is required") + return + } + + // Track affected workspaces for WS notifications. + affectedWorkspaces := make(map[string]bool) + + for _, rid := range req.RuntimeIDs { + // Look up the runtime to find its workspace. + rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(rid)) + if err != nil { + slog.Warn("deregister: runtime not found", "runtime_id", rid, "error", err) + continue + } + + if err := h.Queries.SetAgentRuntimeOffline(r.Context(), parseUUID(rid)); err != nil { + slog.Warn("deregister: failed to set offline", "runtime_id", rid, "error", err) + continue + } + + affectedWorkspaces[uuidToString(rt.WorkspaceID)] = true + } + + // Notify frontend clients so they re-fetch runtime list. + for wsID := range affectedWorkspaces { + h.publish(protocol.EventDaemonRegister, wsID, "system", "", map[string]any{ + "action": "deregister", + }) + } + + slog.Info("daemon deregistered", "runtime_ids", req.RuntimeIDs) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + type DaemonHeartbeatRequest struct { RuntimeID string `json:"runtime_id"` } diff --git a/server/pkg/db/generated/runtime.sql.go b/server/pkg/db/generated/runtime.sql.go index 230a8eae..1946ec33 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -105,6 +105,17 @@ func (q *Queries) ListAgentRuntimes(ctx context.Context, workspaceID pgtype.UUID return items, nil } +const setAgentRuntimeOffline = `-- name: SetAgentRuntimeOffline :exec +UPDATE agent_runtime +SET status = 'offline', updated_at = now() +WHERE id = $1 +` + +func (q *Queries) SetAgentRuntimeOffline(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, setAgentRuntimeOffline, id) + return err +} + const updateAgentRuntimeHeartbeat = `-- name: UpdateAgentRuntimeHeartbeat :one UPDATE agent_runtime SET status = 'online', last_seen_at = now(), updated_at = now() diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql index ff8615bb..5a40ee8a 100644 --- a/server/pkg/db/queries/runtime.sql +++ b/server/pkg/db/queries/runtime.sql @@ -39,3 +39,8 @@ UPDATE agent_runtime SET status = 'online', last_seen_at = now(), updated_at = now() WHERE id = $1 RETURNING *; + +-- name: SetAgentRuntimeOffline :exec +UPDATE agent_runtime +SET status = 'offline', updated_at = now() +WHERE id = $1;