package cli import ( "context" "fmt" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "github.com/spf13/cobra" "github.com/thepeterstone/claudomator/internal/api" "github.com/thepeterstone/claudomator/internal/budget" "github.com/thepeterstone/claudomator/internal/config" "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/notify" "github.com/thepeterstone/claudomator/internal/provider/openaicompat" "github.com/thepeterstone/claudomator/internal/scheduler" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/version" ) func newServeCmd() *cobra.Command { var addr string var workspaceRoot string var basePath string cmd := &cobra.Command{ Use: "serve", Short: "Start the Claudomator API server", RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("workspace-root") { cfg.WorkspaceRoot = workspaceRoot } return serve(addr, basePath) }, } cmd.Flags().StringVar(&addr, "addr", "127.0.0.1:8484", "listen address (loopback by default; set external_bind_allowed=true in config to bind externally)") cmd.Flags().StringVar(&basePath, "base-path", "/claudomator", "URL prefix the UI is served under (e.g. /claudomator-oss)") cmd.Flags().StringVar(&workspaceRoot, "workspace-root", "/workspace", "root directory for listing workspaces") cmd.Flags().StringVar(&cfg.ClaudeImage, "claude-image", cfg.ClaudeImage, "docker image for claude agents") cmd.Flags().StringVar(&cfg.GeminiImage, "gemini-image", cfg.GeminiImage, "docker image for gemini agents") return cmd } func serve(addr, basePath string) error { if err := config.ValidateBindAddr(addr, cfg.ExternalBindAllowed); err != nil { return err } if err := cfg.EnsureDirs(); err != nil { return fmt.Errorf("creating dirs: %w", err) } store, err := storage.Open(cfg.DBPath) if err != nil { return fmt.Errorf("opening db: %w", err) } defer store.Close() // Load VAPID keys from DB; generate and persist if missing. if cfg.VAPIDPublicKey == "" || cfg.VAPIDPrivateKey == "" { pub, _ := store.GetSetting("vapid_public_key") priv, _ := store.GetSetting("vapid_private_key") if pub == "" || priv == "" || !notify.ValidateVAPIDPublicKey(pub) { pub, priv, err = notify.GenerateVAPIDKeys() if err != nil { return fmt.Errorf("generating VAPID keys: %w", err) } _ = store.SetSetting("vapid_public_key", pub) _ = store.SetSetting("vapid_private_key", priv) } cfg.VAPIDPublicKey = pub cfg.VAPIDPrivateKey = priv } logger := newLogger(verbose) apiURL := "http://localhost" + addr if len(addr) > 0 && addr[0] != ':' { apiURL = "http://" + addr } // Use configured credentials dir; sync-credentials keeps this populated. claudeConfigDir := cfg.ClaudeConfigDir repoDir, _ := os.Getwd() // Shared per-task agent MCP token registry: runners mint tokens; the API // server mounts /mcp and resolves them. agentRegistry := executor.NewRegistry() runners := map[string]executor.Runner{} // ContainerRunner: binaries are resolved via PATH inside the container image, // so ClaudeBinary/GeminiBinary are left empty (host paths would not exist inside). if cfg.Runners.ClaudeEnabled() { runners["claude"] = &executor.ContainerRunner{ Image: cfg.ClaudeImage, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, SSHAuthSock: cfg.SSHAuthSock, ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, Registry: agentRegistry, } } if cfg.Runners.GeminiEnabled() { runners["gemini"] = &executor.ContainerRunner{ Image: cfg.GeminiImage, Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, SSHAuthSock: cfg.SSHAuthSock, ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, Registry: agentRegistry, } } if cfg.Runners.ContainerEnabled() { runners["container"] = &executor.ContainerRunner{ Image: "claudomator-agent:latest", Logger: logger, LogDir: cfg.LogDir, APIURL: apiURL, DropsDir: cfg.DropsDir, SSHAuthSock: cfg.SSHAuthSock, ClaudeConfigDir: claudeConfigDir, CredentialSyncCmd: filepath.Join(repoDir, "scripts", "sync-credentials"), Store: store, Registry: agentRegistry, } } localClient := buildLocalLLMClient(cfg.LocalModel, logger) if localClient != nil && cfg.Runners.LocalEnabled() { runners["local"] = &executor.NativeRunner{ Provider: openaicompat.New(localClient), Logger: logger, LogDir: cfg.LogDir, DefaultTemperature: cfg.LocalModel.DefaultTemperature, DefaultModel: cfg.LocalModel.Model, // SandboxKind left at its zero value ("host" / sandbox.HostSandbox). // Local models are presumed more trusted/lower-stakes than paid // cloud providers, so they stay on the weaker but zero-overhead // host sandbox — see NativeRunner.SandboxKind doc comment. } logger.Info("local runner registered", "endpoint", cfg.LocalModel.Endpoint, "model", cfg.LocalModel.Model) } // Native cloud providers (anthropic/google's own wire formats, plus // groq/openrouter/openai via the OpenAI-compatible adapter) — see // internal/cli/cloudrunners.go. registerCloudRunners(runners, cfg, logger, cfg.LogDir) pool := executor.NewPool(cfg.MaxConcurrent, runners, store, logger) pool.Classifier = &executor.Classifier{ LLM: localClient, GeminiBinaryPath: cfg.GeminiBinaryPath, } if localClient != nil { pool.LLM = localClient } // Budget accountant: gate paid providers against rolling per-provider caps. // With no limits configured this is a no-op. var accountant *budget.Accountant if len(cfg.Budget.Provider5hUSD) > 0 { window := budget.DefaultWindow if cfg.Budget.Window != "" { if d, derr := time.ParseDuration(cfg.Budget.Window); derr == nil { window = d } else { logger.Warn("invalid budget.window; using default", "value", cfg.Budget.Window, "default", window) } } accountant = budget.New(store, budget.Limits{Window: window, PerProvider: cfg.Budget.Provider5hUSD}) pool.Budget = accountant logger.Info("budget gating enabled", "window", window, "limits", cfg.Budget.Provider5hUSD) } if err := store.SeedProjects(); err != nil { logger.Error("failed to seed projects", "error", err) } pool.RecoverStaleRunning(context.Background()) pool.RecoverStaleQueued(context.Background()) pool.RecoverStaleBlocked() srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath) srv.SetBasePath(basePath) if accountant != nil { srv.SetBudget(accountant) } // Configure notifiers: combine webhook (if set) with web push. notifiers := []notify.Notifier{} if cfg.WebhookURL != "" { notifiers = append(notifiers, notify.NewWebhookNotifier(cfg.WebhookURL, logger)) } webPushNotifier := ¬ify.WebPushNotifier{ Store: store, VAPIDPublicKey: cfg.VAPIDPublicKey, VAPIDPrivateKey: cfg.VAPIDPrivateKey, VAPIDEmail: cfg.VAPIDEmail, Logger: logger, } notifiers = append(notifiers, webPushNotifier) srv.SetNotifier(notify.NewMultiNotifier(logger, notifiers...)) srv.SetVAPIDConfig(cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDEmail) srv.SetPushStore(store) srv.SetDropsDir(cfg.DropsDir) if cfg.WorkspaceRoot != "" { srv.SetWorkspaceRoot(cfg.WorkspaceRoot) } if cfg.LocalModel.UseForElaborate() { srv.SetLLM(localClient) logger.Info("elaboration prefers local llm", "endpoint", cfg.LocalModel.Endpoint) } srv.SetGitHubWebhookConfig(cfg.WebhookSecret, cfg.Projects) if cfg.APIToken != "" { srv.SetAPIToken(cfg.APIToken) logger.Info("API token configured; chatbot MCP endpoint enabled at /chatbot/mcp") } // Register scripts. wd, _ := os.Getwd() srv.SetScripts(api.ScriptRegistry{ "start-next-task": filepath.Join(wd, "scripts", "start-next-task"), "deploy": filepath.Join(wd, "scripts", "deploy"), }) // Graceful shutdown. ctx, cancel := context.WithCancel(context.Background()) defer cancel() srv.SetContext(ctx) srv.StartHub() // Retry-then-escalate scheduler for role-typed tasks (internal/role, // internal/scheduler). Only `serve` runs this background loop — the // one-shot `run` command has no long-lived process to host it in. sch := &scheduler.Scheduler{ Store: store, Pool: pool, Logger: logger, AskUserTimeout: cfg.Scheduler.AskUserTimeout(), } if accountant != nil { // Only assign when non-nil: a nil *budget.Accountant boxed into the // scheduler.BudgetGate interface would be a non-nil interface value // whose methods panic on the nil receiver — same reason pool.Budget // above is only ever assigned inside the `if accountant configured` // branch, never unconditionally. sch.Budget = accountant } go sch.Run(ctx, cfg.Scheduler.PollInterval()) // Story orchestrator (internal/scheduler.StoryOrchestrator, Phase 7b): // drives stories through Builder -> Evaluators -> Arbitration -> // REVIEW_READY as their root/evaluator/arbitration tasks reach COMPLETED // (always via human/chatbot accept, since these are all top-level // tasks — see that type's doc comment for why this is poll-based rather // than hooked into executor.Pool.handleRunResult). Also `serve`-only, // same reasoning as the scheduler above. storyOrch := &scheduler.StoryOrchestrator{ Store: store, Pool: pool, Logger: logger, } go storyOrch.Run(ctx, scheduler.DefaultStoryPollInterval) httpSrv := &http.Server{ Addr: addr, Handler: srv.Handler(), } workerTimeout := 3 * time.Minute if cfg.ShutdownTimeout > 0 { workerTimeout = cfg.ShutdownTimeout } sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) go func() { <-sigCh logger.Info("shutting down: draining workers...", "timeout", workerTimeout) // Stop the HTTP server so no new requests come in. httpCtx, httpCancel := context.WithTimeout(ctx, 5*time.Second) defer httpCancel() if err := httpSrv.Shutdown(httpCtx); err != nil { logger.Warn("http shutdown error", "err", err) } // Wait for in-flight task workers to finish. workerCtx, workerCancel := context.WithTimeout(context.Background(), workerTimeout) defer workerCancel() if err := srv.Pool().Shutdown(workerCtx); err != nil { logger.Warn("worker drain timed out", "err", err) } else { logger.Info("all workers finished cleanly") } }() fmt.Printf("Claudomator %s listening on %s\n", version.Version(), addr) if err := httpSrv.ListenAndServe(); err != http.ErrServerClosed { return err } return nil }