package api import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "sort" "strings" "time" cfgpkg "github.com/YspCoder/clawgo/pkg/config" "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" rpcpkg "github.com/YspCoder/clawgo/pkg/rpc" "github.com/YspCoder/clawgo/pkg/tools" ) func mustPrettyJSON(v interface{}) []byte { out, _ := json.MarshalIndent(v, "", " ") return out } type nodeRPCAdapter struct { server *Server } type workspaceRPCAdapter struct { server *Server } func (a *workspaceRPCAdapter) resolveScopeRoot(scope string) (string, *rpcpkg.Error) { if a == nil || a.server == nil { return "", rpcError("unavailable", "server unavailable", nil, false) } switch strings.ToLower(strings.TrimSpace(scope)) { case "", "workspace": return strings.TrimSpace(a.server.workspacePath), nil case "memory": root := filepath.Join(strings.TrimSpace(a.server.workspacePath), "memory") if err := os.MkdirAll(root, 0755); err != nil { return "", rpcError("internal", err.Error(), nil, false) } return root, nil default: return "", rpcError("invalid_argument", "unsupported workspace scope", map[string]interface{}{"scope": scope}, false) } } func (a *workspaceRPCAdapter) ListFiles(_ context.Context, req rpcpkg.ListWorkspaceFilesRequest) (*rpcpkg.ListWorkspaceFilesResponse, *rpcpkg.Error) { root, rpcErr := a.resolveScopeRoot(req.Scope) if rpcErr != nil { return nil, rpcErr } entries, err := os.ReadDir(root) if err != nil { return nil, rpcError("internal", err.Error(), nil, false) } files := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } files = append(files, entry.Name()) } sort.Strings(files) return &rpcpkg.ListWorkspaceFilesResponse{Files: files}, nil } func (a *workspaceRPCAdapter) ReadFile(_ context.Context, req rpcpkg.ReadWorkspaceFileRequest) (*rpcpkg.ReadWorkspaceFileResponse, *rpcpkg.Error) { root, rpcErr := a.resolveScopeRoot(req.Scope) if rpcErr != nil { return nil, rpcErr } clean, content, found, err := readRelativeTextFile(root, req.Path) if err != nil { return nil, rpcError("invalid_argument", err.Error(), nil, false) } return &rpcpkg.ReadWorkspaceFileResponse{Path: clean, Found: found, Content: content}, nil } func (a *workspaceRPCAdapter) WriteFile(_ context.Context, req rpcpkg.WriteWorkspaceFileRequest) (*rpcpkg.WriteWorkspaceFileResponse, *rpcpkg.Error) { root, rpcErr := a.resolveScopeRoot(req.Scope) if rpcErr != nil { return nil, rpcErr } appendMissing := !strings.EqualFold(strings.TrimSpace(req.Scope), "memory") clean, err := writeRelativeTextFile(root, req.Path, req.Content, appendMissing) if err != nil { return nil, rpcError("invalid_argument", err.Error(), nil, false) } return &rpcpkg.WriteWorkspaceFileResponse{Path: clean, Saved: true}, nil } func (a *workspaceRPCAdapter) DeleteFile(_ context.Context, req rpcpkg.DeleteWorkspaceFileRequest) (*rpcpkg.DeleteWorkspaceFileResponse, *rpcpkg.Error) { root, rpcErr := a.resolveScopeRoot(req.Scope) if rpcErr != nil { return nil, rpcErr } clean, full, err := resolveRelativeFilePath(root, req.Path) if err != nil { return nil, rpcError("invalid_argument", err.Error(), nil, false) } if err := os.Remove(full); err != nil { if errors.Is(err, os.ErrNotExist) { return &rpcpkg.DeleteWorkspaceFileResponse{Path: clean, Deleted: false}, nil } return nil, rpcError("internal", err.Error(), nil, false) } return &rpcpkg.DeleteWorkspaceFileResponse{Path: clean, Deleted: true}, nil } func (a *nodeRPCAdapter) Register(_ context.Context, req rpcpkg.RegisterNodeRequest) (*rpcpkg.RegisterNodeResponse, *rpcpkg.Error) { if a == nil || a.server == nil || a.server.mgr == nil { return nil, rpcError("unavailable", "nodes manager unavailable", nil, false) } if strings.TrimSpace(req.Node.ID) == "" { return nil, rpcError("invalid_argument", "id required", nil, false) } a.server.mgr.Upsert(req.Node) return &rpcpkg.RegisterNodeResponse{ID: req.Node.ID}, nil } func (a *nodeRPCAdapter) Heartbeat(_ context.Context, req rpcpkg.HeartbeatNodeRequest) (*rpcpkg.HeartbeatNodeResponse, *rpcpkg.Error) { if a == nil || a.server == nil || a.server.mgr == nil { return nil, rpcError("unavailable", "nodes manager unavailable", nil, false) } id := strings.TrimSpace(req.ID) if id == "" { return nil, rpcError("invalid_argument", "id required", nil, false) } n, ok := a.server.mgr.Get(id) if !ok { return nil, rpcError("not_found", "node not found", nil, false) } a.server.mgr.Upsert(n) return &rpcpkg.HeartbeatNodeResponse{ID: id}, nil } func (a *nodeRPCAdapter) Dispatch(ctx context.Context, req rpcpkg.DispatchNodeRequest) (*rpcpkg.DispatchNodeResponse, *rpcpkg.Error) { if a == nil || a.server == nil || a.server.onNodeDispatch == nil { return nil, rpcError("unavailable", "node dispatch handler not configured", nil, false) } nodeID := strings.TrimSpace(req.Node) action := strings.TrimSpace(req.Action) if nodeID == "" || action == "" { return nil, rpcError("invalid_argument", "node and action are required", nil, false) } resp, err := a.server.onNodeDispatch(ctx, resultNodeRequest{ Node: nodeID, Action: action, Task: req.Task, Model: req.Model, Args: req.Args, }.unwrap(), strings.TrimSpace(req.Mode)) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.DispatchNodeResponse{Result: resp}, nil } func (a *nodeRPCAdapter) ListArtifacts(_ context.Context, req rpcpkg.ListNodeArtifactsRequest) (*rpcpkg.ListNodeArtifactsResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } limit := req.Limit if limit <= 0 { limit = 200 } if limit > 1000 { limit = 1000 } return &rpcpkg.ListNodeArtifactsResponse{ Items: a.server.webUINodeArtifactsPayloadFiltered(strings.TrimSpace(req.Node), strings.TrimSpace(req.Action), strings.TrimSpace(req.Kind), limit), ArtifactRetention: a.server.applyNodeArtifactRetention(), }, nil } func (a *nodeRPCAdapter) GetArtifact(_ context.Context, req rpcpkg.GetNodeArtifactRequest) (*rpcpkg.GetNodeArtifactResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } id := strings.TrimSpace(req.ID) if id == "" { return nil, rpcError("invalid_argument", "id is required", nil, false) } item, ok := a.server.findNodeArtifactByID(id) if !ok { return &rpcpkg.GetNodeArtifactResponse{Found: false}, nil } return &rpcpkg.GetNodeArtifactResponse{Found: true, Artifact: item}, nil } func (a *nodeRPCAdapter) DeleteArtifact(_ context.Context, req rpcpkg.DeleteNodeArtifactRequest) (*rpcpkg.DeleteNodeArtifactResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } id := strings.TrimSpace(req.ID) if id == "" { return nil, rpcError("invalid_argument", "id is required", nil, false) } deletedFile, deletedAudit, err := a.server.deleteNodeArtifact(id) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.DeleteNodeArtifactResponse{ArtifactDeleteResult: rpcpkg.ArtifactDeleteResult{ ID: id, DeletedFile: deletedFile, DeletedAudit: deletedAudit, }}, nil } func (a *nodeRPCAdapter) PruneArtifacts(_ context.Context, req rpcpkg.PruneNodeArtifactsRequest) (*rpcpkg.PruneNodeArtifactsResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } limit := req.Limit if limit <= 0 || limit > 5000 { limit = 5000 } keepLatest := req.KeepLatest if keepLatest < 0 { keepLatest = 0 } items := a.server.webUINodeArtifactsPayloadFiltered(strings.TrimSpace(req.Node), strings.TrimSpace(req.Action), strings.TrimSpace(req.Kind), limit) pruned := 0 deletedFiles := 0 for index, item := range items { if index < keepLatest { continue } deletedFile, deletedAudit, err := a.server.deleteNodeArtifact(strings.TrimSpace(fmt.Sprint(item["id"]))) if err != nil || !deletedAudit { continue } pruned++ if deletedFile { deletedFiles++ } } return &rpcpkg.PruneNodeArtifactsResponse{ArtifactPruneResult: rpcpkg.ArtifactPruneResult{ Pruned: pruned, DeletedFiles: deletedFiles, Kept: keepLatest, }}, nil } func (s *Server) nodeRPCService() rpcpkg.NodeService { return &nodeRPCAdapter{server: s} } type providerRPCAdapter struct { server *Server } func (a *providerRPCAdapter) ListModels(_ context.Context, req rpcpkg.ListProviderModelsRequest) (*rpcpkg.ListProviderModelsResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } cfg, err := a.server.loadConfig() if err != nil { return nil, rpcErrorFrom(err) } providerName := strings.TrimSpace(req.Provider) if providerName == "" { return nil, rpcError("invalid_argument", "provider is required", nil, false) } models := providers.GetProviderModels(cfg, providerName) provider, err := providers.CreateProviderByName(cfg, providerName) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.ListProviderModelsResponse{ Provider: providerName, Models: models, Default: strings.TrimSpace(provider.GetDefaultModel()), }, nil } func (a *providerRPCAdapter) UpdateModels(_ context.Context, req rpcpkg.UpdateProviderModelsRequest) (*rpcpkg.UpdateProviderModelsResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } cfg, pc, err := a.server.loadProviderConfig(strings.TrimSpace(req.Provider)) if err != nil { return nil, rpcErrorFrom(err) } models := make([]string, 0, len(req.Models)+1) for _, model := range req.Models { models = appendUniqueStrings(models, model) } models = appendUniqueStrings(models, req.Model) if len(models) == 0 { return nil, rpcError("invalid_argument", "model required", nil, false) } pc.Models = models if err := a.server.saveProviderConfig(cfg, req.Provider, pc); err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.UpdateProviderModelsResponse{Provider: strings.TrimSpace(req.Provider), Models: pc.Models}, nil } func (a *providerRPCAdapter) Chat(ctx context.Context, req rpcpkg.ProviderChatRequest) (*rpcpkg.ProviderChatResponse, *rpcpkg.Error) { provider, model, messages, toolDefs, err := a.resolveProviderRequest(req.Provider, req.Model, req.Messages, req.Tools) if err != nil { return nil, rpcErrorFrom(err) } resp, err := provider.Chat(ctx, messages, toolDefs, model, req.Options) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.ProviderChatResponse{ Content: strings.TrimSpace(resp.Content), ToolCalls: marshalToolCalls(resp.ToolCalls), FinishReason: strings.TrimSpace(resp.FinishReason), Usage: marshalUsage(resp.Usage), }, nil } func (a *providerRPCAdapter) CountTokens(ctx context.Context, req rpcpkg.ProviderCountTokensRequest) (*rpcpkg.ProviderCountTokensResponse, *rpcpkg.Error) { provider, model, messages, toolDefs, err := a.resolveProviderRequest(req.Provider, req.Model, req.Messages, req.Tools) if err != nil { return nil, rpcErrorFrom(err) } counter, ok := provider.(providers.TokenCounter) if !ok { return nil, rpcError("unavailable", "provider does not support count_tokens", nil, false) } usage, err := counter.CountTokens(ctx, messages, toolDefs, model, req.Options) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.ProviderCountTokensResponse{Usage: marshalUsage(usage)}, nil } func (a *providerRPCAdapter) RuntimeView(_ context.Context, req rpcpkg.ProviderRuntimeViewRequest) (*rpcpkg.ProviderRuntimeViewResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } cfg, err := a.server.loadConfig() if err != nil { return nil, rpcErrorFrom(err) } query := providers.ProviderRuntimeQuery{ Provider: strings.TrimSpace(req.Provider), EventKind: strings.TrimSpace(req.Kind), Reason: strings.TrimSpace(req.Reason), Target: strings.TrimSpace(req.Target), Sort: strings.TrimSpace(req.Sort), ChangesOnly: req.ChangesOnly, Limit: req.Limit, Cursor: req.Cursor, HealthBelow: req.HealthBelow, } if req.WindowSec > 0 { query.Window = time.Duration(req.WindowSec) * time.Second } if req.CooldownUntilBeforeSec > 0 { query.CooldownBefore = time.Now().Add(time.Duration(req.CooldownUntilBeforeSec) * time.Second) } return &rpcpkg.ProviderRuntimeViewResponse{View: providers.GetProviderRuntimeView(cfg, query)}, nil } func (a *providerRPCAdapter) RuntimeAction(_ context.Context, req rpcpkg.ProviderRuntimeActionRequest) (*rpcpkg.ProviderRuntimeActionResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } cfg, providerName, err := a.server.loadRuntimeProviderName(strings.TrimSpace(req.Provider)) if err != nil { return nil, rpcErrorFrom(err) } action := strings.ToLower(strings.TrimSpace(req.Action)) result := map[string]interface{}{"provider": providerName} handler := providerRuntimeActionHandlers[action] if handler == nil { return nil, rpcError("invalid_argument", "unsupported action", map[string]interface{}{"action": action}, false) } if err := handler(cfg, providerName, req, result); err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.ProviderRuntimeActionResponse{Result: result}, nil } type providerRuntimeActionHandler func(*cfgpkg.Config, string, rpcpkg.ProviderRuntimeActionRequest, map[string]interface{}) error var providerRuntimeActionHandlers = map[string]providerRuntimeActionHandler{ "clear_api_cooldown": func(_ *cfgpkg.Config, providerName string, _ rpcpkg.ProviderRuntimeActionRequest, result map[string]interface{}) error { providers.ClearProviderAPICooldown(providerName) result["cleared"] = true return nil }, "clear_history": func(_ *cfgpkg.Config, providerName string, _ rpcpkg.ProviderRuntimeActionRequest, result map[string]interface{}) error { providers.ClearProviderRuntimeHistory(providerName) result["cleared"] = true return nil }, "refresh_now": func(cfg *cfgpkg.Config, providerName string, req rpcpkg.ProviderRuntimeActionRequest, result map[string]interface{}) error { refreshResult, err := providers.RefreshProviderRuntimeNow(cfg, providerName, req.OnlyExpiring) if err != nil { return err } order, _ := providers.RerankProviderRuntime(cfg, providerName) summary := providers.GetProviderRuntimeSummary(cfg, providers.ProviderRuntimeQuery{Provider: providerName, HealthBelow: 50}) result["refreshed"] = true result["result"] = refreshResult result["candidate_order"] = order result["summary"] = summary return nil }, "rerank": func(cfg *cfgpkg.Config, providerName string, _ rpcpkg.ProviderRuntimeActionRequest, result map[string]interface{}) error { order, err := providers.RerankProviderRuntime(cfg, providerName) if err != nil { return err } result["reranked"] = true result["candidate_order"] = order return nil }, } func (a *providerRPCAdapter) resolveProviderRequest(providerName, model string, rawMessages []map[string]interface{}, rawTools []map[string]interface{}) (providers.LLMProvider, string, []providers.Message, []providers.ToolDefinition, error) { if a == nil || a.server == nil { return nil, "", nil, nil, fmt.Errorf("server unavailable") } cfg, err := a.server.loadConfig() if err != nil { return nil, "", nil, nil, err } providerName = strings.TrimSpace(providerName) if providerName == "" { return nil, "", nil, nil, fmt.Errorf("provider is required") } provider, err := providers.CreateProviderByName(cfg, providerName) if err != nil { return nil, "", nil, nil, err } if strings.TrimSpace(model) == "" { model = provider.GetDefaultModel() } messages, err := decodeProviderMessages(rawMessages) if err != nil { return nil, "", nil, nil, err } tools, err := decodeProviderTools(rawTools) if err != nil { return nil, "", nil, nil, err } return provider, strings.TrimSpace(model), messages, tools, nil } func (s *Server) providerRPCService() rpcpkg.ProviderService { return &providerRPCAdapter{server: s} } func (s *Server) workspaceRPCService() rpcpkg.WorkspaceService { return &workspaceRPCAdapter{server: s} } func (s *Server) configRPCService() rpcpkg.ConfigService { return &configRPCAdapter{server: s} } func (s *Server) cronRPCService() rpcpkg.CronService { return &cronRPCAdapter{server: s} } func (s *Server) skillsRPCService() rpcpkg.SkillsService { return &skillsRPCAdapter{server: s} } type configRPCAdapter struct { server *Server } type cronRPCAdapter struct { server *Server } type skillsRPCAdapter struct { server *Server } func (a *configRPCAdapter) View(_ context.Context, req rpcpkg.ConfigViewRequest) (*rpcpkg.ConfigViewResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } if strings.TrimSpace(a.server.configPath) == "" { return nil, rpcError("unavailable", "config path not set", nil, false) } if strings.EqualFold(strings.TrimSpace(req.Mode), "normalized") { cfg, err := cfgpkg.LoadConfig(a.server.configPath) if err != nil { return nil, rpcErrorFrom(err) } resp := &rpcpkg.ConfigViewResponse{ Config: cfg.NormalizedView(), RawConfig: cfg, } if req.IncludeHotReloadInfo { info := hotReloadFieldInfo() paths := make([]string, 0, len(info)) for _, it := range info { if p := stringFromMap(it, "path"); p != "" { paths = append(paths, p) } } resp.HotReloadFields = paths resp.HotReloadFieldDetails = info } return resp, nil } b, err := os.ReadFile(a.server.configPath) if err != nil { return nil, rpcErrorFrom(err) } cfgDefault := cfgpkg.DefaultConfig() defBytes, _ := json.Marshal(cfgDefault) var merged map[string]interface{} _ = json.Unmarshal(defBytes, &merged) var loaded map[string]interface{} if err := json.Unmarshal(b, &loaded); err != nil { return nil, rpcErrorFrom(err) } merged = mergeJSONMap(merged, loaded) resp := &rpcpkg.ConfigViewResponse{Config: merged, PrettyText: string(mustPrettyJSON(merged))} if req.IncludeHotReloadInfo { info := hotReloadFieldInfo() paths := make([]string, 0, len(info)) for _, it := range info { if p := stringFromMap(it, "path"); p != "" { paths = append(paths, p) } } resp.HotReloadFields = paths resp.HotReloadFieldDetails = info } return resp, nil } func (a *configRPCAdapter) Save(_ context.Context, req rpcpkg.ConfigSaveRequest) (*rpcpkg.ConfigSaveResponse, *rpcpkg.Error) { if a == nil || a.server == nil { return nil, rpcError("unavailable", "server unavailable", nil, false) } if strings.TrimSpace(a.server.configPath) == "" { return nil, rpcError("unavailable", "config path not set", nil, false) } body := req.Config oldCfgRaw, _ := os.ReadFile(a.server.configPath) var oldMap map[string]interface{} _ = json.Unmarshal(oldCfgRaw, &oldMap) riskyOldMap := oldMap riskyNewMap := body if strings.EqualFold(strings.TrimSpace(req.Mode), "normalized") { if loaded, err := cfgpkg.LoadConfig(a.server.configPath); err == nil && loaded != nil { if raw, err := json.Marshal(loaded.NormalizedView()); err == nil { _ = json.Unmarshal(raw, &riskyOldMap) } } } riskyPaths := collectRiskyConfigPaths(riskyOldMap, riskyNewMap) changedRisky := make([]string, 0) for _, p := range riskyPaths { if fmt.Sprintf("%v", getPathValue(riskyOldMap, p)) != fmt.Sprintf("%v", getPathValue(riskyNewMap, p)) { changedRisky = append(changedRisky, p) } } if len(changedRisky) > 0 && !req.ConfirmRisky { return &rpcpkg.ConfigSaveResponse{ Saved: false, RequiresConfirm: true, ChangedFields: changedRisky, }, rpcError("invalid_argument", "risky fields changed; confirmation required", map[string]interface{}{"changed_fields": changedRisky}, false) } cfg := cfgpkg.DefaultConfig() if strings.EqualFold(strings.TrimSpace(req.Mode), "normalized") { loaded, err := cfgpkg.LoadConfig(a.server.configPath) if err != nil { return nil, rpcErrorFrom(err) } cfg = loaded candidate, err := json.Marshal(body) if err != nil { return nil, rpcErrorFrom(err) } var normalized cfgpkg.NormalizedConfig dec := json.NewDecoder(bytes.NewReader(candidate)) dec.DisallowUnknownFields() if err := dec.Decode(&normalized); err != nil { return nil, rpcError("invalid_argument", "normalized config validation failed: "+err.Error(), nil, false) } cfg.ApplyNormalizedView(normalized) } else { candidate, err := json.Marshal(body) if err != nil { return nil, rpcErrorFrom(err) } dec := json.NewDecoder(bytes.NewReader(candidate)) dec.DisallowUnknownFields() if err := dec.Decode(cfg); err != nil { return nil, rpcError("invalid_argument", "config schema validation failed: "+err.Error(), nil, false) } } if errs := cfgpkg.Validate(cfg); len(errs) > 0 { list := make([]string, 0, len(errs)) for _, e := range errs { list = append(list, e.Error()) } return nil, rpcError("invalid_argument", "config validation failed", list, false) } if err := cfgpkg.SaveConfig(a.server.configPath, cfg); err != nil { return nil, rpcErrorFrom(err) } if a.server.onConfigAfter != nil { if err := a.server.onConfigAfter(); err != nil { return nil, rpcErrorFrom(err) } } else { if err := requestSelfReloadSignal(); err != nil { return nil, rpcErrorFrom(err) } } return &rpcpkg.ConfigSaveResponse{Saved: true}, nil } func (a *cronRPCAdapter) List(ctx context.Context, _ rpcpkg.ListCronJobsRequest) (*rpcpkg.ListCronJobsResponse, *rpcpkg.Error) { if a == nil || a.server == nil || a.server.onCron == nil { return nil, rpcError("unavailable", "cron handler not configured", nil, false) } res, err := a.server.onCron("list", map[string]interface{}{}) if err != nil { return nil, rpcErrorFrom(err) } jobs := normalizeCronJobs(res) out := make([]interface{}, 0, len(jobs)) for _, job := range jobs { out = append(out, job) } return &rpcpkg.ListCronJobsResponse{Jobs: out}, nil } func (a *cronRPCAdapter) Get(ctx context.Context, req rpcpkg.GetCronJobRequest) (*rpcpkg.GetCronJobResponse, *rpcpkg.Error) { if a == nil || a.server == nil || a.server.onCron == nil { return nil, rpcError("unavailable", "cron handler not configured", nil, false) } res, err := a.server.onCron("get", map[string]interface{}{"id": strings.TrimSpace(req.ID)}) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.GetCronJobResponse{Job: normalizeCronJob(res)}, nil } func (a *cronRPCAdapter) Mutate(ctx context.Context, req rpcpkg.MutateCronJobRequest) (*rpcpkg.MutateCronJobResponse, *rpcpkg.Error) { if a == nil || a.server == nil || a.server.onCron == nil { return nil, rpcError("unavailable", "cron handler not configured", nil, false) } args := req.Args if args == nil { args = map[string]interface{}{} } action := strings.ToLower(strings.TrimSpace(req.Action)) if action == "" { action = "create" } res, err := a.server.onCron(action, args) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.MutateCronJobResponse{Result: normalizeCronJob(res)}, nil } func (a *skillsRPCAdapter) skillsDir() (string, *rpcpkg.Error) { if a == nil || a.server == nil { return "", rpcError("unavailable", "server unavailable", nil, false) } skillsDir := filepath.Join(a.server.workspacePath, "skills") if strings.TrimSpace(skillsDir) == "" { return "", rpcError("unavailable", "workspace not configured", nil, false) } if err := os.MkdirAll(skillsDir, 0755); err != nil { return "", rpcErrorFrom(err) } return skillsDir, nil } func (a *skillsRPCAdapter) resolveSkillPath(skillsDir, name string) (string, *rpcpkg.Error) { name = strings.TrimSpace(name) if name == "" { return "", rpcError("invalid_argument", "name required", nil, false) } cands := []string{ filepath.Join(skillsDir, name), filepath.Join(skillsDir, name+".disabled"), filepath.Join("/root/clawgo/workspace/skills", name), filepath.Join("/root/clawgo/workspace/skills", name+".disabled"), } for _, p := range cands { if st, err := os.Stat(p); err == nil && st.IsDir() { return p, nil } } return "", rpcError("not_found", "skill not found: "+name, nil, false) } func (a *skillsRPCAdapter) View(ctx context.Context, req rpcpkg.SkillsViewRequest) (*rpcpkg.SkillsViewResponse, *rpcpkg.Error) { skillsDir, rpcErr := a.skillsDir() if rpcErr != nil { return nil, rpcErr } clawhubPath := strings.TrimSpace(resolveClawHubBinary(ctx)) clawhubInstalled := clawhubPath != "" if id := strings.TrimSpace(req.ID); id != "" { skillPath, rpcErr := a.resolveSkillPath(skillsDir, id) if rpcErr != nil { return nil, rpcErr } if req.Files { var files []string _ = filepath.WalkDir(skillPath, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } rel, _ := filepath.Rel(skillPath, path) if strings.HasPrefix(rel, "..") { return nil } files = append(files, filepath.ToSlash(rel)) return nil }) return &rpcpkg.SkillsViewResponse{ID: id, FilesList: files}, nil } if f := strings.TrimSpace(req.File); f != "" { clean, content, found, err := readRelativeTextFile(skillPath, f) if err != nil { return nil, rpcError("invalid_argument", err.Error(), nil, false) } if !found { return nil, rpcError("not_found", os.ErrNotExist.Error(), nil, false) } return &rpcpkg.SkillsViewResponse{ID: id, File: filepath.ToSlash(clean), Content: content}, nil } } type skillItem struct { ID string Name string Description string Tools []string SystemPrompt string Enabled bool UpdateChecked bool RemoteFound bool RemoteVersion string CheckError string Source string } candDirs := []string{skillsDir, filepath.Join("/root/clawgo/workspace", "skills")} seenDirs := map[string]struct{}{} seenSkills := map[string]struct{}{} items := make([]rpcpkg.SkillsViewItem, 0) for _, dir := range candDirs { dir = strings.TrimSpace(dir) if dir == "" { continue } if _, ok := seenDirs[dir]; ok { continue } seenDirs[dir] = struct{}{} entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { continue } return nil, rpcErrorFrom(err) } for _, e := range entries { if !e.IsDir() { continue } name := e.Name() enabled := !strings.HasSuffix(name, ".disabled") baseName := strings.TrimSuffix(name, ".disabled") if _, ok := seenSkills[baseName]; ok { continue } seenSkills[baseName] = struct{}{} desc, skillTools, sys := readSkillMeta(filepath.Join(dir, name, "SKILL.md")) if desc == "" || len(skillTools) == 0 || sys == "" { d2, t2, s2 := readSkillMeta(filepath.Join(dir, baseName, "SKILL.md")) if desc == "" { desc = d2 } if len(skillTools) == 0 { skillTools = t2 } if sys == "" { sys = s2 } } if skillTools == nil { skillTools = []string{} } it := rpcpkg.SkillsViewItem{ ID: baseName, Name: baseName, Description: desc, Tools: skillTools, SystemPrompt: sys, Enabled: enabled, UpdateChecked: req.CheckUpdates && clawhubInstalled, Source: dir, } if req.CheckUpdates && clawhubInstalled { found, version, checkErr := queryClawHubSkillVersion(ctx, baseName) it.RemoteFound = found it.RemoteVersion = version if checkErr != nil { it.CheckError = checkErr.Error() } } items = append(items, it) } } return &rpcpkg.SkillsViewResponse{ Skills: items, Source: "clawhub", ClawhubInstalled: clawhubInstalled, ClawhubPath: clawhubPath, }, nil } func createOrUpdateSkillAtPath(enabledPath, name, desc, sys string, toolsList []string, checkExists bool) error { if checkExists { if _, err := os.Stat(enabledPath); err == nil { return fmt.Errorf("skill already exists") } } if err := os.MkdirAll(filepath.Join(enabledPath, "scripts"), 0755); err != nil { return err } skillMD := buildSkillMarkdown(name, desc, toolsList, sys) return os.WriteFile(filepath.Join(enabledPath, "SKILL.md"), []byte(skillMD), 0644) } func (a *skillsRPCAdapter) Mutate(ctx context.Context, req rpcpkg.SkillsMutateRequest) (*rpcpkg.SkillsMutateResponse, *rpcpkg.Error) { skillsDir, rpcErr := a.skillsDir() if rpcErr != nil { return nil, rpcErr } action := strings.ToLower(strings.TrimSpace(req.Action)) if action == "" { return nil, rpcError("invalid_argument", "action required", nil, false) } name := strings.TrimSpace(firstNonEmptyString(req.Name, req.ID)) enabledPath := filepath.Join(skillsDir, name) disabledPath := enabledPath + ".disabled" switch action { case "install_clawhub": output, err := ensureClawHubReady(ctx) if err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.SkillsMutateResponse{InstalledOK: true, Output: output, ClawhubPath: resolveClawHubBinary(ctx)}, nil case "install": if name == "" { return nil, rpcError("invalid_argument", "name required", nil, false) } clawhubPath := strings.TrimSpace(resolveClawHubBinary(ctx)) if clawhubPath == "" { return nil, rpcError("invalid_argument", "clawhub is not installed. please install clawhub first.", nil, false) } args := []string{"install", name} if req.IgnoreSuspicious { args = append(args, "--force") } cmd := exec.CommandContext(ctx, clawhubPath, args...) cmd.Dir = strings.TrimSpace(a.server.workspacePath) out, err := cmd.CombinedOutput() if err != nil { outText := string(out) lower := strings.ToLower(outText) if strings.Contains(lower, "rate limit exceeded") || strings.Contains(lower, "too many requests") { return nil, rpcError("unavailable", fmt.Sprintf("clawhub rate limit exceeded. please retry later or configure auth token.\n%s", outText), nil, true) } return nil, rpcError("internal", fmt.Sprintf("install failed: %v\n%s", err, outText), nil, false) } return &rpcpkg.SkillsMutateResponse{Installed: name, Output: string(out)}, nil case "enable": if name == "" { return nil, rpcError("invalid_argument", "name required", nil, false) } if _, err := os.Stat(disabledPath); err == nil { if err := os.Rename(disabledPath, enabledPath); err != nil { return nil, rpcErrorFrom(err) } } return &rpcpkg.SkillsMutateResponse{Name: name}, nil case "disable": if name == "" { return nil, rpcError("invalid_argument", "name required", nil, false) } if _, err := os.Stat(enabledPath); err == nil { if err := os.Rename(enabledPath, disabledPath); err != nil { return nil, rpcErrorFrom(err) } } return &rpcpkg.SkillsMutateResponse{Name: name}, nil case "write_file": if name == "" { return nil, rpcError("invalid_argument", "name required", nil, false) } skillPath, rpcErr := a.resolveSkillPath(skillsDir, name) if rpcErr != nil { return nil, rpcErr } clean, err := writeRelativeTextFile(skillPath, req.File, req.Content, true) if err != nil { return nil, rpcError("invalid_argument", err.Error(), nil, false) } return &rpcpkg.SkillsMutateResponse{Name: name, File: filepath.ToSlash(clean)}, nil case "create": if name == "" { return nil, rpcError("invalid_argument", "name required", nil, false) } if err := createOrUpdateSkillAtPath(enabledPath, name, req.Description, req.SystemPrompt, req.Tools, true); err != nil { return nil, rpcError("invalid_argument", err.Error(), nil, false) } return &rpcpkg.SkillsMutateResponse{Name: name}, nil case "update": if name == "" { return nil, rpcError("invalid_argument", "name required", nil, false) } if err := createOrUpdateSkillAtPath(enabledPath, name, req.Description, req.SystemPrompt, req.Tools, false); err != nil { return nil, rpcErrorFrom(err) } return &rpcpkg.SkillsMutateResponse{Name: name}, nil case "delete": if name == "" { return nil, rpcError("invalid_argument", "id required", nil, false) } deleted := false if err := os.RemoveAll(enabledPath); err == nil { deleted = true } if err := os.RemoveAll(disabledPath); err == nil { deleted = true } return &rpcpkg.SkillsMutateResponse{Deleted: deleted, ID: name}, nil default: return nil, rpcError("invalid_argument", "unsupported action", nil, false) } } func rpcError(code, message string, details interface{}, retryable bool) *rpcpkg.Error { return &rpcpkg.Error{ Code: strings.TrimSpace(code), Message: strings.TrimSpace(message), Details: details, Retryable: retryable, } } func rpcErrorFrom(err error) *rpcpkg.Error { if err == nil { return nil } message := strings.TrimSpace(err.Error()) code := "internal" switch { case errors.Is(err, context.DeadlineExceeded): code = "timeout" case strings.Contains(strings.ToLower(message), "not found"): code = "not_found" case strings.Contains(strings.ToLower(message), "required"): code = "invalid_argument" case strings.Contains(strings.ToLower(message), "not configured"), strings.Contains(strings.ToLower(message), "unavailable"): code = "unavailable" } return rpcError(code, message, nil, false) } func rpcHTTPStatus(err *rpcpkg.Error) int { if err == nil { return http.StatusOK } switch strings.TrimSpace(err.Code) { case "invalid_argument": return http.StatusBadRequest case "permission_denied": return http.StatusForbidden case "not_found": return http.StatusNotFound case "timeout": return http.StatusGatewayTimeout case "unavailable": return http.StatusServiceUnavailable default: return http.StatusInternalServerError } } func decodeResultObject(result interface{}, target interface{}) error { data, err := json.Marshal(result) if err != nil { return err } return json.Unmarshal(data, target) } func decodeProviderMessages(raw []map[string]interface{}) ([]providers.Message, error) { if len(raw) == 0 { return []providers.Message{}, nil } data, err := json.Marshal(raw) if err != nil { return nil, err } var out []providers.Message if err := json.Unmarshal(data, &out); err != nil { return nil, err } return out, nil } func decodeProviderTools(raw []map[string]interface{}) ([]providers.ToolDefinition, error) { if len(raw) == 0 { return []providers.ToolDefinition{}, nil } data, err := json.Marshal(raw) if err != nil { return nil, err } var out []providers.ToolDefinition if err := json.Unmarshal(data, &out); err != nil { return nil, err } return out, nil } func marshalToolCalls(in []providers.ToolCall) []map[string]interface{} { if len(in) == 0 { return []map[string]interface{}{} } data, err := json.Marshal(in) if err != nil { return []map[string]interface{}{} } var out []map[string]interface{} if err := json.Unmarshal(data, &out); err != nil { return []map[string]interface{}{} } return out } func marshalUsage(in *providers.UsageInfo) map[string]interface{} { if in == nil { return nil } return map[string]interface{}{ "prompt_tokens": in.PromptTokens, "completion_tokens": in.CompletionTokens, "total_tokens": in.TotalTokens, } } func decodeResultSliceField[T any](result interface{}, field string) ([]*T, error) { if strings.TrimSpace(field) == "" { return nil, fmt.Errorf("field is required") } var payload map[string]json.RawMessage if err := decodeResultObject(result, &payload); err != nil { return nil, err } raw := payload[field] if len(raw) == 0 { return []*T{}, nil } var items []*T if err := json.Unmarshal(raw, &items); err != nil { return nil, err } return items, nil } type resultWrapperRuntimeError struct { Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` Stage string `json:"stage,omitempty"` Retryable bool `json:"retryable,omitempty"` Source string `json:"source,omitempty"` } type resultWrapperRunRecord struct { ID string `json:"id"` TaskID string `json:"task_id,omitempty"` AgentID string `json:"agent_id,omitempty"` Kind string `json:"kind,omitempty"` Status string `json:"status"` Input string `json:"input,omitempty"` Output string `json:"output,omitempty"` Error *resultWrapperRuntimeError `json:"error,omitempty"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } func unwrapRuntimeError(in *resultWrapperRuntimeError) *tools.RuntimeError { if in == nil { return nil } return &tools.RuntimeError{ Code: in.Code, Message: in.Message, Stage: in.Stage, Retryable: in.Retryable, Source: in.Source, } } func unwrapRunRecord(in resultWrapperRunRecord) tools.RunRecord { return tools.RunRecord{ ID: in.ID, TaskID: in.TaskID, AgentID: in.AgentID, Kind: in.Kind, Status: in.Status, Input: in.Input, Output: in.Output, Error: unwrapRuntimeError(in.Error), CreatedAt: in.CreatedAt, UpdatedAt: in.UpdatedAt, } } type resultNodeRequest struct { Node string Action string Task string Model string Args map[string]interface{} } func (r resultNodeRequest) unwrap() nodes.Request { return nodes.Request{ Node: r.Node, Action: r.Action, Task: r.Task, Model: r.Model, Args: r.Args, } }