Files
multica/server/cmd/multica/cmd_daemon.go
Bohan Jiang fcd13aece9 feat(daemon): auto-update CLI when idle (MUL-2100) (#2679)
* feat(daemon): auto-update CLI when idle (MUL-2100)

Add a periodic poller that checks GitHub for a newer multica release
every hour and self-updates when the daemon is idle, reusing the same
brew-or-download upgrade path the Runtimes-page "Update" button already
runs.

- Refactor handleUpdate to call a shared runUpdate(target) helper so
  both server-triggered and auto-triggered upgrades go through the same
  brew detection + atomic replace + restart.
- New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch
  source, dev-build version, an in-flight update, and active tasks. The
  idle gate guarantees we never interrupt a running agent — busy ticks
  silently retry at the next interval.
- Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via
  --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the
  poll period.
- IsNewerVersion / IsReleaseVersion helpers in the cli package, with
  tests covering patch/minor/major bumps, dev-describe strings, and
  malformed input.
- Daemon-side tests cover every skip path (updating, active tasks,
  fetch failure, no-newer) plus the success path that fires
  triggerRestart while keeping the updating flag held to the end.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): close idle race + verify checksum in auto-update (MUL-2100)

Two issues raised in PR #2679 review:

1. The first idle check in tryAutoUpdate only ran before the release-metadata
   fetch, so a poller that won the claim race during the fetch could end up
   handing handleTask a task that triggerRestart was about to cancel via root-
   ctx cancellation. Add a strict claim barrier: runRuntimePoller now
   tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims
   under claimMu only after observing claimsInFlight + activeTasks == 0.
   Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier
   refuses to engage and the update defers to the next tick.

2. The direct-download path replaced the running binary with whatever bytes
   GitHub returned, without checking checksums.txt. Pull the manifest first,
   buffer the archive, and reject on SHA-256 mismatch before extraction. The
   GoReleaser config already publishes checksums.txt; we just consume it.

Also tighten parseReleaseVersion so it stops accepting dev-describe shapes
like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring.
The auto-update loop already guards on IsReleaseVersion, but the lenient
parser was a footgun and the existing test name even said "not newer" while
asserting the opposite.

Tests:
- TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage)
- TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure
- TestTryEnterClaim_RespectsBarrier
- TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256
- TestIsNewerVersion: dev-describe cases now expect false (matches docstring)

Co-authored-by: multica-agent <github@multica.ai>

* chore(daemon): default auto-update poll interval to 6h (MUL-2100)

1h was overly chatty for a release that lands at most a few times a week.
Operators who want a different cadence can still set
MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 18:10:22 +08:00

805 lines
26 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/internal/daemon"
logger_pkg "github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/util"
)
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Control 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 daemonRestartCmd = &cobra.Command{
Use: "restart",
Short: "Restart the running daemon (stop + start)",
RunE: runDaemonRestart,
}
var daemonLogsCmd = &cobra.Command{
Use: "logs",
Short: "Show daemon logs",
RunE: runDaemonLogs,
}
var daemonDiskUsageCmd = &cobra.Command{
Use: "disk-usage",
Short: "Show daemon workspace disk usage by task or workspace",
Long: "Walks the daemon's workspaces root and reports per-task or per-workspace disk usage.\n" +
"Default view is per-task, sorted by size descending. --by-workspace switches to a per-workspace summary;\n" +
"--top N keeps only the largest N entries.\n\n" +
"Bytes are split into total and the artifact-cleanable subset (node_modules, .next, .turbo by default,\n" +
"overridable via MULTICA_GC_ARTIFACT_PATTERNS) so the report stays in sync with what the GC reclaims.\n" +
"The walk skips .git and never follows symlinks. The daemon does not need to be running.",
RunE: runDaemonDiskUsage,
}
func init() {
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)")
f.Duration("codex-semantic-inactivity-timeout", 0, "Codex semantic inactivity timeout (env: MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT)")
f.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)")
f.Bool("no-auto-update", false, "Disable periodic CLI self-update (env: MULTICA_DAEMON_AUTO_UPDATE=false)")
f.Duration("auto-update-interval", 0, "How often to poll GitHub for a newer release (env: MULTICA_DAEMON_AUTO_UPDATE_INTERVAL)")
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")
// restart shares all the same flags as start
rf := daemonRestartCmd.Flags()
rf.Bool("foreground", false, "Run in the foreground instead of background")
rf.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)")
rf.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)")
rf.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)")
rf.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)")
rf.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)")
rf.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)")
rf.Duration("codex-semantic-inactivity-timeout", 0, "Codex semantic inactivity timeout (env: MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT)")
rf.Int("max-concurrent-tasks", 0, "Max tasks running in parallel (env: MULTICA_DAEMON_MAX_CONCURRENT_TASKS)")
rf.Bool("no-auto-update", false, "Disable periodic CLI self-update (env: MULTICA_DAEMON_AUTO_UPDATE=false)")
rf.Duration("auto-update-interval", 0, "How often to poll GitHub for a newer release (env: MULTICA_DAEMON_AUTO_UPDATE_INTERVAL)")
df := daemonDiskUsageCmd.Flags()
df.Bool("by-workspace", false, "Aggregate output by workspace instead of by task")
df.Bool("by-task", false, "Per-task view (default; mutually exclusive with --by-workspace)")
df.Int("top", 0, "Keep only the largest N entries (across all workspaces)")
df.String("output", "table", "Output format: table or json")
df.String("workspaces-root", "", "Override the workspaces root path (default: same as the daemon)")
daemonCmd.AddCommand(daemonStartCmd)
daemonCmd.AddCommand(daemonStopCmd)
daemonCmd.AddCommand(daemonRestartCmd)
daemonCmd.AddCommand(daemonStatusCmd)
daemonCmd.AddCommand(daemonLogsCmd)
daemonCmd.AddCommand(daemonDiskUsageCmd)
}
// daemonDirForProfile returns the state directory for the given profile.
// Empty profile → ~/.multica/, named profile → ~/.multica/profiles/<name>/.
func daemonDirForProfile(profile string) string {
dir, err := cli.ProfileDir(profile)
if err != nil {
return ""
}
return dir
}
func daemonPIDPathForProfile(profile string) string {
return filepath.Join(daemonDirForProfile(profile), "daemon.pid")
}
func daemonLogPathForProfile(profile string) string {
return filepath.Join(daemonDirForProfile(profile), "daemon.log")
}
// healthPortForProfile returns the health check port for the given profile.
// Default profile uses the standard port (19514). Named profiles get a
// deterministic offset derived from the profile name.
func healthPortForProfile(profile string) int {
if profile == "" {
return daemon.DefaultHealthPort
}
// Simple hash: sum of bytes mod 1000, offset from base+1.
var h int
for _, b := range []byte(profile) {
h += int(b)
}
return daemon.DefaultHealthPort + 1 + (h % 1000)
}
// --- 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 {
profile := resolveProfile(cmd)
healthPort := healthPortForProfile(profile)
// Check if daemon is already running.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
health := checkDaemonHealthOnPort(ctx, healthPort)
if health["status"] == "running" {
label := "daemon"
if profile != "" {
label = fmt.Sprintf("daemon [%s]", profile)
}
pid, _ := health["pid"].(float64)
return fmt.Errorf("%s is already running (pid %v). Use 'daemon restart' to restart it", label, int(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 := daemonDirForProfile(profile)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create daemon directory: %w", err)
}
logPath := daemonLogPathForProfile(profile)
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
// On Windows we want to break the child out of the parent shell's Job
// Object so the daemon survives parent-shell exit. If the parent's Job
// has not granted BREAKAWAY_OK, CreateProcess returns
// ERROR_ACCESS_DENIED — fall back to spawning without breakaway, which
// matches the pre-fix behaviour. On Unix the bool is a no-op.
child.SysProcAttr = daemonSysProcAttr(true)
if err := child.Start(); err != nil {
if isAccessDeniedSpawnErr(err) {
// Retry without breakaway. Reset the cmd state — exec.Cmd is
// not safe to Start() twice, so build a fresh one.
child = exec.Command(exePath, args...)
child.Stdout = logFile
child.Stderr = logFile
child.SysProcAttr = daemonSysProcAttr(false)
if err := child.Start(); err != nil {
logFile.Close()
return fmt.Errorf("start daemon (no breakaway): %w", err)
}
} else {
logFile.Close()
return fmt.Errorf("start daemon: %w", err)
}
}
logFile.Close()
pid := child.Process.Pid
// Detach: we don't Wait() on the child — it runs independently.
child.Process.Release()
// Write PID file.
pidPath := daemonPIDPathForProfile(profile)
if err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not write PID file: %v\n", err)
}
// Poll health endpoint until the daemon is ready or timeout.
deadline := time.Now().Add(15 * time.Second)
started := false
for time.Now().Before(deadline) {
time.Sleep(500 * time.Millisecond)
hctx, hcancel := context.WithTimeout(context.Background(), 2*time.Second)
health = checkDaemonHealthOnPort(hctx, healthPort)
hcancel()
if health["status"] == "running" {
started = true
break
}
}
if !started {
fmt.Fprintf(os.Stderr, "Daemon may not have started successfully. Check logs:\n %s\n", logPath)
return nil
}
if profile != "" {
fmt.Fprintf(os.Stderr, "Daemon [%s] started (pid %d, version %s)\n", profile, pid, version)
} else {
fmt.Fprintf(os.Stderr, "Daemon started (pid %d, version %s)\n", pid, version)
}
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())
}
if d, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout"); d > 0 {
args = append(args, "--codex-semantic-inactivity-timeout", d.String())
}
if n, _ := cmd.Flags().GetInt("max-concurrent-tasks"); n > 0 {
args = append(args, "--max-concurrent-tasks", strconv.Itoa(n))
}
if b, _ := cmd.Flags().GetBool("no-auto-update"); b {
args = append(args, "--no-auto-update")
}
if d, _ := cmd.Flags().GetDuration("auto-update-interval"); d > 0 {
args = append(args, "--auto-update-interval", d.String())
}
// Forward global persistent flags.
if v, _ := cmd.Flags().GetString("server-url"); v != "" {
args = append(args, "--server-url", v)
}
if v := resolveProfile(cmd); v != "" {
args = append(args, "--profile", v)
}
return args
}
func runDaemonForeground(cmd *cobra.Command) error {
util.EnsureHiddenConsole()
profile := resolveProfile(cmd)
serverURL := cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", "")
if serverURL == "" {
if c, err := cli.LoadCLIConfigForProfile(profile); err == nil && c.ServerURL != "" {
serverURL = c.ServerURL
}
}
overrides := daemon.Overrides{
ServerURL: serverURL,
DaemonID: flagString(cmd, "daemon-id"),
DeviceName: flagString(cmd, "device-name"),
RuntimeName: flagString(cmd, "runtime-name"),
Profile: profile,
HealthPort: healthPortForProfile(profile),
}
if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 {
overrides.PollInterval = d
}
if d, _ := cmd.Flags().GetDuration("heartbeat-interval"); d > 0 {
overrides.HeartbeatInterval = d
}
if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 {
overrides.AgentTimeout = d
}
if d, _ := cmd.Flags().GetDuration("codex-semantic-inactivity-timeout"); d > 0 {
overrides.CodexSemanticInactivityTimeout = d
}
if n, _ := cmd.Flags().GetInt("max-concurrent-tasks"); n > 0 {
overrides.MaxConcurrentTasks = n
}
if b, _ := cmd.Flags().GetBool("no-auto-update"); b {
overrides.DisableAutoUpdate = true
}
if d, _ := cmd.Flags().GetDuration("auto-update-interval"); d > 0 {
overrides.AutoUpdateCheckInterval = d
}
cfg, err := daemon.LoadConfig(overrides)
if err != nil {
return err
}
cfg.CLIVersion = version
// Set by the Electron Desktop app when it spawns the CLI so the server
// can mark those runtimes as "managed" and hide CLI self-update UI.
cfg.LaunchedBy = os.Getenv("MULTICA_LAUNCHED_BY")
ctx, stop := notifyShutdownContext(context.Background())
defer stop()
logger := logger_pkg.NewLogger("daemon")
d := daemon.New(cfg, logger)
// Write PID file so "daemon stop" can find us.
if dir := daemonDirForProfile(profile); dir != "" {
os.MkdirAll(dir, 0o755)
os.WriteFile(daemonPIDPathForProfile(profile), []byte(strconv.Itoa(os.Getpid())), 0o644)
}
defer os.Remove(daemonPIDPathForProfile(profile))
if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
return err
}
// Check if the daemon needs to restart after a CLI update.
if restartBin := d.RestartBinary(); restartBin != "" {
logger.Info("restarting daemon with updated binary", "path", restartBin)
args := buildDaemonStartArgs(cmd)
child := exec.Command(restartBin, args...)
logPath := daemonLogPathForProfile(profile)
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
logger.Error("failed to open log file for restart", "error", err)
return nil
}
child.Stdout = logFile
child.Stderr = logFile
// Break out of the parent's Job Object on Windows; see the
// runDaemonBackground call site for rationale.
child.SysProcAttr = daemonSysProcAttr(true)
if err := child.Start(); err != nil {
if isAccessDeniedSpawnErr(err) {
child = exec.Command(restartBin, args...)
child.Stdout = logFile
child.Stderr = logFile
child.SysProcAttr = daemonSysProcAttr(false)
if err := child.Start(); err != nil {
logFile.Close()
logger.Error("failed to start new daemon (no breakaway)", "error", err)
return nil
}
} else {
logFile.Close()
logger.Error("failed to start new daemon", "error", err)
return nil
}
}
logFile.Close()
child.Process.Release()
// Write new PID file.
pidPath := daemonPIDPathForProfile(profile)
os.WriteFile(pidPath, []byte(strconv.Itoa(child.Process.Pid)), 0o644)
logger.Info("new daemon started", "pid", child.Process.Pid)
}
return nil
}
// --- daemon restart ---
func runDaemonRestart(cmd *cobra.Command, args []string) error {
profile := resolveProfile(cmd)
healthPort := healthPortForProfile(profile)
// Stop if running.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
health := checkDaemonHealthOnPort(ctx, healthPort)
if health["status"] == "running" {
pid, _ := health["pid"].(float64)
if pid > 0 {
fmt.Fprintf(os.Stderr, "Stopping daemon (pid %d)...\n", int(pid))
if err := requestDaemonShutdown(healthPort); err != nil {
if p, perr := os.FindProcess(int(pid)); perr == nil {
_ = p.Kill()
}
}
for i := 0; i < 10; i++ {
time.Sleep(500 * time.Millisecond)
sctx, scancel := context.WithTimeout(context.Background(), 1*time.Second)
h := checkDaemonHealthOnPort(sctx, healthPort)
scancel()
if h["status"] != "running" {
break
}
}
}
}
// Start fresh.
return runDaemonStart(cmd, args)
}
// --- daemon stop ---
func runDaemonStop(cmd *cobra.Command, _ []string) error {
profile := resolveProfile(cmd)
healthPort := healthPortForProfile(profile)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
health := checkDaemonHealthOnPort(ctx, healthPort)
if health["status"] != "running" {
label := "Daemon"
if profile != "" {
label = fmt.Sprintf("Daemon [%s]", profile)
}
fmt.Fprintf(os.Stderr, "%s is not running.\n", label)
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)
}
// Request graceful shutdown via the daemon's HTTP /shutdown endpoint
// rather than an OS signal. On Windows the daemon is spawned with
// DETACHED_PROCESS so it shares no console with us, which means
// GenerateConsoleCtrlEvent can't reach it; HTTP works on both
// platforms and triggers the same context-cancel path the daemon
// already uses for self-restart.
if err := requestDaemonShutdown(healthPort); err != nil {
fmt.Fprintf(os.Stderr, "Graceful shutdown request failed: %v — falling back to forced kill.\n", err)
if kerr := process.Kill(); kerr != nil {
return fmt.Errorf("kill daemon (pid %d): %w", int(pid), kerr)
}
}
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 := checkDaemonHealthOnPort(ctx2, healthPort)
cancel2()
if h["status"] != "running" {
os.Remove(daemonPIDPathForProfile(profile))
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
}
// requestDaemonShutdown POSTs to the daemon's /shutdown endpoint to ask it
// to exit gracefully. Returns an error if the request could not be delivered
// (network error, non-2xx status, or the endpoint predates this change).
func requestDaemonShutdown(healthPort int) error {
url := fmt.Sprintf("http://127.0.0.1:%d/shutdown", healthPort)
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return nil
}
// --- daemon status ---
func runDaemonStatus(cmd *cobra.Command, _ []string) error {
profile := resolveProfile(cmd)
healthPort := healthPortForProfile(profile)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
health := checkDaemonHealthOnPort(ctx, healthPort)
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, health)
}
label := "Daemon"
if profile != "" {
label = fmt.Sprintf("Daemon [%s]", profile)
}
if health["status"] != "running" {
fmt.Fprintf(os.Stdout, "%s: stopped\n", label)
return nil
}
fmt.Fprintf(os.Stdout, "%s: running (pid %v, uptime %v)\n", label, 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 {
profile := resolveProfile(cmd)
logPath := daemonLogPathForProfile(profile)
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")
return tailLogFile(logPath, lines, follow)
}
// checkDaemonHealthOnPort calls the daemon's local health endpoint on the given port.
func checkDaemonHealthOnPort(ctx context.Context, port int) map[string]any {
addr := fmt.Sprintf("http://127.0.0.1:%d/health", port)
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
}
// --- daemon disk-usage ---
func runDaemonDiskUsage(cmd *cobra.Command, _ []string) error {
profile := resolveProfile(cmd)
rootOverride, _ := cmd.Flags().GetString("workspaces-root")
byWorkspace, _ := cmd.Flags().GetBool("by-workspace")
byTask, _ := cmd.Flags().GetBool("by-task")
top, _ := cmd.Flags().GetInt("top")
output, _ := cmd.Flags().GetString("output")
if byWorkspace && byTask {
return fmt.Errorf("--by-workspace and --by-task are mutually exclusive")
}
if top < 0 {
return fmt.Errorf("--top must be a non-negative integer")
}
workspacesRoot, err := daemon.ResolveWorkspacesRoot(profile, rootOverride)
if err != nil {
return fmt.Errorf("resolve workspaces root: %w", err)
}
report, err := daemon.ScanDiskUsage(workspacesRoot, daemon.ArtifactPatternsFromEnv())
if err != nil {
return err
}
if top > 0 {
if byWorkspace {
if top < len(report.Workspaces) {
report.Workspaces = report.Workspaces[:top]
}
} else if top < len(report.Tasks) {
report.Tasks = report.Tasks[:top]
}
}
if output == "json" {
return cli.PrintJSON(os.Stdout, report)
}
if byWorkspace {
printDiskUsageWorkspaceTable(os.Stdout, report)
return nil
}
printDiskUsageTaskTable(os.Stdout, report)
return nil
}
func printDiskUsageTaskTable(w io.Writer, report daemon.DiskUsageReport) {
fmt.Fprintf(w, "Workspaces root: %s\n", report.WorkspacesRoot)
if report.TotalTaskCount == 0 {
fmt.Fprintln(w, "(no task directories)")
return
}
rows := make([][]string, 0, len(report.Tasks))
var displayedSize, displayedArtifact int64
for _, task := range report.Tasks {
displayedSize += task.SizeBytes
displayedArtifact += task.ArtifactSizeBytes
rows = append(rows, []string{
task.WorkspaceShort + "/" + task.TaskShort,
task.Kind,
emptyDash(task.ParentStatus),
formatAge(task.AgeSeconds),
formatBytes(task.SizeBytes),
formatBytes(task.ArtifactSizeBytes),
})
}
cli.PrintTable(w, []string{"PATH", "KIND", "STATUS", "AGE", "SIZE", "ARTIFACTS"}, rows)
if len(report.Tasks) < report.TotalTaskCount {
// Report-wide totals stay anchored to the full scan; the displayed
// row is what the user is currently looking at. Calling these out
// separately keeps `--top N` from misleading at-a-glance triage.
fmt.Fprintf(w, "\nShowing top %d of %d task(s). Displayed: %s (%s artifacts). Scan total: %s (%s artifacts, %.1f%% reclaimable).\n",
len(report.Tasks), report.TotalTaskCount,
formatBytes(displayedSize), formatBytes(displayedArtifact),
formatBytes(report.TotalSizeBytes), formatBytes(report.TotalArtifactSizeBytes),
report.TotalArtifactRatio*100)
return
}
fmt.Fprintf(w, "\nTotal: %s across %d task(s); %s reclaimable as artifacts (%.1f%%).\n",
formatBytes(report.TotalSizeBytes), report.TotalTaskCount,
formatBytes(report.TotalArtifactSizeBytes), report.TotalArtifactRatio*100)
}
func printDiskUsageWorkspaceTable(w io.Writer, report daemon.DiskUsageReport) {
fmt.Fprintf(w, "Workspaces root: %s\n", report.WorkspacesRoot)
if report.TotalWorkspaceCount == 0 {
fmt.Fprintln(w, "(no workspaces)")
return
}
rows := make([][]string, 0, len(report.Workspaces))
var displayedSize, displayedArtifact int64
for _, ws := range report.Workspaces {
displayedSize += ws.SizeBytes
displayedArtifact += ws.ArtifactSizeBytes
rows = append(rows, []string{
ws.WorkspaceShort,
strconv.Itoa(ws.TaskCount),
formatBytes(ws.SizeBytes),
formatBytes(ws.ArtifactSizeBytes),
formatRatio(ws.ArtifactRatio),
formatAge(ws.OldestAgeSeconds),
})
}
cli.PrintTable(w, []string{"WORKSPACE", "TASKS", "SIZE", "ARTIFACTS", "ARTIFACT %", "OLDEST"}, rows)
if len(report.Workspaces) < report.TotalWorkspaceCount {
fmt.Fprintf(w, "\nShowing top %d of %d workspace(s). Displayed: %s (%s artifacts). Scan total: %s (%s artifacts, %.1f%% reclaimable).\n",
len(report.Workspaces), report.TotalWorkspaceCount,
formatBytes(displayedSize), formatBytes(displayedArtifact),
formatBytes(report.TotalSizeBytes), formatBytes(report.TotalArtifactSizeBytes),
report.TotalArtifactRatio*100)
return
}
fmt.Fprintf(w, "\nTotal: %s across %d workspace(s); %s reclaimable as artifacts (%.1f%%).\n",
formatBytes(report.TotalSizeBytes), report.TotalWorkspaceCount,
formatBytes(report.TotalArtifactSizeBytes), report.TotalArtifactRatio*100)
}
// formatRatio renders a 0..1 fraction as a percentage to one decimal. A
// non-finite or negative input collapses to "0.0%" — total=0 workspaces
// shouldn't surface "NaN%".
func formatRatio(r float64) string {
if r != r || r < 0 { // NaN check via inequality
return "0.0%"
}
return fmt.Sprintf("%.1f%%", r*100)
}
func emptyDash(s string) string {
if s == "" {
return "-"
}
return s
}
// formatBytes renders a byte count in IEC units (KiB/MiB/GiB) with one decimal
// place above 1 KiB. Kept intentionally compact so the table view stays
// scannable at terminal widths.
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
prefix := "KMGTPE"[exp]
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), prefix)
}
// formatAge renders an age in the most human-friendly unit that still keeps
// the value above 1. "0s" stands for "less than a second" — matches what the
// GC log lines look like.
func formatAge(seconds int64) string {
if seconds <= 0 {
return "0s"
}
d := time.Duration(seconds) * time.Second
switch {
case d >= 24*time.Hour:
return fmt.Sprintf("%dd %dh", int(d/(24*time.Hour)), int((d%(24*time.Hour))/time.Hour))
case d >= time.Hour:
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
case d >= time.Minute:
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
default:
return fmt.Sprintf("%ds", seconds)
}
}