Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ CLI_BIN ?= $(BIN_DIR)/csgclaw-cli

ACR_REGISTRY ?= opencsg-registry.cn-beijing.cr.aliyuncs.com
IMAGE ?= $(ACR_REGISTRY)/opencsghq/picoclaw
TAG ?= 2026.5.27
TAG ?= 2026.6.8
DOCKER_EMBED_IMAGE_TAG ?= dev
PICOCLAW_IMAGE_TAG ?= $(DOCKER_EMBED_IMAGE_TAG)
LOCAL_IMAGE ?= picoclaw:local
Expand Down
3 changes: 2 additions & 1 deletion cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func (a *App) registerDefaultCommands() {
completioncmd.NewCmd("csgclaw", completioncmd.FullSpec()),
completioncmd.NewCompleteCmd("csgclaw", completioncmd.FullSpec()),
servecmd.NewInternalServeCmd(),
servecmd.NewInternalRestartCmd(),
)
}

Expand Down Expand Up @@ -180,7 +181,7 @@ func isSpecialOutputCommand(rest []string) bool {
return true
}
switch rest[0] {
case "serve", "stop", "_serve":
case "serve", "stop", "_serve", "_restart":
return true
case "agent":
return len(rest) > 1 && rest[1] == "logs"
Expand Down
124 changes: 124 additions & 0 deletions cli/serve/restart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package serve

import (
"context"
"flag"
"fmt"
"io"
"strings"

"csgclaw/cli/command"
"csgclaw/internal/upgrade"
)

type internalRestartCmd struct{}

func NewInternalRestartCmd() command.Command {
return internalRestartCmd{}
}

func (internalRestartCmd) Name() string {
return "_restart"
}

func (internalRestartCmd) Summary() string {
return "Internal config restart helper."
}

func (internalRestartCmd) Hidden() bool {
return true
}

func (c internalRestartCmd) Run(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error {
fs := run.NewFlagSet("_restart", run.Program+" _restart [flags]", c.Summary())
fs.Usage = func() {
restartUsage(run, fs)
}
if err := fs.Parse(args); err != nil {
return err
}
if len(fs.Args()) != 0 {
return fmt.Errorf("_restart does not accept positional arguments")
}

artifacts := upgrade.RestartArtifactsFromEnv()
fail := func(err error) error {
err = explainRestartError(run.Program, err)
if recordErr := artifacts.RecordFailure(err); recordErr != nil {
return fmt.Errorf("%w\nAlso failed to record restart helper status: %v", err, recordErr)
}
return err
}

restarted, err := upgrade.RestartDaemonFromExecutable(ctx, upgrade.RestartOptions{
ConfigPath: globals.Config,
})
if err != nil {
return fail(err)
}
if !restarted.DaemonWasRunning {
message := fmt.Sprintf("Config saved. Stop the running server and run `%s serve` again.", run.Program)
if recordErr := artifacts.RecordManualRestartRequired(message); recordErr != nil {
return fmt.Errorf("record manual restart status: %w", recordErr)
}
} else {
_ = artifacts.ClearStatus()
}
return renderRestartResult(globals.Output, run.Stdout, restarted, run.Program)
}

func restartUsage(run *command.Context, fs *flag.FlagSet) {
fmt.Fprintln(run.Stderr, "Restart the local CSGClaw daemon after config changes.")
fmt.Fprintln(run.Stderr)
fmt.Fprintln(run.Stderr, "Usage:")
fmt.Fprintf(run.Stderr, " %s _restart [flags]\n", run.Program)
fmt.Fprintln(run.Stderr)
fmt.Fprintln(run.Stderr, "Flags:")
fs.PrintDefaults()
}

func explainRestartError(program string, err error) error {
if err == nil {
return nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "read pid file"), strings.Contains(msg, "parse pid file"), strings.Contains(msg, "stop running daemon"), strings.Contains(msg, "restart daemon"):
return fmt.Errorf("%w\nRestart manually with `%s stop` and `%s serve -d`.", err, program, program)
default:
return err
}
}

type restartResultView struct {
PIDPath string `json:"pid_path,omitempty"`
DaemonRunning bool `json:"daemon_running"`
Restarted bool `json:"restarted"`
ManualRestart bool `json:"manual_restart_required"`
Message string `json:"message,omitempty"`
}

func renderRestartResult(output string, w io.Writer, restarted upgrade.RestartResult, program string) error {
output, err := command.NormalizeOutput(output)
if err != nil {
return err
}
message := "Config saved."
if restarted.Restarted {
message = "Config saved and service restarted."
} else if !restarted.DaemonWasRunning {
message = fmt.Sprintf("%s\nNo daemon detected; stop the running server and run `%s serve` again.", message, program)
}
view := restartResultView{
PIDPath: restarted.PIDPath,
DaemonRunning: restarted.DaemonWasRunning,
Restarted: restarted.Restarted,
ManualRestart: restarted.DaemonWasRunning == false,
Message: message,
}
if output == "json" {
return command.WriteJSON(w, view)
}
_, err = fmt.Fprintln(w, message)
return err
}
6 changes: 6 additions & 0 deletions internal/agent/manager_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ func picoclawBridgeModelID(modelID string) string {
}

func resolveManagerBaseURL(server config.ServerConfig) string {
return ResolveManagerBaseURL(server)
}

// ResolveManagerBaseURL returns the base URL injected into agent runtime config.
// It prefers server.advertise_base_url and otherwise resolves a reachable local IPv4 address.
func ResolveManagerBaseURL(server config.ServerConfig) string {
if server.AdvertiseBaseURL != "" {
baseURL := strings.TrimRight(server.AdvertiseBaseURL, "/")
slog.Debug("local ip detector using advertise_base_url", "base_url", baseURL)
Expand Down
182 changes: 182 additions & 0 deletions internal/api/config_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package api

import (
"encoding/json"
"fmt"
"net/http"
"strings"

"csgclaw/internal/agent"
"csgclaw/internal/apitypes"
"csgclaw/internal/config"
"csgclaw/internal/hub"
"csgclaw/internal/upgrade"
)

func (h *Handler) resolveConfigPath() (string, error) {
path := strings.TrimSpace(h.configPath)
if path == "" {
return config.DefaultPath()
}
return path, nil
}

func (h *Handler) handleServerConfig(w http.ResponseWriter, r *http.Request) {
path, err := h.resolveConfigPath()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

switch r.Method {
case http.MethodGet:
cfg, _, err := h.loadBootstrapConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, serverConfigView(path, cfg))
case http.MethodPut:
var req apitypes.UpdateConfigSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest)
return
}
cfg, path, err := h.loadBootstrapConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
previousManager := cfg.Bootstrap.ResolvedDefaultManagerTemplate()
previousWorker := cfg.Bootstrap.ResolvedDefaultWorkerTemplate()
accessToken := strings.TrimSpace(req.AccessToken)
if accessToken == "" {
accessToken = cfg.Server.AccessToken
}
cfg, err = config.ApplyUserSettings(cfg, config.UserSettings{
ListenAddr: req.ListenAddr,
AdvertiseBaseURL: req.AdvertiseBaseURL,
AccessToken: accessToken,
ShowUpgrade: req.ShowUpgrade,
SandboxProvider: req.SandboxProvider,
DefaultManagerTemplate: req.DefaultManagerTemplate,
DefaultWorkerTemplate: req.DefaultWorkerTemplate,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var bootstrapDefaults *hub.BootstrapDefaults
if h.hub != nil {
defaults, err := hub.ResolveBootstrapDefaults(r.Context(), cfg.Bootstrap, h.hub)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
bootstrapDefaults = &defaults
}
if err := cfg.Save(path); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if h.svc != nil && bootstrapDefaults != nil {
managerChanged := cfg.Bootstrap.ResolvedDefaultManagerTemplate() != previousManager
workerChanged := cfg.Bootstrap.ResolvedDefaultWorkerTemplate() != previousWorker
if managerChanged || workerChanged {
if err := h.svc.SetGatewayRuntime(bootstrapDefaults.ManagerRuntimeKind, bootstrapDefaults.ManagerImage); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
}
writeJSON(w, http.StatusOK, serverConfigView(path, cfg))
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

func serverConfigView(path string, cfg config.Config) apitypes.ConfigSettingsResponse {
settings := config.UserSettingsFromConfig(cfg)
token := strings.TrimSpace(settings.AccessToken)
effective := agent.ResolveManagerBaseURL(cfg.Server)
if effective == "" {
effective = config.ResolveAdvertiseBaseURL(cfg.Server)
}
return apitypes.ConfigSettingsResponse{
Path: path,
ListenAddr: settings.ListenAddr,
AdvertiseBaseURL: settings.AdvertiseBaseURL,
AdvertiseBaseURLEffective: effective,
AccessTokenSet: token != "",
AccessTokenPreview: config.AccessTokenPreview(token),
ShowUpgrade: settings.ShowUpgrade,
SandboxProvider: settings.SandboxProvider,
SupportedSandboxProviders: settings.SupportedSandboxProvider,
DefaultManagerTemplate: settings.DefaultManagerTemplate,
DefaultWorkerTemplate: settings.DefaultWorkerTemplate,
}
}

func (h *Handler) handleServerRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

configPath, err := h.resolveConfigPath()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

restart := h.serverRestartApply
if restart == nil {
restart = upgrade.StartRestartHelper
}
if err := restart(upgrade.RestartHelperOptions{ConfigPath: configPath}); err != nil {
http.Error(w, fmt.Sprintf("start restart helper: %v", err), http.StatusInternalServerError)
return
}

writeJSON(w, http.StatusAccepted, apitypes.ServerRestartResponse{
Status: "accepted",
Message: "restart helper started",
})
}

func (h *Handler) handleServerRestartStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

configPath, err := h.resolveConfigPath()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

record, err := upgrade.ConsumeRestartStatus(configPath)
if err != nil {
http.Error(w, fmt.Sprintf("read restart helper status: %v", err), http.StatusInternalServerError)
return
}

resp := apitypes.ServerRestartStatusResponse{}
switch record.Status {
case upgrade.ApplyStatusManualRestartRequired:
resp.ManualRestartRequired = true
resp.Message = record.Message
case upgrade.ApplyStatusFailed:
resp.LastError = record.Message
default:
if record.Message != "" {
resp.LastError = record.Message
}
}
if resp.ManualRestartRequired || resp.LastError != "" || resp.Message != "" {
writeJSON(w, http.StatusOK, resp)
return
}
writeJSON(w, http.StatusOK, resp)
}
Loading
Loading