feat(cli): restructure CLI commands for better UX

- Add top-level `multica login` that combines auth + workspace auto-discovery
- Restructure daemon into subcommands: start, stop, status, logs
- Add background daemon mode with PID management
- Add daemon deregistration on shutdown (new API endpoint + SQL query)
- Remove unused commands: runtime list, status, agent get/delete/stop
- Make `config` show config directly instead of requiring `config show`
- Update README to reflect new CLI structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-03-29 01:43:45 +08:00
parent 3bb79564ed
commit 38d595d81d
17 changed files with 568 additions and 276 deletions

View file

@ -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 <workspace-id>
# 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
```

View file

@ -24,35 +24,10 @@ var agentListCmd = &cobra.Command{
RunE: runAgentList,
}
var agentGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get agent details",
Args: cobra.ExactArgs(1),
RunE: runAgentGet,
}
var agentDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentDelete,
}
var agentStopCmd = &cobra.Command{
Use: "stop <id>",
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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"`

View file

@ -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 <id>' to add one")
return fmt.Errorf("no watched workspaces configured: run 'multica workspace watch <id>' to add one")
}
var registered int

View file

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

View file

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

View file

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